diff --git a/.pm-checkpoint.json b/.pm-checkpoint.json
index e23ca11..a564a8c 100644
--- a/.pm-checkpoint.json
+++ b/.pm-checkpoint.json
@@ -1,12 +1,12 @@
{
- "lastRun": "2026-03-01T08:44:00+01:00",
+ "lastRun": "2026-03-01T11:31:00+01:00",
"status": "in_progress",
"phase": "04-workout-modification",
"activeTask": "04-03-frontend-workout-edit",
"tasksCompleted": ["01-input-ux", "02-flexible-sets", "03-design-polish", "04-01-schema-migration", "04-02-backend-api"],
"nextTask": "04-03-frontend-workout-edit",
- "recoveryFrom": "2026-03-01T06:42:00+01:00",
- "agentSession": "mild-reef",
- "agentType": "claude-code",
- "notes": "Frontend agent spawned for 04-03. Working on: Edit Workout button, Exercise picker modal, swap/add exercise flows, fork confirmation dialog. Session: mild-reef"
+ "agentSession": "swift-trail",
+ "agentType": "claude-coding-agent",
+ "spawnTime": "2026-03-01T11:31:00+01:00",
+ "notes": "Spawned Claude coding agent (swift-trail) with exec+pty in background. Task: Build Edit Workout button, ExercisePicker modal, swap/add exercise flows, fork confirmation dialog, and save to custom_workouts API. Monitoring progress."
}
diff --git a/frontend/src/components/ExercisePicker.jsx b/frontend/src/components/ExercisePicker.jsx
new file mode 100644
index 0000000..316b4fc
--- /dev/null
+++ b/frontend/src/components/ExercisePicker.jsx
@@ -0,0 +1,112 @@
+import { useState, useEffect, useMemo } from 'react'
+import { Icon } from './Icons'
+
+const API_URL = '/api'
+
+function ExercisePicker({ open, onSelect, onClose, excludeIds = [] }) {
+ const [exercises, setExercises] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [search, setSearch] = useState('')
+ const [activeGroup, setActiveGroup] = useState('Alla')
+
+ useEffect(() => {
+ if (open) {
+ fetchExercises()
+ setSearch('')
+ setActiveGroup('Alla')
+ }
+ }, [open])
+
+ const fetchExercises = async () => {
+ setLoading(true)
+ try {
+ const res = await fetch(`${API_URL}/exercises`)
+ if (!res.ok) throw new Error('Failed to fetch')
+ const data = await res.json()
+ setExercises(data)
+ } catch (err) {
+ console.error('Failed to fetch exercises:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const muscleGroups = useMemo(() => {
+ const groups = new Set(exercises.map(e => e.muscle_group).filter(Boolean))
+ return ['Alla', ...Array.from(groups).sort()]
+ }, [exercises])
+
+ const filtered = useMemo(() => {
+ return exercises.filter(ex => {
+ if (excludeIds.includes(ex.id)) return false
+ if (activeGroup !== 'Alla' && ex.muscle_group !== activeGroup) return false
+ if (search) {
+ const q = search.toLowerCase()
+ return ex.name.toLowerCase().includes(q) ||
+ (ex.muscle_group || '').toLowerCase().includes(q)
+ }
+ return true
+ })
+ }, [exercises, search, activeGroup, excludeIds])
+
+ if (!open) return null
+
+ return (
+
+
e.stopPropagation()}>
+
+
Välj övning
+
+
+
+
+ setSearch(e.target.value)}
+ autoFocus
+ />
+
+
+
+ {muscleGroups.map(group => (
+
+ ))}
+
+
+
+ {loading &&
Laddar övningar...
}
+
+ {!loading && filtered.length === 0 && (
+
Inga övningar hittades.
+ )}
+
+ {!loading && filtered.map(ex => (
+
+ ))}
+
+
+
+ )
+}
+
+export default ExercisePicker
diff --git a/frontend/src/components/Icons.jsx b/frontend/src/components/Icons.jsx
index 3291303..4a59f61 100644
--- a/frontend/src/components/Icons.jsx
+++ b/frontend/src/components/Icons.jsx
@@ -234,6 +234,24 @@ export const Icons = {
),
+ edit: (
+
+ ),
+ search: (
+
+ ),
+ x: (
+
+ ),
// Brand
gravl: (
diff --git a/frontend/src/index.js b/frontend/src/index.js
new file mode 100644
index 0000000..6070656
--- /dev/null
+++ b/frontend/src/index.js
@@ -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 (
+
+
+
+ )
+}
+
+const root = createRoot(document.getElementById('root'))
+root.render()
diff --git a/frontend/src/pages/WorkoutEditPage.jsx b/frontend/src/pages/WorkoutEditPage.jsx
new file mode 100644
index 0000000..be87396
--- /dev/null
+++ b/frontend/src/pages/WorkoutEditPage.jsx
@@ -0,0 +1,175 @@
+import { useState } from 'react'
+import { Icon } from '../components/Icons'
+import ExercisePicker from '../components/ExercisePicker'
+import './WorkoutEditPage.css'
+
+export default function WorkoutEditPage({ workout, onBack, onSave }) {
+ const [exercises, setExercises] = useState(workout.exercises || [])
+ const [pickerOpen, setPickerOpen] = useState(false)
+ const [swapIndex, setSwapIndex] = useState(null) // null = adding, number = swapping
+ const [saving, setSaving] = useState(false)
+
+ 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
+ }))
+ }
+
+ const handleSave = async () => {
+ setSaving(true)
+ 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
+ }))
+ }
+ await onSave(workout.id, payload)
+ } catch (err) {
+ console.error('Failed to save workout:', err)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
+ Redigera pass
+
+
+
+
+
+
{workout.name}
+
{exercises.length} övningar
+
+
+
+ {exercises.map((ex, i) => (
+
+
+
+
{ex.name}
+ {ex.muscle_group}
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ {pickerOpen && (
+
setPickerOpen(false)}
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/styles/App.css b/frontend/src/styles/App.css
new file mode 100644
index 0000000..664e15b
--- /dev/null
+++ b/frontend/src/styles/App.css
@@ -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; } }
diff --git a/scripts/generate-assets.js b/scripts/generate-assets.js
new file mode 100755
index 0000000..d6ebc49
--- /dev/null
+++ b/scripts/generate-assets.js
@@ -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)}`));
+});