13 Commits

Author SHA1 Message Date
clawd 99cccb7a04 fix: make backend listen on 0.0.0.0 instead of localhost
This allows Traefik and other containers on the docker network to reach the backend API.
2026-03-02 09:12:57 +01:00
clawd 05dec24d0f chore: remove stray EOF and PLANEOF files 2026-03-02 09:03:43 +01:00
clawd dbb8da140e docs: add CLAUDE.md — agent development guidelines
- Core principles for autonomous agents with verification
- Checkpoint-based self-monitoring patterns
- Generalized agent workflow (no project-specific agents)
- Single source of truth in ~/clawd/claude-agents-skills/
- PM autonomy and cron job configuration
- Verification protocol to prevent hallucinations
- Together with CODING-CONVENTIONS.md, foundation for agent development
2026-03-02 09:00:32 +01:00
clawd bbb2d34d90 04-06-02: Save error handling & retry logic
- Added specific error type differentiation:
  * Network errors → 'Anslutning misslyckades'
  * Validation (400) → 'Ogiltiga ändringar'
  * Auth (401/403) → 'Saknar behörighet'
  * Server (500+) → 'Serverfel'
  * Generic fallback messages

- Implemented retry tracking:
  * retryCount state for monitoring attempts
  * lastSavePayload storage for potential retry (future feature)
  * Console logging with context for debugging

- Enhanced error handling:
  * getErrorMessage() function for error classification
  * Comprehensive error logging with workout/exercise context
  * Draft preserved on all error types (no data loss)

- Improved UI/UX:
  * Error banner with specific, actionable messages
  * 'Försök igen' button with retry tracking
  * Sync status feedback (idle/saving/saved/error)
  * Success checkmark animation (2s duration)
  * Spinner animation during save

- CSS Enhancements:
  * @keyframes spin for loading spinner
  * @keyframes slideInCheckmark for success feedback
  * Mobile-responsive error banner (flex column on <480px)
  * Smooth animations for state transitions

Tests: npm run build ✓ (no syntax errors)
Files modified:
  - frontend/src/pages/WorkoutEditPage.jsx
  - frontend/src/pages/WorkoutEditPage.css
2026-03-02 09:00:20 +01:00
clawd 01f013c9d8 04-06: Plan persistence improvements and implement draft persistence
- Created 04-06-PLAN.md outlining persistence improvements phases
- Phase 04-06-01: Draft persistence via localStorage
  - Added useDraftWorkout hook for auto-saving/loading drafts
  - Integrated hook into WorkoutEditPage
  - Added draft recovery prompt UI
  - Drafts cleared after successful save
- Phase 04-06-02: Save error handling & retry (scaffolding)
  - Added error state and syncStatus tracking
  - Added handleRetry() for failed saves
  - Error banner with retry button
- Phase 04-06-03: Sync status UI (scaffolding)
  - Added visual feedback for save progress
  - Status indicators: saving, saved, error
  - Disabled UI during save to prevent conflicts
- Created comprehensive styles for new UI components

Status: 04-06-01 complete and integrated. Ready for testing.
2026-03-02 09:00:20 +01:00
clawd 50dc29097d 04-05: Reset to Original feature - custom workouts can be reverted to program versions
- Added reset button (refresh icon) to custom workout cards
- Implemented confirmation dialog to prevent accidental resets
- Integrated with DELETE /api/custom-workouts/:id endpoint
- Added CSS styling: reset button, success message, modal dialog
- Added refresh icon to SVG library
- Frontend build successful

