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

@@ -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<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 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>

View File

@@ -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>

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;
}
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

View File

@@ -19,11 +19,13 @@ interface LastSetStats {
effort: number;
}
type UserOverrides = Record<number, { reps?: number; weightKg?: number }>;
type SessionPhase =
| { phase: 'loading' }
| { 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: '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: '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[]; userOverrides: UserOverrides; paused: boolean }
| { phase: 'complete'; session: KettlebellSession; totalElapsed: number; logged: KettlebellSetLog[] };
type Action =
@@ -36,6 +38,7 @@ type Action =
| { type: 'SET_EFFORT'; value: number }
| { type: 'COMPLETE_SET'; log: KettlebellSetLog }
| { type: 'SKIP_REST' }
| { type: 'TOGGLE_PAUSE' }
| { type: 'FINISH' };
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 {
switch (action.type) {
case 'LOAD': {
@@ -78,6 +92,8 @@ function reducer(state: SessionPhase, action: Action): SessionPhase {
return {
phase: 'active', session, exerciseIdx, setIdx, elapsed, setElapsed: 0, lastSetStats: null, logged: existingLogs,
...initialSetValues(ex),
userOverrides: {},
paused: false,
};
}
}
@@ -102,28 +118,38 @@ function reducer(state: SessionPhase, action: Action): SessionPhase {
lastSetStats: null,
logged: [],
...initialSetValues(ex),
userOverrides: {},
paused: false,
};
}
case 'TICK_ELAPSED': {
if (state.phase === 'active') return { ...state, elapsed: state.elapsed + 1, setElapsed: state.setElapsed + 1 };
if (state.phase === 'resting') return { ...state, elapsed: state.elapsed + 1 };
if (state.phase === 'active' && !state.paused) return { ...state, elapsed: state.elapsed + 1, setElapsed: state.setElapsed + 1 };
if (state.phase === 'resting' && !state.paused) return { ...state, elapsed: state.elapsed + 1 };
return state;
}
case 'TICK_REST': {
if (state.phase !== 'resting') return state;
if (state.phase !== 'resting' || state.paused) return state;
if (state.restRemaining <= 1) {
// Auto-advance to next set
return advanceAfterRest(state);
}
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': {
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': {
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': {
if (state.phase !== 'active') return state;
@@ -163,6 +189,8 @@ function reducer(state: SessionPhase, action: Action): SessionPhase {
elapsed: state.elapsed,
pendingSet,
logged: newLogged,
userOverrides: state.userOverrides,
paused: false,
};
}
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 exercise = exercises[state.exerciseIdx];
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) {
const nextEx = exercises[state.exerciseIdx + 1];
return { ...state, exerciseIdx: state.exerciseIdx + 1, setIdx: 0, setElapsed: 0, lastSetStats, logged: newLogged, ...initialSetValues(nextEx) };
const nextIdx = state.exerciseIdx + 1;
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 };
}
@@ -206,21 +235,26 @@ function advanceAfterRest(state: Extract<SessionPhase, { phase: 'resting' }>): S
setElapsed: 0,
lastSetStats,
logged: state.logged,
...initialSetValues(exercise),
...setValuesWithOverrides(exercise, state.exerciseIdx, state.userOverrides),
userOverrides: state.userOverrides,
paused: false,
};
}
if (state.exerciseIdx + 1 < exercises.length) {
const nextEx = exercises[state.exerciseIdx + 1];
const nextIdx = state.exerciseIdx + 1;
const nextEx = exercises[nextIdx];
return {
phase: 'active',
session: state.session,
exerciseIdx: state.exerciseIdx + 1,
exerciseIdx: nextIdx,
setIdx: 0,
elapsed: state.elapsed,
setElapsed: 0,
lastSetStats,
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 };
@@ -292,6 +326,9 @@ export default function ActiveSession() {
}, [id, state]);
const [showAbandonModal, setShowAbandonModal] = useState(false);
const [showBackModal, setShowBackModal] = useState(false);
const handleTogglePause = useCallback(() => dispatch({ type: 'TOGGLE_PAUSE' }), []);
const handleAbandon = useCallback(async () => {
if (!id) return;
@@ -368,10 +405,19 @@ export default function ActiveSession() {
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 */}
<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
</button>
<ProgressBar
@@ -425,7 +471,7 @@ export default function ActiveSession() {
)}
</div>
<ElapsedTimerCircle seconds={state.setElapsed} />
<ElapsedTimerCircle seconds={state.setElapsed} paused={state.paused} onClick={handleTogglePause} />
{/* Total */}
<div className="flex-1 flex flex-col items-end gap-1.5">
@@ -484,9 +530,18 @@ export default function ActiveSession() {
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">
<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
</button>
<div className="text-lg font-mono font-bold text-content">
@@ -494,8 +549,8 @@ export default function ActiveSession() {
</div>
</div>
<h2 className="text-3xl font-bold text-content">REST</h2>
<RestCountdown remaining={state.restRemaining} total={state.totalRest} />
<h2 className="text-3xl font-bold text-content">{state.paused ? 'PAUSED' : 'REST'}</h2>
<RestCountdown remaining={state.restRemaining} total={state.totalRest} paused={state.paused} onClick={handleTogglePause} />
<p className="text-content-muted text-sm text-center">{nextLabel}</p>
<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 { 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 { Heading, Subheading } from '../components/catalyst/heading';
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 { RoutineWizard } from '../components/kettlebell/RoutineWizard';
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> = {
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',
@@ -24,12 +20,8 @@ export default function Kettlebell() {
const navigate = useNavigate();
const [sessions, setSessions] = useState<KettlebellSession[]>([]);
const [selected, setSelected] = useState<KettlebellSession | null>(null);
const [loading, setLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<KettlebellSession | null>(null);
const [focus, setFocus] = useState('strength');
const [duration, setDuration] = useState(30);
const [weightsInput, setWeightsInput] = useState('16, 24, 32');
const [showWizard, setShowWizard] = useState(false);
useEffect(() => {
getSessions().then(data => {
@@ -62,22 +54,10 @@ export default function Kettlebell() {
}
};
const handleGenerate = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
try {
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 handleWizardSaved = (session: KettlebellSession) => {
setSessions(prev => [session, ...prev]);
setSelected(session);
setShowWizard(false);
};
const exercises = selected?.exercises?.exercises ?? [];
@@ -105,52 +85,19 @@ export default function Kettlebell() {
{/* Left Column */}
<div className="space-y-6 lg:col-span-1">
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
<Subheading className="mb-6 text-primary">Generate Session</Subheading>
<form onSubmit={handleGenerate} className="space-y-4">
<Field>
<Label>Focus</Label>
<select
value={focus}
onChange={e => setFocus(e.target.value)}
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>
<Subheading className="mb-4 text-primary">Generate Session</Subheading>
<p className="text-sm text-content-muted mb-4">
Create an AI-powered kettlebell routine. You can refine it until it's exactly what you want.
</p>
<Button color="dark/zinc" className="w-full" onClick={() => setShowWizard(true)}>
Create Routine
</Button>
</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">
<Subheading className="mb-4">History</Subheading>

View File

@@ -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;