- 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>
228 lines
7.2 KiB
TypeScript
228 lines
7.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { AuthGuard } from "@/components/auth/AuthGuard";
|
|
import { Navbar } from "@/components/layout/Navbar";
|
|
import { BottomNav } from "@/components/layout/BottomNav";
|
|
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";
|
|
|
|
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 PreferencesPage() {
|
|
const router = useRouter();
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState("");
|
|
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();
|
|
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 handleSave = async () => {
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
await api.updatePreferences({ ...preferences });
|
|
router.push("/dashboard");
|
|
} catch (err) {
|
|
console.error("Failed to save preferences:", err);
|
|
setError("Failed to save preferences. Please try again.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AuthGuard>
|
|
<Navbar />
|
|
<BottomNav />
|
|
<main className="pt-20 pb-28 px-4 max-w-2xl mx-auto">
|
|
<h1 className="text-2xl font-bold text-zinc-100 mb-8">Preferences</h1>
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Spinner size="lg" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-12">
|
|
{/* 1. Equipment */}
|
|
<section>
|
|
<EquipmentStep
|
|
selectedIds={preferences.equipment_ids}
|
|
onChange={(ids) => updatePreferences({ equipment_ids: ids })}
|
|
/>
|
|
</section>
|
|
|
|
<hr className="border-zinc-800" />
|
|
|
|
{/* 2. Goals */}
|
|
<section>
|
|
<GoalsStep
|
|
fitnessLevel={preferences.fitness_level}
|
|
primaryGoal={preferences.primary_goal}
|
|
secondaryGoal={preferences.secondary_goal}
|
|
onChange={(data) => updatePreferences(data)}
|
|
/>
|
|
</section>
|
|
|
|
<hr className="border-zinc-800" />
|
|
|
|
{/* 3. Workout Types */}
|
|
<section>
|
|
<WorkoutTypesStep
|
|
selectedIds={preferences.workout_type_ids}
|
|
onChange={(ids) =>
|
|
updatePreferences({ workout_type_ids: ids })
|
|
}
|
|
/>
|
|
</section>
|
|
|
|
<hr className="border-zinc-800" />
|
|
|
|
{/* 4. Schedule */}
|
|
<section>
|
|
<ScheduleStep
|
|
daysPerWeek={preferences.days_per_week}
|
|
preferredDays={preferences.preferred_days}
|
|
onChange={(data) => updatePreferences(data)}
|
|
/>
|
|
</section>
|
|
|
|
<hr className="border-zinc-800" />
|
|
|
|
{/* 5. Duration */}
|
|
<section>
|
|
<DurationStep
|
|
duration={preferences.preferred_workout_duration}
|
|
onChange={(min) =>
|
|
updatePreferences({ preferred_workout_duration: min })
|
|
}
|
|
/>
|
|
</section>
|
|
|
|
<hr className="border-zinc-800" />
|
|
|
|
{/* 6. Target Muscles */}
|
|
<section>
|
|
<MusclesStep
|
|
selectedIds={preferences.muscle_ids}
|
|
onChange={(ids) => updatePreferences({ muscle_ids: ids })}
|
|
/>
|
|
</section>
|
|
|
|
<hr className="border-zinc-800" />
|
|
|
|
{/* 7. Injuries */}
|
|
<section>
|
|
<InjuryStep
|
|
injuryTypes={preferences.injury_types}
|
|
onChange={(injuries) =>
|
|
updatePreferences({ injury_types: injuries })
|
|
}
|
|
/>
|
|
</section>
|
|
|
|
<hr className="border-zinc-800" />
|
|
|
|
{/* 8. Excluded Exercises */}
|
|
<section>
|
|
<ExcludedExercisesStep
|
|
selectedIds={preferences.excluded_exercise_ids}
|
|
onChange={(ids) =>
|
|
updatePreferences({ excluded_exercise_ids: ids })
|
|
}
|
|
/>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sticky save bar */}
|
|
{!loading && (
|
|
<div className="fixed bottom-16 md:bottom-0 left-0 right-0 z-40 bg-zinc-950/95 backdrop-blur border-t border-zinc-800 px-4 py-3">
|
|
<div className="max-w-2xl mx-auto flex items-center justify-between">
|
|
<div className="text-sm">
|
|
{error && (
|
|
<span className="text-red-400">{error}</span>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="primary"
|
|
onClick={handleSave}
|
|
loading={saving}
|
|
>
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</AuthGuard>
|
|
);
|
|
}
|