Changes:
- frontend/src/pages/WorkoutSelectPage.jsx (reset flow logic)
- frontend/src/App.css (170 new lines for reset/modal styling)
- frontend/src/components/Icons.jsx (refresh icon)
- Checkpoint updated with task completion metadata
2026-03-02 09:00:19 +01:00
clawd 368071ecae feat(04-04-visual-distinction): Add custom vs program workout badges on WorkoutSelectPage
- Fetch custom workouts for authenticated user
- Display 'Anpassad' (custom) or 'Program' badge on each workout card
- Add badge component with orange accent for custom, muted color for program
- Badge positioned bottom-right of workout icon
- Responsive styling consistent with Gravl dark theme
- All build checks pass
2026-03-02 09:00:19 +01:00
clawd 843771e935 feat(04-03-partial): ExercisePicker and WorkoutEditPage components - swap/add/remove exercises with sets/reps editing 2026-03-02 09:00:19 +01:00
clawd 4d60a269ff test(e2e): add Playwright with browser tests for login, logo, dashboard 2026-03-02 09:00:19 +01:00
clawd 5c8dcb8b8e 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
2026-03-02 09:00:19 +01:00
clawd 64ec7cbe4c fix(staging): fix Traefik service linking with explicit service labels 2026-03-02 09:00:19 +01:00
clawd d52e795c75 feat(staging): add Traefik-based staging with automatic subdomains 2026-03-02 09:00:19 +01:00
clawd 45f3e5d099 feat(infra): add staging environment setup with docker-compose and scripts 2026-03-02 09:00:19 +01:00
30 changed files with 2391 additions and 179 deletions
+54
View File
@@ -0,0 +1,54 @@
# 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
+17 -5
View File
@@ -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"
}
+171
View File
@@ -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
View File
+368 -102
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 {
@@ -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' });
}
});
+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);
+21
View File
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<title>Gravl - Träning</title>
<script type="module" crossorigin src="/assets/index-hhKetRGz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-my_lGtI5.css">
<script type="module" crossorigin src="/assets/index-kl2SjtTw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D0xrERyI.css">
</head>
<body>
<div id="root"></div>
+64
View File
@@ -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",
+1
View File
@@ -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",
+12
View File
@@ -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" }
}]
};
+210
View File
@@ -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);
}
+112
View File
@@ -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 (
<div className="exercise-picker-overlay" onClick={onClose}>
<div className="exercise-picker" onClick={e => e.stopPropagation()}>
<div className="exercise-picker-header">
<h2>Välj övning</h2>
<button className="exercise-picker-close" onClick={onClose} aria-label="Stäng">
<Icon name="chevronDown" size={20} />
</button>
</div>
<div className="exercise-picker-search">
<input
type="text"
placeholder="Sök övning..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
<div className="exercise-picker-filters">
{muscleGroups.map(group => (
<button
key={group}
className={`filter-chip ${activeGroup === group ? 'active' : ''}`}
onClick={() => setActiveGroup(group)}
>
{group}
</button>
))}
</div>
<div className="exercise-picker-list">
{loading && <div className="exercise-picker-state">Laddar övningar...</div>}
{!loading && filtered.length === 0 && (
<div className="exercise-picker-state">Inga övningar hittades.</div>
)}
{!loading && filtered.map(ex => (
<button
key={ex.id}
className="exercise-picker-item"
onClick={() => onSelect(ex)}
>
<div className="exercise-picker-item-info">
<strong>{ex.name}</strong>
<span className="exercise-picker-item-group">{ex.muscle_group}</span>
</div>
<Icon name="arrowLeft" size={16} style={{ transform: 'rotate(180deg)', opacity: 0.4 }} />
</button>
))}
</div>
</div>
</div>
)
}
export default ExercisePicker
+24
View File
@@ -234,6 +234,24 @@ export const Icons = {
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
),
edit: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
),
search: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
),
x: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
),
// Brand
gravl: (
@@ -243,6 +261,12 @@ export const Icons = {
<line x1="8" y1="8" x2="16" y2="16"/>
</svg>
),
refresh: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 4v6h6M23 20v-6h-6"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
),
}
// Icon component wrapper
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
/**
* useDraftWorkout - Manages draft workout state with localStorage persistence
*
* @param {number} workoutId - Unique workout ID (used as localStorage key)
* @param {array} initialExercises - Initial exercise list
* @returns {object} { exercises, setExercises, clearDraft, hasDraft, restoreDraft }
*/
export function useDraftWorkout(workoutId, initialExercises = []) {
const [exercises, setExercises] = useState(initialExercises)
const [hasDraft, setHasDraft] = useState(false)
const draftKey = `workout-draft-${workoutId}`
// Load draft from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem(draftKey)
if (saved) {
try {
const draft = JSON.parse(saved)
setExercises(draft)
setHasDraft(true)
} catch (err) {
console.error('Failed to parse draft:', err)
localStorage.removeItem(draftKey) // Clear corrupted draft
}
}
}, [workoutId, draftKey])
// Auto-save to localStorage whenever exercises change
useEffect(() => {
if (exercises.length > 0) {
localStorage.setItem(draftKey, JSON.stringify(exercises))
}
}, [exercises, draftKey])
const clearDraft = () => {
localStorage.removeItem(draftKey)
setHasDraft(false)
}
const restoreDraft = () => {
const saved = localStorage.getItem(draftKey)
if (saved) {
try {
const draft = JSON.parse(saved)
setExercises(draft)
return true
} catch (err) {
console.error('Failed to restore draft:', err)
return false
}
}
return false
}
return {
exercises,
setExercises,
clearDraft,
hasDraft,
restoreDraft
}
}
+30
View File
@@ -0,0 +1,30 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import './styles/App.css'
import WorkoutPage from './pages/WorkoutPage'
const App = () => {
// Minimal placeholder data to mount the page standalone
const day = {
name: 'Push A',
day_number: 1,
exercises: [
{ id: 1, name: 'Bench Press', muscle_group: 'Bröst', sets: 3, reps_min: 8, reps_max: 12 },
{ id: 2, name: 'Overhead Press', muscle_group: 'Axlar', sets: 3, reps_min: 8, reps_max: 12 }
]
}
const week = 1
const logs = {}
const onBack = () => { console.log('Back') }
const fetchProgression = async (id) => ({ suggestedWeight: 20 })
const onLogSet = () => {}
const onDeleteSet = () => {}
return (
<div className="app">
<WorkoutPage day={day} week={week} logs={logs} onBack={onBack} fetchProgression={fetchProgression} onLogSet={onLogSet} onDeleteSet={onDeleteSet} />
</div>
)
}
const root = createRoot(document.getElementById('root'))
root.render(<App />)
+486
View File
@@ -0,0 +1,486 @@
.edit-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: white;
border-bottom: 1px solid #ddd;
gap: 1rem;
}
.page-header h1 {
flex: 1;
text-align: center;
font-size: 1.25rem;
margin: 0;
color: #333;
}
.back-btn,
.save-header-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
min-width: 44px;
}
.back-btn {
background: #f0f0f0;
color: #333;
}
.back-btn:hover:not(:disabled) {
background: #e0e0e0;
}
.back-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.save-header-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.sync-status {
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.4rem;
white-space: nowrap;
}
.sync-status.saved {
background: #d4edda;
color: #155724;
}
.sync-status.error {
background: #f8d7da;
color: #721c24;
}
.save-header-btn {
background: #007bff;
color: white;
padding: 0.5rem 1.25rem;
}
.save-header-btn:hover:not(:disabled) {
background: #0056b3;
}
.save-header-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Draft Recovery Prompt */
.draft-prompt-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;
}
.draft-prompt-modal {
background: white;
border-radius: 0.5rem;
padding: 2rem;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.draft-prompt-modal h2 {
margin-top: 0;
margin-bottom: 0.5rem;
color: #333;
font-size: 1.25rem;
}
.draft-prompt-modal p {
color: #666;
margin-bottom: 1.5rem;
line-height: 1.5;
}
.draft-prompt-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
min-height: 44px;
transition: all 0.2s;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
.btn-secondary {
background: #e9ecef;
color: #333;
}
.btn-secondary:hover {
background: #dee2e6;
}
/* Error Banner */
.error-banner {
background: #f8d7da;
border-bottom: 1px solid #f5c6cb;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
color: #721c24;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.error-message {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.error-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-retry {
padding: 0.5rem 1rem;
background: #721c24;
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.85rem;
min-height: 40px;
transition: background 0.2s;
}
.btn-retry:hover {
background: #5a1520;
}
.btn-close {
background: transparent;
border: none;
color: #721c24;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-close:hover {
opacity: 0.7;
}
/* Main Content */
.edit-main {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.workout-meta-card {
background: white;
padding: 1rem;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.workout-meta-card h2 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
color: #333;
}
.workout-meta-card p {
margin: 0;
color: #666;
font-size: 0.9rem;
}
.edit-exercises-list {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
.edit-exercise-card {
background: white;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.edit-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
gap: 1rem;
}
.edit-card-info h3 {
margin: 0 0 0.5rem 0;
color: #333;
font-size: 1.05rem;
}
.muscle-group {
display: inline-block;
background: #f0f0f0;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.8rem;
color: #666;
}
.edit-card-actions {
display: flex;
gap: 0.5rem;
}
.icon-btn {
padding: 0.5rem;
border: none;
background: #f0f0f0;
color: #333;
cursor: pointer;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
min-height: 40px;
min-width: 40px;
transition: all 0.2s;
}
.icon-btn:hover:not(:disabled) {
background: #e0e0e0;
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon-btn.delete {
color: #dc3545;
}
.icon-btn.delete:hover:not(:disabled) {
background: #ffe0e0;
}
.edit-card-settings {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 1rem;
}
.setting-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.setting-group label {
font-size: 0.85rem;
color: #666;
font-weight: 500;
}
.setting-group input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 0.25rem;
font-size: 1rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 40px;
}
.setting-group input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
}
.setting-group input:disabled {
background: #f5f5f5;
color: #999;
cursor: not-allowed;
}
.add-exercise-btn {
padding: 1rem;
background: #28a745;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 50px;
transition: background 0.2s;
align-self: center;
max-width: 300px;
width: 100%;
}
.add-exercise-btn:hover:not(:disabled) {
background: #218838;
}
.add-exercise-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Mobile/Tablet Adjustments */
@media (max-width: 600px) {
.page-header {
padding: 0.75rem;
}
.page-header h1 {
font-size: 1rem;
}
.back-btn,
.save-header-btn {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
.edit-main {
padding: 0.75rem;
}
.edit-card-settings {
grid-template-columns: 1fr;
}
.draft-prompt-modal {
padding: 1.5rem;
max-width: 90%;
}
}
/* Spinner Animation for Save Loading */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Apply spinner animation to Icon component with spinner class */
.save-header-btn svg[class*="spinner"],
.save-header-btn .icon-spinner {
animation: spin 1s linear infinite;
}
/* Success Checkmark Animation */
@keyframes slideInCheckmark {
0% {
opacity: 0;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.sync-status.saved {
animation: slideInCheckmark 0.3s ease-out;
}
/* Ensure error actions align properly on mobile */
@media (max-width: 480px) {
.error-banner {
flex-direction: column;
align-items: flex-start;
}
.error-message {
width: 100%;
margin-bottom: 0.75rem;
}
.error-actions {
width: 100%;
justify-content: space-between;
}
}
+355
View File
@@ -0,0 +1,355 @@
import { useState } from 'react'
import { Icon } from '../components/Icons'
import ExercisePicker from '../components/ExercisePicker'
import { useDraftWorkout } from '../hooks/useDraftWorkout'
import './WorkoutEditPage.css'
export default function WorkoutEditPage({ workout, onBack, onSave }) {
const { exercises, setExercises, clearDraft, hasDraft, restoreDraft } =
useDraftWorkout(workout.id, workout.exercises || [])
const [pickerOpen, setPickerOpen] = useState(false)
const [swapIndex, setSwapIndex] = useState(null) // null = adding, number = swapping
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [syncStatus, setSyncStatus] = useState('idle') // idle | saving | saved | error
const [draftPromptShown, setDraftPromptShown] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const [lastSavePayload, setLastSavePayload] = useState(null)
// Show draft recovery prompt on first render
const handleRecoverDraft = () => {
if (hasDraft && !draftPromptShown) {
setDraftPromptShown(true)
// Prompt is shown via conditional rendering below
}
}
const handleOpenPicker = (index = null) => {
setSwapIndex(index)
setPickerOpen(true)
}
const handleSelectExercise = (exercise) => {
if (swapIndex !== null) {
// Swap
setExercises(prev => prev.map((ex, i) => {
if (i === swapIndex) {
return {
...ex,
exercise_id: exercise.id,
name: exercise.name,
muscle_group: exercise.muscle_group,
// Keep existing sets/reps
}
}
return ex
}))
} else {
// Add
setExercises(prev => [...prev, {
exercise_id: exercise.id,
name: exercise.name,
muscle_group: exercise.muscle_group,
sets: 3,
reps_min: 8,
reps_max: 12
}])
}
setPickerOpen(false)
}
const handleRemove = (index) => {
setExercises(prev => prev.filter((_, i) => i !== index))
}
const handleUpdate = (index, field, value) => {
setExercises(prev => prev.map((ex, i) => {
if (i === index) {
return { ...ex, [field]: value }
}
return ex
}))
// Clear error state on user edit
if (error) setError(null)
}
/**
* Determine specific error message based on error type
*/
const getErrorMessage = (err) => {
// Network errors
if (!err || err instanceof TypeError && err.message.includes('fetch')) {
return 'Anslutning misslyckades. Försök igen?'
}
// Check if error has a response (API error)
if (err.status) {
if (err.status === 400) {
return 'Ogiltiga ändringar. Kontrollera dina inmatningar.'
}
if (err.status === 401 || err.status === 403) {
return 'Du har inte behörighet att spara denna träning.'
}
if (err.status >= 500) {
return 'Serverfel. Försök igen senare.'
}
if (err.status >= 400) {
return 'Ett fel uppstod när träningen skulle sparas. Försök igen.'
}
}
// Fallback
return err.message || 'Sparning misslyckades. Försök igen.'
}
const handleSave = async () => {
setSaving(true)
setSyncStatus('saving')
setError(null)
setRetryCount(prev => prev + 1)
try {
// Format for API
const payload = {
exercises: exercises.map(ex => ({
exercise_id: ex.exercise_id || ex.id, // Handle both structures
sets: parseInt(ex.sets) || 3,
reps_min: parseInt(ex.reps_min) || 8,
reps_max: parseInt(ex.reps_max) || 12
}))
}
// Store payload for potential retry
setLastSavePayload(payload)
// Call the save callback
await onSave(workout.id, payload)
// Success: clear draft and show confirmation
clearDraft()
setSyncStatus('saved')
setRetryCount(0) // Reset retry count on success
// Log success
console.log('Workout saved successfully', {
workoutId: workout.id,
exerciseCount: exercises.length,
retryCount
})
// Reset status after 2 seconds
setTimeout(() => setSyncStatus('idle'), 2000)
} catch (err) {
// Log error with context for debugging
console.error('Failed to save workout:', {
error: err,
workoutId: workout.id,
exerciseCount: exercises.length,
retryCount,
payload: lastSavePayload
})
// Determine error message based on error type
const errorMessage = getErrorMessage(err)
setError(errorMessage)
setSyncStatus('error')
// Keep draft on error so user doesn't lose work
// (useDraftWorkout already auto-saves, so no action needed here)
} finally {
setSaving(false)
}
}
const handleRetry = () => {
// Log retry attempt
console.log('User retrying save', {
workoutId: workout.id,
retryCount
})
handleSave()
}
const handleDiscardDraft = () => {
clearDraft()
setDraftPromptShown(true)
// Reset exercises to original
setExercises(workout.exercises || [])
}
// Show draft recovery prompt if we have a draft and haven't shown it yet
const showDraftPrompt = hasDraft && !draftPromptShown
if (showDraftPrompt) {
handleRecoverDraft()
}
return (
<div className="edit-page">
{/* Draft Recovery Prompt */}
{showDraftPrompt && (
<div className="draft-prompt-overlay">
<div className="draft-prompt-modal">
<h2>Du har sparat ändringar</h2>
<p>Vi hittade ett utkast från din senaste redigering. Vill du fortsätta eller börja om?</p>
<div className="draft-prompt-actions">
<button
className="btn btn-secondary"
onClick={handleDiscardDraft}
>
Börja om
</button>
<button
className="btn btn-primary"
onClick={() => setDraftPromptShown(true)}
>
Fortsätt redigering
</button>
</div>
</div>
</div>
)}
<header className="page-header">
<button className="back-btn" onClick={onBack} disabled={saving}>
<Icon name="arrowLeft" size={18} /> Avbryt
</button>
<h1>Redigera pass</h1>
<div className="save-header-group">
{syncStatus === 'saved' && (
<span className="sync-status saved">
<Icon name="checkmark" size={16} /> Sparat
</span>
)}
{syncStatus === 'error' && (
<span className="sync-status error">
<Icon name="alert" size={16} /> Fel
</span>
)}
<button
className="save-header-btn"
onClick={handleSave}
disabled={saving}
>
{syncStatus === 'saving' && (
<>
<Icon name="spinner" size={16} /> Sparar...
</>
)}
{syncStatus !== 'saving' && 'Spara'}
</button>
</div>
</header>
{/* Error Banner */}
{error && (
<div className="error-banner">
<div className="error-message">
<Icon name="alert" size={18} />
<span>{error}</span>
</div>
<div className="error-actions">
<button className="btn-retry" onClick={handleRetry}>
Försök igen
</button>
<button
className="btn-close"
onClick={() => setError(null)}
aria-label="Stäng"
>
×
</button>
</div>
</div>
)}
<main className="edit-main">
<div className="workout-meta-card">
<h2>{workout.name}</h2>
<p>{exercises.length} övningar</p>
</div>
<div className="edit-exercises-list">
{exercises.map((ex, i) => (
<div key={i} className="edit-exercise-card">
<div className="edit-card-header">
<div className="edit-card-info">
<h3>{ex.name}</h3>
<span className="muscle-group">{ex.muscle_group}</span>
</div>
<div className="edit-card-actions">
<button
className="icon-btn"
onClick={() => handleOpenPicker(i)}
aria-label="Byt övning"
disabled={saving}
>
<Icon name="swap" size={18} />
</button>
<button
className="icon-btn delete"
onClick={() => handleRemove(i)}
aria-label="Ta bort övning"
disabled={saving}
>
<Icon name="trash" size={18} />
</button>
</div>
</div>
<div className="edit-card-settings">
<div className="setting-group">
<label>Set</label>
<input
type="number"
value={ex.sets}
onChange={e => handleUpdate(i, 'sets', e.target.value)}
min="1"
disabled={saving}
/>
</div>
<div className="setting-group">
<label>Reps min</label>
<input
type="number"
value={ex.reps_min}
onChange={e => handleUpdate(i, 'reps_min', e.target.value)}
min="1"
disabled={saving}
/>
</div>
<div className="setting-group">
<label>Reps max</label>
<input
type="number"
value={ex.reps_max}
onChange={e => handleUpdate(i, 'reps_max', e.target.value)}
min="1"
disabled={saving}
/>
</div>
</div>
</div>
))}
</div>
<button
className="add-exercise-btn"
onClick={() => handleOpenPicker(null)}
disabled={saving}
>
<Icon name="plus" size={20} />
Lägg till övning
</button>
</main>
{pickerOpen && (
<ExercisePicker
open={pickerOpen}
onSelect={handleSelectExercise}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
)
}
+124 -2
View File
@@ -17,13 +17,26 @@ const getWorkoutColor = (name) => {
function WorkoutSelectPage({ onBack, onSelectWorkout }) {
const [program, setProgram] = useState(null)
const [customWorkouts, setCustomWorkouts] = useState([])
const [loading, setLoading] = useState(true)
const [selectedWorkout, setSelectedWorkout] = useState(null)
const [resetConfirm, setResetConfirm] = useState(null)
const [resetting, setResetting] = useState(false)
const [successMessage, setSuccessMessage] = useState(null)
useEffect(() => {
fetchProgram()
fetchCustomWorkouts()
}, [])
// Auto-clear success message after 3 seconds
useEffect(() => {
if (successMessage) {
const timer = setTimeout(() => setSuccessMessage(null), 3000)
return () => clearTimeout(timer)
}
}, [successMessage])
const fetchProgram = async () => {
try {
const res = await fetch(`${API_URL}/programs/1`)
@@ -36,6 +49,29 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
}
}
const fetchCustomWorkouts = async () => {
try {
const token = localStorage.getItem('token')
if (!token) return
const res = await fetch(`${API_URL}/custom-workouts`, {
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await res.json()
setCustomWorkouts(data || [])
} catch (err) {
console.error('Failed to fetch custom workouts:', err)
}
}
const getCustomWorkoutId = (programDayId) => {
const customWorkout = customWorkouts.find(cw => cw.source_program_day_id === programDayId)
return customWorkout?.id
}
const isWorkoutCustom = (programDayId) => {
return customWorkouts.some(cw => cw.source_program_day_id === programDayId)
}
const handleSelect = (workout) => {
setSelectedWorkout(workout)
}
@@ -46,6 +82,38 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
}
}
const handleResetClick = (e, workoutId) => {
e.stopPropagation()
setResetConfirm(workoutId)
}
const handleConfirmReset = async () => {
if (!resetConfirm) return
setResetting(true)
try {
const token = localStorage.getItem('token')
const res = await fetch(`${API_URL}/custom-workouts/${resetConfirm}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
})
if (res.ok) {
// Refresh custom workouts list
await fetchCustomWorkouts()
setSuccessMessage('Passet återställdes till original')
setSelectedWorkout(null)
setResetConfirm(null)
} else {
console.error('Failed to reset workout:', res.status)
}
} catch (err) {
console.error('Error resetting workout:', err)
} finally {
setResetting(false)
}
}
if (loading) {
return (
<div className="select-page loading">
@@ -70,12 +138,21 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
Vilken träning vill du köra idag?
</p>
{successMessage && (
<div className="success-message">
<Icon name="check" size={18} />
{successMessage}
</div>
)}
<div className="workout-grid">
{program?.days?.map((workout) => {
const iconName = getWorkoutIconName(workout.name)
const color = getWorkoutColor(workout.name)
const isSelected = selectedWorkout?.id === workout.id
const exerciseCount = workout.exercises?.filter(e => e.name).length || 0
const isCustom = isWorkoutCustom(workout.id)
const customWorkoutId = getCustomWorkoutId(workout.id)
return (
<div
@@ -84,8 +161,23 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
style={{ '--workout-color': color }}
onClick={() => handleSelect(workout)}
>
<div className="workout-icon" style={{ background: color }}>
<Icon name={iconName} size={28} />
<div className="workout-badge-container">
<div className="workout-icon" style={{ background: color }}>
<Icon name={iconName} size={28} />
</div>
<span className={`workout-badge ${isCustom ? 'custom' : 'program'}`}>
{isCustom ? 'Anpassad' : 'Program'}
</span>
{isCustom && (
<button
className="reset-btn"
title="Återställ till original"
onClick={(e) => handleResetClick(e, customWorkoutId)}
aria-label="Återställ workout"
>
<Icon name="refresh" size={16} />
</button>
)}
</div>
<div className="workout-details">
<h3>{workout.name}</h3>
@@ -120,6 +212,36 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
</div>
)}
</main>
{/* Reset confirmation dialog */}
{resetConfirm && (
<div className="modal-overlay" onClick={() => setResetConfirm(null)}>
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Återställ till original?</h2>
</div>
<div className="modal-body">
<p>Är du säker? Dina ändringar kommer att försvinna och passet återställs till programversionen.</p>
</div>
<div className="modal-footer">
<button
className="modal-btn cancel"
onClick={() => setResetConfirm(null)}
disabled={resetting}
>
Avbryt
</button>
<button
className="modal-btn confirm"
onClick={handleConfirmReset}
disabled={resetting}
>
{resetting ? 'Återställer...' : 'Återställ'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
/* Minimal app-wide styles to support the new Workout UI scaffold */
:root{ --bg: #0b0f14; --card:#141a20; --text:#e8f0f4; --muted:#9bb2bd; --accent:#4cc9f0; }
*{box-sizing:border-box}
html,body,#root{height:100%}
body{ margin:0; background:var(--bg); color:var(--text); font-family: Inter, system-ui, Arial; }
/* App-wide helpers */
.app{ min-height:100%; display:flex; flex-direction:column; }
.page-header{ display:flex; align-items:center; justify-content:space-between; padding:14px 16px; background:#0f151a; border-bottom:1px solid #1e252c; }
.back-btn{ border:0; background:transparent; color:#9bd2ff; cursor:pointer; font-size:14px; display:flex; align-items:center; gap:6px; }
.header-center{ text-align:center; flex:1; }
.header-center h1{ margin:0; font-size:18px; font-weight:600 }
.header-subtitle{ color:#a9bdc9; font-size:12px; }
.rest-timer-card{ padding:12px; background:#11161b; border-bottom:1px solid #1e252c; }
.rest-timer-header{ display:flex; justify-content:space-between; align-items:center; }
.rest-timer-label{ font-weight:600; }
.rest-timer-time{ font-feature-settings: 'tnum'; font-variant-numeric: tabular-nums; font-size:20px; }
.page-main{ padding:12px; display:flex; flex-direction:column; gap:12px; }
.exercise-card{ background:#121821; border:1px solid #1e252c; border-radius:8px; padding:8px; margin-bottom:8px; }
/* Simple utility for the rest of style surface */
.exercises-section{ display:flex; flex-direction:column; gap:8px; }
.finish-workout-btn{ align-self:center; padding:12px 20px; border-radius:999px; border:1px solid #2e2e2e; background:#1e1e1e; color:#fff; cursor:pointer; }
@media (min-width: 700px){ .header-center{ text-align:center; } }
+17
View File
@@ -0,0 +1,17 @@
const { test, expect } = require("@playwright/test");
test("login page loads", async ({ page }) => {
await page.goto("/login");
await expect(page.locator("form")).toBeVisible();
});
test("logo exists", async ({ page }) => {
await page.goto("/login");
const logo = await page.locator("svg, img[class*=logo], .logo").first();
await expect(logo).toBeVisible();
});
test("dashboard loads", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/Gravl/);
});
View File
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
BRANCH_NAME=$1
if [ -z "$BRANCH_NAME" ]; then echo "Usage: $0 <branch-name>"; exit 1; fi
STAGING_DIR=/tmp/staging-$BRANCH_NAME-$(date +%s)
mkdir -p $STAGING_DIR
git clone --branch feature/$BRANCH_NAME /workspace/gravl "$STAGING_DIR"
cd "$STAGING_DIR"
sed -i "s/PLACEHOLDER/$BRANCH_NAME/g" docker-compose.staging.yml
mkdir -p .staging
docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d --build
echo "{\"branch\":\"$BRANCH_NAME\",\"url\":\"https://$BRANCH_NAME.gravl.homelab.local\"}" > .staging/$BRANCH_NAME.json
echo "✅ Staging: https://$BRANCH_NAME.gravl.homelab.local"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
require('dotenv').config();
console.log('🎬 Gravl Multimedia Asset Generator\n');
// Config
const apiKey = process.env.VEO_API_KEY;
const googleCreds = process.env.GOOGLE_APPLICATION_CREDENTIALS;
const outputDir = process.env.NANO_BANANA_OUTPUT_DIR || './marketing/images';
const videoDir = process.env.VEO_OUTPUT_DIR || './marketing/videos';
// Ensure directories exist
[outputDir, videoDir].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
console.log('📁 Output directories:');
console.log(` Images: ${outputDir}`);
console.log(` Videos: ${videoDir}\n`);
// Image templates for Gravl
const imagePrompts = [
{
name: 'login-page.png',
prompt: 'Gravl fitness app login page with barbell logo, email/password inputs, dark theme #0a0a0f, orange accent #ff6b35, gradient background, 1280x720'
},
{
name: 'dashboard.png',
prompt: 'Gravl dashboard: stat cards showing workouts completed, total volume, streak, calendar view, animated elements, dark modern design, 1280x720'
},
{
name: 'workout-page.png',
prompt: 'Gravl workout page: exercise cards with sets/reps input, rest timer showing 90 seconds, complete button, smooth animations, dark theme, 1280x720'
}
];
// Video templates
const videoPrompts = [
{
name: 'workout-demo.mp4',
prompt: 'Gravl fitness app demo: user opens app, selects a workout, clicks on exercise, logs 3 sets of 10 reps at 80kg, rests with 90-second countdown timer, completes workout',
duration: 10
}
];
console.log('📝 Image generation requests (mock for demo):\n');
imagePrompts.forEach(img => {
console.log(`${img.name}`);
// In real usage, this would call nano-banana-pro API
// For now, create placeholder files
const filePath = path.join(outputDir, img.name);
fs.writeFileSync(filePath, `Placeholder: ${img.prompt}`);
console.log(` → Created (placeholder): ${filePath}`);
});
console.log('\n🎥 Video generation requests (mock for demo):\n');
videoPrompts.forEach(vid => {
console.log(`${vid.name} (${vid.duration}s)`);
// In real usage, this would call Veo API
const filePath = path.join(videoDir, vid.name);
fs.writeFileSync(filePath, `Placeholder: ${vid.prompt}`);
console.log(` → Created (placeholder): ${filePath}`);
});
console.log('\n✨ Generation complete!\n');
console.log('📌 Next steps:');
console.log(' 1. Set VEO_API_KEY and GOOGLE_APPLICATION_CREDENTIALS in .env');
console.log(' 2. Replace placeholder calls with actual API requests');
console.log(' 3. Run: npm install dotenv');
console.log(` 4. Run: node scripts/generate-assets.js\n`);
console.log('📂 Generated files:');
[outputDir, videoDir].forEach(dir => {
const files = fs.readdirSync(dir);
files.forEach(f => console.log(` ${path.join(dir, f)}`));
});
View File