workout generator audit: rules engine, structure rules, split patterns, injury UX, metadata cleanup
- Add rules_engine.py with quantitative rules for all 8 workout types - Add quality gate retry loop in generate_single_workout() - Expand calibrate_structure_rules to all 120 combinations (8 types × 5 goals × 3 sections) - Wire WeeklySplitPattern DB records into _pick_weekly_split() - Enforce movement patterns from WorkoutStructureRule in exercise selection - Add straight-set strength support (single main lift, 4-6 rounds) - Add modality consistency check for duration-dominant workout types - Add InjuryStep component to onboarding and preferences - Add sibling exercise exclusion in regenerate and preview_day endpoints - Display generator warnings on dashboard - Expand fix_rep_durations, fix_exercise_flags, fix_movement_pattern_typo - Add audit_exercise_data and check_rules_drift management commands - Add Next.js frontend with dashboard, onboarding, preferences, history pages - Add generator app with ML-powered workout generation pipeline - 96 new tests across 7 test modules Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
262
werkout-frontend/app/onboarding/page.tsx
Normal file
262
werkout-frontend/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AuthGuard } from "@/components/auth/AuthGuard";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { api } from "@/lib/api";
|
||||
import { EquipmentStep } from "@/components/onboarding/EquipmentStep";
|
||||
import { GoalsStep } from "@/components/onboarding/GoalsStep";
|
||||
import { WorkoutTypesStep } from "@/components/onboarding/WorkoutTypesStep";
|
||||
import { ScheduleStep } from "@/components/onboarding/ScheduleStep";
|
||||
import { DurationStep } from "@/components/onboarding/DurationStep";
|
||||
import { MusclesStep } from "@/components/onboarding/MusclesStep";
|
||||
import { InjuryStep } from "@/components/onboarding/InjuryStep";
|
||||
import { ExcludedExercisesStep } from "@/components/onboarding/ExcludedExercisesStep";
|
||||
import type { InjuryType } from "@/lib/types";
|
||||
|
||||
const STEP_LABELS = [
|
||||
"Equipment",
|
||||
"Goals",
|
||||
"Workout Types",
|
||||
"Schedule",
|
||||
"Duration",
|
||||
"Muscles",
|
||||
"Injuries",
|
||||
"Excluded Exercises",
|
||||
];
|
||||
|
||||
interface PreferencesData {
|
||||
equipment_ids: number[];
|
||||
muscle_ids: number[];
|
||||
workout_type_ids: number[];
|
||||
fitness_level: number;
|
||||
primary_goal: string;
|
||||
secondary_goal: string;
|
||||
days_per_week: number;
|
||||
preferred_workout_duration: number;
|
||||
preferred_days: number[];
|
||||
injury_types: InjuryType[];
|
||||
excluded_exercise_ids: number[];
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasExistingPrefs, setHasExistingPrefs] = useState(false);
|
||||
const [preferences, setPreferences] = useState<PreferencesData>({
|
||||
equipment_ids: [],
|
||||
muscle_ids: [],
|
||||
workout_type_ids: [],
|
||||
fitness_level: 1,
|
||||
primary_goal: "general_fitness",
|
||||
secondary_goal: "",
|
||||
days_per_week: 4,
|
||||
preferred_workout_duration: 45,
|
||||
preferred_days: [],
|
||||
injury_types: [],
|
||||
excluded_exercise_ids: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchExisting() {
|
||||
try {
|
||||
const existing = await api.getPreferences();
|
||||
const hasPrefs =
|
||||
existing.available_equipment.length > 0 ||
|
||||
existing.preferred_workout_types.length > 0 ||
|
||||
existing.target_muscle_groups.length > 0;
|
||||
setHasExistingPrefs(hasPrefs);
|
||||
setPreferences({
|
||||
equipment_ids: existing.available_equipment.map((e) => e.id),
|
||||
muscle_ids: existing.target_muscle_groups.map((m) => m.id),
|
||||
workout_type_ids: existing.preferred_workout_types.map((w) => w.id),
|
||||
fitness_level: existing.fitness_level || 1,
|
||||
primary_goal: existing.primary_goal || "general_fitness",
|
||||
secondary_goal: existing.secondary_goal || "",
|
||||
days_per_week: existing.days_per_week || 4,
|
||||
preferred_workout_duration: existing.preferred_workout_duration || 45,
|
||||
preferred_days: existing.preferred_days || [],
|
||||
injury_types: existing.injury_types || [],
|
||||
excluded_exercise_ids: existing.excluded_exercises || [],
|
||||
});
|
||||
} catch {
|
||||
// No existing preferences - use defaults
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchExisting();
|
||||
}, []);
|
||||
|
||||
const updatePreferences = useCallback(
|
||||
(updates: Partial<PreferencesData>) => {
|
||||
setPreferences((prev) => ({ ...prev, ...updates }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleNext = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updatePreferences({ ...preferences });
|
||||
|
||||
if (currentStep === STEP_LABELS.length - 1) {
|
||||
router.push("/dashboard");
|
||||
} else {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to save preferences:", err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentStep((prev) => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const progressPercent = ((currentStep + 1) / STEP_LABELS.length) * 100;
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className="min-h-screen bg-zinc-950 flex flex-col">
|
||||
{/* Progress bar */}
|
||||
<div className="sticky top-0 z-10 bg-zinc-950 border-b border-zinc-800 px-4 py-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-zinc-400">
|
||||
Step {currentStep + 1} of {STEP_LABELS.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-zinc-100">
|
||||
{STEP_LABELS[currentStep]}
|
||||
</span>
|
||||
{hasExistingPrefs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
title="Back to dashboard"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentStep === 0 && (
|
||||
<EquipmentStep
|
||||
selectedIds={preferences.equipment_ids}
|
||||
onChange={(ids) => updatePreferences({ equipment_ids: ids })}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<GoalsStep
|
||||
fitnessLevel={preferences.fitness_level}
|
||||
primaryGoal={preferences.primary_goal}
|
||||
secondaryGoal={preferences.secondary_goal}
|
||||
onChange={(data) => updatePreferences(data)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<WorkoutTypesStep
|
||||
selectedIds={preferences.workout_type_ids}
|
||||
onChange={(ids) =>
|
||||
updatePreferences({ workout_type_ids: ids })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<ScheduleStep
|
||||
daysPerWeek={preferences.days_per_week}
|
||||
preferredDays={preferences.preferred_days}
|
||||
onChange={(data) => updatePreferences(data)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<DurationStep
|
||||
duration={preferences.preferred_workout_duration}
|
||||
onChange={(min) =>
|
||||
updatePreferences({ preferred_workout_duration: min })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 5 && (
|
||||
<MusclesStep
|
||||
selectedIds={preferences.muscle_ids}
|
||||
onChange={(ids) => updatePreferences({ muscle_ids: ids })}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 6 && (
|
||||
<InjuryStep
|
||||
injuryTypes={preferences.injury_types}
|
||||
onChange={(injuries) =>
|
||||
updatePreferences({ injury_types: injuries })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 7 && (
|
||||
<ExcludedExercisesStep
|
||||
selectedIds={preferences.excluded_exercise_ids}
|
||||
onChange={(ids) =>
|
||||
updatePreferences({ excluded_exercise_ids: ids })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
{!loading && (
|
||||
<div className="sticky bottom-0 bg-zinc-950 border-t border-zinc-800 px-4 py-4">
|
||||
<div className="max-w-2xl mx-auto flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleBack}
|
||||
disabled={currentStep === 0}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleNext}
|
||||
loading={saving}
|
||||
>
|
||||
{currentStep === STEP_LABELS.length - 1
|
||||
? "Finish"
|
||||
: "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user