mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 07:28:47 +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:
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
*,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user