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

- Interactive routine generation wizard with AI refinement loop (generate-draft,
  refine, save-draft endpoints + RoutineWizard modal component)
- Replace +/- stepper buttons with slider controls for reps/weight during workout
- Persist user-modified reps/weight across sets of the same exercise
- Add pause/resume by tapping timer dials, with back-button confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-03-20 22:29:06 -06:00
parent d7c1f8f677
commit 11e086166c
10 changed files with 613 additions and 130 deletions

View File

@@ -55,3 +55,47 @@ class KettlebellModule(dspy.Module):
kettlebell_module = KettlebellModule()
class RefineKettlebellSession(dspy.Signature):
"""Refine an existing kettlebell workout session based on user feedback.
You are given the current session and the user's feedback. Apply the requested changes
while keeping the rest of the session intact. Maintain proper exercise sequencing,
warm-up/cooldown structure, and ensure total work time still fits the target duration.
"""
current_session: str = dspy.InputField(desc="JSON representation of the current session")
user_feedback: str = dspy.InputField(desc="User's requested changes to the session")
user_profile: str = dspy.InputField(desc="User details including age, weight, fitness level, and goals")
available_weights_kg: str = dspy.InputField(desc="Comma-separated list of available kettlebell weights in kg")
focus: str = dspy.InputField(desc="Session focus: strength, conditioning, mobility, fat loss, etc.")
duration_minutes: int = dspy.InputField(desc="Target session duration in minutes")
session: KettlebellSessionOutput = dspy.OutputField(desc="Refined structured kettlebell session")
class KettlebellRefineModule(dspy.Module):
def __init__(self):
super().__init__()
self.refine = dspy.ChainOfThought(RefineKettlebellSession)
def forward(
self,
current_session: str,
user_feedback: str,
user_profile: str,
available_weights_kg: str,
focus: str,
duration_minutes: int,
):
return self.refine(
current_session=current_session,
user_feedback=user_feedback,
user_profile=user_profile,
available_weights_kg=available_weights_kg,
focus=focus,
duration_minutes=duration_minutes,
)
kettlebell_refine_module = KettlebellRefineModule()

View File

@@ -1,3 +1,4 @@
import json
from collections import defaultdict
from 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(
*,