feat(04-03-partial): ExercisePicker and WorkoutEditPage components - swap/add/remove exercises with sets/reps editing

This commit is contained in:
2026-03-01 15:36:47 +01:00
parent 4d60a269ff
commit 843771e935
7 changed files with 446 additions and 5 deletions
+5 -5
View File
@@ -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."
}
+112
View File
@@ -0,0 +1,112 @@
import { useState, useEffect, useMemo } from 'react'
import { Icon } from './Icons'
const API_URL = '/api'
function ExercisePicker({ open, onSelect, onClose, excludeIds = [] }) {
const [exercises, setExercises] = useState([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [activeGroup, setActiveGroup] = useState('Alla')
useEffect(() => {
if (open) {
fetchExercises()
setSearch('')
setActiveGroup('Alla')
}
}, [open])
const fetchExercises = async () => {
setLoading(true)
try {
const res = await fetch(`${API_URL}/exercises`)
if (!res.ok) throw new Error('Failed to fetch')
const data = await res.json()
setExercises(data)
} catch (err) {
console.error('Failed to fetch exercises:', err)
} finally {
setLoading(false)
}
}
const muscleGroups = useMemo(() => {
const groups = new Set(exercises.map(e => e.muscle_group).filter(Boolean))
return ['Alla', ...Array.from(groups).sort()]
}, [exercises])
const filtered = useMemo(() => {
return exercises.filter(ex => {
if (excludeIds.includes(ex.id)) return false
if (activeGroup !== 'Alla' && ex.muscle_group !== activeGroup) return false
if (search) {
const q = search.toLowerCase()
return ex.name.toLowerCase().includes(q) ||
(ex.muscle_group || '').toLowerCase().includes(q)
}
return true
})
}, [exercises, search, activeGroup, excludeIds])
if (!open) return null
return (
<div className="exercise-picker-overlay" onClick={onClose}>
<div className="exercise-picker" onClick={e => e.stopPropagation()}>
<div className="exercise-picker-header">
<h2>Välj övning</h2>
<button className="exercise-picker-close" onClick={onClose} aria-label="Stäng">
<Icon name="chevronDown" size={20} />
</button>
</div>
<div className="exercise-picker-search">
<input
type="text"
placeholder="Sök övning..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
<div className="exercise-picker-filters">
{muscleGroups.map(group => (
<button
key={group}
className={`filter-chip ${activeGroup === group ? 'active' : ''}`}
onClick={() => setActiveGroup(group)}
>
{group}
</button>
))}
</div>
<div className="exercise-picker-list">
{loading && <div className="exercise-picker-state">Laddar övningar...</div>}
{!loading && filtered.length === 0 && (
<div className="exercise-picker-state">Inga övningar hittades.</div>
)}
{!loading && filtered.map(ex => (
<button
key={ex.id}
className="exercise-picker-item"
onClick={() => onSelect(ex)}
>
<div className="exercise-picker-item-info">
<strong>{ex.name}</strong>
<span className="exercise-picker-item-group">{ex.muscle_group}</span>
</div>
<Icon name="arrowLeft" size={16} style={{ transform: 'rotate(180deg)', opacity: 0.4 }} />
</button>
))}
</div>
</div>
</div>
)
}
export default ExercisePicker
+18
View File
@@ -234,6 +234,24 @@ export const Icons = {
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
),
edit: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
),
search: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
),
x: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
),
// Brand
gravl: (
+30
View File
@@ -0,0 +1,30 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import './styles/App.css'
import WorkoutPage from './pages/WorkoutPage'
const App = () => {
// Minimal placeholder data to mount the page standalone
const day = {
name: 'Push A',
day_number: 1,
exercises: [
{ id: 1, name: 'Bench Press', muscle_group: 'Bröst', sets: 3, reps_min: 8, reps_max: 12 },
{ id: 2, name: 'Overhead Press', muscle_group: 'Axlar', sets: 3, reps_min: 8, reps_max: 12 }
]
}
const week = 1
const logs = {}
const onBack = () => { console.log('Back') }
const fetchProgression = async (id) => ({ suggestedWeight: 20 })
const onLogSet = () => {}
const onDeleteSet = () => {}
return (
<div className="app">
<WorkoutPage day={day} week={week} logs={logs} onBack={onBack} fetchProgression={fetchProgression} onLogSet={onLogSet} onDeleteSet={onDeleteSet} />
</div>
)
}
const root = createRoot(document.getElementById('root'))
root.render(<App />)
+175
View File
@@ -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 (
<div className="edit-page">
<header className="page-header">
<button className="back-btn" onClick={onBack}>
<Icon name="arrowLeft" size={18} /> Avbryt
</button>
<h1>Redigera pass</h1>
<button
className="save-header-btn"
onClick={handleSave}
disabled={saving}
>
{saving ? 'Sparar...' : 'Spara'}
</button>
</header>
<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"
>
<Icon name="swap" size={18} />
</button>
<button
className="icon-btn delete"
onClick={() => handleRemove(i)}
aria-label="Ta bort övning"
>
<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"
/>
</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"
/>
</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"
/>
</div>
</div>
</div>
))}
</div>
<button className="add-exercise-btn" onClick={() => handleOpenPicker(null)}>
<Icon name="plus" size={20} />
Lägg till övning
</button>
</main>
{pickerOpen && (
<ExercisePicker
open={pickerOpen}
onSelect={handleSelectExercise}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
/* Minimal app-wide styles to support the new Workout UI scaffold */
:root{ --bg: #0b0f14; --card:#141a20; --text:#e8f0f4; --muted:#9bb2bd; --accent:#4cc9f0; }
*{box-sizing:border-box}
html,body,#root{height:100%}
body{ margin:0; background:var(--bg); color:var(--text); font-family: Inter, system-ui, Arial; }
/* App-wide helpers */
.app{ min-height:100%; display:flex; flex-direction:column; }
.page-header{ display:flex; align-items:center; justify-content:space-between; padding:14px 16px; background:#0f151a; border-bottom:1px solid #1e252c; }
.back-btn{ border:0; background:transparent; color:#9bd2ff; cursor:pointer; font-size:14px; display:flex; align-items:center; gap:6px; }
.header-center{ text-align:center; flex:1; }
.header-center h1{ margin:0; font-size:18px; font-weight:600 }
.header-subtitle{ color:#a9bdc9; font-size:12px; }
.rest-timer-card{ padding:12px; background:#11161b; border-bottom:1px solid #1e252c; }
.rest-timer-header{ display:flex; justify-content:space-between; align-items:center; }
.rest-timer-label{ font-weight:600; }
.rest-timer-time{ font-feature-settings: 'tnum'; font-variant-numeric: tabular-nums; font-size:20px; }
.page-main{ padding:12px; display:flex; flex-direction:column; gap:12px; }
.exercise-card{ background:#121821; border:1px solid #1e252c; border-radius:8px; padding:8px; margin-bottom:8px; }
/* Simple utility for the rest of style surface */
.exercises-section{ display:flex; flex-direction:column; gap:8px; }
.finish-workout-btn{ align-self:center; padding:12px 20px; border-radius:999px; border:1px solid #2e2e2e; background:#1e1e1e; color:#fff; cursor:pointer; }
@media (min-width: 700px){ .header-center{ text-align:center; } }
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
require('dotenv').config();
console.log('🎬 Gravl Multimedia Asset Generator\n');
// Config
const apiKey = process.env.VEO_API_KEY;
const googleCreds = process.env.GOOGLE_APPLICATION_CREDENTIALS;
const outputDir = process.env.NANO_BANANA_OUTPUT_DIR || './marketing/images';
const videoDir = process.env.VEO_OUTPUT_DIR || './marketing/videos';
// Ensure directories exist
[outputDir, videoDir].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
console.log('📁 Output directories:');
console.log(` Images: ${outputDir}`);
console.log(` Videos: ${videoDir}\n`);
// Image templates for Gravl
const imagePrompts = [
{
name: 'login-page.png',
prompt: 'Gravl fitness app login page with barbell logo, email/password inputs, dark theme #0a0a0f, orange accent #ff6b35, gradient background, 1280x720'
},
{
name: 'dashboard.png',
prompt: 'Gravl dashboard: stat cards showing workouts completed, total volume, streak, calendar view, animated elements, dark modern design, 1280x720'
},
{
name: 'workout-page.png',
prompt: 'Gravl workout page: exercise cards with sets/reps input, rest timer showing 90 seconds, complete button, smooth animations, dark theme, 1280x720'
}
];
// Video templates
const videoPrompts = [
{
name: 'workout-demo.mp4',
prompt: 'Gravl fitness app demo: user opens app, selects a workout, clicks on exercise, logs 3 sets of 10 reps at 80kg, rests with 90-second countdown timer, completes workout',
duration: 10
}
];
console.log('📝 Image generation requests (mock for demo):\n');
imagePrompts.forEach(img => {
console.log(`${img.name}`);
// In real usage, this would call nano-banana-pro API
// For now, create placeholder files
const filePath = path.join(outputDir, img.name);
fs.writeFileSync(filePath, `Placeholder: ${img.prompt}`);
console.log(` → Created (placeholder): ${filePath}`);
});
console.log('\n🎥 Video generation requests (mock for demo):\n');
videoPrompts.forEach(vid => {
console.log(`${vid.name} (${vid.duration}s)`);
// In real usage, this would call Veo API
const filePath = path.join(videoDir, vid.name);
fs.writeFileSync(filePath, `Placeholder: ${vid.prompt}`);
console.log(` → Created (placeholder): ${filePath}`);
});
console.log('\n✨ Generation complete!\n');
console.log('📌 Next steps:');
console.log(' 1. Set VEO_API_KEY and GOOGLE_APPLICATION_CREDENTIALS in .env');
console.log(' 2. Replace placeholder calls with actual API requests');
console.log(' 3. Run: npm install dotenv');
console.log(` 4. Run: node scripts/generate-assets.js\n`);
console.log('📂 Generated files:');
[outputDir, videoDir].forEach(dir => {
const files = fs.readdirSync(dir);
files.forEach(f => console.log(` ${path.join(dir, f)}`));
});