1f2a892391
- New design system: Stitch (kinetic-precision.css) with lime (#cafd00) accent - New Google Fonts: Lexend, Plus Jakarta Sans, Space Grotesk - New page: BenchmarksPage with strength/endurance/body tracking - Redesigned: Dashboard, ProgressPage, WorkoutPage, LoginPage + LoginPage.css - Add shared glassmorphism nav, kinetic buttons, intensity indicators - Build: 265KB JS / 88KB CSS / 2.54s (clean)
858 lines
30 KiB
React
858 lines
30 KiB
React
import { useState, useEffect } from 'react'
|
||
import { Icon } from '../components/Icons'
|
||
import SwapWorkoutModal from '../components/SwapWorkoutModal'
|
||
import '../styles/kinetic-precision.css'
|
||
|
||
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 [originalExercises, setOriginalExercises] = useState({}) // { exerciseId: originalExercise }
|
||
const [recentSwaps, setRecentSwaps] = useState({}) // { exerciseId: { undoId, timer } }
|
||
const [toast, setToast] = useState(null) // { message, type: 'success'|'error' }
|
||
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])
|
||
|
||
useEffect(() => {
|
||
if (!toast) return
|
||
const timer = setTimeout(() => setToast(null), 3000)
|
||
return () => clearTimeout(timer)
|
||
}, [toast])
|
||
|
||
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 handleSwapWorkout = async (alternative) => {
|
||
if (!swapExercise) return
|
||
|
||
try {
|
||
setAlternativesLoading(true)
|
||
|
||
// Call API to swap exercise
|
||
const res = await fetch(`${API_URL}/workouts/${swapExercise.id}/swap`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
fromExerciseId: swapExercise.exercise_id,
|
||
toExerciseId: alternative.exercise_id || alternative.id,
|
||
workoutDate: day.date
|
||
})
|
||
})
|
||
|
||
if (!res.ok) throw new Error('Swap failed')
|
||
const swapData = await res.json()
|
||
|
||
// Update local state
|
||
setSwappedExercises(prev => ({
|
||
...prev,
|
||
[swapExercise.id]: alternative
|
||
}))
|
||
|
||
// Store original exercise for undo
|
||
setOriginalExercises(prev => ({
|
||
...prev,
|
||
[swapExercise.id]: swapExercise
|
||
}))
|
||
|
||
// Show undo button for 30 seconds
|
||
const undoId = swapData.id || `swap-${swapExercise.id}-${Date.now()}`
|
||
const timer = setTimeout(() => {
|
||
setRecentSwaps(prev => {
|
||
const newSwaps = { ...prev }
|
||
delete newSwaps[swapExercise.id]
|
||
return newSwaps
|
||
})
|
||
}, 30000)
|
||
|
||
setRecentSwaps(prev => ({
|
||
...prev,
|
||
[swapExercise.id]: { undoId, timer }
|
||
}))
|
||
|
||
setToast({ message: `${swapExercise.name} bytt mot ${alternative.name}`, type: 'success' })
|
||
setSwapExercise(null)
|
||
} catch (err) {
|
||
console.error('Swap failed:', err)
|
||
setToast({ message: 'Kunde inte byta övning', type: 'error' })
|
||
} finally {
|
||
setAlternativesLoading(false)
|
||
}
|
||
}
|
||
|
||
const undoSwap = async (exerciseId) => {
|
||
try {
|
||
const swapInfo = recentSwaps[exerciseId]
|
||
if (!swapInfo) return
|
||
|
||
// Clear timer
|
||
clearTimeout(swapInfo.timer)
|
||
|
||
// Call API to undo
|
||
const res = await fetch(`${API_URL}/workouts/${swapInfo.undoId}/undo`, {
|
||
method: 'DELETE'
|
||
})
|
||
|
||
if (!res.ok) throw new Error('Undo failed')
|
||
|
||
// Update local state
|
||
setSwappedExercises(prev => {
|
||
const newSwaps = { ...prev }
|
||
delete newSwaps[exerciseId]
|
||
return newSwaps
|
||
})
|
||
|
||
setOriginalExercises(prev => {
|
||
const newOriginals = { ...prev }
|
||
delete newOriginals[exerciseId]
|
||
return newOriginals
|
||
})
|
||
|
||
setRecentSwaps(prev => {
|
||
const newSwaps = { ...prev }
|
||
delete newSwaps[exerciseId]
|
||
return newSwaps
|
||
})
|
||
|
||
setToast({ message: 'Byte ångrat', type: 'success' })
|
||
} catch (err) {
|
||
console.error('Undo failed:', err)
|
||
setToast({ message: 'Kunde inte ångra byte', type: 'error' })
|
||
}
|
||
}
|
||
|
||
|
||
|
||
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 original = originalExercises[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}
|
||
originalExercise={original}
|
||
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)}
|
||
onUndo={() => undoSwap(exercise.id)}
|
||
canUndo={Boolean(recentSwaps[exercise.id])}
|
||
exerciseIndex={idx + 1}
|
||
totalExercises={exercises.length}
|
||
/>
|
||
)
|
||
})}
|
||
</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>
|
||
|
||
<SwapWorkoutModal
|
||
exercise={swapExercise}
|
||
alternatives={alternatives}
|
||
loading={alternativesLoading}
|
||
error={alternativesError}
|
||
onSwap={handleSwapWorkout}
|
||
onClose={() => setSwapExercise(null)}
|
||
/>
|
||
|
||
{/* Toast Notification */}
|
||
{toast && (
|
||
<div className={`toast-notification toast-${toast.type}`}>
|
||
{toast.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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
|
||
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
|
||
|
||
// 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 (
|
||
<div className={`exercise-card ${expanded ? 'expanded' : ''} ${completedSets === setList.length && setList.length > 0 ? 'all-done' : ''}`}>
|
||
{/* EXERCISE FOCUS HEADER */}
|
||
<div className="exercise-header" onClick={onToggle} style={{ paddingBottom: expanded ? '0.5rem' : undefined }}>
|
||
<div style={{ flex: 1 }}>
|
||
{/* Progress indicator */}
|
||
{exerciseIndex != null && (
|
||
<span style={{
|
||
fontFamily: 'Space Grotesk, monospace',
|
||
fontSize: '0.65rem',
|
||
color: '#767575',
|
||
letterSpacing: '0.06em',
|
||
textTransform: 'uppercase',
|
||
display: 'block',
|
||
marginBottom: '0.25rem',
|
||
}}>Övning {exerciseIndex} av {totalExercises}</span>
|
||
)}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
<h3 style={{
|
||
fontFamily: 'Lexend, sans-serif',
|
||
fontWeight: 700,
|
||
fontSize: '1.1rem',
|
||
color: '#ffffff',
|
||
margin: 0,
|
||
}}>{exercise.name}</h3>
|
||
{isSwapped && originalExercise && (
|
||
<span className="swap-badge" style={{ fontSize: '0.6rem' }}>Bytt</span>
|
||
)}
|
||
</div>
|
||
{exercise.muscle_group && (
|
||
<span style={{
|
||
display: 'inline-block',
|
||
marginTop: '0.25rem',
|
||
fontFamily: 'Space Grotesk, monospace',
|
||
fontSize: '0.7rem',
|
||
color: '#767575',
|
||
letterSpacing: '0.03em',
|
||
}}>{exercise.muscle_group}</span>
|
||
)}
|
||
</div>
|
||
<div className="exercise-actions">
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '0.25rem' }}>
|
||
<span style={{
|
||
fontFamily: 'Space Grotesk, monospace',
|
||
fontSize: '0.75rem',
|
||
color: '#adaaaa',
|
||
letterSpacing: '0.03em',
|
||
}}>{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
|
||
<span className={`progress-badge ${completedSets === setList.length ? 'complete' : ''}`}>
|
||
{completedSets}/{setList.length}
|
||
</span>
|
||
</div>
|
||
<div className="exercise-buttons">
|
||
<button
|
||
className="swap-btn"
|
||
onClick={(event) => {
|
||
event.stopPropagation()
|
||
onSwap?.()
|
||
}}
|
||
aria-label="Byt övning"
|
||
title="Byt övning"
|
||
>
|
||
<Icon name="swap" size={16} />
|
||
</button>
|
||
{canUndo && (
|
||
<button
|
||
className="undo-btn"
|
||
onClick={(event) => {
|
||
event.stopPropagation()
|
||
onUndo?.()
|
||
}}
|
||
aria-label="Ångra byte"
|
||
title="Ångra byte"
|
||
>
|
||
<Icon name="undo" size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{expanded && (
|
||
<div className="exercise-body">
|
||
{/* Progression hint */}
|
||
{progression && (
|
||
<div className="progression-hint" style={{ marginBottom: '0.75rem' }}>
|
||
{progression.reason}
|
||
{progression.suggestedWeight && (
|
||
<strong> {progression.suggestedWeight} kg</strong>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Target line */}
|
||
{(exercise.reps_min || exercise.reps_max) && (
|
||
<div style={{
|
||
background: '#131313',
|
||
borderRadius: '6px',
|
||
padding: '0.5rem 0.75rem',
|
||
marginBottom: '0.75rem',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
}}>
|
||
<span style={{
|
||
fontFamily: 'Space Grotesk, monospace',
|
||
fontSize: '0.7rem',
|
||
color: '#767575',
|
||
letterSpacing: '0.04em',
|
||
textTransform: 'uppercase',
|
||
}}>Mål</span>
|
||
<span style={{
|
||
fontFamily: 'Lexend, sans-serif',
|
||
fontWeight: 700,
|
||
fontSize: '0.875rem',
|
||
color: '#adaaaa',
|
||
}}>
|
||
{exercise.sets} set · {exercise.reps_min}{exercise.reps_max && exercise.reps_max !== exercise.reps_min ? `–${exercise.reps_max}` : ''} reps
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="sets-list">
|
||
{setList.map((input, idx) => {
|
||
const setIsPR = isPR(input, idx)
|
||
return (
|
||
<div key={idx} className={`set-row ${input.completed ? 'completed' : ''}`}>
|
||
<div className="set-row-top">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
<span className="set-number" style={{
|
||
fontFamily: 'Space Grotesk, monospace',
|
||
fontSize: '0.75rem',
|
||
color: '#767575',
|
||
letterSpacing: '0.04em',
|
||
textTransform: 'uppercase',
|
||
}}>Set {idx + 1}</span>
|
||
{setIsPR && (
|
||
<span style={{
|
||
fontFamily: 'Space Grotesk, monospace',
|
||
fontSize: '0.6rem',
|
||
fontWeight: 700,
|
||
color: '#516700',
|
||
background: '#cafd00',
|
||
padding: '0.1rem 0.35rem',
|
||
borderRadius: '4px',
|
||
letterSpacing: '0.04em',
|
||
}}>PR</span>
|
||
)}
|
||
</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>
|
||
</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" style={{
|
||
fontFamily: 'Lexend, sans-serif',
|
||
color: '#cafd00',
|
||
fontSize: '1.35rem',
|
||
fontWeight: 700,
|
||
}}>{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" style={{
|
||
fontFamily: 'Lexend, sans-serif',
|
||
fontSize: '1.35rem',
|
||
fontWeight: 700,
|
||
}}>{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>
|
||
{/* Previous session reference */}
|
||
{progression?.lastWeight && progression?.lastReps && (
|
||
<div style={{
|
||
fontFamily: 'Space Grotesk, monospace',
|
||
fontSize: '0.7rem',
|
||
color: '#767575',
|
||
letterSpacing: '0.03em',
|
||
marginTop: '0.25rem',
|
||
marginBottom: '0.25rem',
|
||
}}>
|
||
Förra träningen: {progression.lastWeight}kg×{progression.lastReps}
|
||
</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
|