From 6ad917c9b9313f59437b9813dbe3918ef104e3d7 Mon Sep 17 00:00:00 2001 From: Clawd Agent Date: Fri, 6 Mar 2026 15:06:31 +0100 Subject: [PATCH] feat(06-01): Implement workout swap/rotation system - API, DB, frontend - Add workout_swaps table migration (007_add_workout_swap_tracking.sql) - Implement 4 API endpoints: POST /swap, DELETE /undo, GET /swaps, GET /available - Add request validation, error handling, user isolation, muscle group checks - Create SwapWorkoutModal React component with modal UI - Integrate swap functionality into WorkoutPage - Add proper styling for swap modal - All endpoints require authentication - Database migration includes performance indexes --- backend/src/index.js | 2 + backend/src/routes/workouts.js | 370 +++++++++++++++++ .../007_add_workout_swap_tracking.sql | 21 + frontend/src/components/SwapWorkoutModal.css | 374 ++++++++++++++++++ frontend/src/components/SwapWorkoutModal.jsx | 105 +++++ frontend/src/pages/WorkoutPage.jsx | 171 +++++++- 6 files changed, 1022 insertions(+), 21 deletions(-) create mode 100644 backend/src/routes/workouts.js create mode 100644 db/migrations/007_add_workout_swap_tracking.sql create mode 100644 frontend/src/components/SwapWorkoutModal.css create mode 100644 frontend/src/components/SwapWorkoutModal.jsx diff --git a/backend/src/index.js b/backend/src/index.js index fe640b1..ecc945b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -8,6 +8,7 @@ const requestLoggerMiddleware = require('./middleware/requestLogger'); const { getHealthStatus, getUptime } = require('./utils/health'); const { createExerciseResearchRouter } = require('./routes/exerciseResearch'); const { createExerciseRecommendationRouter } = require('./routes/exerciseRecommendations'); +const { createWorkoutRouter } = require('./routes/workouts'); const { searchExerciseResearch } = require('./services/exaSearch'); const app = express(); @@ -29,6 +30,7 @@ app.use(requestLoggerMiddleware); // Add request logging middleware app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch: searchExerciseResearch })); app.use('/api/exercises', createExerciseRecommendationRouter()); +app.use('/api/workouts', createWorkoutRouter({ pool })); const authMiddleware = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; diff --git a/backend/src/routes/workouts.js b/backend/src/routes/workouts.js new file mode 100644 index 0000000..67cae94 --- /dev/null +++ b/backend/src/routes/workouts.js @@ -0,0 +1,370 @@ +const express = require('express'); +const logger = require('../utils/logger'); + +function createWorkoutRouter({ pool }) { + const router = express.Router(); + + // Middleware to verify authentication + const authMiddleware = (req, res, next) => { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) return res.status(401).json({ error: 'No token provided' }); + try { + const jwt = require('jsonwebtoken'); + const JWT_SECRET = process.env.JWT_SECRET || 'gravl-secret-key-change-in-production'; + req.user = jwt.verify(token, JWT_SECRET); + next(); + } catch (err) { + res.status(401).json({ error: 'Invalid token' }); + } + }; + + // POST /api/workouts/:programExerciseId/swap - Create a workout swap record + router.post('/:programExerciseId/swap', authMiddleware, async (req, res) => { + try { + const { programExerciseId } = req.params; + const { fromExerciseId, toExerciseId, workoutDate } = req.body; + const userId = req.user.id; + + // Validation + if (!programExerciseId || !fromExerciseId || !toExerciseId || !workoutDate) { + return res.status(400).json({ error: 'Missing required fields: programExerciseId, fromExerciseId, toExerciseId, workoutDate' }); + } + + // Validate numeric IDs + const programExerciseIdNum = parseInt(programExerciseId); + const fromExerciseIdNum = parseInt(fromExerciseId); + const toExerciseIdNum = parseInt(toExerciseId); + const userIdNum = parseInt(userId); + + if (isNaN(programExerciseIdNum) || isNaN(fromExerciseIdNum) || isNaN(toExerciseIdNum)) { + return res.status(400).json({ error: 'Invalid exercise IDs format' }); + } + + // Validate date format (YYYY-MM-DD) + if (!/^\d{4}-\d{2}-\d{2}$/.test(workoutDate)) { + return res.status(400).json({ error: 'Invalid date format. Use YYYY-MM-DD' }); + } + + // Verify exercises exist and get their details + const fromExerciseResult = await pool.query( + 'SELECT id, name, muscle_group FROM exercises WHERE id = $1', + [fromExerciseIdNum] + ); + + if (fromExerciseResult.rows.length === 0) { + return res.status(404).json({ error: 'From exercise not found' }); + } + + const toExerciseResult = await pool.query( + 'SELECT id, name, muscle_group FROM exercises WHERE id = $1', + [toExerciseIdNum] + ); + + if (toExerciseResult.rows.length === 0) { + return res.status(404).json({ error: 'To exercise not found' }); + } + + const fromExercise = fromExerciseResult.rows[0]; + const toExercise = toExerciseResult.rows[0]; + + // Verify exercises have same muscle group + if (fromExercise.muscle_group !== toExercise.muscle_group) { + return res.status(400).json({ + error: 'Exercises must have the same muscle group for swapping', + details: { + fromMuscleGroup: fromExercise.muscle_group, + toMuscleGroup: toExercise.muscle_group + } + }); + } + + // Insert into workout_swaps table + const swapResult = await pool.query( + `INSERT INTO workout_swaps (user_id, program_exercise_id, from_exercise_id, to_exercise_id, swap_date, created_at) + VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) + RETURNING id, created_at`, + [userIdNum, programExerciseIdNum, fromExerciseIdNum, toExerciseIdNum, workoutDate] + ); + + const swapId = swapResult.rows[0].id; + const createdAt = swapResult.rows[0].created_at; + + // Update existing workout logs for this date to reference the swap + await pool.query( + `UPDATE workout_logs + SET swap_history_id = $1 + WHERE user_id = $2 AND program_exercise_id = $3 AND date = $4 AND swap_history_id IS NULL`, + [swapId, userIdNum, programExerciseIdNum, workoutDate] + ); + + logger.info('Workout swap created', { + userId: userIdNum, + swapId, + fromExerciseId: fromExerciseIdNum, + toExerciseId: toExerciseIdNum, + date: workoutDate + }); + + res.status(200).json({ + success: true, + swapId, + message: 'Swap recorded', + swap: { + id: swapId, + from_exercise: { + id: fromExercise.id, + name: fromExercise.name, + muscle_group: fromExercise.muscle_group + }, + to_exercise: { + id: toExercise.id, + name: toExercise.name, + muscle_group: toExercise.muscle_group + }, + date: workoutDate, + created_at: createdAt + } + }); + } catch (err) { + logger.error('Error creating swap', { error: err.message, stack: err.stack }); + res.status(500).json({ error: 'Database error' }); + } + }); + + // DELETE /api/workouts/:swapId/undo - Revert a swap + router.delete('/:swapId/undo', authMiddleware, async (req, res) => { + try { + const { swapId } = req.params; + const userId = req.user.id; + + // Validation + if (!swapId) { + return res.status(400).json({ error: 'Missing swapId parameter' }); + } + + const swapIdNum = parseInt(swapId); + if (isNaN(swapIdNum)) { + return res.status(400).json({ error: 'Invalid swap ID format' }); + } + + const userIdNum = parseInt(userId); + + // Find swap record and verify it belongs to the user + const swapResult = await pool.query( + 'SELECT id, user_id FROM workout_swaps WHERE id = $1', + [swapIdNum] + ); + + if (swapResult.rows.length === 0) { + return res.status(404).json({ error: 'Swap not found' }); + } + + const swap = swapResult.rows[0]; + + // Verify ownership + if (swap.user_id !== userIdNum) { + return res.status(403).json({ error: 'You do not own this swap' }); + } + + // Clear swap references from workout_logs + await pool.query( + `UPDATE workout_logs + SET swap_history_id = NULL + WHERE swap_history_id = $1`, + [swapIdNum] + ); + + // Delete the swap record + await pool.query( + 'DELETE FROM workout_swaps WHERE id = $1', + [swapIdNum] + ); + + logger.info('Workout swap reverted', { + userId: userIdNum, + swapId: swapIdNum + }); + + res.status(200).json({ + success: true, + message: 'Swap reverted' + }); + } catch (err) { + logger.error('Error reverting swap', { error: err.message, stack: err.stack }); + res.status(500).json({ error: 'Database error' }); + } + }); + + // GET /api/workouts/:programExerciseId/swaps - Get swap history + router.get('/:programExerciseId/swaps', authMiddleware, async (req, res) => { + try { + const { programExerciseId } = req.params; + const { limit = 10, offset = 0, fromDate } = req.query; + const userId = req.user.id; + + // Validation + if (!programExerciseId) { + return res.status(400).json({ error: 'Missing programExerciseId parameter' }); + } + + const programExerciseIdNum = parseInt(programExerciseId); + if (isNaN(programExerciseIdNum)) { + return res.status(400).json({ error: 'Invalid programExerciseId format' }); + } + + const limitNum = Math.min(parseInt(limit) || 10, 100); + const offsetNum = parseInt(offset) || 0; + + // Verify exercise exists + const exerciseResult = await pool.query( + 'SELECT id FROM program_exercises WHERE id = $1 AND user_id = $2', + [programExerciseIdNum, userId] + ); + + if (exerciseResult.rows.length === 0) { + return res.status(404).json({ error: 'Exercise not found or access denied' }); + } + + // Build query + let query = ` + SELECT + ws.id, + ws.swap_date as date, + ws.created_at, + fe.id as from_exercise_id, + fe.name as from_exercise_name, + fe.muscle_group as from_muscle_group, + te.id as to_exercise_id, + te.name as to_exercise_name, + te.muscle_group as to_muscle_group + FROM workout_swaps ws + JOIN exercises fe ON ws.from_exercise_id = fe.id + JOIN exercises te ON ws.to_exercise_id = te.id + WHERE ws.program_exercise_id = $1 AND ws.user_id = $2 + `; + + const params = [programExerciseIdNum, userId]; + let paramIdx = 3; + + if (fromDate && /^\d{4}-\d{2}-\d{2}$/.test(fromDate)) { + query += ` AND ws.swap_date >= $${paramIdx++}`; + params.push(fromDate); + } + + query += ' ORDER BY ws.created_at DESC LIMIT $' + paramIdx + ' OFFSET $' + (paramIdx + 1); + params.push(limitNum, offsetNum); + + const result = await pool.query(query, params); + + const swaps = result.rows.map(row => ({ + id: row.id, + from_exercise: { + id: row.from_exercise_id, + name: row.from_exercise_name, + muscle_group: row.from_muscle_group + }, + to_exercise: { + id: row.to_exercise_id, + name: row.to_exercise_name, + muscle_group: row.to_muscle_group + }, + date: row.date, + created_at: row.created_at + })); + + logger.debug('Swap history retrieved', { + userId, + programExerciseId: programExerciseIdNum, + count: swaps.length + }); + + res.status(200).json(swaps); + } catch (err) { + logger.error('Error fetching swaps', { error: err.message, stack: err.stack }); + res.status(500).json({ error: 'Database error' }); + } + }); + + // GET /api/workouts/:date/available - Get available exercises for a date + router.get('/:date/available', authMiddleware, async (req, res) => { + try { + const { date } = req.params; + const { programDayId } = req.query; + const userId = req.user.id; + + // Validation + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return res.status(400).json({ error: 'Invalid date format. Use YYYY-MM-DD' }); + } + + const userIdNum = parseInt(userId); + + let query = ` + SELECT + pe.id as program_exercise_id, + pe.exercise_id, + e.name, + e.muscle_group, + pe.sets, + pe.reps_min, + pe.reps_max, + pd.program_day_id, + ( + SELECT COUNT(*) + FROM exercises e2 + WHERE e2.muscle_group = e.muscle_group + AND e2.id != e.id + ) as alternatives + FROM program_exercises pe + JOIN exercises e ON pe.exercise_id = e.id + JOIN program_days pd ON pe.program_day_id = pd.id + JOIN programs p ON pd.program_id = p.id + WHERE p.user_id = $1 + `; + + const params = [userIdNum]; + let paramIdx = 2; + + if (programDayId) { + const programDayIdNum = parseInt(programDayId); + if (!isNaN(programDayIdNum)) { + query += ` AND pd.program_day_id = $${paramIdx++}`; + params.push(programDayIdNum); + } + } + + query += ' ORDER BY pd.day_of_week, pe.exercise_order'; + + const result = await pool.query(query, params); + + const exercises = result.rows.map(row => ({ + id: row.exercise_id, + programExerciseId: row.program_exercise_id, + name: row.name, + muscleGroup: row.muscle_group, + sets: row.sets, + reps_min: row.reps_min, + reps_max: row.reps_max, + alternatives: row.alternatives + })); + + logger.debug('Available exercises retrieved', { + userId: userIdNum, + date, + count: exercises.length + }); + + res.status(200).json({ + date, + exercises + }); + } catch (err) { + logger.error('Error fetching available exercises', { error: err.message, stack: err.stack }); + res.status(500).json({ error: 'Database error' }); + } + }); + + return router; +} + +module.exports = { createWorkoutRouter }; diff --git a/db/migrations/007_add_workout_swap_tracking.sql b/db/migrations/007_add_workout_swap_tracking.sql new file mode 100644 index 0000000..c64bb8d --- /dev/null +++ b/db/migrations/007_add_workout_swap_tracking.sql @@ -0,0 +1,21 @@ +-- Track which exercises were swapped +CREATE TABLE IF NOT EXISTS workout_swaps ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + program_exercise_id INTEGER NOT NULL REFERENCES program_exercises(id) ON DELETE CASCADE, + from_exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + to_exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + swap_date DATE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Add reference in workout_logs to track origin +ALTER TABLE workout_logs + ADD COLUMN IF NOT EXISTS swapped_from_id INTEGER REFERENCES workout_logs(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS swap_history_id INTEGER REFERENCES workout_swaps(id) ON DELETE SET NULL; + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_workout_swaps_user_date ON workout_swaps(user_id, swap_date); +CREATE INDEX IF NOT EXISTS idx_workout_swaps_exercise ON workout_swaps(program_exercise_id); +CREATE INDEX IF NOT EXISTS idx_workout_logs_swapped_from ON workout_logs(swapped_from_id); +CREATE INDEX IF NOT EXISTS idx_workout_logs_swap_history ON workout_logs(swap_history_id); diff --git a/frontend/src/components/SwapWorkoutModal.css b/frontend/src/components/SwapWorkoutModal.css new file mode 100644 index 0000000..b5053fc --- /dev/null +++ b/frontend/src/components/SwapWorkoutModal.css @@ -0,0 +1,374 @@ +/* ============================================ + SWAP WORKOUT MODAL + ============================================ */ + +.swap-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: flex-end; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease-out; + padding: 0; +} + +.swap-modal-content { + background: white; + border-radius: 12px 12px 0 0; + width: 100%; + max-width: 500px; + max-height: 80vh; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.1); +} + +.swap-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.swap-modal-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +.swap-modal-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #999; + padding: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: all 0.2s; +} + +.swap-modal-close:hover { + background: #f0f0f0; + color: #333; +} + +.swap-modal-close:active { + transform: scale(0.95); +} + +/* ============================================ + CURRENT EXERCISE + ============================================ */ + +.swap-current-exercise { + background: #f5f5f5; + padding: 16px; + border-radius: 8px; + border-left: 4px solid var(--accent); +} + +.swap-current-label { + font-size: 12px; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + font-weight: 500; +} + +.swap-current-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.swap-current-group { + font-size: 13px; + color: #666; +} + +/* ============================================ + ALTERNATIVES LIST + ============================================ */ + +.swap-alternatives-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.swap-alternatives-label { + font-size: 12px; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; + padding: 0 4px; +} + +.swap-alternative-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 12px; + border: 1px solid #ddd; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + min-height: 48px; +} + +.swap-alternative-item:hover { + background: #fafafa; + border-color: var(--accent); + box-shadow: 0 2px 8px rgba(255, 107, 74, 0.1); +} + +.swap-alternative-item:active { + transform: scale(0.98); +} + +.swap-alternative-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.swap-alternative-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + word-break: break-word; +} + +.swap-alternative-group { + font-size: 12px; + color: #999; +} + +.swap-alternative-desc { + font-size: 12px; + color: #666; + margin-top: 2px; + line-height: 1.3; + word-break: break-word; +} + +.swap-alternative-icon { + color: #ccc; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +/* ============================================ + LOADING STATE + ============================================ */ + +.swap-loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + gap: 12px; +} + +.swap-spinner { + width: 32px; + height: 32px; + border: 3px solid #f0f0f0; + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.swap-loading-state p { + color: #999; + font-size: 13px; + margin: 0; +} + +/* ============================================ + EMPTY STATE + ============================================ */ + +.swap-empty-state { + display: flex; + align-items: center; + justify-content: center; + padding: 32px 20px; +} + +.swap-empty-state p { + color: #999; + font-size: 13px; + text-align: center; + margin: 0; +} + +/* ============================================ + ERROR MESSAGE + ============================================ */ + +.swap-error-message { + display: flex; + align-items: flex-start; + gap: 8px; + background: #fff5f5; + border: 1px solid #fdd; + border-radius: 6px; + padding: 12px; + color: #c33; + font-size: 13px; +} + +.swap-error-message svg { + flex-shrink: 0; + margin-top: 2px; +} + +/* ============================================ + ACTIONS + ============================================ */ + +.swap-modal-actions { + display: flex; + gap: 8px; + padding-top: 8px; + border-top: 1px solid #eee; +} + +.swap-cancel-btn { + flex: 1; + padding: 12px 16px; + background: #f5f5f5; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + min-height: 44px; +} + +.swap-cancel-btn:hover:not(:disabled) { + background: #e8e8e8; + border-color: #ccc; +} + +.swap-cancel-btn:active:not(:disabled) { + transform: scale(0.98); +} + +.swap-cancel-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ============================================ + ANIMATIONS + ============================================ */ + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ============================================ + MOBILE RESPONSIVE + ============================================ */ + +@media (max-width: 600px) { + .swap-modal-content { + border-radius: 12px 12px 0 0; + max-height: 90vh; + padding: 16px; + } + + .swap-modal-header h3 { + font-size: 16px; + } + + .swap-alternative-item { + min-height: 56px; + padding: 12px; + } + + .swap-alternative-name { + font-size: 15px; + } + + .swap-current-exercise { + padding: 12px; + } + + .swap-modal-actions { + flex-direction: column; + gap: 8px; + } + + .swap-cancel-btn { + min-height: 48px; + } +} + +/* Dark mode support (if app has dark mode) */ +@media (prefers-color-scheme: dark) { + .swap-modal-content { + background: var(--bg-secondary); + } + + .swap-modal-close { + color: #999; + } + + .swap-modal-close:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + } + + .swap-current-exercise { + background: rgba(255, 255, 255, 0.05); + } + + .swap-alternative-item { + border-color: #444; + } + + .swap-alternative-item:hover { + background: rgba(255, 255, 255, 0.08); + } + + .swap-cancel-btn { + background: rgba(255, 255, 255, 0.1); + border-color: #444; + } + + .swap-cancel-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.15); + } +} diff --git a/frontend/src/components/SwapWorkoutModal.jsx b/frontend/src/components/SwapWorkoutModal.jsx new file mode 100644 index 0000000..3b3c8e7 --- /dev/null +++ b/frontend/src/components/SwapWorkoutModal.jsx @@ -0,0 +1,105 @@ +import { Icon } from './Icons' +import './SwapWorkoutModal.css' + +function SwapWorkoutModal({ + exercise, + alternatives = [], + onSwap, + onClose, + loading = false, + error = '' +}) { + if (!exercise) return null + + const handleSwap = async (alternative) => { + if (onSwap) { + await onSwap(alternative) + } + } + + return ( +
+
e.stopPropagation()}> +
+

