From cfa7e9e35615eab03a49a544c5ee6afc007d7804 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 1 Feb 2026 14:20:00 +0100 Subject: [PATCH] Add WorkoutPage with warmup exercises (Claude Code) - Dedicated workout page with progress tracking - Warmup section with general + muscle-specific exercises - Preparatory sets (2x10 @ 50% of first exercise) - Checkbox tracking for warmup completion - Progress bar showing completed exercises - Animated 'Finish workout' button when done - Mobile-first CSS with responsive design Built by Claude Code 2.1.29 --- frontend/src/App.css | 302 ++++++++++++++++++++++++ frontend/src/App.jsx | 157 +------------ frontend/src/pages/WorkoutPage.jsx | 353 +++++++++++++++++++++++++++++ 3 files changed, 658 insertions(+), 154 deletions(-) create mode 100644 frontend/src/pages/WorkoutPage.jsx diff --git a/frontend/src/App.css b/frontend/src/App.css index f451209..5723abf 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1150,3 +1150,305 @@ font-size: 0.85rem; margin-top: 0.5rem; } + +/* ============================================ + WORKOUT PAGE STYLES + ============================================ */ + +.workout-page { + min-height: 100vh; + background: var(--bg); +} + +.workout-page .page-header { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 1rem; + align-items: center; +} + +.workout-page .header-center { + text-align: center; +} + +.workout-page .header-center h1 { + font-size: 1.1rem; + font-weight: 600; + margin: 0; +} + +.workout-page .header-subtitle { + font-size: 0.8rem; + color: var(--text-muted); +} + +.workout-page .header-progress { + background: var(--accent); + color: white; + padding: 0.4rem 0.75rem; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; +} + +.workout-page .workout-main { + padding-bottom: 2rem; +} + +/* Progress Bar */ +.workout-progress-bar { + height: 4px; + background: var(--border); + border-radius: 2px; + margin-bottom: 1.5rem; + overflow: hidden; +} + +.workout-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--success)); + border-radius: 2px; + transition: width 0.3s ease; +} + +/* Uppvärmningssektion */ +.warmup-section { + background: var(--bg-secondary); + border-radius: 16px; + border: 1px solid var(--border); + margin-bottom: 1.5rem; + overflow: hidden; + transition: all 0.3s; +} + +.warmup-section.completed { + border-color: var(--success); + background: rgba(78, 204, 163, 0.05); +} + +.warmup-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.25rem; + cursor: pointer; + user-select: none; +} + +.warmup-title { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.warmup-icon { + font-size: 1.5rem; +} + +.warmup-title h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 0; +} + +.warmup-progress { + background: var(--bg); + padding: 0.25rem 0.6rem; + border-radius: 12px; + font-size: 0.8rem; + color: var(--text-muted); +} + +.expand-icon { + font-size: 0.75rem; + color: var(--text-muted); + transition: transform 0.2s; +} + +.expand-icon.expanded { + transform: rotate(180deg); +} + +.warmup-content { + padding: 0 1.25rem 1.25rem; +} + +.warmup-category { + margin-bottom: 1.25rem; +} + +.warmup-category:last-of-type { + margin-bottom: 1rem; +} + +.warmup-category h3 { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 0.75rem; + font-weight: 500; +} + +.warmup-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.warmup-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: var(--bg); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s; +} + +.warmup-item:hover { + background: var(--bg-card-hover, var(--bg)); +} + +.warmup-item.done { + background: rgba(78, 204, 163, 0.15); +} + +.warmup-item.done .warmup-name { + text-decoration: line-through; + opacity: 0.7; +} + +.warmup-check { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--bg-secondary); + color: var(--text-muted); + font-size: 0.85rem; + flex-shrink: 0; +} + +.warmup-item.done .warmup-check { + background: var(--success); + color: white; +} + +.warmup-item-icon { + font-size: 1.1rem; + flex-shrink: 0; +} + +.warmup-name { + flex: 1; + font-size: 0.95rem; +} + +.warmup-duration { + font-size: 0.85rem; + color: var(--text-muted); + white-space: nowrap; +} + +.warmup-done-btn { + width: 100%; + padding: 1rem; + background: var(--bg); + border: 2px dashed var(--border); + border-radius: 12px; + color: var(--text-muted); + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s; +} + +.warmup-done-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.warmup-done-btn.completed { + background: var(--success); + border: none; + color: white; + font-weight: 600; +} + +/* Övningssektion */ +.exercises-section { + margin-bottom: 1.5rem; +} + +.exercises-section h2 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 1rem; +} + +/* Exercise Card utökad */ +.exercise-card.all-done { + border-color: var(--success); + background: rgba(78, 204, 163, 0.05); +} + +.exercise-card.all-done .exercise-info h3::after { + content: ' ✓'; + color: var(--success); +} + +/* Avsluta pass knapp */ +.finish-workout-btn { + width: 100%; + padding: 1.25rem; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 16px; + color: var(--text-muted); + font-size: 1rem; + cursor: pointer; + transition: all 0.2s; + margin-top: 1rem; +} + +.finish-workout-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.finish-workout-btn.ready { + background: linear-gradient(135deg, var(--accent) 0%, #6366f1 100%); + border: none; + color: white; + font-weight: 600; + animation: pulse-glow 2s infinite; +} + +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(233, 69, 96, 0.4); + } + 50% { + box-shadow: 0 0 20px 5px rgba(233, 69, 96, 0.2); + } +} + +/* Mobile optimeringar för WorkoutPage */ +@media (max-width: 480px) { + .workout-page .page-header { + padding: 0.75rem 1rem; + } + + .workout-page .header-center h1 { + font-size: 1rem; + } + + .warmup-item { + padding: 0.6rem; + } + + .warmup-name { + font-size: 0.9rem; + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0ae3f18..fa9ec32 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,9 @@ -import { useState, useEffect } from 'react' +import { useState } from 'react' import { useAuth } from './context/AuthContext' import Dashboard from './pages/Dashboard' import ProfilePage from './pages/ProfilePage' import ProgressPage from './pages/ProgressPage' +import WorkoutPage from './pages/WorkoutPage' import './App.css' const API_URL = '/api' @@ -118,7 +119,7 @@ function App() { // Workout view if (view === 'workout' && selectedDay) { return ( - { - loadProgressions() - }, [day]) - - const loadProgressions = async () => { - const progs = {} - for (const exercise of day.exercises) { - if (exercise.id) { - progs[exercise.id] = await fetchProgression(exercise.id) - } - } - setProgressions(progs) - } - - const exercises = day.exercises?.filter(e => e.name) || [] - - return ( -
-
- -
-

{day.name}

- Vecka {week} • Dag {day.day_number} -
-
- -
- {exercises.map((exercise, idx) => ( - setExpandedExercise( - expandedExercise === exercise.id ? null : exercise.id - )} - onLogSet={onLogSet} - /> - ))} -
-
- ) -} - -function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet }) { - const [setInputs, setSetInputs] = useState({}) - - useEffect(() => { - // Initialize with suggested weight or last logged - const initial = {} - for (let i = 1; i <= exercise.sets; i++) { - const existingLog = logs.find(l => l.set_number === i) - initial[i] = { - weight: existingLog?.weight || progression?.suggestedWeight || '', - reps: existingLog?.reps || '', - completed: existingLog?.completed || false - } - } - setSetInputs(initial) - }, [exercise, logs, progression]) - - const handleInputChange = (setNum, field, value) => { - setSetInputs(prev => ({ - ...prev, - [setNum]: { ...prev[setNum], [field]: value } - })) - } - - const handleComplete = (setNum) => { - const input = setInputs[setNum] - const newCompleted = !input.completed - setSetInputs(prev => ({ - ...prev, - [setNum]: { ...prev[setNum], completed: newCompleted } - })) - onLogSet(exercise.id, setNum, input.weight, input.reps, newCompleted) - } - - const completedSets = Object.values(setInputs).filter(s => s.completed).length - - return ( -
-
-
-

{exercise.name}

- {exercise.muscle_group} -
-
- {exercise.sets}×{exercise.reps_min}-{exercise.reps_max} - - {completedSets}/{exercise.sets} - -
-
- - {expanded && ( -
- {progression && ( -
- 💡 {progression.reason} - {progression.suggestedWeight && ( - → {progression.suggestedWeight} kg - )} -
- )} - -
- {Array.from({ length: exercise.sets }, (_, i) => i + 1).map(setNum => { - const input = setInputs[setNum] || { weight: '', reps: '', completed: false } - return ( -
- Set {setNum} -
- handleInputChange(setNum, 'weight', e.target.value)} - className="weight-input" - inputMode="decimal" - /> - × - handleInputChange(setNum, 'reps', e.target.value)} - className="reps-input" - inputMode="numeric" - /> -
- -
- ) - })} -
-
- )} -
- ) -} - export default App diff --git a/frontend/src/pages/WorkoutPage.jsx b/frontend/src/pages/WorkoutPage.jsx new file mode 100644 index 0000000..03bb1ce --- /dev/null +++ b/frontend/src/pages/WorkoutPage.jsx @@ -0,0 +1,353 @@ +import { useState, useEffect } from 'react' + +// Uppvärmningsövningar baserat på muskelgrupp +const warmupExercises = { + general: [ + { name: 'Cykel eller roddmaskin', duration: '5 min', icon: '🚴' }, + { name: 'Armcirklar', duration: '30 sek/riktning', icon: '🔄' }, + { name: 'Bensvingar (framåt/bakåt)', duration: '10 per ben', icon: '🦵' }, + { name: 'Bensvingar (sidled)', duration: '10 per ben', icon: '🦵' }, + { name: 'Höftcirklar', duration: '10 per riktning', icon: '⭕' }, + ], + specific: { + 'Bröst': [ + { name: 'Lätta armhävningar', reps: '10-15' }, + { name: 'Band pull-aparts', reps: '15-20' }, + ], + 'Rygg': [ + { name: 'Lat stretch', reps: '30 sek/sida' }, + { name: 'Lätta rodd-drag', reps: '15-20' }, + ], + 'Ben': [ + { name: 'Bodyweight squats', reps: '15-20' }, + { name: 'Utfallssteg', reps: '10/ben' }, + ], + 'Axlar': [ + { name: 'Axelrotationer', reps: '10/riktning' }, + { name: 'Band dislocates', reps: '10-15' }, + ], + 'Armar': [ + { name: 'Handledscirklar', reps: '10/riktning' }, + { name: 'Lätta bicepscurls', reps: '15-20' }, + ], + } +} + +// Mappa övningar till muskelgrupper +function getMuscleGroups(exercises) { + const groups = new Set() + exercises.forEach(ex => { + if (ex.muscle_group) { + groups.add(ex.muscle_group) + } + }) + return Array.from(groups) +} + +function WorkoutPage({ day, week, logs, onLogSet, onBack, fetchProgression }) { + const [progressions, setProgressions] = useState({}) + const [expandedExercise, setExpandedExercise] = useState(null) + const [warmupDone, setWarmupDone] = useState(false) + const [warmupExpanded, setWarmupExpanded] = useState(true) + const [completedWarmups, setCompletedWarmups] = useState(new Set()) + + useEffect(() => { + loadProgressions() + }, [day]) + + const loadProgressions = async () => { + const progs = {} + for (const exercise of day.exercises) { + if (exercise.id) { + progs[exercise.id] = await fetchProgression(exercise.id) + } + } + setProgressions(progs) + } + + const exercises = day.exercises?.filter(e => e.name) || [] + const muscleGroups = getMuscleGroups(exercises) + + // Beräkna progress + const completedExercises = exercises.filter(ex => { + const exLogs = logs[ex.id] || [] + const completedSets = exLogs.filter(l => l.completed).length + return completedSets >= ex.sets + }).length + + const toggleWarmup = (idx) => { + const newCompleted = new Set(completedWarmups) + if (newCompleted.has(idx)) { + newCompleted.delete(idx) + } else { + newCompleted.add(idx) + } + setCompletedWarmups(newCompleted) + } + + // Kombinera generell och specifik uppvärmning + const generalWarmups = warmupExercises.general + const specificWarmups = muscleGroups.flatMap(group => + warmupExercises.specific[group] || [] + ) + + const totalWarmups = generalWarmups.length + specificWarmups.length + const warmupProgress = completedWarmups.size + + return ( +
+
+ +
+

