Files
gravl/frontend/src/pages/WorkoutPage.jsx
T
clawd 3b6b12d354 design: WorkoutPage Hevy-style redesign + AlternativeModal + backend API
- Add GET /api/exercises/:id/alternatives endpoint
- Add GET /api/exercises/:id/last-workout endpoint
- New AlternativeModal component for swapping exercises
- WorkoutPage: single-tap logging, +/- buttons, rest timer
- Updated Icons with new workout icons
- Polish: card shadows, borders, micro-interactions
- Tasks directory for project management
2026-02-28 21:25:23 +01:00

608 lines
22 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { Icon } from '../components/Icons'
import AlternativeModal from '../components/AlternativeModal'
const API_URL = '/api'
// Uppvärmningsövningar baserat på muskelgrupp
const warmupExercises = {
general: [
{ name: 'Cykel eller roddmaskin', duration: '5 min' },
{ name: 'Armcirklar', duration: '30 sek/riktning' },
{ name: 'Bensvingar (framåt/bakåt)', duration: '10 per ben' },
{ name: 'Bensvingar (sidled)', duration: '10 per ben' },
{ name: 'Höftcirklar', duration: '10 per riktning' },
],
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, onDeleteSet, 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())
const [swapExercise, setSwapExercise] = useState(null)
const [alternatives, setAlternatives] = useState([])
const [alternativesLoading, setAlternativesLoading] = useState(false)
const [alternativesError, setAlternativesError] = useState('')
const [swappedExercises, setSwappedExercises] = useState({})
const defaultRestSeconds = 90
const [restSeconds, setRestSeconds] = useState(defaultRestSeconds)
const [restRunning, setRestRunning] = useState(false)
useEffect(() => {
loadProgressions()
}, [day])
useEffect(() => {
if (!restRunning) return
const timer = setInterval(() => {
setRestSeconds(prev => {
if (prev <= 1) {
setRestRunning(false)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [restRunning])
const loadProgressions = async () => {
const progs = {}
for (const exercise of day.exercises) {
if (exercise.id) {
progs[exercise.id] = await fetchProgression(exercise.id)
}
}
setProgressions(progs)
}
const openAlternatives = async (exercise) => {
if (!exercise?.exercise_id) {
setAlternativesError('Saknar övningsdata för alternativa val.')
setSwapExercise(exercise)
return
}
setSwapExercise(exercise)
setAlternatives([])
setAlternativesError('')
setAlternativesLoading(true)
try {
const res = await fetch(`${API_URL}/exercises/${exercise.exercise_id}/alternatives`)
if (!res.ok) throw new Error('Failed to fetch alternatives')
const data = await res.json()
setAlternatives(data)
} catch (err) {
console.error('Failed to fetch alternatives:', err)
setAlternativesError('Kunde inte hämta alternativ.')
} finally {
setAlternativesLoading(false)
}
}
const handleSelectAlternative = (alternative) => {
if (!swapExercise) return
setSwappedExercises(prev => ({
...prev,
[swapExercise.id]: alternative
}))
setSwapExercise(null)
}
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
const formatRestTime = (totalSeconds) => {
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
const startRest = (seconds = defaultRestSeconds) => {
setRestSeconds(seconds)
setRestRunning(true)
}
const toggleRest = () => {
setRestRunning(prev => !prev)
}
const resetRest = () => {
setRestRunning(false)
setRestSeconds(defaultRestSeconds)
}
return (
<div className="workout-page">
<header className="page-header">
<button className="back-btn" onClick={onBack}>
<Icon name="arrowLeft" size={16} /> 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">
{/* Vila */}
<section className="rest-timer-card">
<div className="rest-timer-header">
<div className="rest-timer-label">Vilotimer</div>
<div className={`rest-timer-time ${restRunning ? 'running' : ''}`}>
{formatRestTime(restSeconds)}
</div>
</div>
<div className="rest-timer-actions">
<button className="rest-timer-btn primary" onClick={toggleRest}>
{restRunning ? 'Pausa' : 'Starta vila'}
</button>
<button className="rest-timer-btn secondary" onClick={resetRest}>
Återställ
</button>
</div>
<div className="rest-timer-presets">
<button className="rest-timer-chip" onClick={() => startRest(60)}>1:00</button>
<button className="rest-timer-chip" onClick={() => startRest(90)}>1:30</button>
<button className="rest-timer-chip" onClick={() => startRest(120)}>2:00</button>
</div>
</section>
{/* 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"><Icon name="fire" size={20} /></span>
<h2>Uppvärmning</h2>
<span className="warmup-progress">{warmupProgress}/{totalWarmups}</span>
</div>
<span className={`expand-icon ${warmupExpanded ? 'expanded' : ''}`}>
<Icon name="chevronDown" size={16} />
</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) ? <Icon name="check" size={14} /> : ''}
</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) ? <Icon name="check" size={14} /> : ''}
</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') ? <Icon name="check" size={14} /> : ''}
</span>
<span className="warmup-name">Lätta set {exercises[0].name}</span>
<span className="warmup-duration">2x10 @ 50%</span>
</div>
</div>
</div>
)}
<button
className={`warmup-done-btn ${warmupDone ? 'completed' : ''}`}
onClick={() => setWarmupDone(!warmupDone)}
>
{warmupDone ? (
<><Icon name="check" size={18} /> Uppvärmning klar</>
) : (
'Markera uppvärmning som klar'
)}
</button>
</div>
)}
</section>
{/* Övningslista */}
<section className="exercises-section">
<h2>Övningar</h2>
{exercises.map((exercise, idx) => {
const swapped = swappedExercises[exercise.id]
const displayExercise = swapped
? { ...exercise, name: swapped.name, muscle_group: swapped.muscle_group, description: swapped.description }
: exercise
return (
<ExerciseCard
key={exercise.id || idx}
exercise={displayExercise}
isSwapped={Boolean(swapped)}
logs={logs[exercise.id] || []}
progression={progressions[exercise.id]}
expanded={expandedExercise === exercise.id}
onToggle={() => setExpandedExercise(
expandedExercise === exercise.id ? null : exercise.id
)}
onLogSet={onLogSet}
onDeleteSet={onDeleteSet}
onStartRest={startRest}
onSwap={() => openAlternatives(exercise)}
/>
)
})}
</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>
<AlternativeModal
exercise={swapExercise}
alternatives={alternatives}
loading={alternativesLoading}
error={alternativesError}
onSelect={handleSelectAlternative}
onClose={() => setSwapExercise(null)}
/>
</div>
)
}
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet, onSwap, isSwapped, onStartRest }) {
const [setList, setSetList] = useState([])
const [showAddModal, setShowAddModal] = useState(false)
const weightStep = 2.5
const repsStep = 1
useEffect(() => {
const initial = []
for (let i = 1; i <= exercise.sets; i++) {
const existingLog = logs.find(l => l.set_number === i)
initial.push({
weight: existingLog?.weight?.toString() || progression?.suggestedWeight?.toString() || '',
reps: existingLog?.reps?.toString() || '',
completed: existingLog?.completed || false
})
}
setSetList(initial)
}, [exercise, logs, progression])
const handleInputChange = (idx, field, value) => {
setSetList(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s))
}
const parseNumber = (value) => {
const parsed = parseFloat(value)
return Number.isFinite(parsed) ? parsed : 0
}
const formatWeight = (value) => {
const fixed = Number.isInteger(value) ? String(value) : value.toFixed(1)
return fixed.replace(/\.0$/, '')
}
const handleAdjust = (idx, field, delta, min = 0) => {
const current = parseNumber(setList[idx]?.[field])
const next = Math.max(min, current + delta)
if (field === 'weight') {
handleInputChange(idx, field, formatWeight(next))
} else {
handleInputChange(idx, field, String(Math.round(next)))
}
}
const handleComplete = (idx) => {
const input = setList[idx]
const newCompleted = !input.completed
setSetList(prev => prev.map((s, i) => i === idx ? { ...s, completed: newCompleted } : s))
onLogSet(exercise.id, idx + 1, input.weight, input.reps, newCompleted)
if (newCompleted) {
onStartRest?.()
}
}
const handleAddNormal = () => {
const last = setList[setList.length - 1] || { weight: '', reps: '' }
setSetList(prev => [...prev, { weight: last.weight, reps: last.reps, completed: false }])
setShowAddModal(false)
}
const handleAddDropset = () => {
const last = setList[setList.length - 1] || { weight: '0', reps: '10' }
const baseWeight = parseFloat(last.weight) || 0
const drop1 = Math.round((baseWeight * 0.80) / 2.5) * 2.5
const drop2 = Math.round((baseWeight * 0.60) / 2.5) * 2.5
const newSets = [
{ weight: last.weight, reps: '10', completed: false },
{ weight: drop1.toString(), reps: '10', completed: false },
{ weight: drop2.toString(), reps: '10', completed: false },
]
setSetList(prev => [...prev, ...newSets])
setShowAddModal(false)
}
const handleDeleteSet = (idx) => {
if (setList.length <= 1) return
setSetList(prev => prev.filter((_, i) => i !== idx))
if (onDeleteSet) onDeleteSet(exercise.id, idx + 1)
}
const completedSets = setList.filter(s => s.completed).length
return (
<div className={`exercise-card ${expanded ? 'expanded' : ''} ${completedSets === setList.length && setList.length > 0 ? 'all-done' : ''}`}>
<div className="exercise-header" onClick={onToggle}>
<div className="exercise-info">
<h3>{exercise.name}</h3>
<span className="muscle-group">{exercise.muscle_group}</span>
{isSwapped && <span className="swap-badge">Alternativ</span>}
</div>
<div className="exercise-actions">
<div className="exercise-meta">
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
<span className={`progress-badge ${completedSets === setList.length ? 'complete' : ''}`}>
{completedSets}/{setList.length}
</span>
</div>
<button
className="swap-btn"
onClick={(event) => {
event.stopPropagation()
onSwap?.()
}}
aria-label="Byt övning"
>
<Icon name="swap" size={16} />
</button>
</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">
{setList.map((input, idx) => (
<div key={idx} className={`set-row ${input.completed ? 'completed' : ''}`}>
<div className="set-row-top">
<span className="set-number">Set {idx + 1}</span>
<button
className={`delete-set-btn ${setList.length <= 1 ? 'disabled' : ''}`}
onClick={() => handleDeleteSet(idx)}
disabled={setList.length <= 1}
aria-label={`Ta bort set ${idx + 1}`}
>
<Icon name="trash" size={16} />
</button>
</div>
<div className="set-controls">
<div className="set-metric">
<span className="metric-label">Vikt</span>
<div className="metric-controls">
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'weight', -weightStep)}
aria-label="Minska vikt"
>
</button>
<div className="metric-value">
<span className="metric-number">{input.weight === '' ? '0' : input.weight}</span>
<span className="metric-suffix">kg</span>
</div>
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'weight', weightStep)}
aria-label="Öka vikt"
>
+
</button>
</div>
</div>
<div className="set-metric">
<span className="metric-label">Reps</span>
<div className="metric-controls">
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'reps', -repsStep)}
aria-label="Minska reps"
>
</button>
<div className="metric-value">
<span className="metric-number">{input.reps === '' ? '0' : input.reps}</span>
</div>
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'reps', repsStep)}
aria-label="Öka reps"
>
+
</button>
</div>
</div>
</div>
<button
className={`klart-btn ${input.completed ? 'done' : ''}`}
onClick={() => handleComplete(idx)}
>
{input.completed ? <Icon name="check" size={18} /> : null}
KLART
</button>
</div>
))}
</div>
<button
className="add-set-btn"
onClick={() => setShowAddModal(true)}
>
+ Lägg till set
</button>
{showAddModal && (
<div className="set-type-modal-overlay" onClick={() => setShowAddModal(false)}>
<div className="set-type-modal" onClick={e => e.stopPropagation()}>
<h3>Välj settyp</h3>
<button className="set-type-option" onClick={handleAddNormal}>
<strong>Vanligt set</strong>
<span>Lägg till ett set</span>
</button>
<button className="set-type-option dropset" onClick={handleAddDropset}>
<strong>Dropset</strong>
<span>3 set med viktnedtrappning (20% per steg)</span>
</button>
<button className="set-type-cancel" onClick={() => setShowAddModal(false)}>
Avbryt
</button>
</div>
</div>
)}
</div>
)}
</div>
)
}
export default WorkoutPage