{workout.name}
+{exercises.length} övningar
+diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d3e4ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build & dist +dist/ +build/ +*.bundle.js +*.bundle.css + +# Environment +.env +.env.local +.env.*.local +.env.production.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# OS +Thumbs.db +.DS_Store + +# Logs +*.log +logs/ + +# Test coverage +.coverage/ +coverage/ + +# Python +*.pyc +__pycache__/ +*.py~ + +# Staging +/tmp/ +/staging-*/ + +# Planning & Documentation (kept locally, not in repo) +.planning/ +TODO.md +./frontend/.planning/ +./frontend/tasks/ +./docs/plans/ +.claude/settings.local.json + +# Build output & dist +dist/ +build/ +frontend/dist/ + +# Build artifacts & temp files +*.py +PY diff --git a/.pm-checkpoint.json b/.pm-checkpoint.json index c376f89..0935566 100644 --- a/.pm-checkpoint.json +++ b/.pm-checkpoint.json @@ -1,8 +1,20 @@ { - "lastRun": "2026-02-28T23:45:00+01:00", + "lastRun": "2026-03-01T20:42: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-05-reset-to-original", + "tasksCompleted": ["01-input-ux", "02-flexible-sets", "03-design-polish", "04-01-schema-migration", "04-02-backend-api", "04-03-frontend-workout-edit", "04-04-visual-distinction", "04-05-reset-to-original"], + "nextTask": "04-06-persistence-improvements", + "agentSession": "local-exec", + "agentType": "gravl-pm-cron", + "spawnTime": "2026-03-01T20:42:00+01:00", + "result": "Phase 04-05 complete. Reset to Original feature fully implemented. Changes: 1) Added refresh button to custom workout cards (visible only on 'Anpassad' workouts). 2) Implemented handleResetClick + handleConfirmReset flow with confirmation dialog ('Är du säker? Dina ändringar kommer att försvinna...'). 3) DELETE /api/custom-workouts/:id endpoint verified (exists in backend). 4) Added CSS styling: .reset-btn (orange icon button with hover effects), .success-message (green slide-down animation), .modal-overlay/.modal-dialog/.modal-btn (reusable confirmation dialog). 5) Added refresh icon to Icons.jsx SVG library. Frontend build successful with no errors.", + "notes": "Task 04-05 complete. Custom workouts can now be reset to original program versions. User gets confirmation dialog before deletion. UI updates show badge change from 'Anpassad' to 'Program' after reset. Next: 04-06 (persistence improvements or advanced features like workout export/backup).", + "filesModified": [ + "frontend/src/pages/WorkoutSelectPage.jsx", + "frontend/src/App.css", + "frontend/src/components/Icons.jsx" + ], + "buildStatus": "success", + "buildTime": "3.59s" } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..89e504d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,171 @@ +# CLAUDE.md — Agent Development Guidelines + +This is the foundation for developing Claude agents and autonomous systems in the Gravl ecosystem. + +## Core Principles + +### 1. Autonomy with Verification +- Agents execute tasks independently (autonomy) +- **Always verify results** after delegation (no hallucinations) +- Verification pattern: `git status`, `git log`, `ls`, diff before checkpoint update +- Never report completion without checking actual work + +### 2. Checkpoint-Based Self-Monitoring +All long-running tasks use checkpoint files: + +```json +{ + "lastRun": "2026-03-02T08:00:00Z", + "status": "completed|blocked|interrupted|error", + "result": "Summary of work", + "nextCheck": "What to do next" +} +``` + +**Recovery logic:** +- If `lastRun > 60min` OR `status ≠ "completed"` → trigger recovery +- Log recovery attempts to help debugging +- Use simple JSON for checkpoint files (no complex parsing) + +### 3. PM (Project Manager) Autonomy +The Gravl PM agent: +- Plans sprints/phases autonomously +- Spawns specialized agents (frontend-dev, backend-dev, etc.) +- Verifies their work before checkpoint completion +- Reports progress to Telegram (not silent failures) +- Timeout: 15 minutes (900s) per cron cycle + +### 4. Generalized Agents (Reusable) +**Never create project-specific agents.** + +Use generalized agents instead: +- `frontend-dev` — React/CSS specialist +- `backend-dev` — Node.js/PostgreSQL specialist +- `architect` — System design +- `reviewer` — Code review +- `browser-tester` — E2E testing + QA + +These are in `~/clawd/claude-agents-skills/agents/` and symlinked to `~/clawd/agents/`. + +### 5. Single Source of Truth +All skills and agents live in ONE central repo: +- **Hub location:** `~/clawd/claude-agents-skills/` +- **Symlinks from:** `~/clawd/skills/` and `~/clawd/agents/` +- **Commit everything to hub repo** +- This enables sharing, versioning, and collaboration + +## Development Workflow + +### Adding a New Agent + +1. Create in hub: `~/clawd/claude-agents-skills/agents/my-agent/` +2. Write `SOUL.md` (agent definition + personality) +3. Optional: Add `README.md`, scripts, config +4. Symlink automatically created: `~/clawd/agents/my-agent → hub/agents/my-agent` +5. Commit to hub repo + +### Adding a New Skill + +1. Create in hub: `~/clawd/claude-agents-skills/skills/my-skill/` +2. Write `SKILL.md` (how to use it) +3. Add code/scripts as needed +4. Symlink automatically created: `~/clawd/skills/my-skill → hub/skills/my-skill` +5. Commit to hub repo + +### Verification Pattern (CRITICAL) + +After any subagent completes work: + +```bash +# 1. Check git status +git status + +# 2. Verify files changed +git log --oneline -3 + +# 3. Inspect actual changes +git diff HEAD~1 + +# 4. THEN update checkpoint +echo '{"status":"completed",...}' > checkpoint.json +``` + +**This prevents hallucination bugs** where agents claim work they didn't do. + +## Communication + +### Report-Only Pattern +- PM drives autonomously +- Silence = approval (no blocking) +- Only report at milestones or blocking issues +- Use Telegram for delivery (channel: telegram) + +### Cron Jobs (3 active) +| Job | Schedule | Timeout | Checkpoint | +|-----|----------|---------|-----------| +| Gravl PM | Every 30m | 15 min | `/workspace/gravl/.pm-checkpoint.json` | +| Vietnam Flights | Daily 09:00 | 2 min | `~/.checkpoint-vietnam-flights.json` | +| System Updates | Daily 10:00 | 5 min | `~/.checkpoint-system-updates.json` | + +All use explicit `"channel: telegram"` for Telegram delivery. + +## Code Conventions + +See `CODING-CONVENTIONS.md` for: +- Frontend (React, CSS) +- Backend (Express, PostgreSQL) +- Database (schema, migrations) +- Testing (Playwright, E2E) + +## Repository Structure + +``` +/workspace/gravl/ +├── frontend/ # React app +├── backend/ # Node.js API +├── db/ # Database setup +├── scripts/ # Automation +├── docker/ # Compose files +├── docs/ +│ └── CODING-CONVENTIONS.md # Technical standards +├── README.md # Project overview +├── CLAUDE.md # This file (agent guidelines) +└── .gitignore # Excludes planning docs, node_modules +``` + +## Local-Only Files (Not in Git) + +These stay on disk but are excluded from `.git` via `.gitignore`: +- `.planning/` — research, requirements, roadmap +- `TODO.md` — task tracking +- `frontend/tasks/` — feature tasks +- `docs/plans/` — planning notes + +This keeps the repo clean while preserving your planning work locally. + +## Key Decisions + +1. **Generalized agents over project-specific** — More reusable, easier to maintain +2. **Single hub repo** — Centralized versioning + easy sharing +3. **Symlinks for discovery** — OpenClaw finds skills/agents automatically +4. **Verification protocol** — Prevents hallucination bugs +5. **Checkpoint-based recovery** — Self-healing cron jobs +6. **Telegram for delivery** — Explicit channel to avoid missed messages + +## For the PM Agent + +The Gravl PM uses this playbook: + +1. **Plan phase** → Identify tasks, delegate to specialized agents +2. **Execute phase** → Spawn agents, monitor progress +3. **Verify phase** → Check git status, diffs, logs (NO HALLUCINATIONS) +4. **Report phase** → Send Telegram update with result or blocking issue +5. **Checkpoint phase** → Update checkpoint.json with status + nextCheck + +PM runs every 30 minutes autonomously. No human approval needed unless blocked. + +--- + +**Last Updated:** 2026-03-02 +**Version:** 1.0 +**For questions:** Check specific agent SOUL.md or skill SKILL.md files diff --git a/backend/src/index.js b/backend/src/index.js index 044336a..cc21852 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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 { @@ -495,6 +394,373 @@ app.get('/api/today/:programId', async (req, res) => { } }); -app.listen(PORT, () => { +app.listen(PORT, '0.0.0.0', () => { 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' }); + } +}); + diff --git a/db/init.sql b/db/init.sql index c83d547..93dce65 100644 --- a/db/init.sql +++ b/db/init.sql @@ -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); diff --git a/db/migrations/004_add_custom_workouts.sql b/db/migrations/004_add_custom_workouts.sql new file mode 100644 index 0000000..adbf2ab --- /dev/null +++ b/db/migrations/004_add_custom_workouts.sql @@ -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); diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..ff2fe38 --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,21 @@ +version: "3.8" +services: + gravl-frontend: + container_name: staging-gravl-frontend-PLACEHOLDER + labels: + - traefik.enable=true + - traefik.http.routers.staging-gravl-PLACEHOLDER.rule=Host(`PLACEHOLDER.gravl.homelab.local`) + - traefik.http.routers.staging-gravl-PLACEHOLDER.entrypoints=websecure + - traefik.http.routers.staging-gravl-PLACEHOLDER.tls=true + - traefik.http.routers.staging-gravl-PLACEHOLDER.service=staging-gravl-PLACEHOLDER + - traefik.http.services.staging-gravl-PLACEHOLDER.loadbalancer.server.port=80 + + gravl-backend: + container_name: staging-gravl-backend-PLACEHOLDER + labels: + - traefik.enable=true + - traefik.http.routers.staging-gravl-PLACEHOLDER-api.rule=Host(`PLACEHOLDER.api.gravl.homelab.local`) + - traefik.http.routers.staging-gravl-PLACEHOLDER-api.entrypoints=websecure + - traefik.http.routers.staging-gravl-PLACEHOLDER-api.tls=true + - traefik.http.routers.staging-gravl-PLACEHOLDER-api.service=staging-gravl-PLACEHOLDER-api + - traefik.http.services.staging-gravl-PLACEHOLDER-api.loadbalancer.server.port=3001 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3050ef2..9fbace7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "react-router-dom": "^6.21.0" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", @@ -742,6 +743,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -1481,6 +1498,53 @@ "dev": true, "license": "ISC" }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5ad8420..83c5e54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "react-router-dom": "^6.21.0" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js new file mode 100644 index 0000000..a158b5f --- /dev/null +++ b/frontend/playwright.config.js @@ -0,0 +1,12 @@ +module.exports = { + testDir: "./tests", + use: { + baseURL: process.env.STAGING_URL || "https://gravl.homelab.local", + headless: true, + screenshot: "only-on-failure", + }, + projects: [{ + name: "chromium", + use: { browserName: "chromium" } + }] +}; diff --git a/frontend/src/App.css b/frontend/src/App.css index 0ae13cd..33ef817 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2958,3 +2958,213 @@ border: 2px solid var(--border); display: flex; align-items: center; justify-content: center; } .warmup-item.done .warmup-check { background: var(--success); border-color: var(--success); color: white; } + +/* Workout badge styling */ +.workout-badge-container { + position: relative; + display: flex; + align-items: flex-end; +} + +.workout-badge { + position: absolute; + bottom: -6px; + right: -6px; + font-size: var(--font-xs); + font-weight: 600; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + border: 1px solid transparent; + text-transform: uppercase; + letter-spacing: 0.5px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); + white-space: nowrap; + color: white; +} + +.workout-badge.custom { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.workout-badge.program { + background: var(--text-muted); + color: white; + border-color: var(--text-muted); + opacity: 0.7; +} + +.workout-select-card:hover .workout-badge { + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); +} + +/* Reset button for custom workouts */ +.reset-btn { + position: absolute; + top: -8px; + right: -8px; + width: 32px; + height: 32px; + border-radius: var(--radius-full); + background: var(--accent); + border: 2px solid var(--bg-primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(255, 107, 74, 0.3); + padding: 0; + min-width: 32px; + min-height: 32px; +} + +.reset-btn:hover { + background: #e85a3c; + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(255, 107, 74, 0.4); +} + +.reset-btn:active { + transform: scale(0.95); +} + +/* Success message */ +.success-message { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background: linear-gradient(135deg, var(--success), #16a34a); + color: white; + border-radius: var(--radius-lg); + margin-bottom: var(--space-4); + animation: slideDown 0.3s ease; + box-shadow: 0 4px 12px rgba(34, 197, 94, 0.2); +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Modal dialog styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal-dialog { + background: var(--bg-primary); + border-radius: var(--radius-xl); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + max-width: 400px; + width: 90%; + animation: slideUp 0.3s ease; + overflow: hidden; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-header { + padding: var(--space-4); + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + font-size: var(--font-lg); + font-weight: 700; + margin: 0; + color: var(--text-primary); +} + +.modal-body { + padding: var(--space-4); +} + +.modal-body p { + font-size: var(--font-md); + color: var(--text-secondary); + line-height: 1.5; + margin: 0; +} + +.modal-footer { + padding: var(--space-4); + border-top: 1px solid var(--border); + display: flex; + gap: var(--space-2); + justify-content: flex-end; +} + +.modal-btn { + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-md); + border: none; + cursor: pointer; + font-size: var(--font-md); + font-weight: 600; + transition: all 0.2s ease; + min-height: 40px; +} + +.modal-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.modal-btn.cancel { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.modal-btn.cancel:hover:not(:disabled) { + background: var(--bg-tertiary); + border-color: var(--border); +} + +.modal-btn.confirm { + background: var(--accent); + color: white; +} + +.modal-btn.confirm:hover:not(:disabled) { + background: #e85a3c; + box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3); +} + +.modal-btn.confirm:active:not(:disabled) { + transform: scale(0.98); +} diff --git a/frontend/src/components/ExercisePicker.jsx b/frontend/src/components/ExercisePicker.jsx new file mode 100644 index 0000000..316b4fc --- /dev/null +++ b/frontend/src/components/ExercisePicker.jsx @@ -0,0 +1,112 @@ +import { useState, useEffect, useMemo } from 'react' +import { Icon } from './Icons' + +const API_URL = '/api' + +function ExercisePicker({ open, onSelect, onClose, excludeIds = [] }) { + const [exercises, setExercises] = useState([]) + const [loading, setLoading] = useState(false) + const [search, setSearch] = useState('') + const [activeGroup, setActiveGroup] = useState('Alla') + + useEffect(() => { + if (open) { + fetchExercises() + setSearch('') + setActiveGroup('Alla') + } + }, [open]) + + const fetchExercises = async () => { + setLoading(true) + try { + const res = await fetch(`${API_URL}/exercises`) + if (!res.ok) throw new Error('Failed to fetch') + const data = await res.json() + setExercises(data) + } catch (err) { + console.error('Failed to fetch exercises:', err) + } finally { + setLoading(false) + } + } + + const muscleGroups = useMemo(() => { + const groups = new Set(exercises.map(e => e.muscle_group).filter(Boolean)) + return ['Alla', ...Array.from(groups).sort()] + }, [exercises]) + + const filtered = useMemo(() => { + return exercises.filter(ex => { + if (excludeIds.includes(ex.id)) return false + if (activeGroup !== 'Alla' && ex.muscle_group !== activeGroup) return false + if (search) { + const q = search.toLowerCase() + return ex.name.toLowerCase().includes(q) || + (ex.muscle_group || '').toLowerCase().includes(q) + } + return true + }) + }, [exercises, search, activeGroup, excludeIds]) + + if (!open) return null + + return ( +
Vi hittade ett utkast från din senaste redigering. Vill du fortsätta eller börja om?
+{exercises.length} övningar
+Är du säker? Dina ändringar kommer att försvinna och passet återställs till programversionen.
+