{day.name}

+ Vecka {week} • Dag {day.day_number} +
+
+ {completedExercises}/{exercises.length} +
+
+ +
+ {/* Progress Bar */} +
+
+
+ + {/* Uppvärmningssektion */} +
+
setWarmupExpanded(!warmupExpanded)} + > +
+ 🔥 +

Uppvärmning

+ {warmupProgress}/{totalWarmups} +
+ +
+ + {warmupExpanded && ( +
+ {/* Generell uppvärmning */} +
+

Generell uppvärmning (5-10 min)

+
+ {generalWarmups.map((warmup, idx) => ( +
toggleWarmup(idx)} + > + + {completedWarmups.has(idx) ? '✓' : '○'} + + {warmup.icon} + {warmup.name} + {warmup.duration || warmup.reps} +
+ ))} +
+
+ + {/* Specifik uppvärmning */} + {specificWarmups.length > 0 && ( +
+

Specifik för {muscleGroups.join(', ')}

+
+ {specificWarmups.map((warmup, idx) => { + const globalIdx = generalWarmups.length + idx + return ( +
toggleWarmup(globalIdx)} + > + + {completedWarmups.has(globalIdx) ? '✓' : '○'} + + {warmup.name} + {warmup.reps} +
+ ) + })} +
+
+ )} + + {/* Lätt set av första övningen */} + {exercises[0] && ( +
+

Förberedande set

+
+
{ + const newCompleted = new Set(completedWarmups) + if (newCompleted.has('prep')) { + newCompleted.delete('prep') + } else { + newCompleted.add('prep') + } + setCompletedWarmups(newCompleted) + }} + > + + {completedWarmups.has('prep') ? '✓' : '○'} + + Lätta set {exercises[0].name} + 2×10 @ 50% +
+
+
+ )} + + +
+ )} +
+ + {/* Övningslista */} +
+

