Add interactive routine wizard, slider controls, value persistence, and timer pause
All checks were successful
Deploy to VPS / deploy (push) Successful in 24s

- 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) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-03-20 22:29:06 -06:00
parent d7c1f8f677
commit 11e086166c
10 changed files with 613 additions and 130 deletions

View File

@@ -55,3 +55,47 @@ class KettlebellModule(dspy.Module):
kettlebell_module = KettlebellModule() 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()

View File

@@ -1,3 +1,4 @@
import json
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -6,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlmodel import Session, select 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.api import deps
from app.models.kettlebell import KettlebellSession, KettlebellSetLog from app.models.kettlebell import KettlebellSession, KettlebellSetLog
@@ -19,6 +20,39 @@ class KettlebellGenerateRequest(BaseModel):
available_weights: list[float] 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): class LogSetRequest(BaseModel):
exercise_order: int exercise_order: int
set_number: 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) @router.post("/generate", response_model=KettlebellSession)
def generate_session( def generate_session(
*, *,

View File

@@ -1,5 +1,5 @@
import client from './client'; import client from './client';
import type { KettlebellSession, KettlebellSetLog } from '../types/kettlebell'; import type { KettlebellSession, KettlebellSetLog, DraftSession } from '../types/kettlebell';
export interface GenerateSessionRequest { export interface GenerateSessionRequest {
focus: string; focus: string;
@@ -7,6 +7,18 @@ export interface GenerateSessionRequest {
available_weights: number[]; 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 { export interface LogSetRequest {
exercise_order: number; exercise_order: number;
set_number: number; set_number: number;
@@ -49,3 +61,12 @@ export const abandonSession = (id: number) =>
export const deleteSession = (id: number) => export const deleteSession = (id: number) =>
client.delete(`/kettlebell/${id}`); client.delete(`/kettlebell/${id}`);
export const generateDraft = (data: GenerateSessionRequest) =>
client.post<DraftSession>('/kettlebell/generate-draft', data).then(r => r.data);
export const refineDraft = (data: RefineRequest) =>
client.post<DraftSession>('/kettlebell/refine', data).then(r => r.data);
export const saveDraft = (data: SaveDraftRequest) =>
client.post<KettlebellSession>('/kettlebell/save-draft', data).then(r => r.data);

View File

@@ -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 h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60; const s = seconds % 60;
@@ -24,7 +29,12 @@ export function ElapsedTimerCircle({ seconds }: ElapsedTimerProps) {
return ( return (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
<div className="relative w-48 h-48"> <div
className={`relative w-48 h-48 ${onClick ? 'cursor-pointer' : ''} ${paused ? 'animate-pulse' : ''}`}
onClick={onClick}
role={onClick ? 'button' : undefined}
aria-label={onClick ? (paused ? 'Resume timer' : 'Pause timer') : undefined}
>
<svg className="w-full h-full -rotate-90" viewBox="0 0 160 160"> <svg className="w-full h-full -rotate-90" viewBox="0 0 160 160">
<circle <circle
cx="80" cy="80" r={radius} cx="80" cy="80" r={radius}
@@ -41,13 +51,14 @@ export function ElapsedTimerCircle({ seconds }: ElapsedTimerProps) {
strokeLinecap="round" strokeLinecap="round"
strokeDasharray={circumference} strokeDasharray={circumference}
strokeDashoffset={circumference * 0.25} strokeDashoffset={circumference * 0.25}
className="text-primary" className={paused ? 'text-amber-400' : 'text-primary'}
/> />
</svg> </svg>
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-4xl font-mono font-bold tabular-nums text-content"> <span className="text-4xl font-mono font-bold tabular-nums text-content">
{h > 0 ? `${pad(h)}:` : ''}{pad(m)}:{pad(s)} {h > 0 ? `${pad(h)}:` : ''}{pad(m)}:{pad(s)}
</span> </span>
{paused && <span className="text-xs text-amber-400 font-semibold mt-1">PAUSED</span>}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,11 @@
interface RestCountdownProps { interface RestCountdownProps {
remaining: number; remaining: number;
total: 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 radius = 54;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
const progress = total > 0 ? remaining / total : 0; const progress = total > 0 ? remaining / total : 0;
@@ -15,7 +17,12 @@ export function RestCountdown({ remaining, total }: RestCountdownProps) {
return ( return (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<div className="relative w-36 h-36"> <div
className={`relative w-36 h-36 ${onClick ? 'cursor-pointer' : ''} ${paused ? 'animate-pulse' : ''}`}
onClick={onClick}
role={onClick ? 'button' : undefined}
aria-label={onClick ? (paused ? 'Resume timer' : 'Pause timer') : undefined}
>
<svg className="w-full h-full -rotate-90" viewBox="0 0 120 120"> <svg className="w-full h-full -rotate-90" viewBox="0 0 120 120">
<circle <circle
cx="60" cy="60" r={radius} cx="60" cy="60" r={radius}
@@ -32,13 +39,14 @@ export function RestCountdown({ remaining, total }: RestCountdownProps) {
strokeLinecap="round" strokeLinecap="round"
strokeDasharray={circumference} strokeDasharray={circumference}
strokeDashoffset={dashOffset} strokeDashoffset={dashOffset}
className="text-primary transition-all duration-1000" className={`transition-all duration-1000 ${paused ? 'text-amber-400' : 'text-primary'}`}
/> />
</svg> </svg>
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-mono font-bold tabular-nums text-content"> <span className="text-3xl font-mono font-bold tabular-nums text-content">
{pad(m)}:{pad(s)} {pad(m)}:{pad(s)}
</span> </span>
{paused && <span className="text-xs text-amber-400 font-semibold mt-1">PAUSED</span>}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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<WizardStep>('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<DraftSession | null>(null);
const [feedback, setFeedback] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const chatEndRef = useRef<HTMLDivElement>(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 (
<Dialog open={open} onClose={onClose} size="4xl">
{step === 'configure' && (
<>
<DialogTitle>Create Routine</DialogTitle>
<DialogBody>
<form onSubmit={handleGenerate} className="space-y-4">
<Field>
<Label>Focus</Label>
<div className="flex flex-wrap gap-2">
{FOCUS_OPTIONS.map(f => (
<button
key={f}
type="button"
onClick={() => setFocus(f)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${focus === f
? 'bg-primary text-white border-primary'
: 'bg-base border-border text-content hover:bg-base/80'
}`}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
</Field>
<Field>
<Label>Duration (minutes)</Label>
<div className="flex gap-2">
{DURATION_OPTIONS.map(d => (
<button
key={d}
type="button"
onClick={() => setDuration(d)}
className={`flex-1 py-2 rounded-lg text-sm font-medium border transition-colors ${duration === d
? 'bg-primary text-white border-primary'
: 'bg-base border-border text-content hover:bg-base/80'
}`}
>
{d}m
</button>
))}
</div>
</Field>
<Field>
<Label>Available Weights (kg, comma-separated)</Label>
<Input
type="text"
placeholder="e.g. 16, 24, 32"
value={weightsInput}
onChange={e => setWeightsInput(e.target.value)}
/>
</Field>
<div className="flex gap-3 pt-2">
<Button type="button" plain onClick={onClose}>Cancel</Button>
<Button type="submit" color="dark/zinc" disabled={loading} className="flex-1">
{loading ? 'Generating...' : 'Generate Routine'}
</Button>
</div>
</form>
</DialogBody>
</>
)}
{step === 'review' && draft && (
<>
<div className="flex items-center justify-between mb-4">
<DialogTitle>{draft.title}</DialogTitle>
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-content-muted capitalize">
{draft.difficulty} · {draft.focus} · {draft.total_duration_min}min
</span>
</div>
<DialogBody>
<div className="flex flex-col gap-4 max-h-[60vh] overflow-y-auto">
{/* Exercise list */}
<div className="space-y-3">
{draft.exercises.map((ex, i) => (
<div key={i} className="p-3 bg-base rounded-xl border border-border">
<div className="flex items-start justify-between">
<div>
<p className="font-bold text-content text-sm">{ex.order}. {ex.name}</p>
<p className="text-xs text-content-muted">{ex.description}</p>
</div>
<div className="text-right text-xs text-content-muted shrink-0 ml-3">
<p>{ex.sets} x {ex.reps > 0 ? `${ex.reps} reps` : `${ex.duration_seconds}s`}</p>
<p>{ex.weight_kg} kg · {ex.rest_seconds}s rest</p>
</div>
</div>
<p className="text-xs text-primary mt-1 italic">"{ex.coaching_tip}"</p>
</div>
))}
</div>
{draft.notes && (
<p className="text-sm text-content-muted italic">{draft.notes}</p>
)}
{/* Chat area */}
<div className="border-t border-border pt-3">
<p className="text-xs text-content-muted mb-2">Refine your routine tell the AI what to change:</p>
<div className="space-y-2 max-h-40 overflow-y-auto mb-3">
{messages.map((msg, i) => (
<div key={i} className={`text-sm px-3 py-2 rounded-xl max-w-[85%] ${msg.role === 'user'
? 'bg-primary text-white ml-auto'
: 'bg-zinc-100 dark:bg-zinc-800 text-content'
}`}>
{msg.text}
</div>
))}
<div ref={chatEndRef} />
</div>
<form onSubmit={handleRefine} className="flex gap-2">
<Input
type="text"
placeholder="e.g. Make it harder, swap exercise 3..."
value={feedback}
onChange={e => setFeedback(e.target.value)}
disabled={loading}
className="flex-1"
/>
<Button type="submit" outline disabled={loading || !feedback.trim()}>
{loading ? '...' : 'Refine'}
</Button>
</form>
</div>
</div>
<div className="flex gap-3 pt-4">
<Button type="button" plain onClick={() => setStep('configure')}>Back</Button>
<Button type="button" color="dark/zinc" className="flex-1" onClick={handleAccept} disabled={loading}>
Accept Routine
</Button>
</div>
</DialogBody>
</>
)}
{step === 'saving' && (
<DialogBody>
<div className="flex items-center justify-center py-12">
<p className="text-content-muted">Saving routine...</p>
</div>
</DialogBody>
)}
</Dialog>
);
}

View File

@@ -11,34 +11,36 @@ interface SetLoggerProps {
onComplete: () => void; onComplete: () => void;
} }
function Stepper({ label, value, onChange, step = 1, min = 0 }: { function Slider({ label, value, onChange, min, max, step = 1, unit }: {
label: string; label: string;
value: number; value: number;
onChange: (v: number) => void; onChange: (v: number) => void;
min: number;
max: number;
step?: number; step?: number;
min?: number; unit?: string;
}) { }) {
return ( return (
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col gap-2">
<span className="text-content-muted w-24">{label}</span> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <span className="text-content-muted text-sm">{label}</span>
<button <span className="text-2xl font-bold text-content tabular-nums">
type="button" {value}{unit && <span className="text-sm font-normal text-content-muted ml-1">{unit}</span>}
onClick={() => onChange(Math.max(min, value - step))}
className="w-10 h-10 rounded-full bg-surface border border-border text-xl font-bold text-content flex items-center justify-center active:scale-95"
>
</button>
<span className="w-14 text-center text-xl font-bold text-content tabular-nums">
{value}
</span> </span>
<button </div>
type="button" <input
onClick={() => onChange(value + step)} type="range"
className="w-10 h-10 rounded-full bg-surface border border-border text-xl font-bold text-content flex items-center justify-center active:scale-95" min={min}
> max={max}
+ step={step}
</button> value={value}
onChange={e => 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"
/>
<div className="flex justify-between text-xs text-content-muted">
<span>{min}</span>
<span>{max}</span>
</div> </div>
</div> </div>
); );
@@ -46,22 +48,24 @@ function Stepper({ label, value, onChange, step = 1, min = 0 }: {
function RpeDots({ value, onChange }: { value: number; onChange: (v: number) => void }) { function RpeDots({ value, onChange }: { value: number; onChange: (v: number) => void }) {
return ( return (
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col gap-2">
<span className="text-content-muted w-24">Effort</span> <div className="flex items-center justify-between">
<span className="text-content-muted text-sm">Effort</span>
<span className="text-2xl font-bold text-content tabular-nums">{value}<span className="text-sm font-normal text-content-muted">/10</span></span>
</div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{Array.from({ length: 10 }, (_, i) => i + 1).map((dot) => ( {Array.from({ length: 10 }, (_, i) => i + 1).map((dot) => (
<button <button
key={dot} key={dot}
type="button" type="button"
onClick={() => onChange(dot)} onClick={() => onChange(dot)}
className={`w-6 h-6 rounded-full transition-colors ${dot <= value className={`flex-1 h-8 rounded-full transition-colors ${dot <= value
? 'bg-primary' ? 'bg-primary'
: 'bg-zinc-200 dark:bg-zinc-700' : 'bg-zinc-200 dark:bg-zinc-700'
}`} }`}
aria-label={`RPE ${dot}`} aria-label={`RPE ${dot}`}
/> />
))} ))}
<span className="ml-2 text-sm text-content-muted tabular-nums">{value}/10</span>
</div> </div>
</div> </div>
); );
@@ -82,11 +86,11 @@ export function SetLogger({
return ( return (
<div className="flex flex-col gap-6 px-6 py-4"> <div className="flex flex-col gap-6 px-6 py-4">
{isTimed ? ( {isTimed ? (
<Stepper label="Duration (s)" value={reps} onChange={onRepsChange} step={5} /> <Slider label="Duration" value={reps} onChange={onRepsChange} min={5} max={300} step={5} unit="s" />
) : ( ) : (
<Stepper label="Reps done" value={reps} onChange={onRepsChange} /> <Slider label="Reps" value={reps} onChange={onRepsChange} min={1} max={50} />
)} )}
<Stepper label="Weight (kg)" value={weightKg} onChange={onWeightChange} step={0.5} /> <Slider label="Weight" value={weightKg} onChange={onWeightChange} min={0} max={Math.max(80, Math.ceil(exercise.weight_kg * 1.5))} step={1} unit="kg" />
<RpeDots value={effort} onChange={onEffortChange} /> <RpeDots value={effort} onChange={onEffortChange} />
<button <button

View File

@@ -19,11 +19,13 @@ interface LastSetStats {
effort: number; effort: number;
} }
type UserOverrides = Record<number, { reps?: number; weightKg?: number }>;
type SessionPhase = type SessionPhase =
| { phase: 'loading' } | { phase: 'loading' }
| { phase: 'planning'; session: KettlebellSession } | { phase: 'planning'; session: KettlebellSession }
| { phase: 'active'; session: KettlebellSession; exerciseIdx: number; setIdx: number; elapsed: number; setElapsed: number; lastSetStats: LastSetStats | null; logged: KettlebellSetLog[]; reps: number; weightKg: number; effort: number } | { phase: 'active'; session: KettlebellSession; exerciseIdx: number; setIdx: number; elapsed: number; setElapsed: number; lastSetStats: LastSetStats | null; logged: KettlebellSetLog[]; reps: number; weightKg: number; effort: number; userOverrides: UserOverrides; paused: boolean }
| { phase: 'resting'; session: KettlebellSession; exerciseIdx: number; nextSetIdx: number; restRemaining: number; totalRest: number; elapsed: number; pendingSet: { duration: number; reps: number; weightKg: number; effort: number }; logged: KettlebellSetLog[] } | { phase: 'resting'; session: KettlebellSession; exerciseIdx: number; nextSetIdx: number; restRemaining: number; totalRest: number; elapsed: number; pendingSet: { duration: number; reps: number; weightKg: number; effort: number }; logged: KettlebellSetLog[]; userOverrides: UserOverrides; paused: boolean }
| { phase: 'complete'; session: KettlebellSession; totalElapsed: number; logged: KettlebellSetLog[] }; | { phase: 'complete'; session: KettlebellSession; totalElapsed: number; logged: KettlebellSetLog[] };
type Action = type Action =
@@ -36,6 +38,7 @@ type Action =
| { type: 'SET_EFFORT'; value: number } | { type: 'SET_EFFORT'; value: number }
| { type: 'COMPLETE_SET'; log: KettlebellSetLog } | { type: 'COMPLETE_SET'; log: KettlebellSetLog }
| { type: 'SKIP_REST' } | { type: 'SKIP_REST' }
| { type: 'TOGGLE_PAUSE' }
| { type: 'FINISH' }; | { type: 'FINISH' };
function getExercises(session: KettlebellSession): ExerciseBlock[] { function getExercises(session: KettlebellSession): ExerciseBlock[] {
@@ -50,6 +53,17 @@ function initialSetValues(exercise: ExerciseBlock) {
}; };
} }
function setValuesWithOverrides(exercise: ExerciseBlock, exerciseIdx: number, overrides: UserOverrides) {
const base = initialSetValues(exercise);
const o = overrides[exerciseIdx];
if (!o) return base;
return {
reps: o.reps ?? base.reps,
weightKg: o.weightKg ?? base.weightKg,
effort: base.effort,
};
}
function reducer(state: SessionPhase, action: Action): SessionPhase { function reducer(state: SessionPhase, action: Action): SessionPhase {
switch (action.type) { switch (action.type) {
case 'LOAD': { case 'LOAD': {
@@ -78,6 +92,8 @@ function reducer(state: SessionPhase, action: Action): SessionPhase {
return { return {
phase: 'active', session, exerciseIdx, setIdx, elapsed, setElapsed: 0, lastSetStats: null, logged: existingLogs, phase: 'active', session, exerciseIdx, setIdx, elapsed, setElapsed: 0, lastSetStats: null, logged: existingLogs,
...initialSetValues(ex), ...initialSetValues(ex),
userOverrides: {},
paused: false,
}; };
} }
} }
@@ -102,28 +118,38 @@ function reducer(state: SessionPhase, action: Action): SessionPhase {
lastSetStats: null, lastSetStats: null,
logged: [], logged: [],
...initialSetValues(ex), ...initialSetValues(ex),
userOverrides: {},
paused: false,
}; };
} }
case 'TICK_ELAPSED': { case 'TICK_ELAPSED': {
if (state.phase === 'active') return { ...state, elapsed: state.elapsed + 1, setElapsed: state.setElapsed + 1 }; if (state.phase === 'active' && !state.paused) return { ...state, elapsed: state.elapsed + 1, setElapsed: state.setElapsed + 1 };
if (state.phase === 'resting') return { ...state, elapsed: state.elapsed + 1 }; if (state.phase === 'resting' && !state.paused) return { ...state, elapsed: state.elapsed + 1 };
return state; return state;
} }
case 'TICK_REST': { case 'TICK_REST': {
if (state.phase !== 'resting') return state; if (state.phase !== 'resting' || state.paused) return state;
if (state.restRemaining <= 1) { if (state.restRemaining <= 1) {
// Auto-advance to next set // Auto-advance to next set
return advanceAfterRest(state); return advanceAfterRest(state);
} }
return { ...state, restRemaining: state.restRemaining - 1 }; return { ...state, restRemaining: state.restRemaining - 1 };
} }
case 'TOGGLE_PAUSE': {
if (state.phase === 'active' || state.phase === 'resting') {
return { ...state, paused: !state.paused };
}
return state;
}
case 'SET_REPS': { case 'SET_REPS': {
if (state.phase !== 'active') return state; if (state.phase !== 'active') return state;
return { ...state, reps: action.value }; const repsOverrides = { ...state.userOverrides, [state.exerciseIdx]: { ...state.userOverrides[state.exerciseIdx], reps: action.value } };
return { ...state, reps: action.value, userOverrides: repsOverrides };
} }
case 'SET_WEIGHT': { case 'SET_WEIGHT': {
if (state.phase !== 'active') return state; if (state.phase !== 'active') return state;
return { ...state, weightKg: action.value }; const weightOverrides = { ...state.userOverrides, [state.exerciseIdx]: { ...state.userOverrides[state.exerciseIdx], weightKg: action.value } };
return { ...state, weightKg: action.value, userOverrides: weightOverrides };
} }
case 'SET_EFFORT': { case 'SET_EFFORT': {
if (state.phase !== 'active') return state; if (state.phase !== 'active') return state;
@@ -163,6 +189,8 @@ function reducer(state: SessionPhase, action: Action): SessionPhase {
elapsed: state.elapsed, elapsed: state.elapsed,
pendingSet, pendingSet,
logged: newLogged, logged: newLogged,
userOverrides: state.userOverrides,
paused: false,
}; };
} }
return advanceFromActive(state, newLogged, { ...pendingSet, restTaken: null }); return advanceFromActive(state, newLogged, { ...pendingSet, restTaken: null });
@@ -182,11 +210,12 @@ function advanceFromActive(state: Extract<SessionPhase, { phase: 'active' }>, ne
const exercises = getExercises(state.session); const exercises = getExercises(state.session);
const exercise = exercises[state.exerciseIdx]; const exercise = exercises[state.exerciseIdx];
if (state.setIdx + 1 < exercise.sets) { if (state.setIdx + 1 < exercise.sets) {
return { ...state, setIdx: state.setIdx + 1, setElapsed: 0, lastSetStats, logged: newLogged, ...initialSetValues(exercise) }; return { ...state, setIdx: state.setIdx + 1, setElapsed: 0, lastSetStats, logged: newLogged, ...setValuesWithOverrides(exercise, state.exerciseIdx, state.userOverrides) };
} }
if (state.exerciseIdx + 1 < exercises.length) { if (state.exerciseIdx + 1 < exercises.length) {
const nextEx = exercises[state.exerciseIdx + 1]; const nextIdx = state.exerciseIdx + 1;
return { ...state, exerciseIdx: state.exerciseIdx + 1, setIdx: 0, setElapsed: 0, lastSetStats, logged: newLogged, ...initialSetValues(nextEx) }; const nextEx = exercises[nextIdx];
return { ...state, exerciseIdx: nextIdx, setIdx: 0, setElapsed: 0, lastSetStats, logged: newLogged, ...setValuesWithOverrides(nextEx, nextIdx, state.userOverrides) };
} }
return { phase: 'complete', session: state.session, totalElapsed: state.elapsed, logged: newLogged }; return { phase: 'complete', session: state.session, totalElapsed: state.elapsed, logged: newLogged };
} }
@@ -206,21 +235,26 @@ function advanceAfterRest(state: Extract<SessionPhase, { phase: 'resting' }>): S
setElapsed: 0, setElapsed: 0,
lastSetStats, lastSetStats,
logged: state.logged, logged: state.logged,
...initialSetValues(exercise), ...setValuesWithOverrides(exercise, state.exerciseIdx, state.userOverrides),
userOverrides: state.userOverrides,
paused: false,
}; };
} }
if (state.exerciseIdx + 1 < exercises.length) { if (state.exerciseIdx + 1 < exercises.length) {
const nextEx = exercises[state.exerciseIdx + 1]; const nextIdx = state.exerciseIdx + 1;
const nextEx = exercises[nextIdx];
return { return {
phase: 'active', phase: 'active',
session: state.session, session: state.session,
exerciseIdx: state.exerciseIdx + 1, exerciseIdx: nextIdx,
setIdx: 0, setIdx: 0,
elapsed: state.elapsed, elapsed: state.elapsed,
setElapsed: 0, setElapsed: 0,
lastSetStats, lastSetStats,
logged: state.logged, logged: state.logged,
...initialSetValues(nextEx), ...setValuesWithOverrides(nextEx, nextIdx, state.userOverrides),
userOverrides: state.userOverrides,
paused: false,
}; };
} }
return { phase: 'complete', session: state.session, totalElapsed: state.elapsed, logged: state.logged }; return { phase: 'complete', session: state.session, totalElapsed: state.elapsed, logged: state.logged };
@@ -292,6 +326,9 @@ export default function ActiveSession() {
}, [id, state]); }, [id, state]);
const [showAbandonModal, setShowAbandonModal] = useState(false); const [showAbandonModal, setShowAbandonModal] = useState(false);
const [showBackModal, setShowBackModal] = useState(false);
const handleTogglePause = useCallback(() => dispatch({ type: 'TOGGLE_PAUSE' }), []);
const handleAbandon = useCallback(async () => { const handleAbandon = useCallback(async () => {
if (!id) return; if (!id) return;
@@ -368,10 +405,19 @@ export default function ActiveSession() {
onCancel={() => setShowAbandonModal(false)} onCancel={() => setShowAbandonModal(false)}
/> />
)} )}
{showBackModal && (
<ConfirmModal
title="Leave Session"
message="Session will be paused. You can resume later from the Kettlebell page."
confirmLabel="Leave"
onConfirm={() => navigate('/kettlebell')}
onCancel={() => setShowBackModal(false)}
/>
)}
{/* Sticky top bar */} {/* Sticky top bar */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-surface sticky top-0 z-10"> <div className="flex items-center justify-between px-4 py-3 border-b border-border bg-surface sticky top-0 z-10">
<button onClick={() => navigate('/kettlebell')} className="text-content-muted hover:text-content text-sm"> <button onClick={() => setShowBackModal(true)} className="text-content-muted hover:text-content text-sm">
Back Back
</button> </button>
<ProgressBar <ProgressBar
@@ -425,7 +471,7 @@ export default function ActiveSession() {
)} )}
</div> </div>
<ElapsedTimerCircle seconds={state.setElapsed} /> <ElapsedTimerCircle seconds={state.setElapsed} paused={state.paused} onClick={handleTogglePause} />
{/* Total */} {/* Total */}
<div className="flex-1 flex flex-col items-end gap-1.5"> <div className="flex-1 flex flex-col items-end gap-1.5">
@@ -484,9 +530,18 @@ export default function ActiveSession() {
onCancel={() => setShowAbandonModal(false)} onCancel={() => setShowAbandonModal(false)}
/> />
)} )}
{showBackModal && (
<ConfirmModal
title="Leave Session"
message="Session will be paused. You can resume later from the Kettlebell page."
confirmLabel="Leave"
onConfirm={() => navigate('/kettlebell')}
onCancel={() => setShowBackModal(false)}
/>
)}
<div className="flex items-center justify-between w-full max-w-sm"> <div className="flex items-center justify-between w-full max-w-sm">
<button onClick={() => navigate('/kettlebell')} className="text-content-muted hover:text-content text-sm"> <button onClick={() => setShowBackModal(true)} className="text-content-muted hover:text-content text-sm">
Back Back
</button> </button>
<div className="text-lg font-mono font-bold text-content"> <div className="text-lg font-mono font-bold text-content">
@@ -494,8 +549,8 @@ export default function ActiveSession() {
</div> </div>
</div> </div>
<h2 className="text-3xl font-bold text-content">REST</h2> <h2 className="text-3xl font-bold text-content">{state.paused ? 'PAUSED' : 'REST'}</h2>
<RestCountdown remaining={state.restRemaining} total={state.totalRest} /> <RestCountdown remaining={state.restRemaining} total={state.totalRest} paused={state.paused} onClick={handleTogglePause} />
<p className="text-content-muted text-sm text-center">{nextLabel}</p> <p className="text-content-muted text-sm text-center">{nextLabel}</p>
<button <button

View File

@@ -1,18 +1,14 @@
import { useState, useEffect, FormEvent } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { generateSession, getSessions, deleteSession, retrySession } from '../api/kettlebell'; import { getSessions, deleteSession, retrySession } from '../api/kettlebell';
import type { KettlebellSession } from '../types/kettlebell'; import type { KettlebellSession } from '../types/kettlebell';
import { Heading, Subheading } from '../components/catalyst/heading'; import { Heading, Subheading } from '../components/catalyst/heading';
import { Button } from '../components/catalyst/button'; import { Button } from '../components/catalyst/button';
import { Field, Label } from '../components/catalyst/fieldset';
import { Input } from '../components/catalyst/input';
import { ConfirmModal } from '../components/ConfirmModal'; import { ConfirmModal } from '../components/ConfirmModal';
import { RoutineWizard } from '../components/kettlebell/RoutineWizard';
import { BarChart2 } from 'lucide-react'; import { BarChart2 } from 'lucide-react';
const FOCUS_OPTIONS = ['strength', 'conditioning', 'fat loss', 'mobility', 'endurance'];
const DURATION_OPTIONS = [20, 30, 45, 60];
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
generated: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300', generated: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300',
in_progress: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200', in_progress: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
@@ -24,12 +20,8 @@ export default function Kettlebell() {
const navigate = useNavigate(); const navigate = useNavigate();
const [sessions, setSessions] = useState<KettlebellSession[]>([]); const [sessions, setSessions] = useState<KettlebellSession[]>([]);
const [selected, setSelected] = useState<KettlebellSession | null>(null); const [selected, setSelected] = useState<KettlebellSession | null>(null);
const [loading, setLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<KettlebellSession | null>(null); const [deleteTarget, setDeleteTarget] = useState<KettlebellSession | null>(null);
const [showWizard, setShowWizard] = useState(false);
const [focus, setFocus] = useState('strength');
const [duration, setDuration] = useState(30);
const [weightsInput, setWeightsInput] = useState('16, 24, 32');
useEffect(() => { useEffect(() => {
getSessions().then(data => { getSessions().then(data => {
@@ -62,22 +54,10 @@ export default function Kettlebell() {
} }
}; };
const handleGenerate = async (e: FormEvent) => { const handleWizardSaved = (session: KettlebellSession) => {
e.preventDefault(); setSessions(prev => [session, ...prev]);
setLoading(true); setSelected(session);
try { setShowWizard(false);
const available_weights = weightsInput
.split(',')
.map(s => parseFloat(s.trim()))
.filter(n => !isNaN(n) && n > 0);
const session = await generateSession({ focus, duration_minutes: duration, available_weights });
setSessions(prev => [session, ...prev]);
setSelected(session);
} catch {
toast.error('Failed to generate session');
} finally {
setLoading(false);
}
}; };
const exercises = selected?.exercises?.exercises ?? []; const exercises = selected?.exercises?.exercises ?? [];
@@ -105,52 +85,19 @@ export default function Kettlebell() {
{/* Left Column */} {/* Left Column */}
<div className="space-y-6 lg:col-span-1"> <div className="space-y-6 lg:col-span-1">
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border"> <div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
<Subheading className="mb-6 text-primary">Generate Session</Subheading> <Subheading className="mb-4 text-primary">Generate Session</Subheading>
<form onSubmit={handleGenerate} className="space-y-4"> <p className="text-sm text-content-muted mb-4">
<Field> Create an AI-powered kettlebell routine. You can refine it until it's exactly what you want.
<Label>Focus</Label> </p>
<select <Button color="dark/zinc" className="w-full" onClick={() => setShowWizard(true)}>
value={focus} Create Routine
onChange={e => setFocus(e.target.value)} </Button>
className="w-full rounded-lg border border-border bg-base text-content px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
{FOCUS_OPTIONS.map(f => (
<option key={f} value={f}>{f.charAt(0).toUpperCase() + f.slice(1)}</option>
))}
</select>
</Field>
<Field>
<Label>Duration (minutes)</Label>
<div className="flex gap-2">
{DURATION_OPTIONS.map(d => (
<button
key={d}
type="button"
onClick={() => setDuration(d)}
className={`flex-1 py-2 rounded-lg text-sm font-medium border transition-colors ${duration === d
? 'bg-primary text-white border-primary'
: 'bg-base border-border text-content hover:bg-base/80'
}`}
>
{d}m
</button>
))}
</div>
</Field>
<Field>
<Label>Available Weights (kg, comma-separated)</Label>
<Input
type="text"
placeholder="e.g. 16, 24, 32"
value={weightsInput}
onChange={e => setWeightsInput(e.target.value)}
/>
</Field>
<Button type="submit" color="dark/zinc" disabled={loading} className="w-full">
{loading ? 'Generating...' : 'Generate Session'}
</Button>
</form>
</div> </div>
<RoutineWizard
open={showWizard}
onClose={() => setShowWizard(false)}
onSaved={handleWizardSaved}
/>
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border h-96 overflow-y-auto"> <div className="bg-surface p-6 rounded-2xl shadow-sm border border-border h-96 overflow-y-auto">
<Subheading className="mb-4">History</Subheading> <Subheading className="mb-4">History</Subheading>

View File

@@ -25,6 +25,15 @@ export interface KettlebellSession {
created_at: string; created_at: string;
} }
export interface DraftSession {
title: string;
focus: string;
total_duration_min: number;
difficulty: string;
exercises: ExerciseBlock[];
notes: string;
}
export interface KettlebellSetLog { export interface KettlebellSetLog {
id: number; id: number;
session_id: number; session_id: number;