feat(phase-4): Backend API for custom workouts

- Add custom_workouts and custom_workout_exercises tables (schema)
- New endpoints:
  - GET /api/exercises - List all exercises for picker
  - POST /api/custom-workouts - Fork program workout
  - GET /api/custom-workouts - List user's custom workouts
  - GET /api/custom-workouts/:id - Get workout with exercises
  - PUT /api/custom-workouts/:id - Update workout exercises
  - DELETE /api/custom-workouts/:id - Delete custom workout
- Updated endpoints for source_type support:
  - GET /api/logs - Filter by source_type and custom_workout_id
  - POST /api/logs - Save with source_type and custom_workout_id
  - DELETE /api/logs - Support custom workout log deletion
- Adds Phase 4 planning overview

Completes: 04-01-schema-migration, 04-02-backend-api
Next: 04-03-frontend-workout-edit
This commit is contained in:
2026-03-01 03:36:53 +01:00
committed by Clawd Agent
parent 22750bfa06
commit 4bd2c9607d
4 changed files with 445 additions and 106 deletions
+6 -5
View File
@@ -1,8 +1,9 @@
{
"lastRun": "2026-02-28T23:45:00+01:00",
"lastRun": "2026-03-01T04:00:00+01:00",
"status": "completed",
"tasksCompleted": ["emoji-replacement", "alternative-modal", "workout-page-ux-redish", "chat-onboarding", "03-01-login-onboarding-polish", "03-02-dashboard-polish", "03-03-workout-experience-polish"],
"activeTask": null,
"nextTask": null,
"notes": "03-03 Workout Experience Polish complete (commit f6b1379). Phase 3 complete: login/onboarding, dashboard, and workout experience all polished."
"phase": "04-workout-modification",
"activeTask": "04-02-backend-api",
"tasksCompleted": ["01-input-ux", "02-flexible-sets", "03-design-polish", "04-01-schema-migration", "04-02-backend-api"],
"nextTask": "04-03-frontend-workout-edit",
"notes": "Backend API complete for custom workouts. Added 6 new endpoints + updated 3 log endpoints with source_type support. Next: Frontend edit UI."
}
+367 -101
View File
@@ -303,107 +303,6 @@ app.get('/api/exercises/:id/last-workout', async (req, res) => {
}
});
// Get workout logs for a user and date
app.get('/api/logs', async (req, res) => {
try {
const { user_id, date, program_exercise_id } = req.query;
let query = 'SELECT * FROM workout_logs WHERE 1=1';
const params = [];
if (user_id) {
params.push(user_id);
query += ` AND user_id = $${params.length}`;
}
if (date) {
params.push(date);
query += ` AND date = $${params.length}`;
}
if (program_exercise_id) {
params.push(program_exercise_id);
query += ` AND program_exercise_id = $${params.length}`;
}
query += ' ORDER BY date DESC, set_number ASC';
const result = await pool.query(query, params);
res.json(result.rows);
} catch (err) {
console.error('Error fetching logs:', err);
res.status(500).json({ error: 'Database error' });
}
});
// Get last workout for an exercise (for progression)
app.get('/api/logs/last/:programExerciseId', async (req, res) => {
try {
const { user_id } = req.query;
const result = await pool.query(`
SELECT * FROM workout_logs
WHERE program_exercise_id = $1 AND user_id = $2
ORDER BY date DESC, set_number ASC
LIMIT 10
`, [req.params.programExerciseId, user_id || 1]);
res.json(result.rows);
} catch (err) {
console.error('Error fetching last workout:', err);
res.status(500).json({ error: 'Database error' });
}
});
// Log a set
app.post('/api/logs', async (req, res) => {
try {
const { user_id, program_exercise_id, date, set_number, weight, reps, completed } = req.body;
// Check if log exists for this set
const existing = await pool.query(
'SELECT id FROM workout_logs WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4',
[user_id, program_exercise_id, date, set_number]
);
let result;
if (existing.rows.length > 0) {
// Update existing
result = await pool.query(
'UPDATE workout_logs SET weight = $1, reps = $2, completed = $3 WHERE id = $4 RETURNING *',
[weight, reps, completed, existing.rows[0].id]
);
} else {
// Insert new
result = await pool.query(
'INSERT INTO workout_logs (user_id, program_exercise_id, date, set_number, weight, reps, completed) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *',
[user_id, program_exercise_id, date, set_number, weight, reps, completed]
);
}
res.json(result.rows[0]);
} catch (err) {
console.error('Error logging set:', err);
res.status(500).json({ error: 'Database error' });
}
});
// Delete a specific set log
app.delete('/api/logs', async (req, res) => {
try {
const { user_id, program_exercise_id, date, set_number } = req.body;
const result = await pool.query(
'DELETE FROM workout_logs WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4 RETURNING id',
[user_id, program_exercise_id, date, set_number]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Log not found' });
}
res.json({ deleted: result.rows[0].id });
} catch (err) {
console.error('Error deleting log:', err);
res.status(500).json({ error: 'Database error' });
}
});
// Calculate suggested weight based on progression
app.get('/api/progression/:programExerciseId', async (req, res) => {
try {
@@ -498,3 +397,370 @@ app.get('/api/today/:programId', async (req, res) => {
app.listen(PORT, () => {
console.log(`Gravl API running on port ${PORT}`);
});
// ============================================
// Custom Workouts API (Phase 4: Workout Modification)
// ============================================
// Get all exercises (for picker UI)
app.get('/api/exercises', async (req, res) => {
try {
const result = await pool.query(
'SELECT id, name, muscle_group, description FROM exercises ORDER BY muscle_group, name'
);
res.json(result.rows);
} catch (err) {
console.error('Error fetching exercises:', err);
res.status(500).json({ error: 'Database error' });
}
});
// Create custom workout from program day (fork)
app.post('/api/custom-workouts', authMiddleware, async (req, res) => {
const client = await pool.connect();
try {
const { source_program_day_id, name, description } = req.body;
const user_id = req.user.id;
await client.query('BEGIN');
// Get the program day info and its exercises
const dayResult = await client.query(
'SELECT name, program_id FROM program_days WHERE id = $1',
[source_program_day_id]
);
if (dayResult.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Program day not found' });
}
const dayName = dayResult.rows[0].name;
const workoutName = name || `${dayName} (anpassad)`;
// Create custom workout
const workoutResult = await client.query(
`INSERT INTO custom_workouts (user_id, name, description, source_program_day_id)
VALUES ($1, $2, $3, $4) RETURNING *`,
[user_id, workoutName, description || null, source_program_day_id]
);
const customWorkout = workoutResult.rows[0];
// Copy exercises from program day
const exercisesResult = await client.query(
`INSERT INTO custom_workout_exercises
(custom_workout_id, exercise_id, sets, reps_min, reps_max, order_index, replaced_exercise_id)
SELECT $1, exercise_id, sets, reps_min, reps_max, order_num, NULL
FROM program_exercises WHERE program_day_id = $2
RETURNING *`,
[customWorkout.id, source_program_day_id]
);
await client.query('COMMIT');
res.json({
...customWorkout,
exercises: exercisesResult.rows
});
} catch (err) {
await client.query('ROLLBACK');
console.error('Error creating custom workout:', err);
res.status(500).json({ error: 'Database error' });
} finally {
client.release();
}
});
// List user's custom workouts
app.get('/api/custom-workouts', authMiddleware, async (req, res) => {
try {
const user_id = req.user.id;
const result = await pool.query(
`SELECT cw.*, pd.name as original_day_name, p.name as program_name
FROM custom_workouts cw
LEFT JOIN program_days pd ON cw.source_program_day_id = pd.id
LEFT JOIN programs p ON pd.program_id = p.id
WHERE cw.user_id = $1
ORDER BY cw.created_at DESC`,
[user_id]
);
res.json(result.rows);
} catch (err) {
console.error('Error fetching custom workouts:', err);
res.status(500).json({ error: 'Database error' });
}
});
// Get single custom workout with exercises
app.get('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
try {
const user_id = req.user.id;
const workout_id = req.params.id;
// Get workout header
const workoutResult = await pool.query(
`SELECT cw.*, pd.name as original_day_name, p.name as program_name
FROM custom_workouts cw
LEFT JOIN program_days pd ON cw.source_program_day_id = pd.id
LEFT JOIN programs p ON pd.program_id = p.id
WHERE cw.id = $1 AND cw.user_id = $2`,
[workout_id, user_id]
);
if (workoutResult.rows.length === 0) {
return res.status(404).json({ error: 'Custom workout not found' });
}
// Get exercises with full details
const exercisesResult = await pool.query(
`SELECT cwe.*, e.name, e.muscle_group, e.description,
re.name as replaced_exercise_name,
re.muscle_group as replaced_exercise_muscle_group
FROM custom_workout_exercises cwe
JOIN exercises e ON cwe.exercise_id = e.id
LEFT JOIN exercises re ON cwe.replaced_exercise_id = re.id
WHERE cwe.custom_workout_id = $1
ORDER BY cwe.order_index`,
[workout_id]
);
res.json({
...workoutResult.rows[0],
exercises: exercisesResult.rows
});
} catch (err) {
console.error('Error fetching custom workout:', err);
res.status(500).json({ error: 'Database error' });
}
});
// Update custom workout exercises (replace all)
app.put('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
const client = await pool.connect();
try {
const user_id = req.user.id;
const workout_id = req.params.id;
const { name, description, exercises } = req.body;
await client.query('BEGIN');
// Verify ownership
const workoutCheck = await client.query(
'SELECT id FROM custom_workouts WHERE id = $1 AND user_id = $2',
[workout_id, user_id]
);
if (workoutCheck.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Custom workout not found' });
}
// Update workout details
if (name || description !== undefined) {
await client.query(
`UPDATE custom_workouts
SET name = COALESCE($1, name),
description = COALESCE($2, description),
updated_at = CURRENT_TIMESTAMP
WHERE id = $3`,
[name, description, workout_id]
);
}
// Replace exercises if provided
if (exercises && Array.isArray(exercises)) {
// Delete existing exercises
await client.query(
'DELETE FROM custom_workout_exercises WHERE custom_workout_id = $1',
[workout_id]
);
// Insert new exercises
for (let i = 0; i < exercises.length; i++) {
const ex = exercises[i];
await client.query(
`INSERT INTO custom_workout_exercises
(custom_workout_id, exercise_id, sets, reps_min, reps_max, order_index, replaced_exercise_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[workout_id, ex.exercise_id, ex.sets || 3, ex.reps_min || 8, ex.reps_max || 12,
i, ex.replaced_exercise_id || null]
);
}
}
await client.query('COMMIT');
// Fetch and return updated workout
const updatedResult = await pool.query(
`SELECT cw.*, pd.name as original_day_name, p.name as program_name
FROM custom_workouts cw
LEFT JOIN program_days pd ON cw.source_program_day_id = pd.id
LEFT JOIN programs p ON pd.program_id = p.id
WHERE cw.id = $1`,
[workout_id]
);
const exercisesResult = await pool.query(
`SELECT cwe.*, e.name, e.muscle_group, e.description
FROM custom_workout_exercises cwe
JOIN exercises e ON cwe.exercise_id = e.id
WHERE cwe.custom_workout_id = $1
ORDER BY cwe.order_index`,
[workout_id]
);
res.json({
...updatedResult.rows[0],
exercises: exercisesResult.rows
});
} catch (err) {
await client.query('ROLLBACK');
console.error('Error updating custom workout:', err);
res.status(500).json({ error: 'Database error' });
} finally {
client.release();
}
});
// Delete custom workout
app.delete('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
try {
const user_id = req.user.id;
const workout_id = req.params.id;
const result = await pool.query(
'DELETE FROM custom_workouts WHERE id = $1 AND user_id = $2 RETURNING id',
[workout_id, user_id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Custom workout not found' });
}
res.json({ deleted: result.rows[0].id });
} catch (err) {
console.error('Error deleting custom workout:', err);
res.status(500).json({ error: 'Database error' });
}
});
// ============================================
// Updated Log Endpoints (support source_type)
// ============================================
// Get workout logs (optionally filter by source_type and custom_workout_id)
app.get('/api/logs', async (req, res) => {
try {
const { user_id, date, source_type, custom_workout_id } = req.query;
let query = 'SELECT * FROM workout_logs WHERE user_id = $1';
let params = [user_id];
let paramIdx = 2;
if (date) {
query += ` AND date = $${paramIdx++}`;
params.push(date);
}
if (source_type) {
query += ` AND source_type = $${paramIdx++}`;
params.push(source_type);
}
if (custom_workout_id) {
query += ` AND custom_workout_id = $${paramIdx++}`;
params.push(custom_workout_id);
}
query += ' ORDER BY date DESC, set_number ASC';
const result = await pool.query(query, params);
res.json(result.rows);
} catch (err) {
console.error('Error fetching logs:', err);
res.status(500).json({ error: 'Database error' });
}
});
// Log a set (updated for source_type and custom_workout support)
app.post('/api/logs', async (req, res) => {
try {
const { user_id, program_exercise_id, custom_workout_exercise_id, date, set_number, weight, reps, completed, source_type, custom_workout_id } = req.body;
const source = source_type || 'program';
// Determine which exercise identifier to use for lookup
const exerciseRef = custom_workout_exercise_id || program_exercise_id;
// Check if log exists for this set
let existingQuery, existingParams;
if (source === 'custom' && custom_workout_id) {
existingQuery = `SELECT id FROM workout_logs
WHERE user_id = $1 AND custom_workout_id = $2 AND date = $3 AND set_number = $4`;
existingParams = [user_id, custom_workout_id, date, set_number];
} else {
existingQuery = `SELECT id FROM workout_logs
WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4`;
existingParams = [user_id, program_exercise_id, date, set_number];
}
const existing = await pool.query(existingQuery, existingParams);
let result;
if (existing.rows.length > 0) {
// Update existing
result = await pool.query(
`UPDATE workout_logs
SET weight = $1, reps = $2, completed = $3, source_type = $4
WHERE id = $5 RETURNING *`,
[weight, reps, completed, source, existing.rows[0].id]
);
} else {
// Insert new
result = await pool.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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
[user_id, program_exercise_id, custom_workout_exercise_id, date, set_number,
weight, reps, completed, source, custom_workout_id]
);
}
res.json(result.rows[0]);
} catch (err) {
console.error('Error logging set:', err);
res.status(500).json({ error: 'Database error' });
}
});
// Delete a specific set log (updated for source_type support)
app.delete('/api/logs', async (req, res) => {
try {
const { user_id, program_exercise_id, custom_workout_id, date, set_number } = req.body;
let query, params;
if (custom_workout_id) {
query = `DELETE FROM workout_logs
WHERE user_id = $1 AND custom_workout_id = $2 AND date = $3 AND set_number = $4
RETURNING id`;
params = [user_id, custom_workout_id, date, set_number];
} else {
query = `DELETE FROM workout_logs
WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4
RETURNING id`;
params = [user_id, program_exercise_id, date, set_number];
}
const result = await pool.query(query, params);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Log not found' });
}
res.json({ deleted: result.rows[0].id });
} catch (err) {
console.error('Error deleting log:', err);
res.status(500).json({ error: 'Database error' });
}
});
+35
View File
@@ -179,3 +179,38 @@ INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps
(6, 16, 4, 10, 12, 4), -- Leg Curls 4x10-12
(6, 17, 4, 12, 15, 5) -- Calf Raises 4x12-15
ON CONFLICT DO NOTHING;
-- Custom workouts created by users
CREATE TABLE IF NOT EXISTS custom_workouts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
source_program_day_id INTEGER REFERENCES program_days(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Exercises within a custom workout
CREATE TABLE IF NOT EXISTS custom_workout_exercises (
id SERIAL PRIMARY KEY,
custom_workout_id INTEGER NOT NULL REFERENCES custom_workouts(id) ON DELETE CASCADE,
exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
sets INTEGER NOT NULL DEFAULT 3,
reps_min INTEGER NOT NULL DEFAULT 8,
reps_max INTEGER NOT NULL DEFAULT 12,
rpe_target DECIMAL(3,1),
replaced_exercise_id INTEGER REFERENCES exercises(id) ON DELETE SET NULL,
order_index INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Extend workout_logs to support custom workouts
ALTER TABLE workout_logs
ADD COLUMN IF NOT EXISTS source_type VARCHAR(20) DEFAULT 'program' CHECK (source_type IN ('program', 'custom')),
ADD COLUMN IF NOT EXISTS custom_workout_id INTEGER REFERENCES custom_workouts(id) ON DELETE SET NULL;
-- Indexes for custom workout tables
CREATE INDEX IF NOT EXISTS idx_custom_workouts_user ON custom_workouts(user_id);
CREATE INDEX IF NOT EXISTS idx_custom_workout_exercises_workout ON custom_workout_exercises(custom_workout_id);
CREATE INDEX IF NOT EXISTS idx_workout_logs_custom_workout ON workout_logs(custom_workout_id);
+37
View File
@@ -0,0 +1,37 @@
-- Migration 004: Add custom workout support
-- Allows users to create personalized workout plans based on program days
-- Custom workouts created by users
CREATE TABLE IF NOT EXISTS custom_workouts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
source_program_day_id INTEGER REFERENCES program_days(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Exercises within a custom workout
CREATE TABLE IF NOT EXISTS custom_workout_exercises (
id SERIAL PRIMARY KEY,
custom_workout_id INTEGER NOT NULL REFERENCES custom_workouts(id) ON DELETE CASCADE,
exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
sets INTEGER NOT NULL DEFAULT 3,
reps_min INTEGER NOT NULL DEFAULT 8,
reps_max INTEGER NOT NULL DEFAULT 12,
rpe_target DECIMAL(3,1),
replaced_exercise_id INTEGER REFERENCES exercises(id) ON DELETE SET NULL,
order_index INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Extend workout_logs to support custom workouts
ALTER TABLE workout_logs
ADD COLUMN IF NOT EXISTS source_type VARCHAR(20) DEFAULT 'program' CHECK (source_type IN ('program', 'custom')),
ADD COLUMN IF NOT EXISTS custom_workout_id INTEGER REFERENCES custom_workouts(id) ON DELETE SET NULL;
-- Indexes
CREATE INDEX IF NOT EXISTS idx_custom_workouts_user ON custom_workouts(user_id);
CREATE INDEX IF NOT EXISTS idx_custom_workout_exercises_workout ON custom_workout_exercises(custom_workout_id);
CREATE INDEX IF NOT EXISTS idx_workout_logs_custom_workout ON workout_logs(custom_workout_id);