Övningar

+ {exercises.map((exercise, idx) => ( + setExpandedExercise( + expandedExercise === exercise.id ? null : exercise.id + )} + onLogSet={onLogSet} + /> + ))} +
+ + {/* Avsluta pass */} + +
+
+ ) +} + +function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet }) { + const [setInputs, setSetInputs] = useState({}) + + useEffect(() => { + // Initialize with suggested weight or last logged + const initial = {} + for (let i = 1; i <= exercise.sets; i++) { + const existingLog = logs.find(l => l.set_number === i) + initial[i] = { + weight: existingLog?.weight || progression?.suggestedWeight || '', + reps: existingLog?.reps || '', + completed: existingLog?.completed || false + } + } + setSetInputs(initial) + }, [exercise, logs, progression]) + + const handleInputChange = (setNum, field, value) => { + setSetInputs(prev => ({ + ...prev, + [setNum]: { ...prev[setNum], [field]: value } + })) + } + + const handleComplete = (setNum) => { + const input = setInputs[setNum] + const newCompleted = !input.completed + setSetInputs(prev => ({ + ...prev, + [setNum]: { ...prev[setNum], completed: newCompleted } + })) + onLogSet(exercise.id, setNum, input.weight, input.reps, newCompleted) + } + + const completedSets = Object.values(setInputs).filter(s => s.completed).length + + return ( +
+
+
+

{exercise.name}

+ {exercise.muscle_group} +
+
+ {exercise.sets}×{exercise.reps_min}-{exercise.reps_max} + + {completedSets}/{exercise.sets} + +
+
+ + {expanded && ( +
+ {progression && ( +
+ 💡 {progression.reason} + {progression.suggestedWeight && ( + → {progression.suggestedWeight} kg + )} +
+ )} + +
+ {Array.from({ length: exercise.sets }, (_, i) => i + 1).map(setNum => { + const input = setInputs[setNum] || { weight: '', reps: '', completed: false } + return ( +
+ Set {setNum} +
+ handleInputChange(setNum, 'weight', e.target.value)} + className="weight-input" + inputMode="decimal" + /> + × + handleInputChange(setNum, 'reps', e.target.value)} + className="reps-input" + inputMode="numeric" + /> +
+ +
+ ) + })} +
+
+ )} +
+ ) +} + +export default WorkoutPage