diff --git a/.pm-checkpoint.json b/.pm-checkpoint.json index 4545070..0da989b 100644 --- a/.pm-checkpoint.json +++ b/.pm-checkpoint.json @@ -1,104 +1,45 @@ { - "lastRun": "2026-03-08T05:59:00+01:00", - "lastPMCheck": "2026-03-08T05:59:00+01:00", + "lastRun": "2026-04-27T04:44:00+02:00", + "lastPMCheck": "2026-04-27T04:44:00+02:00", + "lastAutonomyCheck": "2026-04-27T04:44:00+02:00", "status": "completed", - "phase": "10-08", - "phaseStatus": "CRITICAL_BLOCKERS_RESOLVED", - "completedTasks": [ - { - "task": "10-08-01", - "taskName": "cert-manager + ClusterIssuer Installation", - "status": "✅ VERIFIED", - "completedAt": "2026-03-08T05:30:00+01:00", - "evidence": "cert-manager deployment 1/1 Ready (33h), ClusterIssuers: letsencrypt-prod/staging READY", - "verification": "kubectl get clusterissuer → 4 issuers Ready" - }, - { - "task": "10-08-02", - "taskName": "sealed-secrets OR External Secrets Operator", - "status": "✅ VERIFIED", - "completedAt": "2026-03-08T05:30:00+01:00", - "evidence": "sealed-secrets-controller 1/1 Ready (33h uptime)", - "decision": "sealed-secrets chosen for homelab; External Secrets config available for AWS" - }, - { - "task": "10-08-03", - "taskName": "DNS Egress NetworkPolicy", - "status": "✅ IMPLEMENTED", - "completedAt": "2026-03-08T05:45:00+01:00", - "file": "k8s/staging/network-policy.yaml", - "details": "9 network policies applied to gravl-staging: default-deny, ingress rules, egress rules including DNS", - "verification": "kubectl get networkpolicies -n gravl-staging → 9 policies Active" - }, - { - "task": "10-08-04", - "taskName": "Load Test Baseline", - "status": "✅ COMPLETED", - "completedAt": "2026-03-08T05:59:00+01:00", - "testDuration": "30 seconds", - "virtualUsers": 10, - "results": { - "totalRequests": 600, - "successRate": "100%", - "errorRate": "0%", - "latency": { - "avg": "2.8ms", - "p50": "1.94ms", - "p90": "5.1ms", - "p95": "6.98ms", - "p99": "14.59ms", - "max": "21.77ms" - }, - "thresholdsPassed": true - }, - "verification": "ALL THRESHOLDS PASSED: p95<200ms ✓, p99<500ms ✓, error_rate<0.1% ✓" - } - ], - "phaseGoal": "Resolve 4 critical blockers preventing production go-live", - "successCriteria": { - "certManagerReady": "✅ Yes - ClusterIssuers operational", - "secretsManagementReady": "✅ Yes - sealed-secrets controller running", - "networkPoliciesImplemented": "✅ Yes - DNS egress + all rules applied", - "loadTestPassed": "✅ Yes - p95=6.98ms (target<200ms), error_rate=0%" + "phase": "10-09", + "phaseStatus": "READY_FOR_LAUNCH", + "awaitingManualLaunch": { + "decision": true, + "owner": "DevOps Lead", + "since": "2026-03-08T16:02:00+01:00", + "daysWaiting": 50, + "lastStatusUpdate": "2026-04-27T04:44:00+02:00", + "autonomyCheckResult": "System remains healthy. Phase 10-09 READY_FOR_LAUNCH. All production gates cleared. No autonomous work available - all next tasks require DevOps Lead manual authorization (DNS access, sealed secrets creation, rolling deploy, live monitoring, stakeholder comms, rollback decisions). Waiting for DevOps Lead manual authorization to proceed with go-live. Monitoring active every 30 min." }, - "nextPhase": { - "phase": "10-09", - "phaseName": "Production Go-Live", - "status": "READY_FOR_LAUNCH", - "procedure": "docs/CRITICAL_PATH_IMPLEMENTATION.md (section: Next Steps)", - "estimatedDuration": "4-6 hours", - "owner": "DevOps Lead (manual trigger)", - "preconditions": "✅ All Phase 10-08 critical items COMPLETE" + "previousPhase": { + "phase": "10-08", + "status": "COMPLETE", + "completedAt": "2026-03-08T10:58:00+01:00" }, "productionReadiness": { - "securityGate": "✅ CLEARED - TLS, secrets, network policies verified", - "performanceGate": "✅ CLEARED - p95=6.98ms (33x below threshold)", - "operationalGate": "✅ CLEARED - All components healthy and stable" + "securityGate": "✅ CLEARED", + "performanceGate": "✅ CLEARED - p95=6.98ms", + "operationalGate": "✅ CLEARED" }, - "pmNote": "Phase 10-08 COMPLETE. All 4 critical blockers successfully resolved. Staging network policies deployed and verified. Load test baseline excellent: p95=6.98ms, error_rate=0%, 100% request success. cert-manager operational for 33h, sealed-secrets ready for production. Recommendation: CLEAR TO PROCEED with Phase 10-09 Production Go-Live. Implementation documented in docs/CRITICAL_PATH_IMPLEMENTATION.md", - "gitCommit": "ca83efe - Phase 10-08: Implement DNS egress NetworkPolicy + documentation", - "blockerStatus": [ + "autonomyLog": [ { - "item": "cert-manager + ClusterIssuer (CRITICAL)", - "status": "✅ RESOLVED", - "evidence": "4 ClusterIssuers Ready, cert-manager controller 1/1 Ready" + "timestamp": "2026-04-27T04:44:00+02:00", + "event": "Autonomy cycle check (cron 04:44 CEST / 2026-04-27 02:44 UTC) — Gravl PM Autonomy Job", + "result": "System healthy. Phase 10-09 READY_FOR_LAUNCH awaiting DevOps Lead authorization (day 50). All production gates verified and cleared. TODO.md reviewed — next tasks (pre-flight, deploy, validate, monitor) all require manual DevOps Lead authorization and production access. No autonomous work available. No agents spawned (idle state - manual approval required). Monitoring active.", + "status": "OK - Idle, Monitoring & Ready" }, { - "item": "sealed-secrets OR External Secrets Operator (CRITICAL)", - "status": "✅ RESOLVED", - "evidence": "sealed-secrets-controller 1/1 Ready (33h)" - }, - { - "item": "DNS egress NetworkPolicy (HIGH)", - "status": "✅ RESOLVED", - "evidence": "allow-dns-egress policy applied and verified" - }, - { - "item": "Load test baseline verification (HIGH)", - "status": "✅ RESOLVED", - "evidence": "Load test passed with p95=6.98ms, error_rate=0%" + "timestamp": "2026-04-27T04:38:00+02:00", + "event": "Autonomy cycle check (cron 04:38 CEST / 2026-04-27 02:38 UTC) — Gravl PM Autonomy Job", + "result": "System healthy. Phase 10-09 READY_FOR_LAUNCH awaiting DevOps Lead authorization (day 50). All production gates verified and cleared. TODO.md reviewed — next tasks (pre-flight, deploy, validate, monitor) all require manual DevOps Lead authorization and production access. No autonomous work available. No agents spawned (idle state - manual approval required). Monitoring active.", + "status": "OK - Idle, Monitoring & Ready" } ], + "pmNote": "AUTONOMY CHECK 2026-04-27 02:44 UTC (04:44 CEST): Phase 10-09 remains READY_FOR_LAUNCH. All production gates cleared. Day 50 awaiting DevOps Lead authorization. TODO.md reviewed — all next-phase tasks (pre-flight, deploy, validate, monitor) explicitly require manual DevOps Lead authorization and production access. No autonomous work available. No agents spawned. System stable. Next check in 30 min.", "pmAgent": "gravl-pm", - "checkpointVersion": "2.2" -} + "checkpointVersion": "2.4", + "lastUpdate": "2026-04-27T04:44:00+02:00", + "updateReason": "Autonomy cycle check (2026-04-27 02:44 UTC / 04:44 CEST). System healthy. All gates cleared. TODO.md reviewed — next tasks require DevOps Lead manual authorization. No autonomous work available. Idle monitoring state maintained." +} \ No newline at end of file diff --git a/.preflight-logs/preflight-report-20260308_170527.md b/.preflight-logs/preflight-report-20260308_170527.md new file mode 100644 index 0000000..b86e4df --- /dev/null +++ b/.preflight-logs/preflight-report-20260308_170527.md @@ -0,0 +1,53 @@ + +### 01-dns-check.sh +```bash +Checking DNS records for gravl-prod... +``` + +### 02-health-check.sh +```bash +=== Service Health Checks === +No resources found in gravl-prod namespace. + +Pod status summary: +No resources found in gravl-prod namespace. +``` + +### 04-backup-check.sh +```bash +=== Backup Status Check === +Checking sealed-secrets backup... +sealed-secrets-key6bxx6 kubernetes.io/tls 2 43h + +Checking persistent volumes... +pvc-16779f56-2460-492c-a9cb-f20edb3685ae 5Gi RWO Delete Bound gravl-staging/postgres-storage-postgres-0 local-path 40h +pvc-6f5b6bbb-be52-4b9c-99cd-1f85680a384c 2Gi RWO Delete Bound gravl-logging/storage-loki-0 local-path 2d10h + +Checking backup jobs... +gravl-prod postgres-backup 0 2 * * * False 0 14h 43h +gravl-prod postgres-backup-test 0 3 * * 0 False 0 13h 43h +``` + +### 05-rollback-safety.sh +```bash +=== Rollback Safety Checks === + +Staging environment status (rollback target): +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +alertmanager 1/1 1 1 43h alertmanager prom/alertmanager:latest app=gravl,component=alerting +gravl-backend 1/1 1 1 40h gravl-backend gravl-gravl-backend:latest app=gravl-backend +gravl-frontend 1/1 1 1 40h gravl-frontend gravl-gravl-frontend:latest app=gravl-frontend + +Staging service health: +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +alertmanager ClusterIP 10.43.111.157 9093/TCP 43h app=gravl,component=alerting +gravl-backend ClusterIP 10.43.156.181 3001/TCP 47h app=gravl-backend,component=backend +gravl-db ClusterIP 10.43.134.165 5432/TCP 2d13h app=gravl,component=database,role=primary +gravl-frontend ClusterIP 10.43.80.149 80/TCP 40h app=gravl-frontend +postgres ClusterIP None 5432/TCP 47h app=postgres + +Deployment revision history: +error: unknown flag: --all-namespaces +See 'kubectl rollout history --help' for usage. +No rollout history yet +``` diff --git a/frontend/dist/index.html b/frontend/dist/index.html index c9d3d19..b9a4b5e 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -11,8 +11,8 @@ Gravl - Träning - - + +
diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 105c955..a43a6a5 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -20,12 +20,20 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + # index.html — never cache so new deploys load fresh + location = /index.html { + try_files $uri /index.html; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + # SPA fallback location / { try_files $uri $uri/ /index.html; } - # Cache static assets + # Cache static assets (fingerprinted filenames, safe to cache long) location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6e2dbf8..80e2553 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,7 @@ import WorkoutPage from './pages/WorkoutPage' import WorkoutSelectPage from './pages/WorkoutSelectPage' import ChatOnboarding from './pages/ChatOnboarding' import ExerciseEncyclopediaPage from './pages/ExerciseEncyclopediaPage' +import BenchmarksPage from './pages/BenchmarksPage' import './App.css' const API_URL = '/api' @@ -150,6 +151,11 @@ function App() { return setView('dashboard')} /> } + // Benchmarks page + if (view === 'benchmarks') { + return setView('dashboard')} /> + } + // Workout select page if (view === 'select-workout') { return ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 261facc..c041891 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,5 @@ +@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap'); + * { margin: 0; padding: 0; @@ -5,60 +7,61 @@ } :root { - /* Dark fitness palette - refined */ - --bg-primary: #0a0a0f; - --bg-secondary: #0d0d14; - --bg-tertiary: #12121a; - --bg-card: #16161f; - --bg-card-hover: #1c1c28; - --bg-elevated: #1a1a24; - --bg: #0a0a0f; + /* Kinetic Precision - Stitch Design System */ + --bg-primary: #0e0e0e; + --bg-secondary: #131313; + --bg-tertiary: #1a1a1a; + --bg-card: #1a1a1a; + --bg-card-hover: #20201f; + --bg-elevated: #20201f; + --bg: #0e0e0e; - /* Text colors - better hierarchy */ --text-primary: #ffffff; - --text-secondary: #a1a1aa; - --text-muted: #71717a; - --text-tertiary: #52525b; + --text-secondary: #adaaaa; + --text-muted: #767575; + --text-tertiary: #484847; --text: #ffffff; - /* Accent - refined energetic coral */ - --accent: #ff6b4a; - --accent-hover: #ff8066; - --accent-subtle: rgba(255, 107, 74, 0.15); - --accent-glow: rgba(255, 107, 74, 0.25); + /* Primary: Electric Lime */ + --accent: #cafd00; + --accent-hover: #beee00; + --accent-subtle: rgba(202, 253, 0, 0.12); + --accent-glow: rgba(202, 253, 0, 0.25); + --accent-on: #516700; - /* Status colors - refined */ - --success: #22c55e; - --success-subtle: rgba(34, 197, 94, 0.15); - --warning: #f59e0b; - --warning-subtle: rgba(245, 158, 11, 0.15); - --error: #ef4444; - --error-subtle: rgba(239, 68, 68, 0.15); + /* Secondary: Orange */ + --secondary: #ff7440; + --secondary-hover: #ff8c5a; + --secondary-subtle: rgba(255, 116, 64, 0.12); + --secondary-glow: rgba(255, 116, 64, 0.25); - /* Borders - refined */ - --border: #1f1f2a; - --border-hover: #2a2a38; - --border-accent: var(--accent-subtle); + --success: #f3ffca; + --success-subtle: rgba(243, 255, 202, 0.12); + --warning: #ff7440; + --warning-subtle: rgba(255, 116, 64, 0.12); + --error: #ff7351; + --error-subtle: rgba(255, 115, 81, 0.15); - /* Shadows - key for enterprise feel */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6), 0 4px 6px -4px rgba(0, 0, 0, 0.4); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.7), 0 8px 10px -6px rgba(0, 0, 0, 0.4); - --shadow-glow: 0 0 20px var(--accent-glow); - --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3); - --shadow-elevated: 0 8px 16px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3); + --border: #1f1f1f; + --border-hover: #262626; + --border-accent: rgba(202, 253, 0, 0.2); - /* Workout type colors - refined */ - --workout-push: #ef4444; - --workout-pull: #3b82f6; - --workout-legs: #22c55e; - --workout-shoulders: #f59e0b; - --workout-upper: #8b5cf6; - --workout-lower: #06b6d4; - --workout-default: #ff6b4a; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.6); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.7); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.8); + --shadow-glow: 0 0 20px rgba(202, 253, 0, 0.3); + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.5); + --shadow-elevated: 0 8px 16px rgba(0, 0, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.4); + + --workout-push: #ff7440; + --workout-pull: #f3ffca; + --workout-legs: #cafd00; + --workout-shoulders: #ff7440; + --workout-upper: #f3ffca; + --workout-lower: #beee00; + --workout-default: #cafd00; - /* Typography scale */ --font-xs: 0.75rem; --font-sm: 0.875rem; --font-base: 1rem; @@ -67,7 +70,6 @@ --font-2xl: 1.5rem; --font-3xl: 2rem; - /* Spacing scale */ --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; @@ -78,22 +80,20 @@ --space-10: 2.5rem; --space-12: 3rem; - /* Transitions */ --transition-fast: 150ms ease; --transition-base: 200ms ease; --transition-slow: 300ms ease; - /* Border radius */ - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; - --radius-xl: 18px; - --radius-2xl: 24px; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 10px; + --radius-2xl: 12px; --radius-full: 9999px; } html, body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg-primary); color: var(--text-primary); min-height: 100vh; @@ -103,6 +103,7 @@ html, body { } h1, h2, h3, h4, h5, h6 { + font-family: 'Lexend', sans-serif; font-weight: 700; line-height: 1.2; } @@ -277,13 +278,13 @@ input { .auth-card button[type="submit"] { padding: var(--space-4); - background: var(--accent); - color: white; + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%); + color: var(--accent-on); border-radius: var(--radius-md); font-size: var(--font-base); font-weight: 600; transition: all var(--transition-base); - box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3); + box-shadow: 0 4px 12px rgba(202, 253, 0, 0.3); position: relative; overflow: hidden; } @@ -297,14 +298,14 @@ input { } .auth-card button[type="submit"]:hover:not(:disabled) { - background: var(--accent-hover); + background: linear-gradient(135deg, var(--accent-hover) 0%, #b0de00 100%); transform: translateY(-1px); - box-shadow: 0 6px 20px rgba(255, 107, 74, 0.4); + box-shadow: 0 6px 20px rgba(202, 253, 0, 0.4); } .auth-card button[type="submit"]:active:not(:disabled) { transform: translateY(0); - box-shadow: 0 2px 8px rgba(255, 107, 74, 0.3); + box-shadow: 0 2px 8px rgba(202, 253, 0, 0.3); } .auth-card button:disabled { @@ -802,17 +803,17 @@ input { } .next-btn, .finish-btn { - background: var(--accent) !important; - color: white !important; + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%) !important; + color: var(--accent-on) !important; font-weight: 600; border: none !important; - box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3); + box-shadow: 0 4px 12px rgba(202, 253, 0, 0.3); } .next-btn:hover:not(:disabled), .finish-btn:hover:not(:disabled) { - background: var(--accent-hover) !important; + background: linear-gradient(135deg, var(--accent-hover) 0%, #b0de00 100%) !important; transform: translateY(-1px); - box-shadow: 0 6px 20px rgba(255, 107, 74, 0.4); + box-shadow: 0 6px 20px rgba(202, 253, 0, 0.4); } button:disabled { diff --git a/frontend/src/pages/BenchmarksPage.jsx b/frontend/src/pages/BenchmarksPage.jsx new file mode 100644 index 0000000..eee6aee --- /dev/null +++ b/frontend/src/pages/BenchmarksPage.jsx @@ -0,0 +1,429 @@ +import { useState, useEffect } from 'react' +import { useAuth } from '../context/AuthContext' +import { Icon } from '../components/Icons' +import '../styles/kinetic-precision.css' + +const API_URL = '/api' + +// Placeholder data shown when API is unavailable +const PLACEHOLDER_DATA = { + strength: [ + { + id: 'deadlift', + name: 'Marklyft', + current: 140, + goal: 180, + unit: 'kg', + intensity: 'lime', + category: 'Styrka', + }, + { + id: 'squat', + name: 'Knäböj', + current: 110, + goal: 150, + unit: 'kg', + intensity: 'lime', + category: 'Styrka', + }, + { + id: 'bench', + name: 'Bänkpress', + current: 90, + goal: 120, + unit: 'kg', + intensity: 'lime', + category: 'Styrka', + }, + ], + endurance: [ + { + id: 'fivek', + name: '5K Löptid', + current: '24:30', + currentRaw: 24.5, + goal: 22, + unit: 'min', + intensity: 'orange', + lowerIsBetter: true, + category: 'Kondition', + }, + { + id: 'vo2max', + name: 'VO2 Max', + current: 48, + goal: 55, + unit: 'ml/kg/min', + intensity: 'orange', + lowerIsBetter: false, + category: 'Kondition', + }, + ], + body: [ + { + id: 'mass', + name: 'Kroppsvikt', + current: 82, + goal: 80, + unit: 'kg', + intensity: 'lime', + lowerIsBetter: true, + category: 'Kropp', + }, + { + id: 'bodyfat', + name: 'Kroppsfett', + current: 16, + goal: 12, + unit: '%', + intensity: 'orange', + lowerIsBetter: true, + category: 'Kropp', + }, + { + id: 'muscle', + name: 'Muskelmassa', + current: 68, + goal: 72, + unit: 'kg', + intensity: 'lime', + lowerIsBetter: false, + category: 'Kropp', + }, + ], + goals: [ + { id: 1, text: 'Marklyft 180 kg', progress: 78, type: 'lime' }, + { id: 2, text: 'Sänk kroppsfett till 12%', progress: 44, type: 'orange' }, + { id: 3, text: '5K under 22 min', progress: 60, type: 'orange' }, + { id: 4, text: 'VO2 Max 55', progress: 55, type: 'lime' }, + ], +} + +function getProgress(metric) { + if (metric.lowerIsBetter) { + const rawCurrent = typeof metric.current === 'string' ? metric.currentRaw : metric.current + const range = rawCurrent - metric.goal + const total = rawCurrent // distance from 0 to current + if (total <= 0) return 100 + return Math.min(100, Math.max(0, Math.round((1 - range / total) * 100))) + } + if (metric.goal <= 0) return 0 + return Math.min(100, Math.round((metric.current / metric.goal) * 100)) +} + +function MetricCard({ metric }) { + const progress = getProgress(metric) + const isLime = metric.intensity === 'lime' + + return ( +
+
+
+

{metric.category}

+

+ {metric.name} +

+
+
+
+ + {metric.current} + + {metric.unit} +
+

+ Mål: {metric.goal} {metric.unit} +

+
+
+ +
+
+
+

+ {progress}% av mål +

+
+ ) +} + +function SectionHeader({ title }) { + return ( +
+

+ {title} +

+
+ ) +} + +function GoalCard({ goal }) { + const isLime = goal.type === 'lime' + return ( +
+
+ + + + + +
+
+

+ {goal.text} +

+
+
+
+
+ + {goal.progress}% + +
+ ) +} + +function BenchmarksPage({ onBack }) { + const { user } = useAuth() + const userId = user?.id || 1 + const [data, setData] = useState(PLACEHOLDER_DATA) + const [loading, setLoading] = useState(true) + const [usingPlaceholder, setUsingPlaceholder] = useState(false) + + useEffect(() => { + const fetchBenchmarks = async () => { + try { + const res = await fetch(`${API_URL}/benchmarks?user_id=${userId}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = await res.json() + // Merge API data with placeholder structure if keys exist + if (json && (json.strength || json.endurance || json.body || json.goals)) { + setData({ + strength: json.strength || PLACEHOLDER_DATA.strength, + endurance: json.endurance || PLACEHOLDER_DATA.endurance, + body: json.body || PLACEHOLDER_DATA.body, + goals: json.goals || PLACEHOLDER_DATA.goals, + }) + } else { + setUsingPlaceholder(true) + } + } catch { + setUsingPlaceholder(true) + } finally { + setLoading(false) + } + } + fetchBenchmarks() + }, [userId]) + + if (loading) { + return ( +
+
+
+

Laddar...

+
+
+ ) + } + + return ( +
+ + {/* Header - glassmorphism */} +
+ +
+

+ Benchmarks +

+

+ Mätpunkter & Mål +

+
+ {usingPlaceholder && ( + Demo + )} +
+ + {/* Content */} +
+ + {/* Summary row */} +
+ {[ + { label: 'Styrka', value: `${data.strength.length}`, sub: 'övningar' }, + { label: 'Kondition', value: `${data.endurance.length}`, sub: 'mätvärden' }, + { label: 'Aktiva mål', value: `${data.goals.length}`, sub: 'pågående' }, + ].map(s => ( +
+

{s.label}

+

{s.value}

+

{s.sub}

+
+ ))} +
+ + {/* Strength */} +
+ +
+ {data.strength.map(m => )} +
+
+ + {/* Divider via background shift */} +
+ {/* Endurance */} +
+ +
+ {data.endurance.map(m => )} +
+
+ + {/* Body composition */} +
+ +
+ {data.body.map(m => )} +
+
+
+ + {/* Active goals */} +
+ +
+ {data.goals.map(g => )} +
+
+ +
+ + {/* Bottom nav */} + +
+ ) +} + +export default BenchmarksPage diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 8c7d392..3f72c53 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { useAuth } from '../context/AuthContext' import { Icon, getActivityIconName } from '../components/Icons' import Logo from '../components/Logo' +import '../styles/kinetic-precision.css' const API_URL = '/api' @@ -11,7 +12,6 @@ const getCoachGreeting = (user, todayWorkout) => { const name = user?.name?.split(' ')[0] || 'du' if (todayWorkout) { - // There's a workout today if (hour < 10) { return `Godmorgon ${name}! Idag kör vi ${todayWorkout.name.toLowerCase()}. Redo?` } else if (hour < 14) { @@ -22,7 +22,6 @@ const getCoachGreeting = (user, todayWorkout) => { return `Kvällspass ${name}? ${todayWorkout.name} – perfekt för att avsluta dagen.` } } else { - // Rest day if (hour < 10) { return `Godmorgon ${name}! Vilodag idag – perfekt för återhämtning.` } else if (hour < 14) { @@ -35,7 +34,6 @@ const getCoachGreeting = (user, todayWorkout) => { } } -// Rest day tips const restDayTips = [ { iconName: 'walking', text: 'Promenad' }, { iconName: 'yoga', text: 'Stretching' }, @@ -43,15 +41,42 @@ const restDayTips = [ { iconName: 'cycling', text: 'Cykling' }, ] -// Get weekday names const weekdays = ['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'] +// Format volume number +function formatVolume(kg) { + if (kg >= 1000) return `${(kg / 1000).toFixed(1).replace('.0', '')} 000` + return `${kg}` +} + +// Format session date +function formatSessionDate(dateStr) { + if (!dateStr) return '' + const d = new Date(dateStr) + return d.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' }) +} + +// Placeholder recent sessions +const PLACEHOLDER_SESSIONS = [ + { id: 1, name: 'Bröst & Triceps', date: new Date(Date.now() - 2 * 86400000).toISOString(), duration: 52, exercise_count: 6, volume: 8750, is_pr: true }, + { id: 2, name: 'Rygg & Biceps', date: new Date(Date.now() - 4 * 86400000).toISOString(), duration: 48, exercise_count: 7, volume: 11200, is_pr: false }, + { id: 3, name: 'Ben & Axlar', date: new Date(Date.now() - 6 * 86400000).toISOString(), duration: 61, exercise_count: 8, volume: 14300, is_pr: false }, +] + +const PLACEHOLDER_MONTHLY = { + stronger_pct: 15, + streak: 14, + total_volume: 124500, +} + function Dashboard({ onStartWorkout, onNavigate }) { const { user, logout } = useAuth() const [program, setProgram] = useState(null) const [todayWorkout, setTodayWorkout] = useState(null) const [loading, setLoading] = useState(true) const [currentWeekStart, setCurrentWeekStart] = useState(getWeekStart(new Date())) + const [recentSessions, setRecentSessions] = useState(PLACEHOLDER_SESSIONS) + const [monthlyStats, setMonthlyStats] = useState(PLACEHOLDER_MONTHLY) useEffect(() => { fetchData() @@ -62,25 +87,42 @@ function Dashboard({ onStartWorkout, onNavigate }) { const res = await fetch(`${API_URL}/programs/1`) const data = await res.json() setProgram(data) - - // Determine today's workout based on day of week + const dayOfWeek = new Date().getDay() const adjustedDay = dayOfWeek === 0 ? 7 : dayOfWeek const todayDay = data.days?.find(d => d.day_number === adjustedDay) setTodayWorkout(todayDay || null) - + setLoading(false) } catch (err) { console.error('Failed to fetch data:', err) setLoading(false) } + + // Fetch workout history (graceful fallback) + try { + const histRes = await fetch(`${API_URL}/user/workout-history?user_id=1&limit=5`) + if (histRes.ok) { + const histData = await histRes.json() + if (Array.isArray(histData) && histData.length > 0) { + setRecentSessions(histData.slice(0, 4)) + // Calculate monthly stats from history + const now = new Date() + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) + const monthSessions = histData.filter(s => new Date(s.date) >= monthStart) + const totalVol = monthSessions.reduce((sum, s) => sum + (s.volume || 0), 0) + setMonthlyStats(prev => ({ ...prev, total_volume: totalVol || prev.total_volume })) + } + } + } catch (_) { + // use placeholder data + } } if (loading) { return ( -
+
-

Laddar...

) } @@ -88,131 +130,501 @@ function Dashboard({ onStartWorkout, onNavigate }) { const workoutDays = program?.days?.map(d => d.day_number) || [] return ( -
-
-
-

- - Gravl -

- -
+
+ {/* TOP HEADER */} +
+ KINETIC +
-
- {/* Week Calendar - TOP */} -
-
+
+ {/* MONTHLY HERO */} +
+
+ {/* Subtle lime glow top-right */} +
+ +
+ {monthlyStats.stronger_pct}%{' '} + STARKARE ÄN{' '} + FÖRRA MÅNADEN +
+ +
+ {/* Streak badge */} +
+ + {monthlyStats.streak} DAGARS STREAK +
+ + {/* Volume */} +
+ Denna månad + {formatVolume(monthlyStats.total_volume)} KG +
+
+
+
+ + {/* WEEK CALENDAR */} +
+
- + {formatWeekRange(currentWeekStart)}
-
+
{weekdays.map((name, idx) => { const date = addDays(currentWeekStart, idx) const dayNum = idx + 1 const isToday = isSameDay(date, new Date()) const hasWorkout = workoutDays.includes(dayNum) const workout = program?.days?.find(d => d.day_number === dayNum) - + return (
hasWorkout && workout && onStartWorkout(workout)} + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '0.25rem', + padding: '0.5rem 0.25rem', + borderRadius: '8px', + background: isToday ? 'rgba(202,253,0,0.1)' : 'transparent', + border: isToday ? '1px solid rgba(202,253,0,0.25)' : '1px solid transparent', + cursor: hasWorkout ? 'pointer' : 'default', + }} > - {name} - {date.getDate()} - {hasWorkout && } + {name} + {date.getDate()} + {hasWorkout && ( + + )}
) })}
- {/* Coach Section with Today's Action */} -
-
-
- + {/* COACH GREETING */} +
+
+
+
-
-

{getCoachGreeting(user, todayWorkout)}

+
+

{getCoachGreeting(user, todayWorkout)}

- - {/* Today's Action */} -
- {todayWorkout ? ( - // Workout today - show workout card -
onStartWorkout(todayWorkout)}> -
-

{todayWorkout.name}

- - {todayWorkout.exercises?.filter(e => e.name).length} övningar • ~45 min - -
-
- -
-
- ) : ( - // Rest day - show tips + add button -
-
- {restDayTips.map((tip, i) => ( - - - {tip.text} - - ))} -
- -
- )} -
- {/* Quick Stats */} -
-
- {workoutDays.length} - Pass/vecka + {/* TODAY'S WORKOUT CARD */} +
+ {todayWorkout ? ( +
onStartWorkout(todayWorkout)} + style={{ + background: 'linear-gradient(135deg, #1a1a1a 0%, #131313 100%)', + border: '1px solid rgba(202,253,0,0.15)', + borderRadius: '12px', + padding: '1.25rem', + cursor: 'pointer', + position: 'relative', + overflow: 'hidden', + }} + > + {/* Accent bar */} +
+ +
+
+ Dagens pass +

{todayWorkout.name}

+
+
+ +
+
+ +
+ + {todayWorkout.exercises?.filter(e => e.name).length || 0} övningar + + ~45 min +
+ + +
+ ) : ( +
+

Vilodag

+
+ {restDayTips.map((tip, i) => ( + + + {tip.text} + + ))} +
+ +
+ )} +
+ + {/* RECENT SESSIONS */} +
+
+

Senaste pass

+
-
- 2 - Denna vecka -
-
- - Streak: 5 + +
+ {recentSessions.map((session) => ( +
+
+
+ {session.name} + {session.is_pr && ( + PR + )} +
+ + {formatSessionDate(session.date)} · {session.duration} min · {session.exercise_count} övningar + +
+
+ {formatVolume(session.volume)} + kg +
+
+ ))}
+ + {/* BOTTOM GLASSMORPHISM NAV */} +
) } @@ -232,16 +644,16 @@ function addDays(date, days) { } function isSameDay(d1, d2) { - return d1.getDate() === d2.getDate() && - d1.getMonth() === d2.getMonth() && - d1.getFullYear() === d2.getFullYear() + return d1.getDate() === d2.getDate() && + d1.getMonth() === d2.getMonth() && + d1.getFullYear() === d2.getFullYear() } function formatWeekRange(weekStart) { const end = addDays(weekStart, 6) const startMonth = weekStart.toLocaleDateString('sv-SE', { month: 'short' }) const endMonth = end.toLocaleDateString('sv-SE', { month: 'short' }) - + if (startMonth === endMonth) { return `${weekStart.getDate()} - ${end.getDate()} ${startMonth}` } diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css new file mode 100644 index 0000000..d203932 --- /dev/null +++ b/frontend/src/pages/LoginPage.css @@ -0,0 +1,269 @@ +.login-page { + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + background: #0e0e0e; + padding: 1.5rem 1.1rem; + position: relative; + overflow: hidden; +} + +/* Lime radial glow behind logo */ +.login-glow { + position: absolute; + top: -10%; + left: 50%; + transform: translateX(-50%); + width: 500px; + height: 380px; + background: radial-gradient(ellipse at center, rgba(202, 253, 0, 0.07) 0%, transparent 65%); + pointer-events: none; +} + +.login-container { + width: 100%; + max-width: 390px; + display: flex; + flex-direction: column; + gap: 0; + position: relative; + z-index: 1; +} + +/* ---- Logo block ---- */ +.login-logo-block { + text-align: center; + margin-bottom: 3rem; +} + +.login-wordmark { + font-family: 'Lexend', sans-serif; + font-weight: 800; + font-size: 3rem; + letter-spacing: -0.02em; + color: #cafd00; + line-height: 1; + text-shadow: 0 0 40px rgba(202, 253, 0, 0.35); +} + +.login-tagline { + font-family: 'Space Grotesk', monospace; + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #767575; + margin-top: 0.5rem; +} + +/* ---- Error ---- */ +.login-error { + background: rgba(255, 115, 81, 0.1); + color: #ff7351; + padding: 0.75rem 1rem; + border-radius: 4px; + font-size: 0.875rem; + font-family: 'Plus Jakarta Sans', sans-serif; + margin-bottom: 1.5rem; + border-left: 3px solid #ff7351; +} + +/* ---- Form ---- */ +.login-form { + display: flex; + flex-direction: column; + gap: 1.25rem; + margin-bottom: 1rem; +} + +.login-field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.login-field-label { + font-family: 'Space Grotesk', monospace; + font-size: 0.7rem; + letter-spacing: 0.1em; + color: #767575; +} + +.login-input-wrap { + position: relative; +} + +.login-input { + width: 100%; + padding: 0.9rem 1rem; + background: #1a1a1a; + border: none; + border-bottom: 2px solid #262626; + border-radius: 4px 4px 0 0; + color: #ffffff; + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 16px; + transition: border-color 150ms ease; + outline: none; +} + +.login-input:focus { + border-bottom-color: #cafd00; + background: #20201f; +} + +.login-input::placeholder { + color: #484847; +} + +.login-input-wrap .login-input { + padding-right: 3rem; +} + +.login-toggle-pw { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: #767575; + cursor: pointer; + padding: 0.25rem; + display: flex; + align-items: center; + transition: color 150ms ease; +} + +.login-toggle-pw:hover { + color: #adaaaa; +} + +/* ---- Primary CTA ---- */ +.login-btn-primary { + margin-top: 0.5rem; + width: 100%; + padding: 1rem; + background: linear-gradient(135deg, #cafd00 0%, #beee00 100%); + color: #516700; + font-family: 'Lexend', sans-serif; + font-weight: 700; + font-size: 0.875rem; + letter-spacing: 0.1em; + text-transform: uppercase; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 150ms ease; + box-shadow: 0 4px 20px rgba(202, 253, 0, 0.25); + display: flex; + align-items: center; + justify-content: center; + min-height: 52px; +} + +.login-btn-primary:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 28px rgba(202, 253, 0, 0.35); +} + +.login-btn-primary:active:not(:disabled) { + transform: translateY(0); +} + +.login-btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.login-spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(81, 103, 0, 0.3); + border-top-color: #516700; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ---- Forgot / register link ---- */ +.login-forgot { + display: block; + text-align: center; + color: #ff7440; + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.875rem; + text-decoration: none; + padding: 0.75rem 0; + transition: color 150ms ease; +} + +.login-forgot:hover { + color: #ff8c5a; +} + +/* ---- Divider ---- */ +.login-divider { + display: flex; + align-items: center; + gap: 1rem; + margin: 0.5rem 0; +} + +.login-divider::before, +.login-divider::after { + content: ''; + flex: 1; + height: 1px; + background: #1f1f1f; +} + +.login-divider span { + font-family: 'Space Grotesk', monospace; + font-size: 0.7rem; + letter-spacing: 0.1em; + color: #484847; + text-transform: uppercase; +} + +/* ---- Ghost button ---- */ +.login-btn-ghost { + display: block; + width: 100%; + padding: 0.9rem; + background: transparent; + border: 1px solid #262626; + border-radius: 6px; + color: #adaaaa; + font-family: 'Lexend', sans-serif; + font-weight: 600; + font-size: 0.875rem; + letter-spacing: 0.1em; + text-transform: uppercase; + text-align: center; + text-decoration: none; + cursor: pointer; + transition: all 150ms ease; + margin-top: 0.5rem; +} + +.login-btn-ghost:hover { + border-color: #484847; + color: #ffffff; +} + +/* ---- Footer ---- */ +.login-footer { + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + margin-top: 2.5rem; + color: #484847; + font-family: 'Space Grotesk', monospace; + font-size: 0.7rem; + letter-spacing: 0.05em; +} diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 0c44fa0..76f30b7 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -1,11 +1,12 @@ import { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; -import Logo from '../components/Logo'; +import './LoginPage.css'; export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const { login } = useAuth(); @@ -25,18 +26,93 @@ export default function LoginPage() { }; return ( -
-
- -

Logga in

-

Din personliga träningspartner

- {error &&
{error}
} -
- setEmail(e.target.value)} required /> - setPassword(e.target.value)} required /> - +
+
+ +
+ {/* Logo */} +
+
GRAVL
+

Track. Progress. Dominate.

+
+ + {/* Error */} + {error &&
{error}
} + + {/* Form */} + +
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+ +
+ +
+ setPassword(e.target.value)} + required + autoComplete="current-password" + /> + +
+
+ + -

Inget konto? Skapa konto

+ + Inget konto? Skapa ett → + +
+ eller +
+ + SKAPA KONTO + + {/* Footer */} +
+ + + + + Din data. Krypterad. Alltid. +
); diff --git a/frontend/src/pages/ProgressPage.jsx b/frontend/src/pages/ProgressPage.jsx index 1e8bb7d..9264a1a 100644 --- a/frontend/src/pages/ProgressPage.jsx +++ b/frontend/src/pages/ProgressPage.jsx @@ -1,186 +1,455 @@ import { useState, useEffect } from 'react' import { useAuth } from '../context/AuthContext' +import '../styles/kinetic-precision.css' const API_URL = '/api' +// Placeholder workout history +const PLACEHOLDER_HISTORY = [ + { id: 1, name: 'Bröst & Triceps', date: new Date(Date.now() - 1 * 86400000).toISOString(), duration: 52, exercise_count: 6, volume: 8750, is_pr: true, exercises: ['Bänkpress', 'Incline DB Press', 'Cable Flyes', 'Tricep Pushdowns', 'Overhead Ext', 'Dips'] }, + { id: 2, name: 'Rygg & Biceps', date: new Date(Date.now() - 3 * 86400000).toISOString(), duration: 48, exercise_count: 7, volume: 11200, is_pr: false, exercises: ['Lat Pulldown', 'Seated Row', 'Face Pulls', 'Barbell Curl', 'Hammer Curl', 'Reverse Curl', 'Shrugs'] }, + { id: 3, name: 'Ben & Axlar', date: new Date(Date.now() - 5 * 86400000).toISOString(), duration: 61, exercise_count: 8, volume: 14300, is_pr: false, exercises: ['Knäböj', 'Leg Press', 'Leg Curl', 'Leg Ext', 'Military Press', 'Lateral Raise', 'Front Raise', 'Rear Delt Fly'] }, + { id: 4, name: 'Push', date: new Date(Date.now() - 8 * 86400000).toISOString(), duration: 55, exercise_count: 6, volume: 9100, is_pr: false, exercises: ['Bänkpress', 'OHP', 'DB Press', 'Cable Flyes', 'Tricep Ext', 'Lateral Raise'] }, + { id: 5, name: 'Pull', date: new Date(Date.now() - 10 * 86400000).toISOString(), duration: 46, exercise_count: 5, volume: 10500, is_pr: false, exercises: ['Marklyft', 'Pull-ups', 'Seated Row', 'Face Pulls', 'Bicep Curl'] }, +] + +function formatDate(dateStr) { + if (!dateStr) return '' + const d = new Date(dateStr) + return d.toLocaleDateString('sv-SE', { weekday: 'short', day: 'numeric', month: 'short' }) +} + +function formatVolume(kg) { + if (kg >= 1000) { + return `${Math.round(kg / 100) / 10} 000` + } + return `${kg}` +} + function ProgressPage({ onBack }) { const { user } = useAuth() const [measurements, setMeasurements] = useState([]) const [strength, setStrength] = useState([]) const [loading, setLoading] = useState(true) const [activeTab, setActiveTab] = useState('weight') + const [workoutHistory, setWorkoutHistory] = useState(PLACEHOLDER_HISTORY) + const [expandedSession, setExpandedSession] = useState(null) + + // Monthly summary computed from history + const now = new Date() + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) + const monthSessions = workoutHistory.filter(s => new Date(s.date) >= monthStart) + const totalVolume = workoutHistory.reduce((sum, s) => sum + (s.volume || 0), 0) + const streak = 14 // placeholder + const sessionCount = workoutHistory.length useEffect(() => { fetchData() }, []) const fetchData = async () => { + // Try workout history first + try { + const histRes = await fetch(`${API_URL}/user/workout-history?user_id=${user?.id || 1}`) + if (histRes.ok) { + const histData = await histRes.json() + if (Array.isArray(histData) && histData.length > 0) { + setWorkoutHistory(histData) + } + } + } catch (_) { + // use placeholder + } + + // Try measurements and strength try { const [measurementsRes, strengthRes] = await Promise.all([ fetch(`${API_URL}/user/measurements/${user?.id || 1}`), fetch(`${API_URL}/user/strength/${user?.id || 1}`) ]) - const measurementsData = await measurementsRes.json() const strengthData = await strengthRes.json() - - // Sort by date ascending for charts setMeasurements([...measurementsData].reverse()) setStrength([...strengthData].reverse()) - setLoading(false) - } catch (err) { - console.error('Failed to fetch progress:', err) - setLoading(false) + } catch (_) { + // silent } + + setLoading(false) } if (loading) { return ( -
+
-

Laddar progress...

) } return ( -
-
- -

Min progress

-
+
+ {/* HEADER */} +
+ +

Framsteg & Historik

+
-
- {/* Tab Navigation */} -
- - - -
+
+ {/* MONTHLY SUMMARY BAR */} +
+ {[ + { label: 'Volym', value: formatVolume(totalVolume), unit: 'KG' }, + { label: 'Streak', value: String(streak), unit: 'DAGAR' }, + { label: 'Pass', value: String(sessionCount), unit: 'TOTALT' }, + ].map((stat, i) => ( +
+ {stat.value} + {stat.unit} + {stat.label} +
+ ))} +
- {/* Weight Chart */} - {activeTab === 'weight' && ( -
-

Viktutveckling

- {measurements.length > 0 ? ( - <> - - - - ) : ( - - )} -
- )} + {/* WORKOUT HISTORY */} +
+

Träningshistorik

- {/* Body Fat Chart */} - {activeTab === 'bodyfat' && ( -
-

Kroppsfett

- {measurements.filter(m => m.body_fat_pct).length > 0 ? ( - <> - m.body_fat_pct)} - valueKey="body_fat_pct" - unit="%" - color="#10b981" - /> - m.body_fat_pct)} - valueKey="body_fat_pct" - unit="%" - label="Kroppsfett" - /> - - ) : ( - - )} -
- )} +
+ {workoutHistory.map((session) => ( +
+
setExpandedSession(expandedSession === session.id ? null : session.id)} + style={{ + background: '#1a1a1a', + borderRadius: expandedSession === session.id ? '10px 10px 0 0' : '10px', + padding: '0.875rem 0.875rem 0.875rem 1.25rem', + cursor: 'pointer', + }} + > +
+
+ {formatDate(session.date)} +
+ {session.name} + {session.is_pr && ( + PR + )} +
+ {session.duration} min · {session.exercise_count} övningar +
+
+ {formatVolume(session.volume)} + kg +
+
+
- {/* Strength Charts */} - {activeTab === 'strength' && ( -
-

Styrkerekord (1RM)

- {strength.length > 0 ? ( -
-
-

🏋️ Bänkpress

- s.bench_1rm)} - valueKey="bench_1rm" - unit="kg" - color="#f59e0b" - /> - s.bench_1rm)} - valueKey="bench_1rm" - unit="kg" - label="Bänkpress" - /> -
-
-

🦵 Knäböj

- s.squat_1rm)} - valueKey="squat_1rm" - unit="kg" - color="#8b5cf6" - /> - s.squat_1rm)} - valueKey="squat_1rm" - unit="kg" - label="Knäböj" - /> -
-
-

💀 Marklyft

- s.deadlift_1rm)} - valueKey="deadlift_1rm" - unit="kg" - color="#ef4444" - /> - s.deadlift_1rm)} - valueKey="deadlift_1rm" - unit="kg" - label="Marklyft" - /> -
+ {/* Expanded exercise list */} + {expandedSession === session.id && session.exercises && ( +
+ {session.exercises.map((ex, i) => ( +
+ + {ex} +
+ ))} +
+ )}
- ) : ( - - )} -
- )} + ))} +
+
+ + {/* ANALYTICS SECTION (existing tabs - secondary) */} +
+

Mätningar & Styrka

+ + {/* Tab Navigation */} +
+ {[ + { key: 'weight', label: 'Vikt' }, + { key: 'bodyfat', label: 'Kroppsfett' }, + { key: 'strength', label: 'Styrka' }, + ].map(tab => ( + + ))} +
+ + {/* Weight Chart */} + {activeTab === 'weight' && ( +
+ {measurements.length > 0 ? ( + <> + + + + ) : ( + + )} +
+ )} + + {/* Body Fat Chart */} + {activeTab === 'bodyfat' && ( +
+ {measurements.filter(m => m.body_fat_pct).length > 0 ? ( + <> + m.body_fat_pct)} + valueKey="body_fat_pct" + unit="%" + color="#10b981" + /> + m.body_fat_pct)} + valueKey="body_fat_pct" + unit="%" + label="Kroppsfett" + /> + + ) : ( + + )} +
+ )} + + {/* Strength Charts */} + {activeTab === 'strength' && ( +
+ {strength.length > 0 ? ( +
+
+

Bänkpress

+ s.bench_1rm)} + valueKey="bench_1rm" + unit="kg" + color="#f59e0b" + /> + s.bench_1rm)} + valueKey="bench_1rm" + unit="kg" + label="Bänkpress" + /> +
+
+

Knäböj

+ s.squat_1rm)} + valueKey="squat_1rm" + unit="kg" + color="#8b5cf6" + /> + s.squat_1rm)} + valueKey="squat_1rm" + unit="kg" + label="Knäböj" + /> +
+
+

Marklyft

+ s.deadlift_1rm)} + valueKey="deadlift_1rm" + unit="kg" + color="#ef4444" + /> + s.deadlift_1rm)} + valueKey="deadlift_1rm" + unit="kg" + label="Marklyft" + /> +
+
+ ) : ( + + )} +
+ )} +
) @@ -189,21 +458,20 @@ function ProgressPage({ onBack }) { // Simple SVG Line Chart Component function SimpleLineChart({ data, valueKey, unit, color }) { if (!data || data.length === 0) return null - + const values = data.map(d => d[valueKey]).filter(v => v != null) if (values.length === 0) return null const min = Math.min(...values) * 0.95 const max = Math.max(...values) * 1.05 const range = max - min || 1 - + const width = 320 const height = 160 const padding = { top: 20, right: 20, bottom: 30, left: 50 } const chartWidth = width - padding.left - padding.right const chartHeight = height - padding.top - padding.bottom - // Generate points const points = data.map((d, i) => { const x = padding.left + (i / Math.max(data.length - 1, 1)) * chartWidth const y = padding.top + chartHeight - ((d[valueKey] - min) / range) * chartHeight @@ -211,14 +479,11 @@ function SimpleLineChart({ data, valueKey, unit, color }) { }).filter(p => p.value != null) const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ') - - // Y-axis labels const yLabels = [min, (min + max) / 2, max].map(v => v.toFixed(1)) return (
- {/* Grid lines */} {[0, 0.5, 1].map((ratio, i) => ( ))} - - {/* Y-axis labels */} {yLabels.map((label, i) => ( ))} - - {/* Line */} - - {/* Points */} {points.map((p, i) => ( - + ))}
- {formatDate(data[0]?.created_at)} - {formatDate(data[data.length - 1]?.created_at)} + {formatDateShort(data[0]?.created_at)} + {formatDateShort(data[data.length - 1]?.created_at)}
) } -// Progress Statistics Component function ProgressStats({ data, valueKey, unit, label }) { if (!data || data.length === 0) return null - + const values = data.map(d => d[valueKey]).filter(v => v != null) if (values.length === 0) return null @@ -310,15 +562,29 @@ function ProgressStats({ data, valueKey, unit, label }) { function EmptyState({ message }) { return ( -
- 📊 -

{message}

-

Logga mätningar för att se din progress

+
+

{message}

+

Logga mätningar för att se din progress

) } -function formatDate(dateStr) { +function formatDateShort(dateStr) { if (!dateStr) return '-' const date = new Date(dateStr) return date.toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' }) diff --git a/frontend/src/pages/WorkoutPage.jsx b/frontend/src/pages/WorkoutPage.jsx index e9fc397..6d6cb70 100644 --- a/frontend/src/pages/WorkoutPage.jsx +++ b/frontend/src/pages/WorkoutPage.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { Icon } from '../components/Icons' import SwapWorkoutModal from '../components/SwapWorkoutModal' +import '../styles/kinetic-precision.css' const API_URL = '/api' @@ -453,6 +454,8 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg onSwap={() => openAlternatives(exercise)} onUndo={() => undoSwap(exercise.id)} canUndo={Boolean(recentSwaps[exercise.id])} + exerciseIndex={idx + 1} + totalExercises={exercises.length} /> ) })} @@ -488,7 +491,7 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg ) } -function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet, onSwap, isSwapped, onStartRest, originalExercise, onUndo, canUndo }) { +function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet, onSwap, isSwapped, onStartRest, originalExercise, onUndo, canUndo, exerciseIndex, totalExercises }) { const [setList, setSetList] = useState([]) const [showAddModal, setShowAddModal] = useState(false) const weightStep = 2.5 @@ -569,19 +572,62 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe const completedSets = setList.filter(s => s.completed).length + // Compute PR: current set weight exceeds progression last weight + const isPR = (input, idx) => { + const lastWeight = progression?.lastWeight + if (!lastWeight) return false + const w = parseFloat(input.weight) + return !isNaN(w) && w > lastWeight + } + return (
0 ? 'all-done' : ''}`}> -
-
-

{exercise.name}

- {exercise.muscle_group} - {isSwapped && originalExercise && ( - Bytt från {originalExercise.name} + {/* EXERCISE FOCUS HEADER */} +
+
+ {/* Progress indicator */} + {exerciseIndex != null && ( + Övning {exerciseIndex} av {totalExercises} + )} +
+

{exercise.name}

+ {isSwapped && originalExercise && ( + Bytt )} +
+ {exercise.muscle_group && ( + {exercise.muscle_group} + )}
-
- {exercise.sets}×{exercise.reps_min}-{exercise.reps_max} +
+ {exercise.sets}×{exercise.reps_min}-{exercise.reps_max} {completedSets}/{setList.length} @@ -617,8 +663,9 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe {expanded && (
+ {/* Progression hint */} {progression && ( -
+
{progression.reason} {progression.suggestedWeight && ( {progression.suggestedWeight} kg @@ -626,80 +673,154 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
)} + {/* Target line */} + {(exercise.reps_min || exercise.reps_max) && ( +
+ Mål + + {exercise.sets} set · {exercise.reps_min}{exercise.reps_max && exercise.reps_max !== exercise.reps_min ? `–${exercise.reps_max}` : ''} reps + +
+ )} +
- {setList.map((input, idx) => ( -
-
- Set {idx + 1} + {setList.map((input, idx) => { + const setIsPR = isPR(input, idx) + return ( +
+
+
+ Set {idx + 1} + {setIsPR && ( + PR + )} +
+ +
+
+
+ Vikt +
+ +
+ {input.weight === '' ? '0' : input.weight} + kg +
+ +
+
+
+ Reps +
+ +
+ {input.reps === '' ? '0' : input.reps} +
+ +
+
+
+ {/* Previous session reference */} + {progression?.lastWeight && progression?.lastReps && ( +
+ Förra träningen: {progression.lastWeight}kg×{progression.lastReps} +
+ )}
-
-
- Vikt -
- -
- {input.weight === '' ? '0' : input.weight} - kg -
- -
-
-
- Reps -
- -
- {input.reps === '' ? '0' : input.reps} -
- -
-
-
- -
- ))} + ) + })}