From e55f0de49cfbbcca375b3d370f68a56e166c1e8f Mon Sep 17 00:00:00 2001 From: Clawd Agent Date: Sun, 1 Mar 2026 15:36:47 +0100 Subject: [PATCH] feat(04-03-partial): ExercisePicker and WorkoutEditPage components - swap/add/remove exercises with sets/reps editing --- .pm-checkpoint.json | 10 +- frontend/src/components/ExercisePicker.jsx | 112 +++++++++++++ frontend/src/components/Icons.jsx | 18 +++ frontend/src/index.js | 30 ++++ frontend/src/pages/WorkoutEditPage.jsx | 175 +++++++++++++++++++++ frontend/src/styles/App.css | 25 +++ scripts/generate-assets.js | 81 ++++++++++ 7 files changed, 446 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/ExercisePicker.jsx create mode 100644 frontend/src/index.js create mode 100644 frontend/src/pages/WorkoutEditPage.jsx create mode 100644 frontend/src/styles/App.css create mode 100755 scripts/generate-assets.js 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} +
+
+ + +
+
+ +
+
+ + handleUpdate(i, 'sets', e.target.value)} + min="1" + /> +
+
+ + handleUpdate(i, 'reps_min', e.target.value)} + min="1" + /> +
+
+ + handleUpdate(i, 'reps_max', e.target.value)} + min="1" + /> +
+
+
+ ))} +
+ + +
+ + {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)}`)); +});