23 Commits

Author SHA1 Message Date
clawd 5a9ea9c9a8 checkpoint: phase 05 (exercise encyclopedia) ready to start
Phase 04 (workout modification) complete:
- 14 commits rebased onto main
- All features verified and staged
- Ready for merge

Phase 05 structure defined:
- 05-01: Exercise DB schema & CRUD API
- 05-02: AI research integration (exa-search)
- 05-03: Demo video generation (Veo)
- 05-04: Search & Add UI
- 05-05: Exercise detail view
- 05-06: User ratings & feedback

Next: PM starts 05-01 (backend foundation)
2026-03-02 09:26:32 +01:00
clawd fac53a3605 chore: add dist and build artifacts to .gitignore
- Exclude frontend/dist/ (build output)
- Exclude .py files (script templates)
- Exclude PY temp files
2026-03-02 09:25:10 +01:00
clawd 994f406050 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:25:10 +01:00
clawd f941011130 chore: remove stray EOF and PLANEOF files 2026-03-02 09:25:10 +01:00
clawd fa95e880b2 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:25:10 +01:00
clawd f63f4c0420 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:25:10 +01:00
clawd 475cf10b17 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:25:10 +01:00
clawd cf85e9e314 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:25:10 +01:00
clawd b5c9250a10 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:25:10 +01:00
clawd a24199e56c feat(04-03-partial): ExercisePicker and WorkoutEditPage components - swap/add/remove exercises with sets/reps editing 2026-03-02 09:25:10 +01:00
clawd 5fd21719d0 test(e2e): add Playwright with browser tests for login, logo, dashboard 2026-03-02 09:25:10 +01:00
clawd 4bd2c9607d 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:25:10 +01:00
clawd 22750bfa06 fix(staging): fix Traefik service linking with explicit service labels 2026-03-02 09:25:10 +01:00
clawd 4b39f39e3e feat(staging): add Traefik-based staging with automatic subdomains 2026-03-02 09:25:10 +01:00
clawd 7694ca6313 feat(infra): add staging environment setup with docker-compose and scripts 2026-03-02 09:25:10 +01:00
sphinxen 15d7aff096 Merge pull request 'feature/03-design-polish' (#1) from feature/03-design-polish into main
Reviewed-on: https://gitea.homelab.local/clawd/gravl/pulls/1
2026-03-02 09:08:10 +01:00
clawd 362f4eed49 checkpoint: mark phase 3 complete (03-01, 03-02, 03-03) 2026-03-01 00:03:48 +01:00
clawd 6d1da03fec 03-03: Workout Experience Polish - enhanced exercise cards, progress badges, rest timer, KLART button, warmup styling 2026-02-28 23:47:36 +01:00
clawd 5d0e0e3952 feat(dashboard): polish header logo, stat cards, calendar and animations
- Replace gravl icon text with Logo component in dashboard header
- Stat cards: gradient depth + per-card colour accent (orange/green/amber)
- Calendar today: pulsing glow animation; workout days get subtle brand tint
- Arrow nudge animation on today-workout-card hover
- Section stagger fade-in on page load (calendar → coach → stats)
- Larger stat-value font (3xl) with tighter letter-spacing
- Consistent gap spacing in dashboard-main (space-6)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:22:34 +01:00
clawd be4a149a47 feat(auth): polish login/register with logo, gradients and animations 2026-02-28 22:59:08 +01:00
clawd 0cd6cd0269 checkpoint: mark 03-01-login-onboarding-polish as completed 2026-02-28 22:58:24 +01:00
clawd e40b486ae5 feat(onboarding): add conversational ChatOnboarding component 2026-02-28 22:06:15 +01:00
clawd 04bab32e26 design: WorkoutPage Hevy-style redesign + AlternativeModal + backend API
- Add GET /api/exercises/:id/alternatives endpoint
- Add GET /api/exercises/:id/last-workout endpoint
- New AlternativeModal component for swapping exercises
- WorkoutPage: single-tap logging, +/- buttons, rest timer
- Updated Icons with new workout icons
- Polish: card shadows, borders, micro-interactions
- Tasks directory for project management
2026-02-28 21:25:23 +01:00
39 changed files with 5903 additions and 919 deletions
+63
View File
@@ -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
+105
View File
@@ -0,0 +1,105 @@
{
"lastRun": "2026-03-02T09:25:00Z",
"status": "ready",
"currentPhase": "05",
"currentPhaseName": "Exercise Encyclopedia - AI-Powered Exercise Database",
"nextPlannedTask": "phase-05-tasks",
"result": "Phase 04 (Workout Modification) complete and rebased onto main. Ready to start Phase 05.",
"phase05Tasks": [
{
"id": "05-01",
"name": "Exercise Database Schema & CRUD API",
"description": "Create exercise table with: id, name, primaryMuscles[], secondaryMuscles[], instructions, difficulty, equipment, videoUrl, sources[], addedBy, addedAt. Implement GET/POST/PUT/DELETE endpoints.",
"subtasks": [
"Create migration: exercises table",
"Implement API routes: GET /exercises, POST /exercises, PUT /exercises/:id, DELETE /exercises/:id",
"Add search: GET /exercises?search=clean-jerk&muscle=chest",
"Test with Postman/curl"
]
},
{
"id": "05-02",
"name": "Research Agent Integration",
"description": "When user searches for non-existent exercise (e.g., 'clean-jerk'), spawn research agent to find credible sources and structure data.",
"subtasks": [
"Setup exa-search integration (via exa-search skill)",
"Create research prompt template (exercises, muscles, technique)",
"Parse results into exercise object",
"Store in database automatically"
]
},
{
"id": "05-03",
"name": "Video Generation (Demo Videos)",
"description": "Generate demo videos for exercises using Google Veo (via claude-multimedia skill).",
"subtasks": [
"Create video generation prompt template",
"Test Veo API integration",
"Store video URLs in exercise record"
]
},
{
"id": "05-04",
"name": "Exercise Search & Add UI",
"description": "Frontend search component that triggers research on unknown exercises.",
"subtasks": [
"ExerciseSearchPage component with autocomplete",
"Show existing exercises from DB",
"Button to 'Research & Add' unknown exercises",
"Display research progress/status"
]
},
{
"id": "05-05",
"name": "Exercise Detail View",
"description": "Display full exercise profile with muscles, instructions, demo video, difficulty, equipment.",
"subtasks": [
"ExerciseDetailPage component",
"Show muscle tags (highlight primary/secondary)",
"Render instructions with styling",
"Embed demo video player",
"Show metadata (difficulty, equipment, sources)"
]
},
{
"id": "05-06",
"name": "User Ratings & Community Feedback",
"description": "Allow users to rate exercises (1-5 stars) and leave feedback.",
"subtasks": [
"Add rating fields to exercise record",
"Implement rating UI (star picker)",
"POST /exercises/:id/ratings endpoint",
"Display average rating & user reviews"
]
}
],
"phase04Verification": {
"status": "complete",
"completedFeatures": [
"Custom workout creation & editing",
"Reset to original (revert customizations)",
"Visual distinction (custom vs program)",
"Persistence with draft auto-save",
"Error handling & retry logic"
],
"gitCommits": "14 commits rebased onto main",
"stagedInProduction": true
},
"branchStructure": {
"main": "a72deba (Phase 02 baseline)",
"feature/03-design-polish": "f6e98ae (Design Polish — 50 commits, ready for PR)",
"feature/04-workout-modification": "fac53a3 (Workout Modification — 14 commits, rebased, ready for merge)",
"feature/05-exercise-encyclopedia": "fac53a3 (NEW — Starting from Phase 04 baseline)"
},
"recommendations": [
"05-01 is backend foundation — start there",
"05-02 requires exa-search skill (verify it's available)",
"05-03 requires claude-multimedia skill with Veo integration",
"05-04 & 05-05 depend on 05-01 API being ready",
"Consider E2E tests for research flow (05-02)"
]
}
+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
+407 -86
View File
@@ -248,103 +248,57 @@ app.get('/api/days/:dayId/exercises', async (req, res) => {
}
});
// Get workout logs for a user and date
app.get('/api/logs', async (req, res) => {
// Get alternative exercises for a given exercise (same muscle group)
app.get('/api/exercises/:id/alternatives', 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}`;
const exerciseResult = await pool.query(
'SELECT muscle_group FROM exercises WHERE id = $1',
[req.params.id]
);
if (!exerciseResult.rows.length) {
return res.status(404).json({ error: 'Exercise not found' });
}
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);
const muscleGroup = exerciseResult.rows[0].muscle_group;
const alternatives = await pool.query(
`SELECT id, name, muscle_group, description
FROM exercises
WHERE muscle_group = $1 AND id <> $2
ORDER BY name`,
[muscleGroup, req.params.id]
);
res.json(alternatives.rows);
} catch (err) {
console.error('Error fetching logs:', err);
console.error('Error fetching alternatives:', 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) => {
// Get last workout for a specific exercise id
app.get('/api/exercises/:id/last-workout', 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]);
WITH latest AS (
SELECT wl.date
FROM workout_logs wl
JOIN program_exercises pe ON wl.program_exercise_id = pe.id
WHERE pe.exercise_id = $1 AND wl.user_id = $2
ORDER BY wl.date DESC
LIMIT 1
)
SELECT wl.*
FROM workout_logs wl
JOIN program_exercises pe ON wl.program_exercise_id = pe.id
JOIN latest l ON wl.date = l.date
WHERE pe.exercise_id = $1 AND wl.user_id = $2
ORDER BY wl.set_number ASC
`, [req.params.id, 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);
console.error('Error fetching last workout for exercise:', err);
res.status(500).json({ error: 'Database error' });
}
});
@@ -440,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
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0a0a0f" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<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">
</head>
<body>
<div id="root"></div>
</body>
</html>
+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" }
}]
};
+1848 -719
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -5,6 +5,7 @@ import ProfilePage from './pages/ProfilePage'
import ProgressPage from './pages/ProgressPage'
import WorkoutPage from './pages/WorkoutPage'
import WorkoutSelectPage from './pages/WorkoutSelectPage'
import ChatOnboarding from './pages/ChatOnboarding'
import './App.css'
const API_URL = '/api'
@@ -21,6 +22,10 @@ function App() {
const userId = user?.id || 1
const today = new Date().toISOString().split('T')[0]
if (user && !user.onboarding_complete) {
return <ChatOnboarding />
}
const fetchProgram = async () => {
if (program) return // Already loaded
try {
@@ -0,0 +1,51 @@
import { Icon } from './Icons'
function AlternativeModal({ exercise, alternatives, loading, error, onSelect, onClose }) {
if (!exercise) return null
return (
<div className="alternative-modal-overlay" onClick={onClose}>
<div className="alternative-modal" onClick={(event) => event.stopPropagation()}>
<div className="alternative-modal-header">
<div>
<h3>Alternativa övningar</h3>
<p>För {exercise.name}</p>
</div>
<button className="alternative-modal-close" onClick={onClose} aria-label="Stäng">
<Icon name="chevronDown" size={18} />
</button>
</div>
{loading && (
<div className="alternative-modal-state">Laddar alternativ...</div>
)}
{!loading && error && (
<div className="alternative-modal-state error">{error}</div>
)}
{!loading && !error && alternatives.length === 0 && (
<div className="alternative-modal-state">Inga alternativ hittades.</div>
)}
{!loading && !error && alternatives.length > 0 && (
<div className="alternative-list">
{alternatives.map((alt) => (
<div key={alt.id} className="alternative-item">
<div className="alternative-info">
<strong>{alt.name}</strong>
<span>{alt.description || 'Ingen beskrivning tillgänglig.'}</span>
</div>
<button className="alternative-select-btn" onClick={() => onSelect(alt)}>
Välj
</button>
</div>
))}
</div>
)}
</div>
</div>
)
}
export default AlternativeModal
+18
View File
@@ -0,0 +1,18 @@
export default function CoachMessage({ text, typing = false }) {
return (
<div className={`chat-message coach ${typing ? 'typing' : ''}`}>
<div className="chat-avatar">C</div>
<div className="chat-bubble">
{typing ? (
<div className="typing-indicator" aria-label="Coach skriver">
<span></span>
<span></span>
<span></span>
</div>
) : (
text
)}
</div>
</div>
)
}
+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
+32
View File
@@ -62,6 +62,14 @@ export const Icons = {
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
),
swap: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="7 7 3 11 7 15"/>
<polyline points="17 9 21 13 17 17"/>
<line x1="3" y1="11" x2="21" y2="11"/>
<line x1="3" y1="13" x2="21" y2="13"/>
</svg>
),
check: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
@@ -226,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: (
@@ -235,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
+9
View File
@@ -0,0 +1,9 @@
export default function Logo() {
return (
<svg viewBox="0 0 48 48" className="logo-mark" aria-hidden="true">
<path d="M12 16h4v16h-4zM20 12h8v24h-8zM32 16h4v16h-4z" fill="currentColor"/>
<rect x="8" y="20" width="4" height="8" fill="currentColor"/>
<rect x="36" y="20" width="4" height="8" fill="currentColor"/>
</svg>
);
}
+19
View File
@@ -0,0 +1,19 @@
export default function QuickReplies({ options = [], onSelect, disabled = false }) {
if (!options.length) return null
return (
<div className="quick-replies">
{options.map((option) => (
<button
key={`${option.label}-${option.value}`}
type="button"
className={`quick-reply ${option.variant || ''}`.trim()}
onClick={() => onSelect(option)}
disabled={disabled || option.disabled}
>
{option.label}
</button>
))}
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
export default function UserMessage({ text }) {
return (
<div className="chat-message user">
<div className="chat-bubble">
{text}
</div>
</div>
)
}
+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
}
}
+784 -60
View File
@@ -5,39 +5,91 @@
}
:root {
/* Dark fitness palette */
/* Dark fitness palette - refined */
--bg-primary: #0a0a0f;
--bg-secondary: #0d0d12;
--bg-card: #15151b;
--bg-card-hover: #1a1a22;
--bg-secondary: #0d0d14;
--bg-tertiary: #12121a;
--bg-card: #16161f;
--bg-card-hover: #1c1c28;
--bg-elevated: #1a1a24;
--bg: #0a0a0f;
/* Text colors */
/* Text colors - better hierarchy */
--text-primary: #ffffff;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
--text-tertiary: #52525b;
--text: #ffffff;
/* Accent - energetic orange */
--accent: #ff6b35;
--accent-hover: #ff8555;
/* Accent - refined energetic coral */
--accent: #ff6b4a;
--accent-hover: #ff8066;
--accent-subtle: rgba(255, 107, 74, 0.15);
--accent-glow: rgba(255, 107, 74, 0.25);
/* Status colors */
/* Status colors - refined */
--success: #22c55e;
--success-subtle: rgba(34, 197, 94, 0.15);
--warning: #f59e0b;
--warning-subtle: rgba(245, 158, 11, 0.15);
--error: #ef4444;
--error-subtle: rgba(239, 68, 68, 0.15);
/* Border */
--border: #1f1f28;
/* Borders - refined */
--border: #1f1f2a;
--border-hover: #2a2a38;
--border-accent: var(--accent-subtle);
/* Workout type colors - muted, professional */
/* Shadows - key for enterprise feel */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.7), 0 8px 10px -6px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 20px var(--accent-glow);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-elevated: 0 8px 16px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
/* Workout type colors - refined */
--workout-push: #ef4444;
--workout-pull: #3b82f6;
--workout-legs: #22c55e;
--workout-shoulders: #f59e0b;
--workout-upper: #8b5cf6;
--workout-lower: #06b6d4;
--workout-default: #ff6b35;
--workout-default: #ff6b4a;
/* Typography scale */
--font-xs: 0.75rem;
--font-sm: 0.875rem;
--font-base: 1rem;
--font-lg: 1.125rem;
--font-xl: 1.25rem;
--font-2xl: 1.5rem;
--font-3xl: 2rem;
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* Border radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 18px;
--radius-2xl: 24px;
--radius-full: 9999px;
}
html, body {
@@ -47,10 +99,12 @@ html, body {
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
line-height: 1.2;
}
#root {
@@ -62,74 +116,744 @@ button {
cursor: pointer;
border: none;
outline: none;
font-size: var(--font-base);
}
input {
font-family: inherit;
outline: none;
font-size: var(--font-base);
}
/* Scrollbar styling */
/* Scrollbar styling - refined */
::-webkit-scrollbar {
width: 6px;
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
background: var(--text-tertiary);
}
/* Auth pages */
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.auth-card { background: var(--bg-card); padding: 40px; border-radius: 16px; width: 100%; max-width: 400px; text-align: center; }
.auth-card h1 { font-size: 2.5rem; margin-bottom: 8px; }
.auth-card h2 { color: var(--text-secondary); font-weight: 400; margin-bottom: 24px; }
.auth-card form { display: flex; flex-direction: column; gap: 16px; }
.auth-card input { padding: 14px 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 1rem; }
.auth-card input:focus { border-color: var(--accent); }
.auth-card button[type="submit"] { padding: 14px; background: var(--accent); color: white; border-radius: 8px; font-size: 1rem; font-weight: 600; transition: background 0.2s; }
.auth-card button[type="submit"]:hover:not(:disabled) { background: var(--accent-hover); }
.auth-card button:disabled { opacity: 0.6; cursor: not-allowed; }
.auth-card .error { background: rgba(233,69,96,0.15); color: var(--accent); padding: 12px; border-radius: 8px; margin-bottom: 16px; }
.auth-link { margin-top: 20px; color: var(--text-secondary); }
.auth-link a { color: var(--accent); text-decoration: none; }
/* ============================================
AUTH PAGES - Premium First Impression
============================================ */
/* Onboarding */
.onboarding { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.onboarding-card { background: var(--bg-card); padding: 32px; border-radius: 16px; width: 100%; max-width: 480px; }
.steps-indicator { display: flex; justify-content: center; gap: 12px; margin-bottom: 28px; }
.steps-indicator span { width: 32px; height: 32px; border-radius: 50%; background: var(--bg-secondary); display: flex; align-items: center; justify-content: center; font-size: 0.875rem; color: var(--text-secondary); }
.steps-indicator span.active { background: var(--accent); color: white; }
.step h2 { margin-bottom: 20px; text-align: center; }
.step .hint { color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 16px; text-align: center; }
.field { margin-bottom: 16px; }
.field label { display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.875rem; }
.field input { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 1rem; }
.field input:focus { border-color: var(--accent); }
.btn-group { display: flex; gap: 8px; }
.btn-group.vertical { flex-direction: column; }
.btn-group button { flex: 1; padding: 12px; border-radius: 8px; background: var(--bg-secondary); color: var(--text-secondary); border: 1px solid var(--border); transition: all 0.2s; }
.btn-group button:hover { border-color: var(--accent); }
.btn-group button.active { background: var(--accent); color: white; border-color: var(--accent); }
.rm-fields { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 8px; }
.rm-fields .field { margin-bottom: 0; }
.bodyfat-result { background: rgba(78,204,163,0.15); color: var(--success); padding: 16px; border-radius: 8px; text-align: center; margin: 16px 0; }
.nav-btns { display: flex; gap: 12px; margin-top: 24px; }
.nav-btns button { flex: 1; padding: 14px; border-radius: 8px; font-size: 1rem; }
.nav-btns button:first-child { background: var(--bg-secondary); color: var(--text-secondary); }
.next-btn, .finish-btn { background: var(--accent) !important; color: white !important; font-weight: 600; }
.next-btn:hover:not(:disabled), .finish-btn:hover:not(:disabled) { background: var(--accent-hover) !important; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-5);
background: var(--bg-primary);
position: relative;
overflow: hidden;
}
/* Subtle background gradient */
.auth-page::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
ellipse at 30% 20%,
rgba(255, 107, 74, 0.03) 0%,
transparent 50%
),
radial-gradient(
ellipse at 70% 80%,
rgba(99, 102, 241, 0.03) 0%,
transparent 50%
);
pointer-events: none;
}
.auth-card {
background: var(--bg-card);
padding: var(--space-10) var(--space-8);
border-radius: var(--radius-2xl);
width: 100%;
max-width: 420px;
text-align: center;
box-shadow: var(--shadow-elevated);
border: 1px solid var(--border);
position: relative;
z-index: 1;
}
.auth-card h1 {
font-size: var(--font-3xl);
margin-bottom: var(--space-2);
background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.auth-card h2 {
color: var(--text-secondary);
font-weight: 500;
margin-bottom: var(--space-8);
font-size: var(--font-lg);
}
.logo-mark {
width: 56px;
height: 56px;
color: var(--accent);
margin: 0 auto var(--space-4);
display: block;
}
.auth-title {
font-size: var(--font-2xl);
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.auth-tagline {
color: var(--text-secondary);
font-size: var(--font-sm);
margin-bottom: var(--space-6);
}
@keyframes auth-error-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.auth-error {
animation: auth-error-in 0.2s ease-out;
}
.auth-card form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.auth-card input {
padding: var(--space-4) var(--space-5);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 16px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.auth-card input:hover {
border-color: var(--border-hover);
}
.auth-card input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.auth-card input::placeholder {
color: var(--text-tertiary);
}
.auth-card button[type="submit"] {
padding: var(--space-4);
background: var(--accent);
color: white;
border-radius: var(--radius-md);
font-size: var(--font-base);
font-weight: 600;
transition: all var(--transition-base);
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
position: relative;
overflow: hidden;
}
.auth-card button[type="submit"]::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.1) 0%, transparent 50%);
pointer-events: none;
}
.auth-card button[type="submit"]:hover:not(:disabled) {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(255, 107, 74, 0.4);
}
.auth-card button[type="submit"]:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(255, 107, 74, 0.3);
}
.auth-card button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-card .error {
background: var(--error-subtle);
color: var(--error);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
font-size: var(--font-sm);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.auth-link {
margin-top: var(--space-6);
color: var(--text-muted);
font-size: var(--font-sm);
}
.auth-link a {
color: var(--accent);
text-decoration: none;
font-weight: 500;
transition: color var(--transition-fast);
}
.auth-link a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
/* ============================================
ONBOARDING - Premium Step Wizard
============================================ */
.onboarding {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-5);
background: var(--bg-primary);
position: relative;
overflow: hidden;
}
.onboarding::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
ellipse at 30% 20%,
rgba(255, 107, 74, 0.04) 0%,
transparent 50%
),
radial-gradient(
ellipse at 70% 80%,
rgba(99, 102, 241, 0.04) 0%,
transparent 50%
);
pointer-events: none;
}
.onboarding-card {
background: var(--bg-card);
padding: var(--space-8);
border-radius: var(--radius-2xl);
width: 100%;
max-width: 520px;
box-shadow: var(--shadow-elevated);
border: 1px solid var(--border);
position: relative;
z-index: 1;
}
.steps-indicator {
display: flex;
justify-content: center;
gap: var(--space-3);
margin-bottom: var(--space-8);
}
.steps-indicator span {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-muted);
transition: all var(--transition-base);
border: 2px solid var(--border);
}
.steps-indicator span.active {
background: var(--accent);
color: white;
border-color: var(--accent);
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
}
.step h2 {
margin-bottom: var(--space-6);
text-align: center;
font-size: var(--font-xl);
}
.step .hint {
color: var(--text-muted);
font-size: var(--font-sm);
margin-bottom: var(--space-4);
text-align: center;
}
.field {
margin-bottom: var(--space-4);
}
.field label {
display: block;
margin-bottom: var(--space-2);
color: var(--text-secondary);
font-size: var(--font-sm);
font-weight: 500;
}
.field input {
width: 100%;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 16px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.field input:hover {
border-color: var(--border-hover);
}
/* ============================================
CHAT ONBOARDING
============================================ */
.chat-onboarding {
min-height: 100vh;
display: flex;
justify-content: center;
padding: var(--space-5);
background: radial-gradient(circle at top, rgba(255, 107, 74, 0.08), transparent 55%), var(--bg-primary);
}
.chat-shell {
width: 100%;
max-width: 720px;
min-height: calc(100vh - var(--space-10));
background: var(--bg-card);
border-radius: var(--radius-2xl);
border: 1px solid var(--border);
box-shadow: var(--shadow-elevated);
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-header {
padding: var(--space-5) var(--space-5) var(--space-4);
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, rgba(255, 107, 74, 0.1), rgba(18, 18, 26, 0.9));
}
.chat-header h1 {
font-size: var(--font-2xl);
}
.chat-subtitle {
font-size: var(--font-sm);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: var(--space-1);
}
.chat-status {
font-size: var(--font-sm);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-full);
background: rgba(34, 197, 94, 0.12);
color: var(--success);
border: 1px solid rgba(34, 197, 94, 0.3);
}
.chat-status.saving {
background: rgba(245, 158, 11, 0.12);
color: var(--warning);
border-color: rgba(245, 158, 11, 0.3);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
background: var(--bg-elevated);
}
.chat-message {
display: flex;
align-items: flex-end;
gap: var(--space-3);
animation: slideUp 0.3s ease both;
}
.chat-message.user {
justify-content: flex-end;
}
.chat-message.user .chat-bubble {
background: var(--accent);
color: #fff;
border-bottom-right-radius: var(--radius-sm);
box-shadow: var(--shadow-glow);
}
.chat-message.coach .chat-bubble {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-bottom-left-radius: var(--radius-sm);
}
.chat-bubble {
max-width: 80%;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
font-size: var(--font-base);
line-height: 1.5;
}
.chat-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent-subtle);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
flex-shrink: 0;
}
.chat-actions {
padding: var(--space-4);
border-top: 1px solid var(--border);
background: var(--bg-card);
}
.chat-input-area {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.chat-input-row {
display: flex;
gap: var(--space-3);
}
.chat-input-row input {
flex: 1;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: var(--font-base);
}
.chat-input-row input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-subtle);
}
.send-btn {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--accent);
color: #fff;
font-weight: 600;
transition: transform var(--transition-fast);
}
.send-btn:active {
transform: scale(0.97);
}
.chat-error {
color: var(--error);
font-size: var(--font-sm);
}
.quick-replies {
display: flex;
gap: var(--space-2);
overflow-x: auto;
padding-bottom: var(--space-1);
-webkit-overflow-scrolling: touch;
}
.quick-reply {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-full);
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
white-space: nowrap;
transition: all var(--transition-fast);
}
.quick-reply:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.quick-reply:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quick-reply.ghost {
background: transparent;
color: var(--text-muted);
}
.typing-indicator {
display: flex;
gap: var(--space-2);
align-items: center;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: typingPulse 1.2s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes slideUp {
from { transform: translateY(12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes typingPulse {
0%, 100% { transform: translateY(0); opacity: 0.4; }
50% { transform: translateY(-4px); opacity: 1; }
}
@media (max-width: 700px) {
.chat-onboarding {
padding: var(--space-3);
}
.chat-shell {
min-height: calc(100vh - var(--space-6));
}
.chat-header {
padding: var(--space-4);
}
.chat-messages {
padding: var(--space-4);
}
}
.field input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.field input::placeholder {
color: var(--text-tertiary);
}
.btn-group {
display: flex;
gap: var(--space-2);
}
.btn-group.vertical {
flex-direction: column;
}
.btn-group button {
flex: 1;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border);
transition: all var(--transition-base);
font-weight: 500;
min-height: 44px;
}
.btn-group button:hover {
border-color: var(--accent);
color: var(--text-primary);
background: var(--bg-tertiary);
}
.btn-group button.active {
background: var(--accent);
color: white;
border-color: var(--accent);
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.25);
}
.rm-fields {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
margin-top: var(--space-2);
}
.rm-fields .field {
margin-bottom: 0;
}
.bodyfat-result {
background: var(--success-subtle);
color: var(--success);
padding: var(--space-4);
border-radius: var(--radius-md);
text-align: center;
margin: var(--space-4) 0;
border: 1px solid rgba(34, 197, 94, 0.2);
}
.bodyfat-result strong {
font-size: var(--font-lg);
}
.nav-btns {
display: flex;
gap: var(--space-3);
margin-top: var(--space-6);
}
.nav-btns button {
flex: 1;
padding: var(--space-4);
border-radius: var(--radius-md);
font-size: var(--font-base);
transition: all var(--transition-base);
min-height: 44px;
}
.nav-btns button:first-child {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.nav-btns button:first-child:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-hover);
}
.next-btn, .finish-btn {
background: var(--accent) !important;
color: white !important;
font-weight: 600;
border: none !important;
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
}
.next-btn:hover:not(:disabled), .finish-btn:hover:not(:disabled) {
background: var(--accent-hover) !important;
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(255, 107, 74, 0.4);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Header logout */
.header-left { display: flex; align-items: center; gap: 16px; }
.logout-btn { padding: 6px 12px; background: var(--bg-secondary); color: var(--text-secondary); border-radius: 6px; font-size: 0.75rem; }
.logout-btn:hover { background: var(--border); }
.header-left {
display: flex;
align-items: center;
gap: var(--space-4);
}
.logout-btn {
padding: var(--space-2) var(--space-3);
background: var(--bg-secondary);
color: var(--text-muted);
border-radius: var(--radius-sm);
font-size: var(--font-xs);
transition: all var(--transition-base);
border: 1px solid var(--border);
}
.logout-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-hover);
}
/* ============================================
GLOBAL INPUT ACCESSIBILITY
Ensure all inputs have font-size >= 16px to prevent iOS auto-zoom
============================================ */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
input[type="tel"],
select,
textarea {
font-size: 16px;
}
+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 />)
+2 -2
View File
@@ -5,7 +5,7 @@ import { AuthProvider, useAuth } from './context/AuthContext'
import App from './App.jsx'
import RegisterPage from './pages/RegisterPage'
import LoginPage from './pages/LoginPage'
import OnboardingWizard from './pages/OnboardingWizard'
import ChatOnboarding from './pages/ChatOnboarding'
import './index.css'
function ProtectedRoute({ children, requireOnboarding = true }) {
@@ -31,7 +31,7 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<Routes>
<Route path="/register" element={<AuthRoute><RegisterPage /></AuthRoute>} />
<Route path="/login" element={<AuthRoute><LoginPage /></AuthRoute>} />
<Route path="/onboarding" element={<ProtectedRoute requireOnboarding={false}><OnboardingWizard /></ProtectedRoute>} />
<Route path="/onboarding" element={<ProtectedRoute requireOnboarding={false}><ChatOnboarding /></ProtectedRoute>} />
<Route path="/*" element={<ProtectedRoute><App /></ProtectedRoute>} />
</Routes>
</AuthProvider>
+557
View File
@@ -0,0 +1,557 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import CoachMessage from '../components/CoachMessage'
import UserMessage from '../components/UserMessage'
import QuickReplies from '../components/QuickReplies'
const API = '/api'
const initialData = {
name: '',
gender: '',
age: '',
height_cm: '',
weight: '',
neck_cm: '',
waist_cm: '',
hip_cm: '',
experience_level: '',
bench_1rm: '',
squat_1rm: '',
deadlift_1rm: '',
goal: '',
workouts_per_week: ''
}
const calcBodyFat = (gender, waist, neck, hip, height) => {
if (!waist || !neck || !height) return null
if (gender === 'female' && !hip) return null
if (gender === 'male') {
return Math.max(
0,
495 / (1.0324 - 0.19077 * Math.log10(waist - neck) + 0.15456 * Math.log10(height)) - 450
).toFixed(1)
}
return Math.max(
0,
495 / (1.29579 - 0.35004 * Math.log10(waist + hip - neck) + 0.221 * Math.log10(height)) - 450
).toFixed(1)
}
const toNumberOrNull = (value) => {
if (value === '' || value === null || value === undefined) return null
const numberValue = Number(value)
return Number.isNaN(numberValue) ? null : numberValue
}
export default function ChatOnboarding() {
const { token, updateProfile, refreshProfile } = useAuth()
const navigate = useNavigate()
const [data, setData] = useState(initialData)
const [messages, setMessages] = useState([])
const [currentIndex, setCurrentIndex] = useState(0)
const [answers, setAnswers] = useState([])
const [inputValue, setInputValue] = useState('')
const [error, setError] = useState('')
const [isTyping, setIsTyping] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const endRef = useRef(null)
const messageIdRef = useRef(0)
const typingTimeoutRef = useRef(null)
const questions = useMemo(() => {
return [
{
id: 'name',
field: 'name',
type: 'text',
prompt: 'Hej! Jag är din coach. Vad heter du?',
placeholder: 'Ditt namn',
inputType: 'text'
},
{
id: 'goal',
field: 'goal',
type: 'options',
prompt: values => `Kul att träffas${values.name ? ` ${values.name}` : ''}! Vad är ditt största mål?`,
options: [
{ label: 'Bygga muskler', value: 'muscle' },
{ label: 'Styrka', value: 'strength' },
{ label: 'Gå ner i vikt', value: 'fat_loss' },
{ label: 'Hälsa', value: 'general' }
]
},
{
id: 'experience_level',
field: 'experience_level',
type: 'options',
prompt: 'Hur länge har du tränat?',
options: [
{ label: 'Ny', value: 'beginner' },
{ label: '< 1 år', value: 'beginner' },
{ label: '1-3 år', value: 'intermediate' },
{ label: '3+ år', value: 'advanced' }
]
},
{
id: 'workouts_per_week',
field: 'workouts_per_week',
type: 'options',
prompt: 'Hur många pass kan du köra per vecka?',
options: [2, 3, 4, 5, 6].map(n => ({ label: `${n}`, value: n }))
},
{
id: 'gender',
field: 'gender',
type: 'options',
prompt: 'Super! Vi tar några snabba basfrågor. Vilket kön identifierar du dig som?',
options: [
{ label: 'Man', value: 'male' },
{ label: 'Kvinna', value: 'female' }
]
},
{
id: 'age',
field: 'age',
type: 'text',
prompt: 'Hur gammal är du?',
placeholder: 'Ålder',
inputType: 'number',
validate: value => (value > 0 && value < 120 ? '' : 'Skriv in en giltig ålder.')
},
{
id: 'height_cm',
field: 'height_cm',
type: 'text',
prompt: 'Hur lång är du? (cm)',
placeholder: '175',
inputType: 'number',
unit: 'cm',
validate: value => (value > 50 && value < 260 ? '' : 'Skriv in din längd i cm.')
},
{
id: 'weight',
field: 'weight',
type: 'text',
prompt: 'Vad väger du just nu? (kg)',
placeholder: '75',
inputType: 'number',
unit: 'kg',
validate: value => (value > 20 && value < 300 ? '' : 'Skriv in din vikt i kg.')
},
{
id: 'neck_cm',
field: 'neck_cm',
type: 'text',
prompt: 'Om du vet: halsmått i cm?',
placeholder: '38',
inputType: 'number',
unit: 'cm',
optional: true
},
{
id: 'waist_cm',
field: 'waist_cm',
type: 'text',
prompt: 'Midjemått i cm?',
placeholder: '85',
inputType: 'number',
unit: 'cm',
optional: true
},
{
id: 'hip_cm',
field: 'hip_cm',
type: 'text',
prompt: 'Höftmått i cm?',
placeholder: '95',
inputType: 'number',
unit: 'cm',
optional: true,
shouldAsk: values => values.gender === 'female'
},
{
id: 'bench_1rm',
field: 'bench_1rm',
type: 'text',
prompt: 'Har du en uppskattad 1RM i bänkpress? (valfritt, kg)',
placeholder: '100',
inputType: 'number',
unit: 'kg',
optional: true,
shouldAsk: values => values.experience_level && values.experience_level !== 'beginner'
},
{
id: 'squat_1rm',
field: 'squat_1rm',
type: 'text',
prompt: '1RM i knäböj? (valfritt, kg)',
placeholder: '140',
inputType: 'number',
unit: 'kg',
optional: true,
shouldAsk: values => values.experience_level && values.experience_level !== 'beginner'
},
{
id: 'deadlift_1rm',
field: 'deadlift_1rm',
type: 'text',
prompt: '1RM i marklyft? (valfritt, kg)',
placeholder: '160',
inputType: 'number',
unit: 'kg',
optional: true,
shouldAsk: values => values.experience_level && values.experience_level !== 'beginner'
}
]
}, [])
const currentQuestion = questions[currentIndex]
const addMessage = (message) => {
messageIdRef.current += 1
setMessages(prev => [...prev, { id: messageIdRef.current, ...message }])
}
const scrollToBottom = () => {
if (endRef.current) {
endRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' })
}
}
useEffect(() => {
scrollToBottom()
}, [messages, isTyping])
const getPrompt = (question, values) => {
if (!question) return ''
return typeof question.prompt === 'function' ? question.prompt(values) : question.prompt
}
useEffect(() => {
if (messages.length === 0 && currentQuestion) {
addMessage({ sender: 'coach', text: getPrompt(currentQuestion, data), questionIndex: currentIndex })
}
return () => {
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
}
}, [messages.length, currentQuestion, currentIndex, data])
const applyAnswer = (values, question, value) => {
if (!question.field) return values
return { ...values, [question.field]: value }
}
const rebuildDataFromAnswers = (updatedAnswers) => {
return updatedAnswers.reduce((acc, answer) => {
const question = questions[answer.questionIndex]
if (!question) return acc
return applyAnswer(acc, question, answer.value)
}, { ...initialData })
}
const findNextIndex = (startIndex, nextData) => {
for (let i = startIndex + 1; i < questions.length; i += 1) {
const question = questions[i]
if (!question?.shouldAsk || question.shouldAsk(nextData)) return i
}
return null
}
const isMeasurementQuestion = (questionId) => ['weight', 'neck_cm', 'waist_cm', 'hip_cm'].includes(questionId)
const isStrengthQuestion = (questionId) => ['bench_1rm', 'squat_1rm', 'deadlift_1rm'].includes(questionId)
const isProfileQuestion = (questionId) => ['gender', 'age', 'height_cm', 'experience_level', 'goal', 'workouts_per_week'].includes(questionId)
const saveProfile = async (values, complete = false) => {
const payload = {
gender: values.gender || null,
age: toNumberOrNull(values.age),
height_cm: toNumberOrNull(values.height_cm),
experience_level: values.experience_level || null,
goal: values.goal || null,
workouts_per_week: toNumberOrNull(values.workouts_per_week),
onboarding_complete: complete
}
await updateProfile(payload)
}
const saveMeasurements = async (values) => {
const bodyFat = calcBodyFat(
values.gender,
toNumberOrNull(values.waist_cm),
toNumberOrNull(values.neck_cm),
toNumberOrNull(values.hip_cm),
toNumberOrNull(values.height_cm)
)
if (!values.weight && !values.neck_cm && !values.waist_cm && !values.hip_cm) return
await fetch(`${API}/user/measurements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({
weight: toNumberOrNull(values.weight),
neck_cm: toNumberOrNull(values.neck_cm),
waist_cm: toNumberOrNull(values.waist_cm),
hip_cm: toNumberOrNull(values.hip_cm),
body_fat_pct: bodyFat
})
})
}
const saveStrength = async (values) => {
if (!values.bench_1rm && !values.squat_1rm && !values.deadlift_1rm) return
await fetch(`${API}/user/strength`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({
bench_1rm: toNumberOrNull(values.bench_1rm),
squat_1rm: toNumberOrNull(values.squat_1rm),
deadlift_1rm: toNumberOrNull(values.deadlift_1rm)
})
})
}
const maybeAutoSave = async (questionId, nextData, nextIndex) => {
if (isProfileQuestion(questionId)) {
await saveProfile(nextData, false)
}
if (isMeasurementQuestion(questionId)) {
const nextQuestion = nextIndex !== null ? questions[nextIndex] : null
if (!nextQuestion || !isMeasurementQuestion(nextQuestion.id)) {
await saveMeasurements(nextData)
}
}
if (isStrengthQuestion(questionId)) {
const nextQuestion = nextIndex !== null ? questions[nextIndex] : null
if (!nextQuestion || !isStrengthQuestion(nextQuestion.id)) {
await saveStrength(nextData)
}
}
}
const handleAnswer = async (answerValue, answerLabel = null) => {
if (!currentQuestion) return
const label = answerLabel ?? `${answerValue}`
setError('')
const nextData = applyAnswer(data, currentQuestion, answerValue)
const nextIndex = findNextIndex(currentIndex, nextData)
addMessage({ sender: 'user', text: label, questionIndex: currentIndex })
setAnswers(prev => [...prev, { questionIndex: currentIndex, value: answerValue, label }])
setData(nextData)
setInputValue('')
try {
await maybeAutoSave(currentQuestion.id, nextData, nextIndex)
} catch (err) {
console.error('Autosave error:', err)
}
if (nextIndex === null) {
setIsTyping(true)
typingTimeoutRef.current = setTimeout(async () => {
addMessage({
sender: 'coach',
text: 'Perfekt! Jag har allt jag behöver. Låt mig bygga ditt program...',
questionIndex: currentIndex + 1
})
setIsTyping(false)
await finishOnboarding(nextData)
}, 700)
return
}
setIsTyping(true)
typingTimeoutRef.current = setTimeout(() => {
addMessage({ sender: 'coach', text: getPrompt(questions[nextIndex], nextData), questionIndex: nextIndex })
setCurrentIndex(nextIndex)
setIsTyping(false)
}, 600)
}
const finishOnboarding = async (values) => {
setIsSaving(true)
try {
await saveProfile(values, true)
await saveMeasurements(values)
await saveStrength(values)
if (refreshProfile) await refreshProfile()
navigate('/')
} catch (err) {
console.error('Onboarding error:', err)
setIsSaving(false)
}
}
const handleTextSubmit = () => {
if (!currentQuestion) return
const raw = inputValue.trim()
if (!raw && !currentQuestion.optional) {
setError('Skriv ett svar för att gå vidare.')
return
}
let numericValue = raw
if (currentQuestion.inputType === 'number' && raw) {
const parsed = Number(raw)
if (Number.isNaN(parsed)) {
setError('Skriv ett giltigt nummer.')
return
}
const validationMessage = currentQuestion.validate ? currentQuestion.validate(parsed) : ''
if (validationMessage) {
setError(validationMessage)
return
}
numericValue = parsed
}
if (!raw && currentQuestion.optional) {
handleAnswer('', 'Hoppar')
return
}
const label = currentQuestion.unit ? `${raw} ${currentQuestion.unit}` : raw
handleAnswer(numericValue, label)
}
const handleQuickReply = (option) => {
if (option.action === 'back') {
handleBack()
return
}
if (option.action === 'skip') {
handleAnswer('', 'Hoppar')
return
}
handleAnswer(option.value, option.label)
}
const handleBack = () => {
if (!answers.length) return
const lastAnswer = answers[answers.length - 1]
const targetIndex = lastAnswer.questionIndex
const trimmedMessages = [...messages]
const lastCoachIndex = trimmedMessages
.map((msg, idx) => (msg.sender === 'coach' && msg.questionIndex === targetIndex ? idx : -1))
.filter(idx => idx !== -1)
.pop()
if (lastCoachIndex !== undefined) {
trimmedMessages.splice(lastCoachIndex + 1)
}
const updatedAnswers = answers.slice(0, -1)
setAnswers(updatedAnswers)
setMessages(trimmedMessages)
setCurrentIndex(targetIndex)
setData(rebuildDataFromAnswers(updatedAnswers))
setIsTyping(false)
setInputValue('')
setError('')
}
const renderInputArea = () => {
if (!currentQuestion) return null
if (currentQuestion.type === 'options') {
const options = [...currentQuestion.options]
const actionOptions = []
if (currentQuestion.optional) {
actionOptions.push({ label: 'Hoppa över', action: 'skip', variant: 'ghost' })
}
if (answers.length) {
actionOptions.push({ label: 'Ändra senaste', action: 'back', variant: 'ghost' })
}
return (
<>
<QuickReplies
options={[...options, ...actionOptions]}
onSelect={handleQuickReply}
disabled={isTyping || isSaving}
/>
</>
)
}
const actionOptions = []
if (currentQuestion.optional) {
actionOptions.push({ label: 'Hoppa över', action: 'skip', variant: 'ghost' })
}
if (answers.length) {
actionOptions.push({ label: 'Ändra senaste', action: 'back', variant: 'ghost' })
}
return (
<div className="chat-input-area">
<div className="chat-input-row">
<input
type={currentQuestion.inputType || 'text'}
inputMode={currentQuestion.inputType === 'number' ? 'numeric' : 'text'}
placeholder={currentQuestion.placeholder}
value={inputValue}
onChange={event => setInputValue(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') handleTextSubmit()
}}
disabled={isTyping || isSaving}
/>
<button
type="button"
className="send-btn"
onClick={handleTextSubmit}
disabled={isTyping || isSaving}
>
Skicka
</button>
</div>
{error && <div className="chat-error">{error}</div>}
<QuickReplies
options={actionOptions}
onSelect={handleQuickReply}
disabled={isTyping || isSaving}
/>
</div>
)
}
return (
<div className="chat-onboarding">
<div className="chat-shell">
<header className="chat-header">
<div>
<p className="chat-subtitle">Coach</p>
<h1>Personlig onboarding</h1>
</div>
<span className={`chat-status ${isSaving ? 'saving' : ''}`}>
{isSaving ? 'Sparar...' : 'Redo'}
</span>
</header>
<div className="chat-messages">
{messages.map(message => (
message.sender === 'coach' ? (
<CoachMessage key={message.id} text={message.text} />
) : (
<UserMessage key={message.id} text={message.text} />
)
))}
{isTyping && <CoachMessage typing />}
<div ref={endRef}></div>
</div>
<div className="chat-actions">
{renderInputArea()}
</div>
</div>
</div>
)
}
+5 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../context/AuthContext'
import { Icon, getActivityIconName } from '../components/Icons'
import Logo from '../components/Logo'
const API_URL = '/api'
@@ -90,7 +91,10 @@ function Dashboard({ onStartWorkout, onNavigate }) {
<div className="dashboard">
<header className="dashboard-header">
<div className="header-top">
<h1 className="brand-title"><Icon name="gravl" size={22} /> Gravl</h1>
<h1 className="brand-title">
<Logo />
<span className="brand-name">Gravl</span>
</h1>
<nav className="nav-menu">
<button className="nav-btn active"><Icon name="home" size={18} /></button>
<button className="nav-btn" onClick={() => onNavigate('progress')}><Icon name="chart" size={18} /></button>
+5 -3
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Logo from '../components/Logo';
export default function LoginPage() {
const [email, setEmail] = useState('');
@@ -26,9 +27,10 @@ export default function LoginPage() {
return (
<div className="auth-page">
<div className="auth-card">
<h1>🏋 Gravl</h1>
<h2>Logga in</h2>
{error && <div className="error">{error}</div>}
<Logo />
<h1 className="auth-title">Logga in</h1>
<p className="auth-tagline">Din personliga träningspartner</p>
{error && <div className="error auth-error">{error}</div>}
<form onSubmit={handleSubmit}>
<input type="email" placeholder="E-post" value={email} onChange={e => setEmail(e.target.value)} required />
<input type="password" placeholder="Lösenord" value={password} onChange={e => setPassword(e.target.value)} required />
+5 -3
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Logo from '../components/Logo';
export default function RegisterPage() {
const [email, setEmail] = useState('');
@@ -26,9 +27,10 @@ export default function RegisterPage() {
return (
<div className="auth-page">
<div className="auth-card">
<h1>🏋 Gravl</h1>
<h2>Skapa konto</h2>
{error && <div className="error">{error}</div>}
<Logo />
<h1 className="auth-title">Skapa konto</h1>
<p className="auth-tagline">Börja din träningsresa</p>
{error && <div className="error auth-error">{error}</div>}
<form onSubmit={handleSubmit}>
<input type="email" placeholder="E-post" value={email} onChange={e => setEmail(e.target.value)} required />
<input type="password" placeholder="Lösenord" value={password} onChange={e => setPassword(e.target.value)} required minLength={6} />
+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>
)
}
+243 -43
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { Icon } from '../components/Icons'
import WeightInput from '../components/WeightInput'
import RepsInput from '../components/RepsInput'
import AlternativeModal from '../components/AlternativeModal'
const API_URL = '/api'
// Uppvärmningsövningar baserat på muskelgrupp
const warmupExercises = {
@@ -53,11 +54,33 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
const [warmupDone, setWarmupDone] = useState(false)
const [warmupExpanded, setWarmupExpanded] = useState(true)
const [completedWarmups, setCompletedWarmups] = useState(new Set())
const [swapExercise, setSwapExercise] = useState(null)
const [alternatives, setAlternatives] = useState([])
const [alternativesLoading, setAlternativesLoading] = useState(false)
const [alternativesError, setAlternativesError] = useState('')
const [swappedExercises, setSwappedExercises] = useState({})
const defaultRestSeconds = 90
const [restSeconds, setRestSeconds] = useState(defaultRestSeconds)
const [restRunning, setRestRunning] = useState(false)
useEffect(() => {
loadProgressions()
}, [day])
useEffect(() => {
if (!restRunning) return
const timer = setInterval(() => {
setRestSeconds(prev => {
if (prev <= 1) {
setRestRunning(false)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [restRunning])
const loadProgressions = async () => {
const progs = {}
for (const exercise of day.exercises) {
@@ -68,6 +91,40 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
setProgressions(progs)
}
const openAlternatives = async (exercise) => {
if (!exercise?.exercise_id) {
setAlternativesError('Saknar övningsdata för alternativa val.')
setSwapExercise(exercise)
return
}
setSwapExercise(exercise)
setAlternatives([])
setAlternativesError('')
setAlternativesLoading(true)
try {
const res = await fetch(`${API_URL}/exercises/${exercise.exercise_id}/alternatives`)
if (!res.ok) throw new Error('Failed to fetch alternatives')
const data = await res.json()
setAlternatives(data)
} catch (err) {
console.error('Failed to fetch alternatives:', err)
setAlternativesError('Kunde inte hämta alternativ.')
} finally {
setAlternativesLoading(false)
}
}
const handleSelectAlternative = (alternative) => {
if (!swapExercise) return
setSwappedExercises(prev => ({
...prev,
[swapExercise.id]: alternative
}))
setSwapExercise(null)
}
const exercises = day.exercises?.filter(e => e.name) || []
const muscleGroups = getMuscleGroups(exercises)
@@ -97,6 +154,26 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
const totalWarmups = generalWarmups.length + specificWarmups.length
const warmupProgress = completedWarmups.size
const formatRestTime = (totalSeconds) => {
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
const startRest = (seconds = defaultRestSeconds) => {
setRestSeconds(seconds)
setRestRunning(true)
}
const toggleRest = () => {
setRestRunning(prev => !prev)
}
const resetRest = () => {
setRestRunning(false)
setRestSeconds(defaultRestSeconds)
}
return (
<div className="workout-page">
<header className="page-header">
@@ -113,6 +190,29 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
</header>
<main className="page-main workout-main">
{/* Vila */}
<section className="rest-timer-card">
<div className="rest-timer-header">
<div className="rest-timer-label">Vilotimer</div>
<div className={`rest-timer-time ${restRunning ? 'running' : ''}`}>
{formatRestTime(restSeconds)}
</div>
</div>
<div className="rest-timer-actions">
<button className="rest-timer-btn primary" onClick={toggleRest}>
{restRunning ? 'Pausa' : 'Starta vila'}
</button>
<button className="rest-timer-btn secondary" onClick={resetRest}>
Återställ
</button>
</div>
<div className="rest-timer-presets">
<button className="rest-timer-chip" onClick={() => startRest(60)}>1:00</button>
<button className="rest-timer-chip" onClick={() => startRest(90)}>1:30</button>
<button className="rest-timer-chip" onClick={() => startRest(120)}>2:00</button>
</div>
</section>
{/* Progress Bar */}
<div className="workout-progress-bar">
<div
@@ -228,20 +328,30 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
{/* Övningslista */}
<section className="exercises-section">
<h2>Övningar</h2>
{exercises.map((exercise, idx) => (
<ExerciseCard
key={exercise.id || idx}
exercise={exercise}
logs={logs[exercise.id] || []}
progression={progressions[exercise.id]}
expanded={expandedExercise === exercise.id}
onToggle={() => setExpandedExercise(
expandedExercise === exercise.id ? null : exercise.id
)}
onLogSet={onLogSet}
onDeleteSet={onDeleteSet}
/>
))}
{exercises.map((exercise, idx) => {
const swapped = swappedExercises[exercise.id]
const displayExercise = swapped
? { ...exercise, name: swapped.name, muscle_group: swapped.muscle_group, description: swapped.description }
: exercise
return (
<ExerciseCard
key={exercise.id || idx}
exercise={displayExercise}
isSwapped={Boolean(swapped)}
logs={logs[exercise.id] || []}
progression={progressions[exercise.id]}
expanded={expandedExercise === exercise.id}
onToggle={() => setExpandedExercise(
expandedExercise === exercise.id ? null : exercise.id
)}
onLogSet={onLogSet}
onDeleteSet={onDeleteSet}
onStartRest={startRest}
onSwap={() => openAlternatives(exercise)}
/>
)
})}
</section>
{/* Avsluta pass */}
@@ -254,13 +364,24 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
: `Avsluta pass (${completedExercises}/${exercises.length} klara)`}
</button>
</main>
<AlternativeModal
exercise={swapExercise}
alternatives={alternatives}
loading={alternativesLoading}
error={alternativesError}
onSelect={handleSelectAlternative}
onClose={() => setSwapExercise(null)}
/>
</div>
)
}
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet }) {
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet, onSwap, isSwapped, onStartRest }) {
const [setList, setSetList] = useState([])
const [showAddModal, setShowAddModal] = useState(false)
const weightStep = 2.5
const repsStep = 1
useEffect(() => {
const initial = []
@@ -279,11 +400,34 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
setSetList(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s))
}
const parseNumber = (value) => {
const parsed = parseFloat(value)
return Number.isFinite(parsed) ? parsed : 0
}
const formatWeight = (value) => {
const fixed = Number.isInteger(value) ? String(value) : value.toFixed(1)
return fixed.replace(/\.0$/, '')
}
const handleAdjust = (idx, field, delta, min = 0) => {
const current = parseNumber(setList[idx]?.[field])
const next = Math.max(min, current + delta)
if (field === 'weight') {
handleInputChange(idx, field, formatWeight(next))
} else {
handleInputChange(idx, field, String(Math.round(next)))
}
}
const handleComplete = (idx) => {
const input = setList[idx]
const newCompleted = !input.completed
setSetList(prev => prev.map((s, i) => i === idx ? { ...s, completed: newCompleted } : s))
onLogSet(exercise.id, idx + 1, input.weight, input.reps, newCompleted)
if (newCompleted) {
onStartRest?.()
}
}
const handleAddNormal = () => {
@@ -320,12 +464,25 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
<div className="exercise-info">
<h3>{exercise.name}</h3>
<span className="muscle-group">{exercise.muscle_group}</span>
{isSwapped && <span className="swap-badge">Alternativ</span>}
</div>
<div className="exercise-meta">
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
<span className={`progress-badge ${completedSets === setList.length ? 'complete' : ''}`}>
{completedSets}/{setList.length}
</span>
<div className="exercise-actions">
<div className="exercise-meta">
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
<span className={`progress-badge ${completedSets === setList.length ? 'complete' : ''}`}>
{completedSets}/{setList.length}
</span>
</div>
<button
className="swap-btn"
onClick={(event) => {
event.stopPropagation()
onSwap?.()
}}
aria-label="Byt övning"
>
<Icon name="swap" size={16} />
</button>
</div>
</div>
@@ -343,31 +500,74 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
<div className="sets-list">
{setList.map((input, idx) => (
<div key={idx} className={`set-row ${input.completed ? 'completed' : ''}`}>
<span className="set-number">Set {idx + 1}</span>
<div className="set-inputs">
<WeightInput
value={input.weight}
onChange={(val) => handleInputChange(idx, 'weight', val)}
/>
<span className="input-separator">×</span>
<RepsInput
value={input.reps}
onChange={(val) => handleInputChange(idx, 'reps', val)}
/>
<div className="set-row-top">
<span className="set-number">Set {idx + 1}</span>
<button
className={`delete-set-btn ${setList.length <= 1 ? 'disabled' : ''}`}
onClick={() => handleDeleteSet(idx)}
disabled={setList.length <= 1}
aria-label={`Ta bort set ${idx + 1}`}
>
<Icon name="trash" size={16} />
</button>
</div>
<div className="set-controls">
<div className="set-metric">
<span className="metric-label">Vikt</span>
<div className="metric-controls">
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'weight', -weightStep)}
aria-label="Minska vikt"
>
</button>
<div className="metric-value">
<span className="metric-number">{input.weight === '' ? '0' : input.weight}</span>
<span className="metric-suffix">kg</span>
</div>
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'weight', weightStep)}
aria-label="Öka vikt"
>
+
</button>
</div>
</div>
<div className="set-metric">
<span className="metric-label">Reps</span>
<div className="metric-controls">
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'reps', -repsStep)}
aria-label="Minska reps"
>
</button>
<div className="metric-value">
<span className="metric-number">{input.reps === '' ? '0' : input.reps}</span>
</div>
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'reps', repsStep)}
aria-label="Öka reps"
>
+
</button>
</div>
</div>
</div>
<button
className={`delete-set-btn ${setList.length <= 1 ? 'disabled' : ''}`}
onClick={() => handleDeleteSet(idx)}
disabled={setList.length <= 1}
aria-label={`Ta bort set ${idx + 1}`}
>
<Icon name="trash" size={16} />
</button>
<button
className={`complete-btn ${input.completed ? 'done' : ''}`}
className={`klart-btn ${input.completed ? 'done' : ''}`}
onClick={() => handleComplete(idx)}
>
{input.completed ? <Icon name="check" size={18} /> : ''}
{input.completed ? <Icon name="check" size={18} /> : null}
KLART
</button>
</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/);
});
+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