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
This commit is contained in:
2026-02-01 14:20:00 +01:00
parent add0b2a86b
commit 9a34bb2e44
3 changed files with 658 additions and 154 deletions
+302
View File
@@ -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;
}
}
+3 -154
View File
@@ -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 (
<WorkoutView
<WorkoutPage
day={selectedDay}
week={currentWeek}
logs={logs}
@@ -138,156 +139,4 @@ function App() {
)
}
function WorkoutView({ day, week, logs, onLogSet, onBack, fetchProgression }) {
const [progressions, setProgressions] = useState({})
const [expandedExercise, setExpandedExercise] = useState(null)
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) || []
return (
<div className="app workout-view">
<header className="header workout-header">
<button className="back-btn" onClick={onBack}> Tillbaka</button>
<div className="header-title">
<h1>{day.name}</h1>
<span className="header-subtitle">Vecka {week} Dag {day.day_number}</span>
</div>
</header>
<main className="main workout-main">
{exercises.map((exercise, idx) => (
<ExerciseCard
key={exercise.id || idx}
exercise={exercise}
logs={logs[exercise.id] || []}
progression={progressions[exercise.id]}
expanded={expandedExercise === exercise.id}
onToggle={() => setExpandedExercise(
expandedExercise === exercise.id ? null : exercise.id
)}
onLogSet={onLogSet}
/>
))}
</main>
</div>
)
}
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 (
<div className={`exercise-card ${expanded ? 'expanded' : ''}`}>
<div className="exercise-header" onClick={onToggle}>
<div className="exercise-info">
<h3>{exercise.name}</h3>
<span className="muscle-group">{exercise.muscle_group}</span>
</div>
<div className="exercise-meta">
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
<span className={`progress-badge ${completedSets === exercise.sets ? 'complete' : ''}`}>
{completedSets}/{exercise.sets}
</span>
</div>
</div>
{expanded && (
<div className="exercise-body">
{progression && (
<div className="progression-hint">
💡 {progression.reason}
{progression.suggestedWeight && (
<strong> {progression.suggestedWeight} kg</strong>
)}
</div>
)}
<div className="sets-list">
{Array.from({ length: exercise.sets }, (_, i) => i + 1).map(setNum => {
const input = setInputs[setNum] || { weight: '', reps: '', completed: false }
return (
<div key={setNum} className={`set-row ${input.completed ? 'completed' : ''}`}>
<span className="set-number">Set {setNum}</span>
<div className="set-inputs">
<input
type="number"
placeholder="kg"
value={input.weight}
onChange={(e) => handleInputChange(setNum, 'weight', e.target.value)}
className="weight-input"
inputMode="decimal"
/>
<span className="input-separator">×</span>
<input
type="number"
placeholder="reps"
value={input.reps}
onChange={(e) => handleInputChange(setNum, 'reps', e.target.value)}
className="reps-input"
inputMode="numeric"
/>
</div>
<button
className={`complete-btn ${input.completed ? 'done' : ''}`}
onClick={() => handleComplete(setNum)}
>
{input.completed ? '✓' : '○'}
</button>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
export default App
+353
View File
@@ -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 (
<div className="workout-page">
<header className="page-header">
<button className="back-btn" onClick={onBack}> Tillbaka</button>
<div className="header-center">
<h1>{day.name}</h1>
<span className="header-subtitle">Vecka {week} Dag {day.day_number}</span>
</div>
<div className="header-progress">
<span className="progress-text">{completedExercises}/{exercises.length}</span>
</div>
</header>
<main className="page-main workout-main">
{/* Progress Bar */}
<div className="workout-progress-bar">
<div
className="workout-progress-fill"
style={{ width: `${(completedExercises / exercises.length) * 100}%` }}
/>
</div>
{/* Uppvärmningssektion */}
<section className={`warmup-section ${warmupDone ? 'completed' : ''}`}>
<div
className="warmup-header"
onClick={() => setWarmupExpanded(!warmupExpanded)}
>
<div className="warmup-title">
<span className="warmup-icon">🔥</span>
<h2>Uppvärmning</h2>
<span className="warmup-progress">{warmupProgress}/{totalWarmups}</span>
</div>
<span className={`expand-icon ${warmupExpanded ? 'expanded' : ''}`}></span>
</div>
{warmupExpanded && (
<div className="warmup-content">
{/* Generell uppvärmning */}
<div className="warmup-category">
<h3>Generell uppvärmning (5-10 min)</h3>
<div className="warmup-list">
{generalWarmups.map((warmup, idx) => (
<div
key={idx}
className={`warmup-item ${completedWarmups.has(idx) ? 'done' : ''}`}
onClick={() => toggleWarmup(idx)}
>
<span className="warmup-check">
{completedWarmups.has(idx) ? '✓' : '○'}
</span>
<span className="warmup-item-icon">{warmup.icon}</span>
<span className="warmup-name">{warmup.name}</span>
<span className="warmup-duration">{warmup.duration || warmup.reps}</span>
</div>
))}
</div>
</div>
{/* Specifik uppvärmning */}
{specificWarmups.length > 0 && (
<div className="warmup-category">
<h3>Specifik för {muscleGroups.join(', ')}</h3>
<div className="warmup-list">
{specificWarmups.map((warmup, idx) => {
const globalIdx = generalWarmups.length + idx
return (
<div
key={globalIdx}
className={`warmup-item ${completedWarmups.has(globalIdx) ? 'done' : ''}`}
onClick={() => toggleWarmup(globalIdx)}
>
<span className="warmup-check">
{completedWarmups.has(globalIdx) ? '✓' : '○'}
</span>
<span className="warmup-name">{warmup.name}</span>
<span className="warmup-duration">{warmup.reps}</span>
</div>
)
})}
</div>
</div>
)}
{/* Lätt set av första övningen */}
{exercises[0] && (
<div className="warmup-category">
<h3>Förberedande set</h3>
<div className="warmup-list">
<div
className={`warmup-item ${completedWarmups.has('prep') ? 'done' : ''}`}
onClick={() => {
const newCompleted = new Set(completedWarmups)
if (newCompleted.has('prep')) {
newCompleted.delete('prep')
} else {
newCompleted.add('prep')
}
setCompletedWarmups(newCompleted)
}}
>
<span className="warmup-check">
{completedWarmups.has('prep') ? '✓' : '○'}
</span>
<span className="warmup-name">Lätta set {exercises[0].name}</span>
<span className="warmup-duration">2×10 @ 50%</span>
</div>
</div>
</div>
)}
<button
className={`warmup-done-btn ${warmupDone ? 'completed' : ''}`}
onClick={() => setWarmupDone(!warmupDone)}
>
{warmupDone ? '✓ Uppvärmning klar!' : 'Markera uppvärmning som klar'}
</button>
</div>
)}
</section>
{/* Övningslista */}
<section className="exercises-section">
<h2>Övningar</h2>
{exercises.map((exercise, idx) => (
<ExerciseCard
key={exercise.id || idx}
exercise={exercise}
logs={logs[exercise.id] || []}
progression={progressions[exercise.id]}
expanded={expandedExercise === exercise.id}
onToggle={() => setExpandedExercise(
expandedExercise === exercise.id ? null : exercise.id
)}
onLogSet={onLogSet}
/>
))}
</section>
{/* Avsluta pass */}
<button
className={`finish-workout-btn ${completedExercises === exercises.length ? 'ready' : ''}`}
onClick={onBack}
>
{completedExercises === exercises.length
? '🎉 Avsluta pass'
: `Avsluta pass (${completedExercises}/${exercises.length} klara)`}
</button>
</main>
</div>
)
}
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 (
<div className={`exercise-card ${expanded ? 'expanded' : ''} ${completedSets === exercise.sets ? 'all-done' : ''}`}>
<div className="exercise-header" onClick={onToggle}>
<div className="exercise-info">
<h3>{exercise.name}</h3>
<span className="muscle-group">{exercise.muscle_group}</span>
</div>
<div className="exercise-meta">
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
<span className={`progress-badge ${completedSets === exercise.sets ? 'complete' : ''}`}>
{completedSets}/{exercise.sets}
</span>
</div>
</div>
{expanded && (
<div className="exercise-body">
{progression && (
<div className="progression-hint">
💡 {progression.reason}
{progression.suggestedWeight && (
<strong> {progression.suggestedWeight} kg</strong>
)}
</div>
)}
<div className="sets-list">
{Array.from({ length: exercise.sets }, (_, i) => i + 1).map(setNum => {
const input = setInputs[setNum] || { weight: '', reps: '', completed: false }
return (
<div key={setNum} className={`set-row ${input.completed ? 'completed' : ''}`}>
<span className="set-number">Set {setNum}</span>
<div className="set-inputs">
<input
type="number"
placeholder="kg"
value={input.weight}
onChange={(e) => handleInputChange(setNum, 'weight', e.target.value)}
className="weight-input"
inputMode="decimal"
/>
<span className="input-separator">×</span>
<input
type="number"
placeholder="reps"
value={input.reps}
onChange={(e) => handleInputChange(setNum, 'reps', e.target.value)}
className="reps-input"
inputMode="numeric"
/>
</div>
<button
className={`complete-btn ${input.completed ? 'done' : ''}`}
onClick={() => handleComplete(setNum)}
>
{input.completed ? '✓' : '○'}
</button>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
export default WorkoutPage