mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 15:08:46 +01:00
Add interactive routine wizard, slider controls, value persistence, and timer pause
All checks were successful
Deploy to VPS / deploy (push) Successful in 24s
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:
@@ -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 (
|
||||
<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">
|
||||
<circle
|
||||
cx="80" cy="80" r={radius}
|
||||
@@ -41,13 +51,14 @@ export function ElapsedTimerCircle({ seconds }: ElapsedTimerProps) {
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference * 0.25}
|
||||
className="text-primary"
|
||||
className={paused ? 'text-amber-400' : 'text-primary'}
|
||||
/>
|
||||
</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">
|
||||
{h > 0 ? `${pad(h)}:` : ''}{pad(m)}:{pad(s)}
|
||||
</span>
|
||||
{paused && <span className="text-xs text-amber-400 font-semibold mt-1">PAUSED</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
<circle
|
||||
cx="60" cy="60" r={radius}
|
||||
@@ -32,13 +39,14 @@ export function RestCountdown({ remaining, total }: RestCountdownProps) {
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
className="text-primary transition-all duration-1000"
|
||||
className={`transition-all duration-1000 ${paused ? 'text-amber-400' : 'text-primary'}`}
|
||||
/>
|
||||
</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">
|
||||
{pad(m)}:{pad(s)}
|
||||
</span>
|
||||
{paused && <span className="text-xs text-amber-400 font-semibold mt-1">PAUSED</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
254
frontend/src/components/kettlebell/RoutineWizard.tsx
Normal file
254
frontend/src/components/kettlebell/RoutineWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-content-muted w-24">{label}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-content-muted text-sm">{label}</span>
|
||||
<span className="text-2xl font-bold text-content tabular-nums">
|
||||
{value}{unit && <span className="text-sm font-normal text-content-muted ml-1">{unit}</span>}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(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>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
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>
|
||||
);
|
||||
@@ -46,22 +48,24 @@ function Stepper({ label, value, onChange, step = 1, min = 0 }: {
|
||||
|
||||
function RpeDots({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-content-muted w-24">Effort</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
<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">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((dot) => (
|
||||
<button
|
||||
key={dot}
|
||||
type="button"
|
||||
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-zinc-200 dark:bg-zinc-700'
|
||||
}`}
|
||||
aria-label={`RPE ${dot}`}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-2 text-sm text-content-muted tabular-nums">{value}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -82,11 +86,11 @@ export function SetLogger({
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-6 py-4">
|
||||
{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} />
|
||||
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user