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
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Icon } from '../components/Icons'
|
||||
import WeightInput from '../components/WeightInput'
|
||||
import RepsInput from '../components/RepsInput'
|
||||
import AlternativeModal from '../components/AlternativeModal'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
// Uppvärmningsövningar baserat på muskelgrupp
|
||||
const warmupExercises = {
|
||||
@@ -53,11 +54,33 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
||||
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) {
|
||||
@@ -68,6 +91,40 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
||||
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)
|
||||
|
||||
@@ -97,6 +154,26 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
||||
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">
|
||||
@@ -113,6 +190,29 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
||||
</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
|
||||
@@ -228,20 +328,30 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
||||
{/* Ö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}
|
||||
onDeleteSet={onDeleteSet}
|
||||
/>
|
||||
))}
|
||||
{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 */}
|
||||
@@ -254,13 +364,24 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
||||
: `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 }) {
|
||||
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 = []
|
||||
@@ -279,11 +400,34 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
|
||||
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 = () => {
|
||||
@@ -320,12 +464,25 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
|
||||
<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-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 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>
|
||||
|
||||
@@ -343,31 +500,74 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
|
||||
<div className="sets-list">
|
||||
{setList.map((input, idx) => (
|
||||
<div key={idx} className={`set-row ${input.completed ? 'completed' : ''}`}>
|
||||
<span className="set-number">Set {idx + 1}</span>
|
||||
<div className="set-inputs">
|
||||
<WeightInput
|
||||
value={input.weight}
|
||||
onChange={(val) => handleInputChange(idx, 'weight', val)}
|
||||
/>
|
||||
<span className="input-separator">×</span>
|
||||
<RepsInput
|
||||
value={input.reps}
|
||||
onChange={(val) => handleInputChange(idx, 'reps', val)}
|
||||
/>
|
||||
<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={`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>
|
||||
<button
|
||||
className={`complete-btn ${input.completed ? 'done' : ''}`}
|
||||
className={`klart-btn ${input.completed ? 'done' : ''}`}
|
||||
onClick={() => handleComplete(idx)}
|
||||
>
|
||||
{input.completed ? <Icon name="check" size={18} /> : ''}
|
||||
{input.completed ? <Icon name="check" size={18} /> : null}
|
||||
KLART
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user