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:
2026-02-28 21:25:23 +01:00
parent e2c112781a
commit 2b8a429e1f
14 changed files with 2320 additions and 845 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0a0a0f" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<title>Gravl - Träning</title>
<script type="module" crossorigin src="/assets/index-hhKetRGz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-my_lGtI5.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+1301 -739
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
import { Icon } from './Icons'
function AlternativeModal({ exercise, alternatives, loading, error, onSelect, onClose }) {
if (!exercise) return null
return (
<div className="alternative-modal-overlay" onClick={onClose}>
<div className="alternative-modal" onClick={(event) => event.stopPropagation()}>
<div className="alternative-modal-header">
<div>
<h3>Alternativa övningar</h3>
<p>För {exercise.name}</p>
</div>
<button className="alternative-modal-close" onClick={onClose} aria-label="Stäng">
<Icon name="chevronDown" size={18} />
</button>
</div>
{loading && (
<div className="alternative-modal-state">Laddar alternativ...</div>
)}
{!loading && error && (
<div className="alternative-modal-state error">{error}</div>
)}
{!loading && !error && alternatives.length === 0 && (
<div className="alternative-modal-state">Inga alternativ hittades.</div>
)}
{!loading && !error && alternatives.length > 0 && (
<div className="alternative-list">
{alternatives.map((alt) => (
<div key={alt.id} className="alternative-item">
<div className="alternative-info">
<strong>{alt.name}</strong>
<span>{alt.description || 'Ingen beskrivning tillgänglig.'}</span>
</div>
<button className="alternative-select-btn" onClick={() => onSelect(alt)}>
Välj
</button>
</div>
))}
</div>
)}
</div>
</div>
)
}
export default AlternativeModal
+8
View File
@@ -62,6 +62,14 @@ export const Icons = {
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
),
swap: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="7 7 3 11 7 15"/>
<polyline points="17 9 21 13 17 17"/>
<line x1="3" y1="11" x2="21" y2="11"/>
<line x1="3" y1="13" x2="21" y2="13"/>
</svg>
),
check: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
+498 -60
View File
@@ -5,39 +5,91 @@
}
:root {
/* Dark fitness palette */
/* Dark fitness palette - refined */
--bg-primary: #0a0a0f;
--bg-secondary: #0d0d12;
--bg-card: #15151b;
--bg-card-hover: #1a1a22;
--bg-secondary: #0d0d14;
--bg-tertiary: #12121a;
--bg-card: #16161f;
--bg-card-hover: #1c1c28;
--bg-elevated: #1a1a24;
--bg: #0a0a0f;
/* Text colors */
/* Text colors - better hierarchy */
--text-primary: #ffffff;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
--text-tertiary: #52525b;
--text: #ffffff;
/* Accent - energetic orange */
--accent: #ff6b35;
--accent-hover: #ff8555;
/* Accent - refined energetic coral */
--accent: #ff6b4a;
--accent-hover: #ff8066;
--accent-subtle: rgba(255, 107, 74, 0.15);
--accent-glow: rgba(255, 107, 74, 0.25);
/* Status colors */
/* Status colors - refined */
--success: #22c55e;
--success-subtle: rgba(34, 197, 94, 0.15);
--warning: #f59e0b;
--warning-subtle: rgba(245, 158, 11, 0.15);
--error: #ef4444;
--error-subtle: rgba(239, 68, 68, 0.15);
/* Border */
--border: #1f1f28;
/* Borders - refined */
--border: #1f1f2a;
--border-hover: #2a2a38;
--border-accent: var(--accent-subtle);
/* Workout type colors - muted, professional */
/* Shadows - key for enterprise feel */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.7), 0 8px 10px -6px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 20px var(--accent-glow);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-elevated: 0 8px 16px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
/* Workout type colors - refined */
--workout-push: #ef4444;
--workout-pull: #3b82f6;
--workout-legs: #22c55e;
--workout-shoulders: #f59e0b;
--workout-upper: #8b5cf6;
--workout-lower: #06b6d4;
--workout-default: #ff6b35;
--workout-default: #ff6b4a;
/* Typography scale */
--font-xs: 0.75rem;
--font-sm: 0.875rem;
--font-base: 1rem;
--font-lg: 1.125rem;
--font-xl: 1.25rem;
--font-2xl: 1.5rem;
--font-3xl: 2rem;
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* Border radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 18px;
--radius-2xl: 24px;
--radius-full: 9999px;
}
html, body {
@@ -47,10 +99,12 @@ html, body {
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
line-height: 1.2;
}
#root {
@@ -62,74 +116,458 @@ button {
cursor: pointer;
border: none;
outline: none;
font-size: var(--font-base);
}
input {
font-family: inherit;
outline: none;
font-size: var(--font-base);
}
/* Scrollbar styling */
/* Scrollbar styling - refined */
::-webkit-scrollbar {
width: 6px;
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
background: var(--text-tertiary);
}
/* Auth pages */
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.auth-card { background: var(--bg-card); padding: 40px; border-radius: 16px; width: 100%; max-width: 400px; text-align: center; }
.auth-card h1 { font-size: 2.5rem; margin-bottom: 8px; }
.auth-card h2 { color: var(--text-secondary); font-weight: 400; margin-bottom: 24px; }
.auth-card form { display: flex; flex-direction: column; gap: 16px; }
.auth-card input { padding: 14px 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 1rem; }
.auth-card input:focus { border-color: var(--accent); }
.auth-card button[type="submit"] { padding: 14px; background: var(--accent); color: white; border-radius: 8px; font-size: 1rem; font-weight: 600; transition: background 0.2s; }
.auth-card button[type="submit"]:hover:not(:disabled) { background: var(--accent-hover); }
.auth-card button:disabled { opacity: 0.6; cursor: not-allowed; }
.auth-card .error { background: rgba(233,69,96,0.15); color: var(--accent); padding: 12px; border-radius: 8px; margin-bottom: 16px; }
.auth-link { margin-top: 20px; color: var(--text-secondary); }
.auth-link a { color: var(--accent); text-decoration: none; }
/* ============================================
AUTH PAGES - Premium First Impression
============================================ */
/* Onboarding */
.onboarding { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.onboarding-card { background: var(--bg-card); padding: 32px; border-radius: 16px; width: 100%; max-width: 480px; }
.steps-indicator { display: flex; justify-content: center; gap: 12px; margin-bottom: 28px; }
.steps-indicator span { width: 32px; height: 32px; border-radius: 50%; background: var(--bg-secondary); display: flex; align-items: center; justify-content: center; font-size: 0.875rem; color: var(--text-secondary); }
.steps-indicator span.active { background: var(--accent); color: white; }
.step h2 { margin-bottom: 20px; text-align: center; }
.step .hint { color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 16px; text-align: center; }
.field { margin-bottom: 16px; }
.field label { display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.875rem; }
.field input { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 1rem; }
.field input:focus { border-color: var(--accent); }
.btn-group { display: flex; gap: 8px; }
.btn-group.vertical { flex-direction: column; }
.btn-group button { flex: 1; padding: 12px; border-radius: 8px; background: var(--bg-secondary); color: var(--text-secondary); border: 1px solid var(--border); transition: all 0.2s; }
.btn-group button:hover { border-color: var(--accent); }
.btn-group button.active { background: var(--accent); color: white; border-color: var(--accent); }
.rm-fields { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 8px; }
.rm-fields .field { margin-bottom: 0; }
.bodyfat-result { background: rgba(78,204,163,0.15); color: var(--success); padding: 16px; border-radius: 8px; text-align: center; margin: 16px 0; }
.nav-btns { display: flex; gap: 12px; margin-top: 24px; }
.nav-btns button { flex: 1; padding: 14px; border-radius: 8px; font-size: 1rem; }
.nav-btns button:first-child { background: var(--bg-secondary); color: var(--text-secondary); }
.next-btn, .finish-btn { background: var(--accent) !important; color: white !important; font-weight: 600; }
.next-btn:hover:not(:disabled), .finish-btn:hover:not(:disabled) { background: var(--accent-hover) !important; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-5);
background: var(--bg-primary);
position: relative;
overflow: hidden;
}
/* Subtle background gradient */
.auth-page::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
ellipse at 30% 20%,
rgba(255, 107, 74, 0.03) 0%,
transparent 50%
),
radial-gradient(
ellipse at 70% 80%,
rgba(99, 102, 241, 0.03) 0%,
transparent 50%
);
pointer-events: none;
}
.auth-card {
background: var(--bg-card);
padding: var(--space-10) var(--space-8);
border-radius: var(--radius-2xl);
width: 100%;
max-width: 420px;
text-align: center;
box-shadow: var(--shadow-elevated);
border: 1px solid var(--border);
position: relative;
z-index: 1;
}
.auth-card h1 {
font-size: var(--font-3xl);
margin-bottom: var(--space-2);
background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.auth-card h2 {
color: var(--text-secondary);
font-weight: 500;
margin-bottom: var(--space-8);
font-size: var(--font-lg);
}
.auth-card form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.auth-card input {
padding: var(--space-4) var(--space-5);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 16px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.auth-card input:hover {
border-color: var(--border-hover);
}
.auth-card input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.auth-card input::placeholder {
color: var(--text-tertiary);
}
.auth-card button[type="submit"] {
padding: var(--space-4);
background: var(--accent);
color: white;
border-radius: var(--radius-md);
font-size: var(--font-base);
font-weight: 600;
transition: all var(--transition-base);
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
position: relative;
overflow: hidden;
}
.auth-card button[type="submit"]::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.1) 0%, transparent 50%);
pointer-events: none;
}
.auth-card button[type="submit"]:hover:not(:disabled) {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(255, 107, 74, 0.4);
}
.auth-card button[type="submit"]:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(255, 107, 74, 0.3);
}
.auth-card button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-card .error {
background: var(--error-subtle);
color: var(--error);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
font-size: var(--font-sm);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.auth-link {
margin-top: var(--space-6);
color: var(--text-muted);
font-size: var(--font-sm);
}
.auth-link a {
color: var(--accent);
text-decoration: none;
font-weight: 500;
transition: color var(--transition-fast);
}
.auth-link a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
/* ============================================
ONBOARDING - Premium Step Wizard
============================================ */
.onboarding {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-5);
background: var(--bg-primary);
position: relative;
overflow: hidden;
}
.onboarding::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
ellipse at 30% 20%,
rgba(255, 107, 74, 0.04) 0%,
transparent 50%
),
radial-gradient(
ellipse at 70% 80%,
rgba(99, 102, 241, 0.04) 0%,
transparent 50%
);
pointer-events: none;
}
.onboarding-card {
background: var(--bg-card);
padding: var(--space-8);
border-radius: var(--radius-2xl);
width: 100%;
max-width: 520px;
box-shadow: var(--shadow-elevated);
border: 1px solid var(--border);
position: relative;
z-index: 1;
}
.steps-indicator {
display: flex;
justify-content: center;
gap: var(--space-3);
margin-bottom: var(--space-8);
}
.steps-indicator span {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-muted);
transition: all var(--transition-base);
border: 2px solid var(--border);
}
.steps-indicator span.active {
background: var(--accent);
color: white;
border-color: var(--accent);
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
}
.step h2 {
margin-bottom: var(--space-6);
text-align: center;
font-size: var(--font-xl);
}
.step .hint {
color: var(--text-muted);
font-size: var(--font-sm);
margin-bottom: var(--space-4);
text-align: center;
}
.field {
margin-bottom: var(--space-4);
}
.field label {
display: block;
margin-bottom: var(--space-2);
color: var(--text-secondary);
font-size: var(--font-sm);
font-weight: 500;
}
.field input {
width: 100%;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 16px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.field input:hover {
border-color: var(--border-hover);
}
.field input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.field input::placeholder {
color: var(--text-tertiary);
}
.btn-group {
display: flex;
gap: var(--space-2);
}
.btn-group.vertical {
flex-direction: column;
}
.btn-group button {
flex: 1;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border);
transition: all var(--transition-base);
font-weight: 500;
min-height: 44px;
}
.btn-group button:hover {
border-color: var(--accent);
color: var(--text-primary);
background: var(--bg-tertiary);
}
.btn-group button.active {
background: var(--accent);
color: white;
border-color: var(--accent);
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.25);
}
.rm-fields {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
margin-top: var(--space-2);
}
.rm-fields .field {
margin-bottom: 0;
}
.bodyfat-result {
background: var(--success-subtle);
color: var(--success);
padding: var(--space-4);
border-radius: var(--radius-md);
text-align: center;
margin: var(--space-4) 0;
border: 1px solid rgba(34, 197, 94, 0.2);
}
.bodyfat-result strong {
font-size: var(--font-lg);
}
.nav-btns {
display: flex;
gap: var(--space-3);
margin-top: var(--space-6);
}
.nav-btns button {
flex: 1;
padding: var(--space-4);
border-radius: var(--radius-md);
font-size: var(--font-base);
transition: all var(--transition-base);
min-height: 44px;
}
.nav-btns button:first-child {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.nav-btns button:first-child:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-hover);
}
.next-btn, .finish-btn {
background: var(--accent) !important;
color: white !important;
font-weight: 600;
border: none !important;
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
}
.next-btn:hover:not(:disabled), .finish-btn:hover:not(:disabled) {
background: var(--accent-hover) !important;
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(255, 107, 74, 0.4);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Header logout */
.header-left { display: flex; align-items: center; gap: 16px; }
.logout-btn { padding: 6px 12px; background: var(--bg-secondary); color: var(--text-secondary); border-radius: 6px; font-size: 0.75rem; }
.logout-btn:hover { background: var(--border); }
.header-left {
display: flex;
align-items: center;
gap: var(--space-4);
}
.logout-btn {
padding: var(--space-2) var(--space-3);
background: var(--bg-secondary);
color: var(--text-muted);
border-radius: var(--radius-sm);
font-size: var(--font-xs);
transition: all var(--transition-base);
border: 1px solid var(--border);
}
.logout-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-hover);
}
/* ============================================
GLOBAL INPUT ACCESSIBILITY
Ensure all inputs have font-size >= 16px to prevent iOS auto-zoom
============================================ */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
input[type="tel"],
select,
textarea {
font-size: 16px;
}
+243 -43
View File
@@ -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>
))}
@@ -0,0 +1,3 @@
# Task 001: WorkoutPage UX Redesign
Single-tap logging with +/- buttons and rest timer
Notify: openclaw system event --text Done --mode now