From 11e086166c2138a86c44424f3607a64abb063ecd Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Fri, 20 Mar 2026 22:29:06 -0600 Subject: [PATCH] Add interactive routine wizard, slider controls, value persistence, and timer pause - Interactive routine generation wizard with AI refinement loop (generate-draft, refine, save-draft endpoints + RoutineWizard modal component) - Replace +/- stepper buttons with slider controls for reps/weight during workout - Persist user-modified reps/weight across sets of the same exercise - Add pause/resume by tapping timer dials, with back-button confirmation Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/ai/kettlebell.py | 44 +++ backend/app/api/v1/endpoints/kettlebell.py | 132 ++++++++- frontend/src/api/kettlebell.ts | 23 +- .../components/kettlebell/ElapsedTimer.tsx | 19 +- .../components/kettlebell/RestCountdown.tsx | 16 +- .../components/kettlebell/RoutineWizard.tsx | 254 ++++++++++++++++++ .../src/components/kettlebell/SetLogger.tsx | 60 +++-- frontend/src/pages/ActiveSession.tsx | 93 +++++-- frontend/src/pages/Kettlebell.tsx | 93 ++----- frontend/src/types/kettlebell.ts | 9 + 10 files changed, 613 insertions(+), 130 deletions(-) create mode 100644 frontend/src/components/kettlebell/RoutineWizard.tsx diff --git a/backend/app/ai/kettlebell.py b/backend/app/ai/kettlebell.py index 3ec215c..8e396f4 100644 --- a/backend/app/ai/kettlebell.py +++ b/backend/app/ai/kettlebell.py @@ -55,3 +55,47 @@ class KettlebellModule(dspy.Module): kettlebell_module = KettlebellModule() + + +class RefineKettlebellSession(dspy.Signature): + """Refine an existing kettlebell workout session based on user feedback. + + You are given the current session and the user's feedback. Apply the requested changes + while keeping the rest of the session intact. Maintain proper exercise sequencing, + warm-up/cooldown structure, and ensure total work time still fits the target duration. + """ + + current_session: str = dspy.InputField(desc="JSON representation of the current session") + user_feedback: str = dspy.InputField(desc="User's requested changes to the session") + user_profile: str = dspy.InputField(desc="User details including age, weight, fitness level, and goals") + available_weights_kg: str = dspy.InputField(desc="Comma-separated list of available kettlebell weights in kg") + focus: str = dspy.InputField(desc="Session focus: strength, conditioning, mobility, fat loss, etc.") + duration_minutes: int = dspy.InputField(desc="Target session duration in minutes") + session: KettlebellSessionOutput = dspy.OutputField(desc="Refined structured kettlebell session") + + +class KettlebellRefineModule(dspy.Module): + def __init__(self): + super().__init__() + self.refine = dspy.ChainOfThought(RefineKettlebellSession) + + def forward( + self, + current_session: str, + user_feedback: str, + user_profile: str, + available_weights_kg: str, + focus: str, + duration_minutes: int, + ): + return self.refine( + current_session=current_session, + user_feedback=user_feedback, + user_profile=user_profile, + available_weights_kg=available_weights_kg, + focus=focus, + duration_minutes=duration_minutes, + ) + + +kettlebell_refine_module = KettlebellRefineModule() diff --git a/backend/app/api/v1/endpoints/kettlebell.py b/backend/app/api/v1/endpoints/kettlebell.py index 6256845..bc6bbab 100644 --- a/backend/app/api/v1/endpoints/kettlebell.py +++ b/backend/app/api/v1/endpoints/kettlebell.py @@ -1,3 +1,4 @@ +import json from collections import defaultdict from datetime import datetime, timedelta from typing import Any, Dict, List, Optional @@ -6,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlmodel import Session, select -from app.ai.kettlebell import kettlebell_module +from app.ai.kettlebell import kettlebell_module, kettlebell_refine_module from app.api import deps from app.models.kettlebell import KettlebellSession, KettlebellSetLog @@ -19,6 +20,39 @@ class KettlebellGenerateRequest(BaseModel): available_weights: list[float] +class DraftExercise(BaseModel): + order: int + name: str + description: str + sets: int + reps: int + duration_seconds: int + weight_kg: float + rest_seconds: int + coaching_tip: str + + +class DraftSession(BaseModel): + title: str + focus: str + total_duration_min: int + difficulty: str + exercises: list[DraftExercise] + notes: str + + +class RefineRequest(BaseModel): + draft: DraftSession + feedback: str + focus: str + duration_minutes: int + available_weights: list[float] + + +class SaveDraftRequest(BaseModel): + draft: DraftSession + + class LogSetRequest(BaseModel): exercise_order: int set_number: int @@ -178,6 +212,102 @@ def get_analytics( ) +@router.post("/generate-draft", response_model=DraftSession) +def generate_draft( + *, + current_user: deps.CurrentUser, + request: KettlebellGenerateRequest, +) -> Any: + """Generate a draft kettlebell session (not saved to DB) for review.""" + try: + user_profile = ( + f"Age: {current_user.age or 'unknown'}, " + f"Gender: {current_user.gender or 'unknown'}, " + f"Weight: {current_user.weight or 'unknown'} kg, " + f"Height: {current_user.height or 'unknown'} cm" + ) + available_weights_kg = ", ".join(str(w) for w in sorted(request.available_weights)) + generated = kettlebell_module( + user_profile=user_profile, + available_weights_kg=available_weights_kg, + focus=request.focus, + duration_minutes=request.duration_minutes, + ) + s = generated.session + return DraftSession( + title=s.title, + focus=s.focus, + total_duration_min=s.total_duration_min, + difficulty=s.difficulty, + exercises=[DraftExercise(**ex.model_dump()) for ex in s.exercises], + notes=s.notes, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/refine", response_model=DraftSession) +def refine_draft( + *, + current_user: deps.CurrentUser, + request: RefineRequest, +) -> Any: + """Refine a draft session based on user feedback.""" + try: + user_profile = ( + f"Age: {current_user.age or 'unknown'}, " + f"Gender: {current_user.gender or 'unknown'}, " + f"Weight: {current_user.weight or 'unknown'} kg, " + f"Height: {current_user.height or 'unknown'} cm" + ) + available_weights_kg = ", ".join(str(w) for w in sorted(request.available_weights)) + current_session_json = json.dumps(request.draft.model_dump(), indent=2) + generated = kettlebell_refine_module( + current_session=current_session_json, + user_feedback=request.feedback, + user_profile=user_profile, + available_weights_kg=available_weights_kg, + focus=request.focus, + duration_minutes=request.duration_minutes, + ) + s = generated.session + return DraftSession( + title=s.title, + focus=s.focus, + total_duration_min=s.total_duration_min, + difficulty=s.difficulty, + exercises=[DraftExercise(**ex.model_dump()) for ex in s.exercises], + notes=s.notes, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/save-draft", response_model=KettlebellSession) +def save_draft( + *, + current_user: deps.CurrentUser, + request: SaveDraftRequest, + session: Session = Depends(deps.get_session), +) -> Any: + """Save a finalized draft as a new session.""" + draft = request.draft + kb_session = KettlebellSession( + user_id=current_user.id, + title=draft.title, + focus=draft.focus, + exercises={"exercises": [ex.model_dump() for ex in draft.exercises]}, + total_duration_min=draft.total_duration_min, + difficulty=draft.difficulty, + notes=draft.notes, + status="generated", + ) + session.add(kb_session) + session.commit() + session.refresh(kb_session) + return kb_session + + @router.post("/generate", response_model=KettlebellSession) def generate_session( *, diff --git a/frontend/src/api/kettlebell.ts b/frontend/src/api/kettlebell.ts index 7f580a3..39c15a4 100644 --- a/frontend/src/api/kettlebell.ts +++ b/frontend/src/api/kettlebell.ts @@ -1,5 +1,5 @@ import client from './client'; -import type { KettlebellSession, KettlebellSetLog } from '../types/kettlebell'; +import type { KettlebellSession, KettlebellSetLog, DraftSession } from '../types/kettlebell'; export interface GenerateSessionRequest { focus: string; @@ -7,6 +7,18 @@ export interface GenerateSessionRequest { available_weights: number[]; } +export interface RefineRequest { + draft: DraftSession; + feedback: string; + focus: string; + duration_minutes: number; + available_weights: number[]; +} + +export interface SaveDraftRequest { + draft: DraftSession; +} + export interface LogSetRequest { exercise_order: number; set_number: number; @@ -49,3 +61,12 @@ export const abandonSession = (id: number) => export const deleteSession = (id: number) => client.delete(`/kettlebell/${id}`); + +export const generateDraft = (data: GenerateSessionRequest) => + client.post('/kettlebell/generate-draft', data).then(r => r.data); + +export const refineDraft = (data: RefineRequest) => + client.post('/kettlebell/refine', data).then(r => r.data); + +export const saveDraft = (data: SaveDraftRequest) => + client.post('/kettlebell/save-draft', data).then(r => r.data); diff --git a/frontend/src/components/kettlebell/ElapsedTimer.tsx b/frontend/src/components/kettlebell/ElapsedTimer.tsx index b397d83..81996ce 100644 --- a/frontend/src/components/kettlebell/ElapsedTimer.tsx +++ b/frontend/src/components/kettlebell/ElapsedTimer.tsx @@ -14,7 +14,12 @@ export function ElapsedTimer({ seconds }: ElapsedTimerProps) { ); } -export function ElapsedTimerCircle({ seconds }: ElapsedTimerProps) { +interface ElapsedTimerCircleProps extends ElapsedTimerProps { + paused?: boolean; + onClick?: () => void; +} + +export function ElapsedTimerCircle({ seconds, paused, onClick }: ElapsedTimerCircleProps) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; @@ -24,7 +29,12 @@ export function ElapsedTimerCircle({ seconds }: ElapsedTimerProps) { return (
-
+
-
+
{h > 0 ? `${pad(h)}:` : ''}{pad(m)}:{pad(s)} + {paused && PAUSED}
diff --git a/frontend/src/components/kettlebell/RestCountdown.tsx b/frontend/src/components/kettlebell/RestCountdown.tsx index 71151c8..44e4724 100644 --- a/frontend/src/components/kettlebell/RestCountdown.tsx +++ b/frontend/src/components/kettlebell/RestCountdown.tsx @@ -1,9 +1,11 @@ interface RestCountdownProps { remaining: number; total: number; + paused?: boolean; + onClick?: () => void; } -export function RestCountdown({ remaining, total }: RestCountdownProps) { +export function RestCountdown({ remaining, total, paused, onClick }: RestCountdownProps) { const radius = 54; const circumference = 2 * Math.PI * radius; const progress = total > 0 ? remaining / total : 0; @@ -15,7 +17,12 @@ export function RestCountdown({ remaining, total }: RestCountdownProps) { return (
-
+
-
+
{pad(m)}:{pad(s)} + {paused && PAUSED}
diff --git a/frontend/src/components/kettlebell/RoutineWizard.tsx b/frontend/src/components/kettlebell/RoutineWizard.tsx new file mode 100644 index 0000000..6aa3a4d --- /dev/null +++ b/frontend/src/components/kettlebell/RoutineWizard.tsx @@ -0,0 +1,254 @@ +import { useState, FormEvent, useRef, useEffect } from 'react'; +import { toast } from 'sonner'; +import { Dialog, DialogTitle, DialogBody } from '../catalyst/dialog'; +import { Button } from '../catalyst/button'; +import { Field, Label } from '../catalyst/fieldset'; +import { Input } from '../catalyst/input'; +import { generateDraft, refineDraft, saveDraft } from '../../api/kettlebell'; +import type { DraftSession } from '../../types/kettlebell'; +import type { KettlebellSession } from '../../types/kettlebell'; + +const FOCUS_OPTIONS = ['strength', 'conditioning', 'fat loss', 'mobility', 'endurance']; +const DURATION_OPTIONS = [20, 30, 45, 60]; + +type WizardStep = 'configure' | 'review' | 'saving'; + +interface Message { + role: 'user' | 'assistant'; + text: string; +} + +interface RoutineWizardProps { + open: boolean; + onClose: () => void; + onSaved: (session: KettlebellSession) => void; +} + +export function RoutineWizard({ open, onClose, onSaved }: RoutineWizardProps) { + const [step, setStep] = useState('configure'); + const [focus, setFocus] = useState('strength'); + const [duration, setDuration] = useState(30); + const [weightsInput, setWeightsInput] = useState('16, 24, 32'); + const [loading, setLoading] = useState(false); + const [draft, setDraft] = useState(null); + const [feedback, setFeedback] = useState(''); + const [messages, setMessages] = useState([]); + const chatEndRef = useRef(null); + + useEffect(() => { + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setStep('configure'); + setDraft(null); + setMessages([]); + setFeedback(''); + setLoading(false); + } + }, [open]); + + const parseWeights = () => + weightsInput.split(',').map(s => parseFloat(s.trim())).filter(n => !isNaN(n) && n > 0); + + const handleGenerate = async (e: FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const available_weights = parseWeights(); + const result = await generateDraft({ focus, duration_minutes: duration, available_weights }); + setDraft(result); + setMessages([{ role: 'assistant', text: `Generated "${result.title}" — ${result.difficulty} ${result.focus} session, ${result.total_duration_min} min with ${result.exercises.length} exercises.` }]); + setStep('review'); + } catch { + toast.error('Failed to generate session'); + } finally { + setLoading(false); + } + }; + + const handleRefine = async (e: FormEvent) => { + e.preventDefault(); + if (!draft || !feedback.trim()) return; + const userMsg = feedback.trim(); + setFeedback(''); + setMessages(prev => [...prev, { role: 'user', text: userMsg }]); + setLoading(true); + try { + const result = await refineDraft({ + draft, + feedback: userMsg, + focus, + duration_minutes: duration, + available_weights: parseWeights(), + }); + setDraft(result); + setMessages(prev => [...prev, { role: 'assistant', text: `Updated to "${result.title}" — ${result.exercises.length} exercises, ${result.total_duration_min} min.` }]); + } catch { + toast.error('Failed to refine session'); + setMessages(prev => [...prev, { role: 'assistant', text: 'Sorry, refinement failed. Please try again.' }]); + } finally { + setLoading(false); + } + }; + + const handleAccept = async () => { + if (!draft) return; + setStep('saving'); + try { + const session = await saveDraft({ draft }); + onSaved(session); + } catch { + toast.error('Failed to save session'); + setStep('review'); + } + }; + + return ( + + {step === 'configure' && ( + <> + Create Routine + +
+ + +
+ {FOCUS_OPTIONS.map(f => ( + + ))} +
+
+ + +
+ {DURATION_OPTIONS.map(d => ( + + ))} +
+
+ + + setWeightsInput(e.target.value)} + /> + +
+ + +
+
+
+ + )} + + {step === 'review' && draft && ( + <> +
+ {draft.title} + + {draft.difficulty} · {draft.focus} · {draft.total_duration_min}min + +
+ +
+ {/* Exercise list */} +
+ {draft.exercises.map((ex, i) => ( +
+
+
+

{ex.order}. {ex.name}

+

{ex.description}

+
+
+

{ex.sets} x {ex.reps > 0 ? `${ex.reps} reps` : `${ex.duration_seconds}s`}

+

{ex.weight_kg} kg · {ex.rest_seconds}s rest

+
+
+

"{ex.coaching_tip}"

+
+ ))} +
+ + {draft.notes && ( +

{draft.notes}

+ )} + + {/* Chat area */} +
+

Refine your routine — tell the AI what to change:

+
+ {messages.map((msg, i) => ( +
+ {msg.text} +
+ ))} +
+
+
+ setFeedback(e.target.value)} + disabled={loading} + className="flex-1" + /> + +
+
+
+ +
+ + +
+ + + )} + + {step === 'saving' && ( + +
+

Saving routine...

+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/kettlebell/SetLogger.tsx b/frontend/src/components/kettlebell/SetLogger.tsx index 000c8bc..d6ebc53 100644 --- a/frontend/src/components/kettlebell/SetLogger.tsx +++ b/frontend/src/components/kettlebell/SetLogger.tsx @@ -11,34 +11,36 @@ interface SetLoggerProps { onComplete: () => void; } -function Stepper({ label, value, onChange, step = 1, min = 0 }: { +function Slider({ label, value, onChange, min, max, step = 1, unit }: { label: string; value: number; onChange: (v: number) => void; + min: number; + max: number; step?: number; - min?: number; + unit?: string; }) { return ( -
- {label} -
- - - {value} +
+
+ {label} + + {value}{unit && {unit}} - +
+ onChange(Number(e.target.value))} + className="w-full h-2 rounded-full appearance-none cursor-pointer bg-zinc-200 dark:bg-zinc-700 accent-primary + [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-11 [&::-webkit-slider-thumb]:h-11 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-lg [&::-webkit-slider-thumb]:active:scale-110 [&::-webkit-slider-thumb]:transition-transform" + /> +
+ {min} + {max}
); @@ -46,22 +48,24 @@ function Stepper({ label, value, onChange, step = 1, min = 0 }: { function RpeDots({ value, onChange }: { value: number; onChange: (v: number) => void }) { return ( -
- Effort +
+
+ Effort + {value}/10 +
{Array.from({ length: 10 }, (_, i) => i + 1).map((dot) => (
); @@ -82,11 +86,11 @@ export function SetLogger({ return (
{isTimed ? ( - + ) : ( - + )} - + - + {/* Total */}
@@ -484,9 +530,18 @@ export default function ActiveSession() { onCancel={() => setShowAbandonModal(false)} /> )} + {showBackModal && ( + navigate('/kettlebell')} + onCancel={() => setShowBackModal(false)} + /> + )}
-
@@ -494,8 +549,8 @@ export default function ActiveSession() {
-

REST

- +

{state.paused ? 'PAUSED' : 'REST'}

+

{nextLabel}

- ))} -
- - - - setWeightsInput(e.target.value)} - /> - - - + Generate Session +

+ Create an AI-powered kettlebell routine. You can refine it until it's exactly what you want. +

+
+ setShowWizard(false)} + onSaved={handleWizardSaved} + />
History diff --git a/frontend/src/types/kettlebell.ts b/frontend/src/types/kettlebell.ts index b87433e..e16d6d5 100644 --- a/frontend/src/types/kettlebell.ts +++ b/frontend/src/types/kettlebell.ts @@ -25,6 +25,15 @@ export interface KettlebellSession { created_at: string; } +export interface DraftSession { + title: string; + focus: string; + total_duration_min: number; + difficulty: string; + exercises: ExerciseBlock[]; + notes: string; +} + export interface KettlebellSetLog { id: number; session_id: number;