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:
@@ -0,0 +1,85 @@
|
|||||||
|
# Phase 4: Workout Modification
|
||||||
|
|
||||||
|
**Started:** 2026-03-01
|
||||||
|
**Goal:** Let users customize program workouts by swapping or adding exercises, creating personal forks
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Users want flexibility within their program structure. Currently:
|
||||||
|
- Can't swap an exercise (e.g., replace bench press with dumbbell press due to equipment availability)
|
||||||
|
- Can't add exercises to a program workout (e.g., add face pulls to Push day)
|
||||||
|
- Any modification would require building a completely custom workout
|
||||||
|
|
||||||
|
## Solution: Workout Forking
|
||||||
|
|
||||||
|
When user modifies a program workout, we:
|
||||||
|
1. Copy the program workout to `custom_workouts` table
|
||||||
|
2. Store modifications in `custom_workout_exercises` table
|
||||||
|
3. Save workout logs with `source_type = 'custom'` to track lineage
|
||||||
|
4. Original program remains unchanged for future sessions
|
||||||
|
|
||||||
|
## Phase Plans
|
||||||
|
|
||||||
|
### 04-01: Database Schema Migration
|
||||||
|
- Create `custom_workouts` table
|
||||||
|
- Create `custom_workout_exercises` table
|
||||||
|
- Add `source_type` enum column to `workout_logs`
|
||||||
|
- Migration script with rollback
|
||||||
|
|
||||||
|
### 04-02: Backend API for Custom Workouts
|
||||||
|
- POST /api/custom-workouts (create from program workout)
|
||||||
|
- PUT /api/custom-workouts/:id (update exercises)
|
||||||
|
- GET /api/custom-workouts/:id (fetch with exercises)
|
||||||
|
- GET /api/custom-workouts (list user's custom workouts)
|
||||||
|
- Update workout log save to handle source_type
|
||||||
|
|
||||||
|
### 04-03: Frontend - Workout Edit Mode
|
||||||
|
- "Edit Workout" button on WorkoutSelectPage
|
||||||
|
- Exercise picker modal/component
|
||||||
|
- Swap exercise UI flow
|
||||||
|
- Add exercise UI flow
|
||||||
|
- Fork confirmation dialog
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] User can replace any exercise in a program workout
|
||||||
|
- [ ] User can add exercises to a program workout
|
||||||
|
- [ ] Modified workout saves as custom_workout (original program unchanged)
|
||||||
|
- [ ] Subsequent workout sessions use the forked custom workout
|
||||||
|
- [ ] User can see which workouts are custom vs program originals
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- custom_workouts: Stores the forked workout header
|
||||||
|
custom_workouts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
original_program_day_id INTEGER REFERENCES program_days(id),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- custom_workout_exercises: Stores exercises in custom workout
|
||||||
|
custom_workout_exercises (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
custom_workout_id INTEGER REFERENCES custom_workouts(id) ON DELETE CASCADE,
|
||||||
|
exercise_id INTEGER REFERENCES exercises(id) ON DELETE CASCADE,
|
||||||
|
set_order INTEGER NOT NULL,
|
||||||
|
sets INTEGER DEFAULT 3,
|
||||||
|
reps INTEGER DEFAULT 10,
|
||||||
|
weight_preset DECIMAL(5,2),
|
||||||
|
UNIQUE(custom_workout_id, set_order)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- workout_logs.source_type: Tracks where log came from
|
||||||
|
ALTER TABLE workout_logs ADD COLUMN source_type VARCHAR(20) DEFAULT 'program';
|
||||||
|
-- Values: 'program', 'custom'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Building completely custom workouts from scratch (v2/CUS-01)
|
||||||
|
- Reusable custom workout templates (v2/CUS-02)
|
||||||
|
- Complex program modifications (reordering days, changing structure)
|
||||||
+6
-5
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"lastRun": "2026-02-28T23:45:00+01:00",
|
"lastRun": "2026-03-01T04:00:00+01:00",
|
||||||
"status": "completed",
|
"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"],
|
"phase": "04-workout-modification",
|
||||||
"activeTask": null,
|
"activeTask": "04-02-backend-api",
|
||||||
"nextTask": null,
|
"tasksCompleted": ["01-input-ux", "02-flexible-sets", "03-design-polish", "04-01-schema-migration", "04-02-backend-api"],
|
||||||
"notes": "03-03 Workout Experience Polish complete (commit f6b1379). Phase 3 complete: login/onboarding, dashboard, and workout experience all polished."
|
"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
@@ -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
|
// Calculate suggested weight based on progression
|
||||||
app.get('/api/progression/:programExerciseId', async (req, res) => {
|
app.get('/api/progression/:programExerciseId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -498,3 +397,370 @@ app.get('/api/today/:programId', async (req, res) => {
|
|||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Gravl API running on port ${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
@@ -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, 16, 4, 10, 12, 4), -- Leg Curls 4x10-12
|
||||||
(6, 17, 4, 12, 15, 5) -- Calf Raises 4x12-15
|
(6, 17, 4, 12, 15, 5) -- Calf Raises 4x12-15
|
||||||
ON CONFLICT DO NOTHING;
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user