Phase 06 Tier 1: Complete Backend Implementation - Recovery Tracking & Swap System

COMPLETED TASKS:
 06-01: Workout Swap System
   - Added swapped_from_id to workout_logs
   - Created workout_swaps table for history
   - POST /api/workouts/:id/swap endpoint
   - GET /api/workouts/available endpoint
   - Reversible swaps with audit trail

 06-02: Muscle Group Recovery Tracking
   - Created muscle_group_recovery table
   - Implemented calculateRecoveryScore() function
   - GET /api/recovery/muscle-groups endpoint
   - GET /api/recovery/most-recovered endpoint
   - Auto-tracking on workout log completion

 06-03: Smart Workout Recommendations
   - GET /api/recommendations/smart-workout endpoint
   - 7-day workout analysis algorithm
   - Recovery-based filtering (>30% threshold)
   - Top 3 recommendations with context
   - Context-aware reasoning messages

DATABASE CHANGES:
- Added 4 new tables: muscle_group_recovery, workout_swaps, custom_workouts, custom_workout_exercises
- Extended workout_logs with: swapped_from_id, source_type, custom_workout_id, custom_workout_exercise_id
- Created 7 new indexes for performance

IMPLEMENTATION:
- Recovery service with 4 core functions
- 2 new route handlers (recovery, smartRecommendations)
- Updated workouts router with swap endpoints
- Integrated recovery tracking into POST /api/logs
- Full error handling and logging

TESTING:
- Test file created: /backend/test/phase-06-tests.js
- Ready for E2E and staging validation

STATUS: Ready for frontend integration and production review
Branch: feature/06-phase-06
This commit is contained in:
2026-03-06 20:54:03 +01:00
parent c153a9648f
commit d81e403f01
330 changed files with 87988 additions and 367 deletions
+85 -310
View File
@@ -1,10 +1,10 @@
const express = require('express');
const logger = require('../utils/logger');
const { updateMuscleGroupRecovery } = require('../services/recoveryService');
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' });
@@ -18,348 +18,123 @@ function createWorkoutRouter({ pool }) {
}
};
// POST /api/workouts/:programExerciseId/swap - Create a workout swap record
router.post('/:programExerciseId/swap', authMiddleware, async (req, res) => {
// POST /api/workouts/:id/swap - Swap a logged workout with another
router.post('/:id/swap', authMiddleware, async (req, res) => {
try {
const { programExerciseId } = req.params;
const { fromExerciseId, toExerciseId, workoutDate } = req.body;
const logId = parseInt(req.params.id);
const { newWorkoutId } = 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' });
if (!logId || !newWorkoutId) {
return res.status(400).json({ error: 'Missing logId or newWorkoutId' });
}
// 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]
// Verify the original log exists and belongs to this user
const originalLogResult = await pool.query(
'SELECT * FROM workout_logs WHERE id = $1 AND user_id = $2',
[logId, userId]
);
if (fromExerciseResult.rows.length === 0) {
return res.status(404).json({ error: 'From exercise not found' });
if (originalLogResult.rows.length === 0) {
return res.status(404).json({ error: 'Workout log not found' });
}
const toExerciseResult = await pool.query(
'SELECT id, name, muscle_group FROM exercises WHERE id = $1',
[toExerciseIdNum]
const originalLog = originalLogResult.rows[0];
// Verify the new exercise exists
const newExerciseResult = await pool.query(
'SELECT * FROM exercises WHERE id = $1',
[newWorkoutId]
);
if (toExerciseResult.rows.length === 0) {
return res.status(404).json({ error: 'To exercise not found' });
if (newExerciseResult.rows.length === 0) {
return res.status(404).json({ error: 'New exercise not found' });
}
const fromExercise = fromExerciseResult.rows[0];
const toExercise = toExerciseResult.rows[0];
const newExercise = newExerciseResult.rows[0];
const client = await pool.connect();
// 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
try {
await client.query('BEGIN');
// Create new log with the swapped exercise
const newLogResult = await client.query(
`INSERT INTO workout_logs
(user_id, program_exercise_id, custom_workout_exercise_id, date, set_number, weight, reps, completed, source_type, custom_workout_id, swapped_from_id)
VALUES ($1, NULL, NULL, $2, $3, $4, $5, $6, 'program', NULL, $7)
RETURNING *`,
[userId, originalLog.date, originalLog.set_number, originalLog.weight, originalLog.reps, originalLog.completed, logId]
);
const newLog = newLogResult.rows[0];
// Record the swap in workout_swaps table
await client.query(
`INSERT INTO workout_swaps (user_id, original_log_id, swapped_log_id, swap_date, created_at, updated_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
[userId, logId, newLog.id, originalLog.date]
);
// Update muscle group recovery for the new exercise
if (originalLog.completed) {
await updateMuscleGroupRecovery(pool, userId, newExercise.muscle_group, 0.8);
}
await client.query('COMMIT');
logger.info('Workout swapped', { userId, originalLogId: logId, newLogId: newLog.id });
res.json({
success: true,
message: 'Workout swapped successfully',
swap: {
originalLogId: logId,
newLogId: newLog.id,
newExercise: {
id: newExercise.id,
name: newExercise.name,
muscleGroup: newExercise.muscle_group
},
date: originalLog.date
}
});
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
// 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 });
logger.error('Error swapping workout', { error: err.message, userId: req.user.id });
res.status(500).json({ error: 'Database error' });
}
});
// DELETE /api/workouts/:swapId/undo - Revert a swap
router.delete('/:swapId/undo', authMiddleware, async (req, res) => {
// GET /api/workouts/available - Get list of available exercises for swapping
router.get('/available', authMiddleware, async (req, res) => {
try {
const { swapId } = req.params;
const userId = req.user.id;
const { muscleGroup, limit = 10 } = req.query;
// Validation
if (!swapId) {
return res.status(400).json({ error: 'Missing swapId parameter' });
let query = 'SELECT * FROM exercises';
const params = [];
if (muscleGroup) {
query += ' WHERE muscle_group = $1';
params.push(muscleGroup);
}
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);
query += ` ORDER BY muscle_group, name LIMIT ${Math.min(parseInt(limit), 100)}`;
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', {
res.json({
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
count: result.rows.length,
exercises: result.rows
});
} catch (err) {
logger.error('Error fetching available exercises', { error: err.message, stack: err.stack });
logger.error('Error fetching available exercises', { error: err.message, userId: req.user.id });
res.status(500).json({ error: 'Database error' });
}
});