Byt övning

+ +
+ + {/* Current Exercise */} +
+
Nuvarande övning
+
{exercise.name}
+
{exercise.muscle_group}
+
+ + {/* Error State */} + {error && ( +
+ + {error} +
+ )} + + {/* Loading State */} + {loading && ( +
+
+

Laddar alternativ...

+
+ )} + + {/* Empty State */} + {!loading && !error && alternatives.length === 0 && ( +
+

Inga alternativ hittades för denna övning.

+
+ )} + + {/* Alternatives List */} + {!loading && !error && alternatives.length > 0 && ( +
+
Alternativ
+ {alternatives.map((alt) => ( +
handleSwap(alt)} + > +
+
{alt.name}
+
{alt.muscle_group}
+ {alt.description && ( +
{alt.description}
+ )} +
+
+ +
+
+ ))} +
+ )} + + {/* Actions */} +
+ +
+
+
+ ) +} + +export default SwapWorkoutModal diff --git a/frontend/src/pages/WorkoutPage.jsx b/frontend/src/pages/WorkoutPage.jsx index 83204cc..e9fc397 100644 --- a/frontend/src/pages/WorkoutPage.jsx +++ b/frontend/src/pages/WorkoutPage.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { Icon } from '../components/Icons' -import AlternativeModal from '../components/AlternativeModal' +import SwapWorkoutModal from '../components/SwapWorkoutModal' const API_URL = '/api' @@ -59,6 +59,9 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg 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) @@ -81,6 +84,12 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg 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) { @@ -116,15 +125,106 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg } } - const handleSelectAlternative = (alternative) => { + const handleSwapWorkout = async (alternative) => { if (!swapExercise) return - setSwappedExercises(prev => ({ - ...prev, - [swapExercise.id]: alternative - })) - setSwapExercise(null) + + 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) @@ -330,6 +430,7 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg

Övningar

{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 @@ -338,6 +439,7 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg openAlternatives(exercise)} + onUndo={() => undoSwap(exercise.id)} + canUndo={Boolean(recentSwaps[exercise.id])} /> ) })} @@ -365,19 +469,26 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg - setSwapExercise(null)} /> + + {/* Toast Notification */} + {toast && ( +
+ {toast.message} +
+ )} ) } -function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet, onSwap, isSwapped, onStartRest }) { +function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet, onSwap, isSwapped, onStartRest, originalExercise, onUndo, canUndo }) { const [setList, setSetList] = useState([]) const [showAddModal, setShowAddModal] = useState(false) const weightStep = 2.5 @@ -464,7 +575,9 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe

{exercise.name}

{exercise.muscle_group} - {isSwapped && Alternativ} + {isSwapped && originalExercise && ( + Bytt från {originalExercise.name} + )}
@@ -473,16 +586,32 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe {completedSets}/{setList.length}
- +
+ + {canUndo && ( + + )} +