Initial commit: Gravl MVP med onboarding

This commit is contained in:
2026-01-31 23:33:20 +01:00
commit 04d74469c7
22 changed files with 5209 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
# Gravl - Träningsapp
En enkel träningsapp för att följa PPL-program (Push/Pull/Legs) med progressionsspårning.
## Features
- 📋 **PPL Program** - 6-dagars Push/Pull/Legs split
- 📊 **Träningslogg** - Logga vikt/reps för varje set
- 📈 **Progression** - Automatiska viktrekommendationer
- 📱 **Mobilanpassad** - Fungerar perfekt på telefon
- 🌙 **Mörkt tema** - Bekvämt för gymmet
## Tech Stack
- **Frontend:** React (Vite) + CSS
- **Backend:** Node.js/Express
- **Database:** PostgreSQL
- **Container:** Docker med nginx
## Quick Start
```bash
# Initiera databasen
psql -h localhost -U postgres -d gravl -f db/init.sql
# Starta med Docker Compose
cd /workspace/gravl
docker compose up -d --build
```
## Åtkomst
- **URL:** https://gravl.homelab.local
- **API:** https://gravl.homelab.local/api
## API Endpoints
| Endpoint | Method | Beskrivning |
|----------|--------|-------------|
| `/api/health` | GET | Hälsokontroll |
| `/api/programs` | GET | Lista alla program |
| `/api/programs/:id` | GET | Hämta program med dagar |
| `/api/days/:id/exercises` | GET | Hämta övningar för en dag |
| `/api/logs` | GET | Hämta träningsloggar |
| `/api/logs` | POST | Logga ett set |
| `/api/progression/:id` | GET | Få viktrekommendation |
## Databasschema
- `programs` - Träningsprogram
- `program_days` - Dagar i programmet (Push A, Pull A, etc.)
- `exercises` - Övningar (Bench Press, Squat, etc.)
- `program_exercises` - Kopplar övningar till dagar med sets/reps
- `workout_logs` - Loggade träningsset
## Progression
Appen rekommenderar att öka vikten med 2.5kg när du når max reps på alla sets.
+52
View File
@@ -0,0 +1,52 @@
# Gravl - Feature Roadmap
## 🔐 Onboarding & Signup
- [ ] Registrering/inloggning (email + lösenord)
- [ ] Onboarding-wizard med steg-för-steg guide
## 👤 Användarprofil
- [ ] Kön
- [ ] Ålder
- [ ] Vikt
- [ ] Kroppsmått för kroppsfettberäkning:
- [ ] Hals
- [ ] Mage
- [ ] Höft (för kvinnor)
- [ ] Automatisk kroppsfett-kalkylering (US Navy-metoden)
## 🎯 Mål & Erfarenhet
- [ ] Ange träningserfarenhet (nybörjare/medel/avancerad)
- [ ] Ange 1RM på basövningar (bänk, knäböj, marklyft)
- [ ] Estimera startvik baserat på erfarenhet/1RM
- [ ] Nybörjare startar lätt automatiskt
- [ ] Ange träningsmål:
- [ ] Styrka
- [ ] Hypertrofi
- [ ] Fettförbränning
- [ ] Allmän fitness
## 📅 Träningsupplägg
- [ ] Användaren anger antal pass/vecka
- [ ] Generera anpassat program utifrån frekvens
- [ ] Adaptiva pass som matchar mål
- [ ] Progressiv överbelastning som pushar användaren
## 📊 Uppföljning & Benchmarks
- [ ] Regelbundna benchmark-tester (var 4-6 vecka)
- [ ] Progressgrafer (vikt, styrka, kroppsfett)
- [ ] Jämförelse mot tidigare resultat
- [ ] Notifikationer/påminnelser för benchmarks
## 📖 Övningsinformation
- [ ] Dedikerad infosida per övning
- [ ] Beskrivning av utförande
- [ ] Muskelgrupper som tränas
- [ ] Demo-video/animation
- [ ] Länk till alternativa övningar
- [ ] Tips & vanliga misstag
## 🔮 Framtida features
- [ ] Social/dela resultat
- [ ] Vila-timer med notis
- [ ] Export av träningsdata
- [ ] Apple Health / Google Fit integration
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY src ./src
EXPOSE 3001
CMD ["npm", "start"]
+1490
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "gravl-backend",
"version": "1.0.0",
"description": "Gravl Training App Backend",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}
+335
View File
@@ -0,0 +1,335 @@
const express = require('express');
const cors = require('cors');
const { Pool } = require('pg');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const app = express();
const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'gravl-secret-key-change-in-production';
const pool = new Pool({
host: process.env.DB_HOST || 'postgres',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'homelab_postgres_2026',
database: process.env.DB_NAME || 'gravl'
});
app.use(cors());
app.use(express.json());
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
try {
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch { res.status(401).json({ error: 'Invalid token' }); }
};
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.post('/api/auth/register', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
const hash = await bcrypt.hash(password, 10);
const result = await pool.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email',
[email.toLowerCase(), hash]
);
const token = jwt.sign({ id: result.rows[0].id, email: result.rows[0].email }, JWT_SECRET, { expiresIn: '30d' });
res.json({ token, user: result.rows[0] });
} catch (err) {
if (err.code === '23505') return res.status(400).json({ error: 'Email already exists' });
console.error('Register error:', err);
res.status(500).json({ error: 'Server error' });
}
});
app.post('/api/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email.toLowerCase()]);
if (!result.rows.length) return res.status(401).json({ error: 'Invalid credentials' });
const user = result.rows[0];
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '30d' });
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Server error' });
}
});
app.get('/api/user/profile', authMiddleware, async (req, res) => {
try {
const result = await pool.query('SELECT id, email, gender, age, weight, neck_cm, waist_cm, hip_cm, experience_level, bench_1rm, squat_1rm, deadlift_1rm, goal, workouts_per_week, onboarding_complete FROM users WHERE id = $1', [req.user.id]);
if (!result.rows.length) return res.status(404).json({ error: 'User not found' });
res.json(result.rows[0]);
} catch (err) {
console.error('Profile error:', err);
res.status(500).json({ error: 'Server error' });
}
});
app.put('/api/user/profile', authMiddleware, async (req, res) => {
try {
const { gender, age, weight, neck_cm, waist_cm, hip_cm, experience_level, bench_1rm, squat_1rm, deadlift_1rm, goal, workouts_per_week, onboarding_complete } = req.body;
const result = await pool.query(
`UPDATE users SET gender=$1, age=$2, weight=$3, neck_cm=$4, waist_cm=$5, hip_cm=$6, experience_level=$7, bench_1rm=$8, squat_1rm=$9, deadlift_1rm=$10, goal=$11, workouts_per_week=$12, onboarding_complete=$13 WHERE id=$14 RETURNING id, email, gender, age, weight, neck_cm, waist_cm, hip_cm, experience_level, bench_1rm, squat_1rm, deadlift_1rm, goal, workouts_per_week, onboarding_complete`,
[gender, age, weight, neck_cm, waist_cm, hip_cm, experience_level, bench_1rm, squat_1rm, deadlift_1rm, goal, workouts_per_week, onboarding_complete, req.user.id]
);
res.json(result.rows[0]);
} catch (err) {
console.error('Update profile error:', err);
res.status(500).json({ error: 'Server error' });
}
});
// Get all programs
app.get('/api/programs', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM programs ORDER BY id');
res.json(result.rows);
} catch (err) {
console.error('Error fetching programs:', err);
res.status(500).json({ error: 'Database error' });
}
});
// Get program details with days
app.get('/api/programs/:id', async (req, res) => {
try {
const program = await pool.query('SELECT * FROM programs WHERE id = $1', [req.params.id]);
if (program.rows.length === 0) {
return res.status(404).json({ error: 'Program not found' });
}
const days = await pool.query(`
SELECT pd.*,
json_agg(json_build_object(
'id', pe.id,
'exercise_id', e.id,
'name', e.name,
'muscle_group', e.muscle_group,
'sets', pe.sets,
'reps_min', pe.reps_min,
'reps_max', pe.reps_max,
'order', pe.order_num
) ORDER BY pe.order_num) as exercises
FROM program_days pd
LEFT JOIN program_exercises pe ON pd.id = pe.program_day_id
LEFT JOIN exercises e ON pe.exercise_id = e.id
WHERE pd.program_id = $1
GROUP BY pd.id
ORDER BY pd.day_number
`, [req.params.id]);
res.json({
...program.rows[0],
days: days.rows
});
} catch (err) {
console.error('Error fetching program:', err);
res.status(500).json({ error: 'Database error' });
}
});
// Get exercises for a specific day
app.get('/api/days/:dayId/exercises', async (req, res) => {
try {
const result = await pool.query(`
SELECT pe.id, pe.sets, pe.reps_min, pe.reps_max, pe.order_num,
e.id as exercise_id, e.name, e.muscle_group, e.description
FROM program_exercises pe
JOIN exercises e ON pe.exercise_id = e.id
WHERE pe.program_day_id = $1
ORDER BY pe.order_num
`, [req.params.dayId]);
res.json(result.rows);
} catch (err) {
console.error('Error fetching exercises:', err);
res.status(500).json({ error: 'Database error' });
}
});
// 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' });
}
});
// Calculate suggested weight based on progression
app.get('/api/progression/:programExerciseId', async (req, res) => {
try {
const { user_id } = req.query;
// Get exercise details
const exerciseInfo = await pool.query(`
SELECT pe.*, e.name FROM program_exercises pe
JOIN exercises e ON pe.exercise_id = e.id
WHERE pe.id = $1
`, [req.params.programExerciseId]);
if (exerciseInfo.rows.length === 0) {
return res.status(404).json({ error: 'Exercise not found' });
}
const exercise = exerciseInfo.rows[0];
// Get last workout logs for this exercise
const lastLogs = await pool.query(`
SELECT * FROM workout_logs
WHERE program_exercise_id = $1 AND user_id = $2 AND completed = true
ORDER BY date DESC, set_number ASC
LIMIT $3
`, [req.params.programExerciseId, user_id || 1, exercise.sets]);
if (lastLogs.rows.length === 0) {
return res.json({
suggestedWeight: 20, // Starting weight
reason: 'No previous data - start light'
});
}
const lastWeight = lastLogs.rows[0].weight;
const allSetsHitMaxReps = lastLogs.rows.every(log => log.reps >= exercise.reps_max);
if (allSetsHitMaxReps) {
// Progress: increase weight by 2.5kg
return res.json({
suggestedWeight: lastWeight + 2.5,
reason: `Hit ${exercise.reps_max} reps on all sets - increase weight!`
});
}
return res.json({
suggestedWeight: lastWeight,
reason: 'Keep same weight until you hit max reps on all sets'
});
} catch (err) {
console.error('Error calculating progression:', err);
res.status(500).json({ error: 'Database error' });
}
});
// Get today's workout based on program day cycle
app.get('/api/today/:programId', async (req, res) => {
try {
const { week } = req.query;
const currentWeek = week || 1;
// Get program days
const days = await pool.query(`
SELECT pd.*,
json_agg(json_build_object(
'id', pe.id,
'exercise_id', e.id,
'name', e.name,
'muscle_group', e.muscle_group,
'sets', pe.sets,
'reps_min', pe.reps_min,
'reps_max', pe.reps_max,
'order', pe.order_num
) ORDER BY pe.order_num) as exercises
FROM program_days pd
LEFT JOIN program_exercises pe ON pd.id = pe.program_day_id
LEFT JOIN exercises e ON pe.exercise_id = e.id
WHERE pd.program_id = $1
GROUP BY pd.id
ORDER BY pd.day_number
`, [req.params.programId]);
res.json({
week: parseInt(currentWeek),
days: days.rows
});
} catch (err) {
console.error('Error fetching today workout:', err);
res.status(500).json({ error: 'Database error' });
}
});
app.listen(PORT, () => {
console.log(`Gravl API running on port ${PORT}`);
});
+181
View File
@@ -0,0 +1,181 @@
-- Gravl Database Schema
-- Users table
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
gender VARCHAR(10),
age INT,
weight DECIMAL(5,1),
neck_cm DECIMAL(4,1),
waist_cm DECIMAL(4,1),
hip_cm DECIMAL(4,1),
experience_level VARCHAR(20),
bench_1rm DECIMAL(5,1),
squat_1rm DECIMAL(5,1),
deadlift_1rm DECIMAL(5,1),
goal VARCHAR(30),
workouts_per_week INT,
onboarding_complete BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
-- Programs table
CREATE TABLE IF NOT EXISTS programs (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
weeks INTEGER DEFAULT 6,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Program days (e.g., Push, Pull, Legs)
CREATE TABLE IF NOT EXISTS program_days (
id SERIAL PRIMARY KEY,
program_id INTEGER REFERENCES programs(id) ON DELETE CASCADE,
day_number INTEGER NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Exercises master table
CREATE TABLE IF NOT EXISTS exercises (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
muscle_group VARCHAR(100),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Program exercises (which exercises on which day)
CREATE TABLE IF NOT EXISTS program_exercises (
id SERIAL PRIMARY KEY,
program_day_id INTEGER REFERENCES program_days(id) ON DELETE CASCADE,
exercise_id INTEGER REFERENCES exercises(id) ON DELETE CASCADE,
sets INTEGER DEFAULT 3,
reps_min INTEGER DEFAULT 8,
reps_max INTEGER DEFAULT 12,
order_num INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Workout logs
CREATE TABLE IF NOT EXISTS workout_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER DEFAULT 1,
program_exercise_id INTEGER REFERENCES program_exercises(id) ON DELETE CASCADE,
date DATE NOT NULL,
set_number INTEGER NOT NULL,
weight DECIMAL(10,2),
reps INTEGER,
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_workout_logs_user_date ON workout_logs(user_id, date);
CREATE INDEX IF NOT EXISTS idx_workout_logs_exercise ON workout_logs(program_exercise_id);
CREATE INDEX IF NOT EXISTS idx_program_days_program ON program_days(program_id);
CREATE INDEX IF NOT EXISTS idx_program_exercises_day ON program_exercises(program_day_id);
-- Insert PPL Program
INSERT INTO programs (name, description, weeks) VALUES
('Push/Pull/Legs', 'Classic 6-day PPL split for strength and hypertrophy. 6-week progressive program.', 6)
ON CONFLICT DO NOTHING;
-- Insert exercises
INSERT INTO exercises (name, muscle_group, description) VALUES
-- Push exercises
('Bench Press', 'Chest', 'Barbell bench press - main chest compound'),
('Overhead Press', 'Shoulders', 'Standing barbell overhead press'),
('Incline Dumbbell Press', 'Chest', 'Incline dumbbell press for upper chest'),
('Lateral Raises', 'Shoulders', 'Dumbbell lateral raises for side delts'),
('Tricep Pushdowns', 'Triceps', 'Cable tricep pushdowns'),
('Overhead Tricep Extension', 'Triceps', 'Cable or dumbbell overhead extension'),
-- Pull exercises
('Deadlift', 'Back', 'Conventional deadlift - main posterior chain compound'),
('Barbell Rows', 'Back', 'Bent over barbell rows'),
('Pull-ups', 'Back', 'Bodyweight or weighted pull-ups'),
('Face Pulls', 'Rear Delts', 'Cable face pulls for rear delts and rotator cuff'),
('Barbell Curls', 'Biceps', 'Standing barbell curls'),
('Hammer Curls', 'Biceps', 'Dumbbell hammer curls'),
-- Legs exercises
('Squat', 'Quads', 'Barbell back squat - main leg compound'),
('Romanian Deadlift', 'Hamstrings', 'RDL for hamstrings and glutes'),
('Leg Press', 'Quads', 'Machine leg press'),
('Leg Curls', 'Hamstrings', 'Lying or seated leg curls'),
('Calf Raises', 'Calves', 'Standing or seated calf raises'),
('Walking Lunges', 'Quads', 'Dumbbell walking lunges')
ON CONFLICT DO NOTHING;
-- Insert program days for PPL
INSERT INTO program_days (program_id, day_number, name) VALUES
(1, 1, 'Push A'),
(1, 2, 'Pull A'),
(1, 3, 'Legs A'),
(1, 4, 'Push B'),
(1, 5, 'Pull B'),
(1, 6, 'Legs B')
ON CONFLICT DO NOTHING;
-- Insert program exercises
-- Push A (day 1)
INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps_max, order_num) VALUES
(1, 1, 4, 6, 8, 1), -- Bench Press 4x6-8
(1, 2, 3, 8, 10, 2), -- OHP 3x8-10
(1, 3, 3, 10, 12, 3), -- Incline DB Press 3x10-12
(1, 4, 3, 12, 15, 4), -- Lateral Raises 3x12-15
(1, 5, 3, 10, 12, 5), -- Tricep Pushdowns 3x10-12
(1, 6, 3, 10, 12, 6) -- Overhead Extension 3x10-12
ON CONFLICT DO NOTHING;
-- Pull A (day 2)
INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps_max, order_num) VALUES
(2, 7, 4, 5, 6, 1), -- Deadlift 4x5-6
(2, 8, 4, 8, 10, 2), -- Barbell Rows 4x8-10
(2, 9, 3, 6, 10, 3), -- Pull-ups 3x6-10
(2, 10, 3, 15, 20, 4), -- Face Pulls 3x15-20
(2, 11, 3, 10, 12, 5), -- Barbell Curls 3x10-12
(2, 12, 3, 10, 12, 6) -- Hammer Curls 3x10-12
ON CONFLICT DO NOTHING;
-- Legs A (day 3)
INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps_max, order_num) VALUES
(3, 13, 4, 6, 8, 1), -- Squat 4x6-8
(3, 14, 3, 10, 12, 2), -- RDL 3x10-12
(3, 15, 3, 10, 12, 3), -- Leg Press 3x10-12
(3, 16, 3, 12, 15, 4), -- Leg Curls 3x12-15
(3, 17, 4, 12, 15, 5) -- Calf Raises 4x12-15
ON CONFLICT DO NOTHING;
-- Push B (day 4)
INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps_max, order_num) VALUES
(4, 2, 4, 6, 8, 1), -- OHP 4x6-8 (main lift)
(4, 1, 3, 8, 10, 2), -- Bench Press 3x8-10
(4, 3, 3, 10, 12, 3), -- Incline DB Press 3x10-12
(4, 4, 4, 12, 15, 4), -- Lateral Raises 4x12-15
(4, 5, 3, 10, 12, 5), -- Tricep Pushdowns 3x10-12
(4, 6, 3, 10, 12, 6) -- Overhead Extension 3x10-12
ON CONFLICT DO NOTHING;
-- Pull B (day 5)
INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps_max, order_num) VALUES
(5, 8, 4, 6, 8, 1), -- Barbell Rows 4x6-8 (main lift)
(5, 9, 4, 6, 10, 2), -- Pull-ups 4x6-10
(5, 7, 3, 8, 10, 3), -- Deadlift 3x8-10 (lighter)
(5, 10, 3, 15, 20, 4), -- Face Pulls 3x15-20
(5, 11, 4, 10, 12, 5), -- Barbell Curls 4x10-12
(5, 12, 3, 10, 12, 6) -- Hammer Curls 3x10-12
ON CONFLICT DO NOTHING;
-- Legs B (day 6)
INSERT INTO program_exercises (program_day_id, exercise_id, sets, reps_min, reps_max, order_num) VALUES
(6, 13, 3, 8, 10, 1), -- Squat 3x8-10
(6, 14, 4, 8, 10, 2), -- RDL 4x8-10 (main lift)
(6, 18, 3, 10, 12, 3), -- Walking Lunges 3x10-12
(6, 16, 4, 10, 12, 4), -- Leg Curls 4x10-12
(6, 17, 4, 12, 15, 5) -- Calf Raises 4x12-15
ON CONFLICT DO NOTHING;
+46
View File
@@ -0,0 +1,46 @@
services:
gravl-backend:
container_name: gravl-backend
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
environment:
- DB_HOST=postgres
- DB_PORT=5432
- DB_USER=postgres
- DB_PASSWORD=homelab_postgres_2026
- DB_NAME=gravl
networks:
- proxy
- homelab
expose:
- "3001"
gravl-frontend:
container_name: gravl-frontend
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- gravl-backend
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.gravl.rule=Host(`gravl.homelab.local`)"
- "traefik.http.routers.gravl.entrypoints=web"
- "traefik.http.routers.gravl.service=gravl"
- "traefik.http.routers.gravl-secure.rule=Host(`gravl.homelab.local`)"
- "traefik.http.routers.gravl-secure.entrypoints=websecure"
- "traefik.http.routers.gravl-secure.tls=true"
- "traefik.http.routers.gravl-secure.service=gravl"
- "traefik.http.services.gravl.loadbalancer.server.port=80"
networks:
proxy:
external: true
homelab:
name: compose_homelab
external: true
+18
View File
@@ -0,0 +1,18 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+16
View File
@@ -0,0 +1,16 @@
<!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="#1a1a2e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Gravl - Träning</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# API proxy to backend
location /api {
proxy_pass http://gravl-backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+1753
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "gravl-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
}
}
+377
View File
@@ -0,0 +1,377 @@
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app.loading {
justify-content: center;
align-items: center;
gap: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Header */
.header {
background: var(--bg-secondary);
padding: 1rem 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.week-selector {
display: flex;
align-items: center;
gap: 0.75rem;
}
.week-selector button {
background: var(--bg-card);
color: var(--text-primary);
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 1.1rem;
transition: all 0.2s;
}
.week-selector button:hover:not(:disabled) {
background: var(--accent);
}
.week-selector button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.week-selector span {
font-weight: 600;
min-width: 80px;
text-align: center;
}
/* Main */
.main {
flex: 1;
padding: 1rem;
max-width: 600px;
margin: 0 auto;
width: 100%;
}
/* Program Info */
.program-info {
margin-bottom: 1.5rem;
}
.program-info h2 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
color: var(--accent);
}
.program-info p {
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.5;
}
/* Days List */
.days-list h3 {
font-size: 1rem;
color: var(--text-secondary);
margin-bottom: 1rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.day-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem;
margin-bottom: 0.75rem;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.day-card:hover {
background: var(--bg-card-hover);
border-color: var(--accent);
}
.day-card:active {
transform: scale(0.98);
}
.day-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.day-number {
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.day-name {
font-size: 1.1rem;
font-weight: 600;
}
.day-exercises {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.exercise-tag {
background: var(--bg-secondary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
color: var(--text-secondary);
}
.exercise-tag.more {
background: var(--accent);
color: white;
}
.day-action {
text-align: right;
color: var(--accent);
font-weight: 600;
font-size: 0.9rem;
}
/* Workout View */
.workout-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.back-btn {
background: none;
color: var(--accent);
font-size: 0.9rem;
padding: 0.25rem 0;
}
.header-title h1 {
font-size: 1.25rem;
}
.header-subtitle {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* Exercise Card */
.exercise-card {
background: var(--bg-card);
border-radius: 12px;
margin-bottom: 0.75rem;
overflow: hidden;
border: 1px solid transparent;
transition: all 0.2s;
}
.exercise-card.expanded {
border-color: var(--accent);
}
.exercise-header {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.exercise-info h3 {
font-size: 1rem;
margin-bottom: 0.25rem;
}
.muscle-group {
font-size: 0.8rem;
color: var(--text-secondary);
}
.exercise-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
}
.sets-info {
font-size: 0.9rem;
color: var(--text-secondary);
}
.progress-badge {
background: var(--bg-secondary);
padding: 0.2rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
}
.progress-badge.complete {
background: var(--success);
color: var(--bg-primary);
}
/* Exercise Body */
.exercise-body {
padding: 0 1rem 1rem;
border-top: 1px solid var(--border);
padding-top: 1rem;
}
.progression-hint {
background: rgba(233, 69, 96, 0.1);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.progression-hint strong {
color: var(--accent);
}
/* Sets List */
.sets-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.set-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
transition: all 0.2s;
}
.set-row.completed {
background: rgba(78, 204, 163, 0.15);
border: 1px solid var(--success);
}
.set-number {
font-size: 0.85rem;
color: var(--text-secondary);
min-width: 45px;
}
.set-inputs {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.weight-input,
.reps-input {
width: 70px;
padding: 0.6rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 1rem;
text-align: center;
}
.weight-input:focus,
.reps-input:focus {
border-color: var(--accent);
}
.input-separator {
color: var(--text-secondary);
}
.complete-btn {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--bg-card);
color: var(--text-secondary);
font-size: 1.25rem;
transition: all 0.2s;
}
.complete-btn:hover {
background: var(--accent);
color: white;
}
.complete-btn.done {
background: var(--success);
color: var(--bg-primary);
}
/* Mobile optimizations */
@media (max-width: 480px) {
.header {
padding: 0.75rem 1rem;
}
.header h1 {
font-size: 1.25rem;
}
.main {
padding: 0.75rem;
}
.weight-input,
.reps-input {
width: 60px;
padding: 0.5rem;
}
}
/* Safe area for notched phones */
@supports (padding: env(safe-area-inset-bottom)) {
.main {
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
}
+330
View File
@@ -0,0 +1,330 @@
import { useState, useEffect } from 'react'
import { useAuth } from './context/AuthContext'
import './App.css'
const API_URL = '/api'
function App() {
const { user, logout } = useAuth()
const [view, setView] = useState('program')
const [program, setProgram] = useState(null)
const [selectedDay, setSelectedDay] = useState(null)
const [currentWeek, setCurrentWeek] = useState(1)
const [logs, setLogs] = useState({})
const [loading, setLoading] = useState(true)
const userId = user?.id || 1
const today = new Date().toISOString().split('T')[0]
useEffect(() => {
fetchProgram()
}, [])
const fetchProgram = async () => {
try {
const res = await fetch(`${API_URL}/programs/1`)
const data = await res.json()
setProgram(data)
setLoading(false)
} catch (err) {
console.error('Failed to fetch program:', err)
setLoading(false)
}
}
const fetchLogs = async (dayId) => {
try {
const day = program.days.find(d => d.id === dayId)
if (!day) return
const newLogs = {}
for (const exercise of day.exercises) {
if (!exercise.id) continue
const res = await fetch(`${API_URL}/logs?user_id=${userId}&date=${today}&program_exercise_id=${exercise.id}`)
const data = await res.json()
newLogs[exercise.id] = data
}
setLogs(newLogs)
} catch (err) {
console.error('Failed to fetch logs:', err)
}
}
const fetchProgression = async (programExerciseId) => {
try {
const res = await fetch(`${API_URL}/progression/${programExerciseId}?user_id=${userId}`)
return await res.json()
} catch (err) {
console.error('Failed to fetch progression:', err)
return null
}
}
const logSet = async (programExerciseId, setNumber, weight, reps, completed) => {
try {
const res = await fetch(`${API_URL}/logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: userId,
program_exercise_id: programExerciseId,
date: today,
set_number: setNumber,
weight: parseFloat(weight) || 0,
reps: parseInt(reps) || 0,
completed
})
})
const data = await res.json()
// Update local logs
setLogs(prev => ({
...prev,
[programExerciseId]: [
...(prev[programExerciseId] || []).filter(l => l.set_number !== setNumber),
data
].sort((a, b) => a.set_number - b.set_number)
}))
} catch (err) {
console.error('Failed to log set:', err)
}
}
const startWorkout = (day) => {
setSelectedDay(day)
setView('workout')
fetchLogs(day.id)
}
if (loading) {
return (
<div className="app loading">
<div className="spinner"></div>
<p>Laddar program...</p>
</div>
)
}
if (view === 'workout' && selectedDay) {
return (
<WorkoutView
day={selectedDay}
week={currentWeek}
logs={logs}
onLogSet={logSet}
onBack={() => setView('program')}
fetchProgression={fetchProgression}
/>
)
}
return (
<div className="app">
<header className="header">
<div className="header-left">
<h1>🏋 Gravl</h1>
<button className="logout-btn" onClick={logout}>Logga ut</button>
</div>
<div className="week-selector">
<button
onClick={() => setCurrentWeek(w => Math.max(1, w - 1))}
disabled={currentWeek === 1}
>
</button>
<span>Vecka {currentWeek}</span>
<button
onClick={() => setCurrentWeek(w => Math.min(program?.weeks || 6, w + 1))}
disabled={currentWeek === (program?.weeks || 6)}
>
</button>
</div>
</header>
<main className="main">
<section className="program-info">
<h2>{program?.name || 'Laddar...'}</h2>
<p>{program?.description}</p>
</section>
<section className="days-list">
<h3>Veckans pass</h3>
{program?.days?.map((day, idx) => (
<div key={day.id} className="day-card" onClick={() => startWorkout(day)}>
<div className="day-header">
<span className="day-number">Dag {day.day_number}</span>
<span className="day-name">{day.name}</span>
</div>
<div className="day-exercises">
{day.exercises?.filter(e => e.name).slice(0, 3).map((ex, i) => (
<span key={i} className="exercise-tag">{ex.name}</span>
))}
{day.exercises?.filter(e => e.name).length > 3 && (
<span className="exercise-tag more">+{day.exercises.filter(e => e.name).length - 3}</span>
)}
</div>
<div className="day-action">
Starta
</div>
</div>
))}
</section>
</main>
</div>
)
}
function WorkoutView({ day, week, logs, onLogSet, onBack, fetchProgression }) {
const [progressions, setProgressions] = useState({})
const [expandedExercise, setExpandedExercise] = useState(null)
useEffect(() => {
loadProgressions()
}, [day])
const loadProgressions = async () => {
const progs = {}
for (const exercise of day.exercises) {
if (exercise.id) {
progs[exercise.id] = await fetchProgression(exercise.id)
}
}
setProgressions(progs)
}
const exercises = day.exercises?.filter(e => e.name) || []
return (
<div className="app workout-view">
<header className="header workout-header">
<button className="back-btn" onClick={onBack}> Tillbaka</button>
<div className="header-title">
<h1>{day.name}</h1>
<span className="header-subtitle">Vecka {week} Dag {day.day_number}</span>
</div>
</header>
<main className="main workout-main">
{exercises.map((exercise, idx) => (
<ExerciseCard
key={exercise.id || idx}
exercise={exercise}
logs={logs[exercise.id] || []}
progression={progressions[exercise.id]}
expanded={expandedExercise === exercise.id}
onToggle={() => setExpandedExercise(
expandedExercise === exercise.id ? null : exercise.id
)}
onLogSet={onLogSet}
/>
))}
</main>
</div>
)
}
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet }) {
const [setInputs, setSetInputs] = useState({})
useEffect(() => {
// Initialize with suggested weight or last logged
const initial = {}
for (let i = 1; i <= exercise.sets; i++) {
const existingLog = logs.find(l => l.set_number === i)
initial[i] = {
weight: existingLog?.weight || progression?.suggestedWeight || '',
reps: existingLog?.reps || '',
completed: existingLog?.completed || false
}
}
setSetInputs(initial)
}, [exercise, logs, progression])
const handleInputChange = (setNum, field, value) => {
setSetInputs(prev => ({
...prev,
[setNum]: { ...prev[setNum], [field]: value }
}))
}
const handleComplete = (setNum) => {
const input = setInputs[setNum]
const newCompleted = !input.completed
setSetInputs(prev => ({
...prev,
[setNum]: { ...prev[setNum], completed: newCompleted }
}))
onLogSet(exercise.id, setNum, input.weight, input.reps, newCompleted)
}
const completedSets = Object.values(setInputs).filter(s => s.completed).length
return (
<div className={`exercise-card ${expanded ? 'expanded' : ''}`}>
<div className="exercise-header" onClick={onToggle}>
<div className="exercise-info">
<h3>{exercise.name}</h3>
<span className="muscle-group">{exercise.muscle_group}</span>
</div>
<div className="exercise-meta">
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
<span className={`progress-badge ${completedSets === exercise.sets ? 'complete' : ''}`}>
{completedSets}/{exercise.sets}
</span>
</div>
</div>
{expanded && (
<div className="exercise-body">
{progression && (
<div className="progression-hint">
💡 {progression.reason}
{progression.suggestedWeight && (
<strong> {progression.suggestedWeight} kg</strong>
)}
</div>
)}
<div className="sets-list">
{Array.from({ length: exercise.sets }, (_, i) => i + 1).map(setNum => {
const input = setInputs[setNum] || { weight: '', reps: '', completed: false }
return (
<div key={setNum} className={`set-row ${input.completed ? 'completed' : ''}`}>
<span className="set-number">Set {setNum}</span>
<div className="set-inputs">
<input
type="number"
placeholder="kg"
value={input.weight}
onChange={(e) => handleInputChange(setNum, 'weight', e.target.value)}
className="weight-input"
inputMode="decimal"
/>
<span className="input-separator">×</span>
<input
type="number"
placeholder="reps"
value={input.reps}
onChange={(e) => handleInputChange(setNum, 'reps', e.target.value)}
className="reps-input"
inputMode="numeric"
/>
</div>
<button
className={`complete-btn ${input.completed ? 'done' : ''}`}
onClick={() => handleComplete(setNum)}
>
{input.completed ? '✓' : '○'}
</button>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
export default App
+74
View File
@@ -0,0 +1,74 @@
import { createContext, useContext, useState, useEffect } from 'react';
const API = '/api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(localStorage.getItem('token'));
const [loading, setLoading] = useState(true);
useEffect(() => {
if (token) fetchProfile();
else setLoading(false);
}, [token]);
const fetchProfile = async () => {
try {
const res = await fetch(`${API}/user/profile`, { headers: { Authorization: `Bearer ${token}` } });
if (res.ok) setUser(await res.json());
else logout();
} catch { logout(); }
setLoading(false);
};
const register = async (email, password) => {
const res = await fetch(`${API}/auth/register`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
localStorage.setItem('token', data.token);
setToken(data.token);
setUser(data.user);
return data;
};
const login = async (email, password) => {
const res = await fetch(`${API}/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
localStorage.setItem('token', data.token);
setToken(data.token);
setUser(data.user);
return data;
};
const updateProfile = async (profile) => {
const res = await fetch(`${API}/user/profile`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(profile)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setUser(data);
return data;
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, token, loading, register, login, logout, updateProfile }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
+109
View File
@@ -0,0 +1,109 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-card: #16213e;
--bg-card-hover: #1f3460;
--text-primary: #eaeaea;
--text-secondary: #a0a0a0;
--accent: #e94560;
--accent-hover: #ff6b6b;
--success: #4ecca3;
--warning: #ffd93d;
--border: #2a2a4a;
}
html, body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
outline: none;
}
input {
font-family: inherit;
outline: none;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Auth pages */
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.auth-card { background: var(--bg-card); padding: 40px; border-radius: 16px; width: 100%; max-width: 400px; text-align: center; }
.auth-card h1 { font-size: 2.5rem; margin-bottom: 8px; }
.auth-card h2 { color: var(--text-secondary); font-weight: 400; margin-bottom: 24px; }
.auth-card form { display: flex; flex-direction: column; gap: 16px; }
.auth-card input { padding: 14px 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 1rem; }
.auth-card input:focus { border-color: var(--accent); }
.auth-card button[type="submit"] { padding: 14px; background: var(--accent); color: white; border-radius: 8px; font-size: 1rem; font-weight: 600; transition: background 0.2s; }
.auth-card button[type="submit"]:hover:not(:disabled) { background: var(--accent-hover); }
.auth-card button:disabled { opacity: 0.6; cursor: not-allowed; }
.auth-card .error { background: rgba(233,69,96,0.15); color: var(--accent); padding: 12px; border-radius: 8px; margin-bottom: 16px; }
.auth-link { margin-top: 20px; color: var(--text-secondary); }
.auth-link a { color: var(--accent); text-decoration: none; }
/* Onboarding */
.onboarding { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.onboarding-card { background: var(--bg-card); padding: 32px; border-radius: 16px; width: 100%; max-width: 480px; }
.steps-indicator { display: flex; justify-content: center; gap: 12px; margin-bottom: 28px; }
.steps-indicator span { width: 32px; height: 32px; border-radius: 50%; background: var(--bg-secondary); display: flex; align-items: center; justify-content: center; font-size: 0.875rem; color: var(--text-secondary); }
.steps-indicator span.active { background: var(--accent); color: white; }
.step h2 { margin-bottom: 20px; text-align: center; }
.step .hint { color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 16px; text-align: center; }
.field { margin-bottom: 16px; }
.field label { display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.875rem; }
.field input { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 1rem; }
.field input:focus { border-color: var(--accent); }
.btn-group { display: flex; gap: 8px; }
.btn-group.vertical { flex-direction: column; }
.btn-group button { flex: 1; padding: 12px; border-radius: 8px; background: var(--bg-secondary); color: var(--text-secondary); border: 1px solid var(--border); transition: all 0.2s; }
.btn-group button:hover { border-color: var(--accent); }
.btn-group button.active { background: var(--accent); color: white; border-color: var(--accent); }
.rm-fields { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 8px; }
.rm-fields .field { margin-bottom: 0; }
.bodyfat-result { background: rgba(78,204,163,0.15); color: var(--success); padding: 16px; border-radius: 8px; text-align: center; margin: 16px 0; }
.nav-btns { display: flex; gap: 12px; margin-top: 24px; }
.nav-btns button { flex: 1; padding: 14px; border-radius: 8px; font-size: 1rem; }
.nav-btns button:first-child { background: var(--bg-secondary); color: var(--text-secondary); }
.next-btn, .finish-btn { background: var(--accent) !important; color: white !important; font-weight: 600; }
.next-btn:hover:not(:disabled), .finish-btn:hover:not(:disabled) { background: var(--accent-hover) !important; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Header logout */
.header-left { display: flex; align-items: center; gap: 16px; }
.logout-btn { padding: 6px 12px; background: var(--bg-secondary); color: var(--text-secondary); border-radius: 6px; font-size: 0.75rem; }
.logout-btn:hover { background: var(--border); }
+40
View File
@@ -0,0 +1,40 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import App from './App.jsx'
import RegisterPage from './pages/RegisterPage'
import LoginPage from './pages/LoginPage'
import OnboardingWizard from './pages/OnboardingWizard'
import './index.css'
function ProtectedRoute({ children, requireOnboarding = true }) {
const { user, loading } = useAuth();
if (loading) return <div className="app loading"><div className="spinner"></div></div>;
if (!user) return <Navigate to="/login" />;
if (requireOnboarding && !user.onboarding_complete) return <Navigate to="/onboarding" />;
return children;
}
function AuthRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <div className="app loading"><div className="spinner"></div></div>;
if (user?.onboarding_complete) return <Navigate to="/" />;
if (user) return <Navigate to="/onboarding" />;
return children;
}
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/register" element={<AuthRoute><RegisterPage /></AuthRoute>} />
<Route path="/login" element={<AuthRoute><LoginPage /></AuthRoute>} />
<Route path="/onboarding" element={<ProtectedRoute requireOnboarding={false}><OnboardingWizard /></ProtectedRoute>} />
<Route path="/*" element={<ProtectedRoute><App /></ProtectedRoute>} />
</Routes>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
)
+41
View File
@@ -0,0 +1,41 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { user } = await login(email, password);
navigate(user.onboarding_complete ? '/' : '/onboarding');
} catch (err) {
setError(err.message);
}
setLoading(false);
};
return (
<div className="auth-page">
<div className="auth-card">
<h1>🏋 Gravl</h1>
<h2>Logga in</h2>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<input type="email" placeholder="E-post" value={email} onChange={e => setEmail(e.target.value)} required />
<input type="password" placeholder="Lösenord" value={password} onChange={e => setPassword(e.target.value)} required />
<button type="submit" disabled={loading}>{loading ? 'Loggar in...' : 'Logga in'}</button>
</form>
<p className="auth-link">Inget konto? <Link to="/register">Skapa konto</Link></p>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const calcBodyFat = (gender, waist, neck, hip, height) => {
if (!waist || !neck || !height) return null;
if (gender === 'female' && !hip) return null;
if (gender === 'male') return Math.max(0, 495 / (1.0324 - 0.19077 * Math.log10(waist - neck) + 0.15456 * Math.log10(height)) - 450).toFixed(1);
return Math.max(0, 495 / (1.29579 - 0.35004 * Math.log10(waist + hip - neck) + 0.22100 * Math.log10(height)) - 450).toFixed(1);
};
export default function OnboardingWizard() {
const [step, setStep] = useState(1);
const [data, setData] = useState({ gender: '', age: '', height_cm: '', weight: '', neck_cm: '', waist_cm: '', hip_cm: '', experience_level: '', bench_1rm: '', squat_1rm: '', deadlift_1rm: '', goal: '', workouts_per_week: '' });
const [saving, setSaving] = useState(false);
const { updateProfile } = useAuth();
const navigate = useNavigate();
const update = (field, value) => setData(d => ({ ...d, [field]: value }));
const bodyFat = calcBodyFat(data.gender, parseFloat(data.waist_cm), parseFloat(data.neck_cm), parseFloat(data.hip_cm), parseFloat(data.height_cm));
const finish = async () => {
setSaving(true);
await updateProfile({ ...data, onboarding_complete: true });
navigate('/');
};
return (
<div className="onboarding">
<div className="onboarding-card">
<div className="steps-indicator">
{[1,2,3,4].map(s => <span key={s} className={step >= s ? 'active' : ''}>{s}</span>)}
</div>
{step === 1 && (
<div className="step">
<h2>Grundinfo</h2>
<div className="field">
<label>Kön</label>
<div className="btn-group">
<button className={data.gender === 'male' ? 'active' : ''} onClick={() => update('gender', 'male')}>Man</button>
<button className={data.gender === 'female' ? 'active' : ''} onClick={() => update('gender', 'female')}>Kvinna</button>
</div>
</div>
<div className="field">
<label>Ålder</label>
<input type="number" value={data.age} onChange={e => update('age', e.target.value)} placeholder="25" />
</div>
<div className="field">
<label>Längd (cm)</label>
<input type="number" value={data.height_cm} onChange={e => update('height_cm', e.target.value)} placeholder="175" />
</div>
<div className="field">
<label>Vikt (kg)</label>
<input type="number" step="0.1" value={data.weight} onChange={e => update('weight', e.target.value)} placeholder="75" />
</div>
<button className="next-btn" onClick={() => setStep(2)} disabled={!data.gender || !data.age || !data.height_cm || !data.weight}>Nästa </button>
</div>
)}
{step === 2 && (
<div className="step">
<h2>Kroppsmått</h2>
<p className="hint">För att beräkna kroppsfett (US Navy-metoden)</p>
<div className="field">
<label>Hals (cm)</label>
<input type="number" step="0.1" value={data.neck_cm} onChange={e => update('neck_cm', e.target.value)} placeholder="38" />
</div>
<div className="field">
<label>Mage/midja (cm)</label>
<input type="number" step="0.1" value={data.waist_cm} onChange={e => update('waist_cm', e.target.value)} placeholder="85" />
</div>
{data.gender === 'female' && (
<div className="field">
<label>Höft (cm)</label>
<input type="number" step="0.1" value={data.hip_cm} onChange={e => update('hip_cm', e.target.value)} placeholder="95" />
</div>
)}
{bodyFat && <div className="bodyfat-result">Beräknat kroppsfett: <strong>{bodyFat}%</strong></div>}
<div className="nav-btns">
<button onClick={() => setStep(1)}> Tillbaka</button>
<button className="next-btn" onClick={() => setStep(3)}>Nästa </button>
</div>
</div>
)}
{step === 3 && (
<div className="step">
<h2>Erfarenhet & styrka</h2>
<div className="field">
<label>Träningserfarenhet</label>
<div className="btn-group vertical">
{['beginner', 'intermediate', 'advanced'].map(l => (
<button key={l} className={data.experience_level === l ? 'active' : ''} onClick={() => update('experience_level', l)}>
{l === 'beginner' ? 'Nybörjare (<1 år)' : l === 'intermediate' ? 'Medel (1-3 år)' : 'Avancerad (3+ år)'}
</button>
))}
</div>
</div>
<p className="hint">1RM (valfritt)</p>
<div className="rm-fields">
<div className="field"><label>Bänk</label><input type="number" value={data.bench_1rm} onChange={e => update('bench_1rm', e.target.value)} placeholder="kg" /></div>
<div className="field"><label>Knäböj</label><input type="number" value={data.squat_1rm} onChange={e => update('squat_1rm', e.target.value)} placeholder="kg" /></div>
<div className="field"><label>Marklyft</label><input type="number" value={data.deadlift_1rm} onChange={e => update('deadlift_1rm', e.target.value)} placeholder="kg" /></div>
</div>
<div className="nav-btns">
<button onClick={() => setStep(2)}> Tillbaka</button>
<button className="next-btn" onClick={() => setStep(4)} disabled={!data.experience_level}>Nästa </button>
</div>
</div>
)}
{step === 4 && (
<div className="step">
<h2>Mål & schema</h2>
<div className="field">
<label>Mål</label>
<div className="btn-group vertical">
{['strength', 'muscle', 'fat_loss', 'general'].map(g => (
<button key={g} className={data.goal === g ? 'active' : ''} onClick={() => update('goal', g)}>
{g === 'strength' ? '💪 Styrka' : g === 'muscle' ? '🏋️ Muskelmassa' : g === 'fat_loss' ? '🔥 Fettförbränning' : '⚖️ Allmän fitness'}
</button>
))}
</div>
</div>
<div className="field">
<label>Pass per vecka</label>
<div className="btn-group">
{[3,4,5,6].map(n => (
<button key={n} className={data.workouts_per_week == n ? 'active' : ''} onClick={() => update('workouts_per_week', n)}>{n}</button>
))}
</div>
</div>
<div className="nav-btns">
<button onClick={() => setStep(3)}> Tillbaka</button>
<button className="finish-btn" onClick={finish} disabled={!data.goal || !data.workouts_per_week || saving}>
{saving ? 'Sparar...' : 'Starta träningen! 🚀'}
</button>
</div>
</div>
)}
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await register(email, password);
navigate('/onboarding');
} catch (err) {
setError(err.message);
}
setLoading(false);
};
return (
<div className="auth-page">
<div className="auth-card">
<h1>🏋 Gravl</h1>
<h2>Skapa konto</h2>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<input type="email" placeholder="E-post" value={email} onChange={e => setEmail(e.target.value)} required />
<input type="password" placeholder="Lösenord" value={password} onChange={e => setPassword(e.target.value)} required minLength={6} />
<button type="submit" disabled={loading}>{loading ? 'Skapar...' : 'Skapa konto'}</button>
</form>
<p className="auth-link">Har redan konto? <Link to="/login">Logga in</Link></p>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
})