Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 210a2d15a9 | |||
| 2f6392a807 | |||
| 2bc4c947ae | |||
| 0c37d6ea91 | |||
| f7c654325f | |||
| 83ccd6c601 | |||
| 53f026aee2 | |||
| 994cc9e984 | |||
| 5a9ea9c9a8 | |||
| fac53a3605 | |||
| 994f406050 | |||
| f941011130 | |||
| fa95e880b2 | |||
| f63f4c0420 | |||
| 475cf10b17 | |||
| cf85e9e314 | |||
| b5c9250a10 | |||
| a24199e56c | |||
| 5fd21719d0 | |||
| 4bd2c9607d | |||
| 22750bfa06 | |||
| 4b39f39e3e | |||
| 7694ca6313 | |||
| 15d7aff096 | |||
| 362f4eed49 | |||
| 6d1da03fec | |||
| 5d0e0e3952 | |||
| be4a149a47 | |||
| 0cd6cd0269 | |||
| e40b486ae5 | |||
| 04bab32e26 |
+63
@@ -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
|
||||||
+24
-6
@@ -1,8 +1,26 @@
|
|||||||
{
|
{
|
||||||
"lastRun": "2026-02-28T23:45:00+01:00",
|
"lastRun": "2026-03-02T19:37:00Z",
|
||||||
"status": "completed",
|
"status": "unblocked",
|
||||||
"tasksCompleted": ["emoji-replacement", "alternative-modal", "workout-page-ux-redish", "chat-onboarding", "03-01-login-onboarding-polish", "03-02-dashboard-polish", "03-03-workout-experience-polish"],
|
"unblockedReason": "OpenCode API configured as fallback for Gemini quota",
|
||||||
"activeTask": null,
|
"currentPhase": "05",
|
||||||
"nextTask": null,
|
"currentTask": "05-03",
|
||||||
"notes": "03-03 Workout Experience Polish complete (commit f6b1379). Phase 3 complete: login/onboarding, dashboard, and workout experience all polished."
|
"result": "Fallback system implemented: Gemini (primary) → OpenCode (fallback)",
|
||||||
|
"nextTask": "05-03: Frontend integration for research display (can now proceed with OpenCode fallback)",
|
||||||
|
|
||||||
|
"apiConfiguration": {
|
||||||
|
"primary": {
|
||||||
|
"provider": "Gemini",
|
||||||
|
"status": "quota-limited",
|
||||||
|
"notes": "Free tier has daily limits"
|
||||||
|
},
|
||||||
|
"fallback": {
|
||||||
|
"provider": "OpenCode",
|
||||||
|
"baseUrl": "https://api.opencode.com/v1",
|
||||||
|
"model": "gpt-4",
|
||||||
|
"status": "configured"
|
||||||
|
},
|
||||||
|
"implementation": "backend/src/utils/gemini-fallback.js"
|
||||||
|
},
|
||||||
|
|
||||||
|
"action": "READY TO RESUME: PM can continue with 05-03 using fallback"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "nodemon src/index.js"
|
"dev": "nodemon src/index.js",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
"pg": "^8.11.3"
|
"pg": "^8.11.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2",
|
||||||
|
"supertest": "^6.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+375
-103
@@ -3,6 +3,8 @@ const cors = require('cors');
|
|||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { createExerciseResearchRouter } = require('./routes/exerciseResearch');
|
||||||
|
const { searchExerciseResearch } = require('./services/exaSearch');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -18,6 +20,7 @@ const pool = new Pool({
|
|||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch: searchExerciseResearch }));
|
||||||
|
|
||||||
const authMiddleware = (req, res, next) => {
|
const authMiddleware = (req, res, next) => {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
@@ -303,107 +306,6 @@ app.get('/api/exercises/:id/last-workout', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get workout logs for a user and date
|
|
||||||
app.get('/api/logs', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { user_id, date, program_exercise_id } = req.query;
|
|
||||||
let query = 'SELECT * FROM workout_logs WHERE 1=1';
|
|
||||||
const params = [];
|
|
||||||
|
|
||||||
if (user_id) {
|
|
||||||
params.push(user_id);
|
|
||||||
query += ` AND user_id = $${params.length}`;
|
|
||||||
}
|
|
||||||
if (date) {
|
|
||||||
params.push(date);
|
|
||||||
query += ` AND date = $${params.length}`;
|
|
||||||
}
|
|
||||||
if (program_exercise_id) {
|
|
||||||
params.push(program_exercise_id);
|
|
||||||
query += ` AND program_exercise_id = $${params.length}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
query += ' ORDER BY date DESC, set_number ASC';
|
|
||||||
|
|
||||||
const result = await pool.query(query, params);
|
|
||||||
res.json(result.rows);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching logs:', err);
|
|
||||||
res.status(500).json({ error: 'Database error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get last workout for an exercise (for progression)
|
|
||||||
app.get('/api/logs/last/:programExerciseId', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { user_id } = req.query;
|
|
||||||
const result = await pool.query(`
|
|
||||||
SELECT * FROM workout_logs
|
|
||||||
WHERE program_exercise_id = $1 AND user_id = $2
|
|
||||||
ORDER BY date DESC, set_number ASC
|
|
||||||
LIMIT 10
|
|
||||||
`, [req.params.programExerciseId, user_id || 1]);
|
|
||||||
res.json(result.rows);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching last workout:', err);
|
|
||||||
res.status(500).json({ error: 'Database error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log a set
|
|
||||||
app.post('/api/logs', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { user_id, program_exercise_id, date, set_number, weight, reps, completed } = req.body;
|
|
||||||
|
|
||||||
// Check if log exists for this set
|
|
||||||
const existing = await pool.query(
|
|
||||||
'SELECT id FROM workout_logs WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4',
|
|
||||||
[user_id, program_exercise_id, date, set_number]
|
|
||||||
);
|
|
||||||
|
|
||||||
let result;
|
|
||||||
if (existing.rows.length > 0) {
|
|
||||||
// Update existing
|
|
||||||
result = await pool.query(
|
|
||||||
'UPDATE workout_logs SET weight = $1, reps = $2, completed = $3 WHERE id = $4 RETURNING *',
|
|
||||||
[weight, reps, completed, existing.rows[0].id]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Insert new
|
|
||||||
result = await pool.query(
|
|
||||||
'INSERT INTO workout_logs (user_id, program_exercise_id, date, set_number, weight, reps, completed) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *',
|
|
||||||
[user_id, program_exercise_id, date, set_number, weight, reps, completed]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error logging set:', err);
|
|
||||||
res.status(500).json({ error: 'Database error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete a specific set log
|
|
||||||
app.delete('/api/logs', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { user_id, program_exercise_id, date, set_number } = req.body;
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
'DELETE FROM workout_logs WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4 RETURNING id',
|
|
||||||
[user_id, program_exercise_id, date, set_number]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'Log not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ deleted: result.rows[0].id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error deleting log:', err);
|
|
||||||
res.status(500).json({ error: 'Database error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate suggested weight based on progression
|
// Calculate suggested weight based on progression
|
||||||
app.get('/api/progression/:programExerciseId', async (req, res) => {
|
app.get('/api/progression/:programExerciseId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -495,6 +397,376 @@ app.get('/api/today/:programId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
if (require.main === module) {
|
||||||
console.log(`Gravl API running on port ${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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const normalizeQuery = (exerciseName, body) => {
|
||||||
|
if (body && typeof body.query === 'string' && body.query.trim()) {
|
||||||
|
return body.query.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body && typeof body.name === 'string' && body.name.trim()) {
|
||||||
|
return body.name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${exerciseName} exercise`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createExerciseResearchRouter = ({ pool, exaSearch }) => {
|
||||||
|
if (!pool || typeof pool.query !== 'function') {
|
||||||
|
throw new Error('Pool with query function is required');
|
||||||
|
}
|
||||||
|
if (!exaSearch || typeof exaSearch !== 'function') {
|
||||||
|
throw new Error('exaSearch function is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/:id/research', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const exerciseId = Number.parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isInteger(exerciseId)) {
|
||||||
|
return res.status(400).json({ error: 'Exercise id must be an integer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exerciseResult = await pool.query(
|
||||||
|
'SELECT id, name, description, muscle_groups, difficulty, equipment_needed FROM exercises WHERE id = $1',
|
||||||
|
[exerciseId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exerciseResult.rows.length) {
|
||||||
|
return res.status(404).json({ error: 'Exercise not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exercise = exerciseResult.rows[0];
|
||||||
|
const query = normalizeQuery(exercise.name, req.body);
|
||||||
|
const requestedResults = req.body?.num_results;
|
||||||
|
const numResults = Number.isInteger(requestedResults) && requestedResults > 0
|
||||||
|
? Math.min(requestedResults, 10)
|
||||||
|
: 5;
|
||||||
|
|
||||||
|
const { summary, results } = await exaSearch({ query, numResults });
|
||||||
|
|
||||||
|
let researchRecord = null;
|
||||||
|
try {
|
||||||
|
const insertResult = await pool.query(
|
||||||
|
`INSERT INTO research_results (exercise_id, query, summary, results, provider)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
[exerciseId, query, summary, JSON.stringify(results), 'exa']
|
||||||
|
);
|
||||||
|
researchRecord = insertResult.rows[0] || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to store research results:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
exercise,
|
||||||
|
query,
|
||||||
|
summary,
|
||||||
|
results,
|
||||||
|
stored: researchRecord
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error running exercise research:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch research' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createExerciseResearchRouter
|
||||||
|
};
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const pool = require('../db/pool');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation helper
|
||||||
|
const validateExercise = (data) => {
|
||||||
|
const errors = [];
|
||||||
|
if (!data.name || typeof data.name !== 'string' || !data.name.trim()) {
|
||||||
|
errors.push('name is required and must be non-empty');
|
||||||
|
}
|
||||||
|
if (data.difficulty && !['beginner', 'intermediate', 'advanced'].includes(data.difficulty)) {
|
||||||
|
errors.push('difficulty must be beginner, intermediate, or advanced');
|
||||||
|
}
|
||||||
|
if (data.muscle_groups && !Array.isArray(data.muscle_groups)) {
|
||||||
|
errors.push('muscle_groups must be an array');
|
||||||
|
}
|
||||||
|
if (data.equipment_needed && !Array.isArray(data.equipment_needed)) {
|
||||||
|
errors.push('equipment_needed must be an array');
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
// CREATE - Add new exercise
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, description, instructions, muscle_groups, difficulty, equipment_needed, video_url, created_by } = req.body;
|
||||||
|
|
||||||
|
const errors = validateExercise({ name, difficulty, muscle_groups, equipment_needed });
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Validation failed', details: errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO exercises (name, description, instructions, muscle_groups, difficulty, equipment_needed, video_url, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
name.trim(),
|
||||||
|
description || null,
|
||||||
|
instructions || null,
|
||||||
|
muscle_groups || [],
|
||||||
|
difficulty || 'intermediate',
|
||||||
|
equipment_needed || [],
|
||||||
|
video_url || null,
|
||||||
|
created_by || 'system'
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'Exercise name already exists' });
|
||||||
|
}
|
||||||
|
console.error('Error creating exercise:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create exercise' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// READ - Get all exercises with search/filter
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { search, difficulty, muscle_group, limit = 50, offset = 0 } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM exercises WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
let paramCount = 1;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query += ` AND (name ILIKE $${paramCount} OR description ILIKE $${paramCount})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (difficulty) {
|
||||||
|
query += ` AND difficulty = $${paramCount}`;
|
||||||
|
params.push(difficulty);
|
||||||
|
paramCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (muscle_group) {
|
||||||
|
query += ` AND $${paramCount} = ANY(muscle_groups)`;
|
||||||
|
params.push(muscle_group);
|
||||||
|
paramCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY name ASC LIMIT $${paramCount} OFFSET $${paramCount + 1}`;
|
||||||
|
params.push(parseInt(limit), parseInt(offset));
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching exercises:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch exercises' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// READ - Get single exercise
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT * FROM exercises WHERE id = $1', [req.params.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Exercise not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching exercise:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch exercise' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UPDATE - Modify exercise
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, description, instructions, muscle_groups, difficulty, equipment_needed, video_url } = req.body;
|
||||||
|
|
||||||
|
const errors = validateExercise({ name, difficulty, muscle_groups, equipment_needed });
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Validation failed', details: errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE exercises
|
||||||
|
SET name = $1, description = $2, instructions = $3, muscle_groups = $4,
|
||||||
|
difficulty = $5, equipment_needed = $6, video_url = $7, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $8
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
name.trim(),
|
||||||
|
description || null,
|
||||||
|
instructions || null,
|
||||||
|
muscle_groups || [],
|
||||||
|
difficulty || 'intermediate',
|
||||||
|
equipment_needed || [],
|
||||||
|
video_url || null,
|
||||||
|
req.params.id
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Exercise not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'Exercise name already exists' });
|
||||||
|
}
|
||||||
|
console.error('Error updating exercise:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update exercise' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE - Remove exercise
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('DELETE FROM exercises WHERE id = $1 RETURNING *', [req.params.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Exercise not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Exercise deleted', id: req.params.id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting exercise:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete exercise' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const DEFAULT_EXA_API_URL = 'https://api.exa.ai/search';
|
||||||
|
|
||||||
|
const buildSummary = (results) => {
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const snippets = results
|
||||||
|
.map((result) => result.snippet || result.highlight)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (snippets.length === 0) {
|
||||||
|
return results
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((result) => result.title)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return snippets.slice(0, 3).join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchExerciseResearch = async ({ query, numResults = 5 }) => {
|
||||||
|
if (!query || typeof query !== 'string') {
|
||||||
|
throw new Error('Query must be a non-empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.EXA_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('EXA_API_KEY is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = process.env.EXA_API_URL || DEFAULT_EXA_API_URL;
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
numResults,
|
||||||
|
type: 'neural',
|
||||||
|
useAutoprompt: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Exa search failed: ${response.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const results = (data.results || []).map((result) => ({
|
||||||
|
id: result.id,
|
||||||
|
title: result.title,
|
||||||
|
url: result.url,
|
||||||
|
snippet: Array.isArray(result.highlights) && result.highlights.length > 0
|
||||||
|
? result.highlights[0]
|
||||||
|
: result.snippet,
|
||||||
|
highlight: result.highlight,
|
||||||
|
publishedDate: result.publishedDate,
|
||||||
|
score: result.score
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: buildSummary(results),
|
||||||
|
results
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
searchExerciseResearch
|
||||||
|
};
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* AI API Fallback System
|
||||||
|
* Tries: Ollama (local) → Gemini → OpenRouter → OpenCode
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
|
||||||
|
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
||||||
|
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'deepseek-v3.2:cloud';
|
||||||
|
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY;
|
||||||
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
||||||
|
const OPENROUTER_BASE_URL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
|
||||||
|
const OPENCODE_API_KEY = process.env.OPENCODE_API_KEY;
|
||||||
|
const OPENCODE_BASE_URL = process.env.OPENCODE_BASE_URL || 'https://api.opencode.com/v1';
|
||||||
|
|
||||||
|
async function generateWithFallback(prompt, options = {}) {
|
||||||
|
console.log('🤖 Generating content...');
|
||||||
|
|
||||||
|
// Tier 1: Try Ollama (local, free)
|
||||||
|
try {
|
||||||
|
console.log(`📍 Tier 1: Attempting Ollama (${OLLAMA_MODEL})...`);
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 30000,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: OLLAMA_MODEL,
|
||||||
|
prompt: prompt,
|
||||||
|
stream: false,
|
||||||
|
temperature: options.temperature || 0.7
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('✅ Ollama success');
|
||||||
|
return { success: true, provider: 'ollama', data };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`⚠️ Ollama error: ${response.status}, trying next...`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Ollama failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2: Try Gemini
|
||||||
|
if (GEMINI_API_KEY) {
|
||||||
|
try {
|
||||||
|
console.log('📍 Tier 2: Attempting Gemini API...');
|
||||||
|
const response = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1/models/gemini-pro:generateContent?key=${GEMINI_API_KEY}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: options.config || {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('✅ Gemini API success');
|
||||||
|
return { success: true, provider: 'gemini', data };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 429 || response.status === 403) {
|
||||||
|
console.warn('⚠️ Gemini quota exceeded, trying next...');
|
||||||
|
} else {
|
||||||
|
throw new Error(`Gemini error: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Gemini failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: Fallback to OpenRouter
|
||||||
|
if (OPENROUTER_API_KEY) {
|
||||||
|
try {
|
||||||
|
console.log('📍 Tier 3: Attempting OpenRouter API...');
|
||||||
|
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': 'https://gravl.app'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: options.model || 'openai/gpt-4',
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
temperature: options.temperature || 0.7,
|
||||||
|
max_tokens: options.maxTokens || 2048
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('✅ OpenRouter API success');
|
||||||
|
return { success: true, provider: 'openrouter', data };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`OpenRouter error: ${response.status}, trying next...`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`OpenRouter failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 4: Final fallback to OpenCode
|
||||||
|
if (OPENCODE_API_KEY) {
|
||||||
|
try {
|
||||||
|
console.log('📍 Tier 4: Attempting OpenCode API...');
|
||||||
|
const response = await fetch(`${OPENCODE_BASE_URL}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${OPENCODE_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: options.model || 'gpt-4',
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
temperature: options.temperature || 0.7,
|
||||||
|
max_tokens: options.maxTokens || 2048
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('✅ OpenCode API success');
|
||||||
|
return { success: true, provider: 'opencode', data };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`OpenCode error: ${response.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`OpenCode failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('All generation APIs failed (Ollama → Gemini → OpenRouter → OpenCode)');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateWithFallback,
|
||||||
|
getAvailableProviders: () => ({
|
||||||
|
ollama: true, // Always available locally
|
||||||
|
gemini: !!GEMINI_API_KEY,
|
||||||
|
openrouter: !!OPENROUTER_API_KEY,
|
||||||
|
opencode: !!OPENCODE_API_KEY
|
||||||
|
})
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const { createExerciseResearchRouter } = require('../../src/routes/exerciseResearch');
|
||||||
|
|
||||||
|
const buildPoolMock = ({ exerciseRow }) => ({
|
||||||
|
query: async (text) => {
|
||||||
|
if (text.includes('FROM exercises')) {
|
||||||
|
return { rows: exerciseRow ? [exerciseRow] : [] };
|
||||||
|
}
|
||||||
|
if (text.includes('INSERT INTO research_results')) {
|
||||||
|
return { rows: [{ id: 1, created_at: '2026-03-02T00:00:00.000Z' }] };
|
||||||
|
}
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildApp = ({ pool, exaSearch }) => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch }));
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('Exercise research returns summary and results', async () => {
|
||||||
|
const pool = buildPoolMock({
|
||||||
|
exerciseRow: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Bench Press',
|
||||||
|
description: 'Barbell press'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const exaSearch = async ({ query, numResults }) => ({
|
||||||
|
summary: `Summary for ${query} (${numResults})`,
|
||||||
|
results: [
|
||||||
|
{ title: 'Guide', url: 'https://example.com', snippet: 'Bench press form.' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = buildApp({ pool, exaSearch });
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/exercises/1/research')
|
||||||
|
.send({ query: 'Bench press technique', num_results: 3 });
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(response.body.exercise.id, 1);
|
||||||
|
assert.equal(response.body.summary, 'Summary for Bench press technique (3)');
|
||||||
|
assert.equal(response.body.results.length, 1);
|
||||||
|
assert.ok(response.body.stored);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Exercise research returns 404 when exercise missing', async () => {
|
||||||
|
const pool = buildPoolMock({ exerciseRow: null });
|
||||||
|
const exaSearch = async () => {
|
||||||
|
throw new Error('Should not call exa');
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = buildApp({ pool, exaSearch });
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/exercises/999/research')
|
||||||
|
.send({ query: 'Missing' });
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 404);
|
||||||
|
assert.equal(response.body.error, 'Exercise not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Exercise research validates id', async () => {
|
||||||
|
const pool = buildPoolMock({ exerciseRow: null });
|
||||||
|
const exaSearch = async () => ({ summary: '', results: [] });
|
||||||
|
|
||||||
|
const app = buildApp({ pool, exaSearch });
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/exercises/not-a-number/research')
|
||||||
|
.send({ query: 'Bench' });
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 400);
|
||||||
|
assert.equal(response.body.error, 'Exercise id must be an integer');
|
||||||
|
});
|
||||||
+35
@@ -179,3 +179,38 @@ INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps
|
|||||||
(6, 16, 4, 10, 12, 4), -- Leg Curls 4x10-12
|
(6, 16, 4, 10, 12, 4), -- Leg Curls 4x10-12
|
||||||
(6, 17, 4, 12, 15, 5) -- Calf Raises 4x12-15
|
(6, 17, 4, 12, 15, 5) -- Calf Raises 4x12-15
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Custom workouts created by users
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_workouts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
source_program_day_id INTEGER REFERENCES program_days(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Exercises within a custom workout
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_workout_exercises (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
custom_workout_id INTEGER NOT NULL REFERENCES custom_workouts(id) ON DELETE CASCADE,
|
||||||
|
exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||||
|
sets INTEGER NOT NULL DEFAULT 3,
|
||||||
|
reps_min INTEGER NOT NULL DEFAULT 8,
|
||||||
|
reps_max INTEGER NOT NULL DEFAULT 12,
|
||||||
|
rpe_target DECIMAL(3,1),
|
||||||
|
replaced_exercise_id INTEGER REFERENCES exercises(id) ON DELETE SET NULL,
|
||||||
|
order_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Extend workout_logs to support custom workouts
|
||||||
|
ALTER TABLE workout_logs
|
||||||
|
ADD COLUMN IF NOT EXISTS source_type VARCHAR(20) DEFAULT 'program' CHECK (source_type IN ('program', 'custom')),
|
||||||
|
ADD COLUMN IF NOT EXISTS custom_workout_id INTEGER REFERENCES custom_workouts(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Indexes for custom workout tables
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_workouts_user ON custom_workouts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_workout_exercises_workout ON custom_workout_exercises(custom_workout_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workout_logs_custom_workout ON workout_logs(custom_workout_id);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Migration 004: Add custom workout support
|
||||||
|
-- Allows users to create personalized workout plans based on program days
|
||||||
|
|
||||||
|
-- Custom workouts created by users
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_workouts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
source_program_day_id INTEGER REFERENCES program_days(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Exercises within a custom workout
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_workout_exercises (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
custom_workout_id INTEGER NOT NULL REFERENCES custom_workouts(id) ON DELETE CASCADE,
|
||||||
|
exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||||
|
sets INTEGER NOT NULL DEFAULT 3,
|
||||||
|
reps_min INTEGER NOT NULL DEFAULT 8,
|
||||||
|
reps_max INTEGER NOT NULL DEFAULT 12,
|
||||||
|
rpe_target DECIMAL(3,1),
|
||||||
|
replaced_exercise_id INTEGER REFERENCES exercises(id) ON DELETE SET NULL,
|
||||||
|
order_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Extend workout_logs to support custom workouts
|
||||||
|
ALTER TABLE workout_logs
|
||||||
|
ADD COLUMN IF NOT EXISTS source_type VARCHAR(20) DEFAULT 'program' CHECK (source_type IN ('program', 'custom')),
|
||||||
|
ADD COLUMN IF NOT EXISTS custom_workout_id INTEGER REFERENCES custom_workouts(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_workouts_user ON custom_workouts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_workout_exercises_workout ON custom_workout_exercises(custom_workout_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workout_logs_custom_workout ON workout_logs(custom_workout_id);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Create exercises table for exercise encyclopedia
|
||||||
|
CREATE TABLE IF NOT EXISTS exercises (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
instructions TEXT,
|
||||||
|
muscle_groups TEXT[] DEFAULT ARRAY[]::text[],
|
||||||
|
difficulty VARCHAR(20) DEFAULT 'intermediate' CHECK (difficulty IN ('beginner', 'intermediate', 'advanced')),
|
||||||
|
equipment_needed TEXT[] DEFAULT ARRAY[]::text[],
|
||||||
|
video_url VARCHAR(255),
|
||||||
|
created_by VARCHAR(50),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_exercises_name ON exercises(name);
|
||||||
|
CREATE INDEX idx_exercises_difficulty ON exercises(difficulty);
|
||||||
|
CREATE INDEX idx_exercises_muscle_groups ON exercises USING GIN(muscle_groups);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Store exercise research summaries and sources
|
||||||
|
CREATE TABLE IF NOT EXISTS research_results (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
results JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
provider VARCHAR(50) NOT NULL DEFAULT 'exa',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_research_results_exercise_id ON research_results(exercise_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_research_results_created_at ON research_results(created_at);
|
||||||
@@ -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
|
||||||
+67
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+20
@@ -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>
|
||||||
Generated
+64
@@ -13,6 +13,7 @@
|
|||||||
"react-router-dom": "^6.21.0"
|
"react-router-dom": "^6.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -742,6 +743,22 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.2",
|
"version": "1.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||||
@@ -1481,6 +1498,53 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"react-router-dom": "^6.21.0"
|
"react-router-dom": "^6.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
}]
|
||||||
|
};
|
||||||
@@ -2958,3 +2958,213 @@
|
|||||||
border: 2px solid var(--border); display: flex; align-items: center; justify-content: center;
|
border: 2px solid var(--border); display: flex; align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
.warmup-item.done .warmup-check { background: var(--success); border-color: var(--success); color: white; }
|
.warmup-item.done .warmup-check { background: var(--success); border-color: var(--success); color: white; }
|
||||||
|
|
||||||
|
/* Workout badge styling */
|
||||||
|
.workout-badge-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workout-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -6px;
|
||||||
|
right: -6px;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||||
|
white-space: nowrap;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workout-badge.custom {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workout-badge.program {
|
||||||
|
background: var(--text-muted);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workout-select-card:hover .workout-badge {
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset button for custom workouts */
|
||||||
|
.reset-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--accent);
|
||||||
|
border: 2px solid var(--bg-primary);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 107, 74, 0.3);
|
||||||
|
padding: 0;
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn:hover {
|
||||||
|
background: #e85a3c;
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success message */
|
||||||
|
.success-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: linear-gradient(135deg, var(--success), #16a34a);
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
animation: slideDown 0.3s ease;
|
||||||
|
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal dialog styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body p {
|
||||||
|
font-size: var(--font-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-md);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn.cancel {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn.cancel:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn.confirm {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn.confirm:hover:not(:disabled) {
|
||||||
|
background: #e85a3c;
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn.confirm:active:not(:disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ProgressPage from './pages/ProgressPage'
|
|||||||
import WorkoutPage from './pages/WorkoutPage'
|
import WorkoutPage from './pages/WorkoutPage'
|
||||||
import WorkoutSelectPage from './pages/WorkoutSelectPage'
|
import WorkoutSelectPage from './pages/WorkoutSelectPage'
|
||||||
import ChatOnboarding from './pages/ChatOnboarding'
|
import ChatOnboarding from './pages/ChatOnboarding'
|
||||||
|
import ExerciseEncyclopediaPage from './pages/ExerciseEncyclopediaPage'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
@@ -144,6 +145,11 @@ function App() {
|
|||||||
return <ProgressPage onBack={() => setView('dashboard')} />
|
return <ProgressPage onBack={() => setView('dashboard')} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exercise encyclopedia
|
||||||
|
if (view === 'encyclopedia') {
|
||||||
|
return <ExerciseEncyclopediaPage onBack={() => setView('dashboard')} />
|
||||||
|
}
|
||||||
|
|
||||||
// Workout select page
|
// Workout select page
|
||||||
if (view === 'select-workout') {
|
if (view === 'select-workout') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const API_URL = '/api'
|
||||||
|
|
||||||
|
function ExerciseResearchPanel({ exerciseId, exerciseName }) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [research, setResearch] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const fetchResearch = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/exercises/${exerciseId}/research`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
throw new Error(data.error || 'Failed to fetch research')
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
setResearch(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="research-panel">
|
||||||
|
<div className="research-panel-header">
|
||||||
|
<h3 className="research-panel-title">Research</h3>
|
||||||
|
{!research && (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary research-btn"
|
||||||
|
onClick={fetchResearch}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Fetching...' : 'Get Research'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{research && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary research-btn"
|
||||||
|
onClick={fetchResearch}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Fetching...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="research-loading">
|
||||||
|
<div className="research-spinner"></div>
|
||||||
|
<span>Searching for information on {exerciseName}...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="research-error">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button className="btn-close" onClick={() => setError(null)}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{research && !loading && (
|
||||||
|
<div className="research-results">
|
||||||
|
{research.summary && (
|
||||||
|
<div className="research-summary">
|
||||||
|
<h4>Summary</h4>
|
||||||
|
<p>{research.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{research.results && research.results.length > 0 && (
|
||||||
|
<div className="research-sources">
|
||||||
|
<h4>Sources</h4>
|
||||||
|
<ul className="research-sources-list">
|
||||||
|
{research.results.map((result, i) => (
|
||||||
|
<li key={i} className="research-source-item">
|
||||||
|
<a
|
||||||
|
href={result.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="research-source-link"
|
||||||
|
>
|
||||||
|
{result.title}
|
||||||
|
</a>
|
||||||
|
{result.snippet && (
|
||||||
|
<p className="research-source-snippet">{result.snippet}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExerciseResearchPanel
|
||||||
@@ -234,6 +234,24 @@ export const Icons = {
|
|||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||||
</svg>
|
</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
|
// Brand
|
||||||
gravl: (
|
gravl: (
|
||||||
@@ -243,6 +261,12 @@ export const Icons = {
|
|||||||
<line x1="8" y1="8" x2="16" y2="16"/>
|
<line x1="8" y1="8" x2="16" y2="16"/>
|
||||||
</svg>
|
</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
|
// Icon component wrapper
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 />)
|
||||||
@@ -98,6 +98,7 @@ function Dashboard({ onStartWorkout, onNavigate }) {
|
|||||||
<nav className="nav-menu">
|
<nav className="nav-menu">
|
||||||
<button className="nav-btn active"><Icon name="home" size={18} /></button>
|
<button className="nav-btn active"><Icon name="home" size={18} /></button>
|
||||||
<button className="nav-btn" onClick={() => onNavigate('progress')}><Icon name="chart" size={18} /></button>
|
<button className="nav-btn" onClick={() => onNavigate('progress')}><Icon name="chart" size={18} /></button>
|
||||||
|
<button className="nav-btn" onClick={() => onNavigate('encyclopedia')} title="Exercise Encyclopedia"><Icon name="search" size={18} /></button>
|
||||||
<button className="nav-btn" onClick={() => onNavigate('profile')}><Icon name="user" size={18} /></button>
|
<button className="nav-btn" onClick={() => onNavigate('profile')}><Icon name="user" size={18} /></button>
|
||||||
<button className="nav-btn logout" onClick={logout}><Icon name="logout" size={18} /></button>
|
<button className="nav-btn logout" onClick={logout}><Icon name="logout" size={18} /></button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import ExerciseResearchPanel from '../components/ExerciseResearchPanel'
|
||||||
|
import './WorkoutEditPage.css'
|
||||||
|
|
||||||
|
const API_URL = '/api'
|
||||||
|
|
||||||
|
function ExerciseEncyclopediaPage({ onBack }) {
|
||||||
|
const [exercises, setExercises] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchExercises = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/exercises?limit=100`)
|
||||||
|
if (!res.ok) throw new Error('Failed to load exercises')
|
||||||
|
const data = await res.json()
|
||||||
|
setExercises(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchExercises()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filtered = exercises.filter(ex =>
|
||||||
|
ex.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="edit-page">
|
||||||
|
<div className="page-header">
|
||||||
|
<button className="back-btn" onClick={onBack}>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<h1>Exercise Encyclopedia</h1>
|
||||||
|
<div style={{ width: 70 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="edit-main">
|
||||||
|
<div className="workout-meta-card">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search exercises..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="encyclopedia-search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="workout-meta-card">
|
||||||
|
<p>Loading exercises...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-banner">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="edit-exercises-list">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="workout-meta-card">
|
||||||
|
<p>No exercises found.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtered.map(exercise => (
|
||||||
|
<div
|
||||||
|
key={exercise.id}
|
||||||
|
className={`edit-exercise-card${selected?.id === exercise.id ? ' exercise-selected' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="edit-card-header"
|
||||||
|
onClick={() => setSelected(selected?.id === exercise.id ? null : exercise)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div className="edit-card-info">
|
||||||
|
<h3>{exercise.name}</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
{exercise.difficulty && (
|
||||||
|
<span className="muscle-group">{exercise.difficulty}</span>
|
||||||
|
)}
|
||||||
|
{(exercise.muscle_groups || []).map(mg => (
|
||||||
|
<span key={mg} className="muscle-group">{mg}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{exercise.description && (
|
||||||
|
<p style={{ margin: '0.5rem 0 0', fontSize: '0.875rem', color: '#666' }}>
|
||||||
|
{exercise.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#999', fontSize: '1.25rem' }}>
|
||||||
|
{selected?.id === exercise.id ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected?.id === exercise.id && (
|
||||||
|
<div className="exercise-detail-expanded">
|
||||||
|
{exercise.instructions && (
|
||||||
|
<div className="exercise-instructions">
|
||||||
|
<h4>Instructions</h4>
|
||||||
|
<p>{exercise.instructions}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ExerciseResearchPanel
|
||||||
|
exerciseId={exercise.id}
|
||||||
|
exerciseName={exercise.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExerciseEncyclopediaPage
|
||||||
@@ -0,0 +1,652 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Encyclopedia search input */
|
||||||
|
.encyclopedia-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
min-height: 44px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encyclopedia-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected exercise highlight */
|
||||||
|
.edit-exercise-card.exercise-selected {
|
||||||
|
border: 2px solid #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded exercise detail */
|
||||||
|
.exercise-detail-expanded {
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
padding-top: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-instructions h4 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #555;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-instructions p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #444;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Research panel */
|
||||||
|
.research-panel {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-panel-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #555;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-btn {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-top-color: #007bff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-summary h4,
|
||||||
|
.research-sources h4 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #555;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-summary p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-sources-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-source-item {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-source-link {
|
||||||
|
color: #007bff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-source-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-source-snippet {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,13 +17,26 @@ const getWorkoutColor = (name) => {
|
|||||||
|
|
||||||
function WorkoutSelectPage({ onBack, onSelectWorkout }) {
|
function WorkoutSelectPage({ onBack, onSelectWorkout }) {
|
||||||
const [program, setProgram] = useState(null)
|
const [program, setProgram] = useState(null)
|
||||||
|
const [customWorkouts, setCustomWorkouts] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [selectedWorkout, setSelectedWorkout] = useState(null)
|
const [selectedWorkout, setSelectedWorkout] = useState(null)
|
||||||
|
const [resetConfirm, setResetConfirm] = useState(null)
|
||||||
|
const [resetting, setResetting] = useState(false)
|
||||||
|
const [successMessage, setSuccessMessage] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProgram()
|
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 () => {
|
const fetchProgram = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/programs/1`)
|
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) => {
|
const handleSelect = (workout) => {
|
||||||
setSelectedWorkout(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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="select-page loading">
|
<div className="select-page loading">
|
||||||
@@ -70,12 +138,21 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
|
|||||||
Vilken träning vill du köra idag?
|
Vilken träning vill du köra idag?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<div className="success-message">
|
||||||
|
<Icon name="check" size={18} />
|
||||||
|
{successMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="workout-grid">
|
<div className="workout-grid">
|
||||||
{program?.days?.map((workout) => {
|
{program?.days?.map((workout) => {
|
||||||
const iconName = getWorkoutIconName(workout.name)
|
const iconName = getWorkoutIconName(workout.name)
|
||||||
const color = getWorkoutColor(workout.name)
|
const color = getWorkoutColor(workout.name)
|
||||||
const isSelected = selectedWorkout?.id === workout.id
|
const isSelected = selectedWorkout?.id === workout.id
|
||||||
const exerciseCount = workout.exercises?.filter(e => e.name).length || 0
|
const exerciseCount = workout.exercises?.filter(e => e.name).length || 0
|
||||||
|
const isCustom = isWorkoutCustom(workout.id)
|
||||||
|
const customWorkoutId = getCustomWorkoutId(workout.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -84,8 +161,23 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
|
|||||||
style={{ '--workout-color': color }}
|
style={{ '--workout-color': color }}
|
||||||
onClick={() => handleSelect(workout)}
|
onClick={() => handleSelect(workout)}
|
||||||
>
|
>
|
||||||
<div className="workout-icon" style={{ background: color }}>
|
<div className="workout-badge-container">
|
||||||
<Icon name={iconName} size={28} />
|
<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>
|
||||||
<div className="workout-details">
|
<div className="workout-details">
|
||||||
<h3>{workout.name}</h3>
|
<h3>{workout.name}</h3>
|
||||||
@@ -120,6 +212,36 @@ function WorkoutSelectPage({ onBack, onSelectWorkout }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; } }
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
Executable
+12
@@ -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"
|
||||||
Executable
+81
@@ -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)}`));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user