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:
Trey t
2026-02-22 20:07:40 -06:00
parent 2a16b75c4b
commit 1c61b80731
111 changed files with 28108 additions and 30 deletions

View File

@@ -0,0 +1,246 @@
"use client";
import { useEffect, useState, 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 { WeeklyPlanGrid } from "@/components/plans/WeeklyPlanGrid";
import { WeekPicker } from "@/components/plans/WeekPicker";
import { Button } from "@/components/ui/Button";
import { Spinner } from "@/components/ui/Spinner";
import { api } from "@/lib/api";
import type { GeneratedWeeklyPlan, WeeklyPreview } from "@/lib/types";
function getCurrentMonday(): string {
const now = new Date();
const day = now.getDay();
const diff = day === 0 ? -6 : 1 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + diff);
const yyyy = monday.getFullYear();
const mm = String(monday.getMonth() + 1).padStart(2, "0");
const dd = String(monday.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
export default function DashboardPage() {
const router = useRouter();
const [selectedMonday, setSelectedMonday] = useState(getCurrentMonday);
const [plans, setPlans] = useState<GeneratedWeeklyPlan[]>([]);
const [preview, setPreview] = useState<WeeklyPreview | null>(null);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [saving, setSaving] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState("");
const fetchPlans = useCallback(async () => {
try {
try {
const prefs = await api.getPreferences();
const hasPrefs =
prefs.available_equipment.length > 0 ||
prefs.preferred_workout_types.length > 0 ||
prefs.target_muscle_groups.length > 0;
if (!hasPrefs) {
router.replace("/onboarding");
return;
}
} catch {
router.replace("/onboarding");
return;
}
const data = await api.getPlans();
setPlans(data);
} catch (err) {
console.error("Failed to fetch plans:", err);
} finally {
setLoading(false);
}
}, [router]);
useEffect(() => {
fetchPlans();
}, [fetchPlans]);
// Clear preview when week changes
useEffect(() => {
setPreview(null);
}, [selectedMonday]);
const savedPlan = plans.find((p) => p.week_start_date === selectedMonday);
const handleGenerate = async () => {
setGenerating(true);
setError("");
try {
const data = await api.previewPlan(selectedMonday);
setPreview(data);
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to generate preview";
setError(msg);
console.error("Failed to generate preview:", err);
} finally {
setGenerating(false);
}
};
const handleConfirm = async () => {
if (!savedPlan) return;
setConfirming(true);
setError("");
try {
await api.confirmPlan(savedPlan.id);
await fetchPlans();
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to confirm plan";
setError(msg);
} finally {
setConfirming(false);
}
};
const handleSave = async () => {
if (!preview) return;
setSaving(true);
setError("");
try {
await api.savePlan(preview);
setPreview(null);
await fetchPlans();
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save plan";
setError(msg);
console.error("Failed to save plan:", err);
} finally {
setSaving(false);
}
};
return (
<AuthGuard>
<Navbar />
<BottomNav />
<main className="pt-20 pb-20 px-4 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-zinc-100">Dashboard</h1>
<WeekPicker
selectedMonday={selectedMonday}
onChange={setSelectedMonday}
/>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
{preview?.warnings && preview.warnings.length > 0 && (
<div className="mb-4 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-yellow-300 text-sm">
<div className="font-medium mb-1">Heads up</div>
<ul className="list-disc list-inside space-y-0.5">
{preview.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
) : preview ? (
/* ===== Preview mode ===== */
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-zinc-200">
Preview
</h2>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setPreview(null)}
>
Discard
</Button>
<Button
variant="secondary"
size="sm"
loading={generating}
onClick={handleGenerate}
>
Regenerate
</Button>
<Button
variant="primary"
size="sm"
loading={saving}
onClick={handleSave}
>
Save Plan
</Button>
</div>
</div>
<WeeklyPlanGrid
preview={preview}
onPreviewChange={setPreview}
/>
</div>
) : savedPlan ? (
/* ===== Saved plan mode ===== */
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-zinc-200">
This Week&apos;s Plan
</h2>
<div className="flex gap-2">
{savedPlan.generated_workouts.some(
(w) => !w.is_rest_day && w.status === "pending"
) && (
<Button
variant="primary"
size="sm"
loading={confirming}
onClick={handleConfirm}
>
Save to Calendar
</Button>
)}
<Button
variant="secondary"
size="sm"
loading={generating}
onClick={handleGenerate}
>
Regenerate
</Button>
</div>
</div>
<WeeklyPlanGrid plan={savedPlan} onUpdate={fetchPlans} />
</div>
) : (
/* ===== No plan ===== */
<div className="flex flex-col items-center justify-center py-20 gap-6">
<p className="text-zinc-400 text-lg text-center">
No plan for this week yet. Let&apos;s get started!
</p>
<Button
variant="primary"
size="lg"
loading={generating}
onClick={handleGenerate}
>
Generate Plan
</Button>
</div>
)}
</main>
</AuthGuard>
);
}

View File

@@ -0,0 +1,44 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Range slider custom styling */
input[type="range"].range-slider {
-webkit-appearance: none;
appearance: none;
accent-color: #39ff14;
}
input[type="range"].range-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #39ff14;
cursor: pointer;
border: 2px solid #09090b;
box-shadow: 0 0 6px rgba(57, 255, 20, 0.4);
}
input[type="range"].range-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #39ff14;
cursor: pointer;
border: 2px solid #09090b;
box-shadow: 0 0 6px rgba(57, 255, 20, 0.4);
}
input[type="range"].range-slider::-webkit-slider-runnable-track {
height: 8px;
border-radius: 9999px;
background: #3f3f46;
}
input[type="range"].range-slider::-moz-range-track {
height: 8px;
border-radius: 9999px;
background: #3f3f46;
}

View File

@@ -0,0 +1,157 @@
"use client";
import { useEffect, useState } from "react";
import { AuthGuard } from "@/components/auth/AuthGuard";
import { Navbar } from "@/components/layout/Navbar";
import { BottomNav } from "@/components/layout/BottomNav";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Spinner } from "@/components/ui/Spinner";
import { api } from "@/lib/api";
import { DIFFICULTY_LABELS } from "@/lib/types";
import type { CompletedWorkout } from "@/lib/types";
function formatDuration(seconds: number | null): string {
if (!seconds) return "N/A";
const hours = Math.floor(seconds / 3600);
const mins = Math.round((seconds % 3600) / 60);
if (hours > 0) {
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
return `${mins}m`;
}
function formatTotalHours(seconds: number): string {
const hours = (seconds / 3600).toFixed(1);
return `${hours}h`;
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
}
function getDifficultyVariant(
difficulty: number
): "default" | "success" | "warning" | "error" | "accent" {
if (difficulty <= 1) return "success";
if (difficulty <= 3) return "warning";
return "error";
}
export default function HistoryPage() {
const [workouts, setWorkouts] = useState<CompletedWorkout[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.getCompletedWorkouts()
.then((data) => {
const sorted = [...data].sort(
(a, b) =>
new Date(b.workout_start_time).getTime() -
new Date(a.workout_start_time).getTime()
);
setWorkouts(sorted);
})
.catch((err) =>
console.error("Failed to fetch completed workouts:", err)
)
.finally(() => setLoading(false));
}, []);
const totalWorkouts = workouts.length;
const totalTime = workouts.reduce((sum, w) => sum + (w.total_time || 0), 0);
const avgDifficulty =
totalWorkouts > 0
? workouts.reduce((sum, w) => sum + w.difficulty, 0) / totalWorkouts
: 0;
return (
<AuthGuard>
<Navbar />
<BottomNav />
<main className="pt-20 pb-20 px-4 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-zinc-100 mb-6">History</h1>
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
) : workouts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-zinc-400 text-lg text-center">
No completed workouts yet.
</p>
</div>
) : (
<div>
{/* Summary Stats */}
<div className="grid grid-cols-3 gap-3 mb-8">
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[#39FF14]">
{totalWorkouts}
</p>
<p className="text-xs text-zinc-500 mt-1">Total Workouts</p>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[#39FF14]">
{avgDifficulty.toFixed(1)}
</p>
<p className="text-xs text-zinc-500 mt-1">Avg Difficulty</p>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[#39FF14]">
{formatTotalHours(totalTime)}
</p>
<p className="text-xs text-zinc-500 mt-1">Total Time</p>
</Card>
</div>
{/* Workout List */}
<div className="flex flex-col gap-3">
{workouts.map((cw) => (
<Card key={cw.id} className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-zinc-100 truncate">
{cw.workout.name}
</h3>
<p className="text-xs text-zinc-500 mt-0.5">
{formatDate(cw.workout_start_time)}
</p>
</div>
<Badge variant={getDifficultyVariant(cw.difficulty)}>
{DIFFICULTY_LABELS[cw.difficulty] || "N/A"}
</Badge>
</div>
<div className="flex items-center gap-4 text-xs text-zinc-400">
<span>{formatDuration(cw.total_time)}</span>
{cw.workout.exercise_count > 0 && (
<span>
{cw.workout.exercise_count} exercise
{cw.workout.exercise_count !== 1 ? "s" : ""}
</span>
)}
</div>
{cw.notes && (
<p className="text-xs text-zinc-500 mt-2 italic">
{cw.notes}
</p>
)}
</Card>
))}
</div>
</div>
)}
</main>
</AuthGuard>
);
}

View File

@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { AuthProvider } from "@/lib/auth";
import "@/app/globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Werkout",
description:
"AI-powered workout generator. Build personalized training plans based on your history and preferences.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body
className={`bg-zinc-950 text-zinc-100 antialiased ${inter.className}`}
>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/Button";
import { Spinner } from "@/components/ui/Spinner";
export default function LoginPage() {
const { user, loading: authLoading, login, register } = useAuth();
const router = useRouter();
const [isRegister, setIsRegister] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
// Redirect if already logged in
if (!authLoading && user) {
router.replace("/dashboard");
return null;
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
if (isRegister) {
await register(email, password, firstName, lastName);
} else {
await login(email, password);
}
router.push("/dashboard");
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError("Something went wrong. Please try again.");
}
} finally {
setLoading(false);
}
}
if (authLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Spinner size="lg" />
</div>
);
}
return (
<div className="flex items-center justify-center min-h-screen px-4">
<div className="w-full max-w-md">
<div className="bg-zinc-900 border border-zinc-700/50 rounded-xl p-8">
{/* Title */}
<h1 className="text-3xl font-black text-center text-accent tracking-wider mb-8">
WERKOUT
</h1>
{/* Error */}
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{isRegister && (
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
First Name
</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-2.5 text-sm
placeholder:text-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30
transition-colors duration-150"
placeholder="First name"
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
Last Name
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-2.5 text-sm
placeholder:text-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30
transition-colors duration-150"
placeholder="Last name"
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
Email or Username
</label>
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-2.5 text-sm
placeholder:text-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30
transition-colors duration-150"
placeholder="Email or username"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-2.5 text-sm
placeholder:text-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30
transition-colors duration-150"
placeholder="Enter password"
/>
</div>
<Button
type="submit"
variant="primary"
size="lg"
loading={loading}
className="mt-2 w-full"
>
{isRegister ? "Create Account" : "Log In"}
</Button>
</form>
{/* Toggle */}
<p className="mt-6 text-center text-sm text-zinc-400">
{isRegister
? "Already have an account? "
: "Don't have an account? "}
<button
type="button"
onClick={() => {
setIsRegister(!isRegister);
setError("");
}}
className="text-accent hover:underline font-medium"
>
{isRegister ? "Log In" : "Register"}
</button>
</p>
</div>
</div>
</div>
);
}

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

View File

@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { Spinner } from "@/components/ui/Spinner";
export default function HomePage() {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading) {
if (user) {
router.replace("/dashboard");
} else {
router.replace("/login");
}
}
}, [user, loading, router]);
return (
<div className="flex items-center justify-center min-h-screen">
<Spinner size="lg" />
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import Link from "next/link";
import { AuthGuard } from "@/components/auth/AuthGuard";
import { Navbar } from "@/components/layout/Navbar";
import { BottomNav } from "@/components/layout/BottomNav";
import { WeeklyPlanGrid } from "@/components/plans/WeeklyPlanGrid";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Spinner } from "@/components/ui/Spinner";
import { api } from "@/lib/api";
import type { GeneratedWeeklyPlan } from "@/lib/types";
function formatDate(dateStr: string): string {
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function getStatusVariant(
status: string
): "success" | "warning" | "error" | "default" {
switch (status) {
case "completed":
return "success";
case "pending":
return "warning";
case "failed":
return "error";
default:
return "default";
}
}
export default function PlanDetailPage({
params,
}: {
params: { planId: string };
}) {
const { planId } = params;
const [plan, setPlan] = useState<GeneratedWeeklyPlan | null>(null);
const [loading, setLoading] = useState(true);
const fetchPlan = useCallback(async () => {
try {
const data = await api.getPlan(Number(planId));
setPlan(data);
} catch (err) {
console.error("Failed to fetch plan:", err);
} finally {
setLoading(false);
}
}, [planId]);
useEffect(() => {
fetchPlan();
}, [fetchPlan]);
return (
<AuthGuard>
<Navbar />
<BottomNav />
<main className="pt-20 pb-20 px-4 max-w-5xl mx-auto">
<Link
href="/plans"
className="inline-flex items-center gap-1 text-sm text-zinc-400 hover:text-zinc-100 transition-colors mb-4"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Plans
</Link>
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
) : !plan ? (
<div className="text-center py-20">
<p className="text-zinc-400">Plan not found.</p>
</div>
) : (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-zinc-100 mb-1">
{formatDate(plan.week_start_date)} &ndash;{" "}
{formatDate(plan.week_end_date)}
</h1>
<Badge variant={getStatusVariant(plan.status)}>
{plan.status}
</Badge>
</div>
<Button variant="secondary" size="sm" onClick={fetchPlan}>
Refresh
</Button>
</div>
<WeeklyPlanGrid plan={plan} onUpdate={fetchPlan} />
</div>
)}
</main>
</AuthGuard>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AuthGuard } from "@/components/auth/AuthGuard";
import { Navbar } from "@/components/layout/Navbar";
import { BottomNav } from "@/components/layout/BottomNav";
import { PlanCard } from "@/components/plans/PlanCard";
import { Spinner } from "@/components/ui/Spinner";
import { api } from "@/lib/api";
import type { GeneratedWeeklyPlan } from "@/lib/types";
export default function PlansPage() {
const [plans, setPlans] = useState<GeneratedWeeklyPlan[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.getPlans()
.then((data) => {
const sorted = [...data].sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
setPlans(sorted);
})
.catch((err) => console.error("Failed to fetch plans:", err))
.finally(() => setLoading(false));
}, []);
return (
<AuthGuard>
<Navbar />
<BottomNav />
<main className="pt-20 pb-20 px-4 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-zinc-100 mb-6">Plans</h1>
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
) : plans.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<p className="text-zinc-400 text-lg text-center">
No plans generated yet.
</p>
<Link
href="/dashboard"
className="text-[#39FF14] hover:underline text-sm font-medium"
>
Go to Dashboard to generate one
</Link>
</div>
) : (
<div className="flex flex-col gap-3">
{plans.map((plan) => (
<PlanCard key={plan.id} plan={plan} />
))}
</div>
)}
</main>
</AuthGuard>
);
}

View File

@@ -0,0 +1,227 @@
"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>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import { AuthGuard } from "@/components/auth/AuthGuard";
import { Navbar } from "@/components/layout/Navbar";
import { BottomNav } from "@/components/layout/BottomNav";
import { Spinner } from "@/components/ui/Spinner";
import { api } from "@/lib/api";
interface Rule {
value: unknown;
description: string;
category: string;
}
const CATEGORY_LABELS: Record<string, string> = {
rep_floors: "Rep Floors",
duration: "Duration",
superset: "Superset Structure",
coherence: "Workout Coherence",
};
const CATEGORY_ORDER = ["rep_floors", "duration", "superset", "coherence"];
function formatValue(value: unknown): string {
if (typeof value === "boolean") return value ? "Yes" : "No";
if (typeof value === "number") return String(value);
if (typeof value === "string") return value.replace(/_/g, " ");
return String(value);
}
export default function RulesPage() {
const [rules, setRules] = useState<Record<string, Rule> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
async function fetchRules() {
try {
const data = await api.getRules();
setRules(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load rules");
} finally {
setLoading(false);
}
}
fetchRules();
}, []);
// Group rules by category
const grouped: Record<string, [string, Rule][]> = {};
if (rules) {
for (const [key, rule] of Object.entries(rules)) {
const cat = rule.category;
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push([key, rule]);
}
}
const sortedCategories = CATEGORY_ORDER.filter((c) => grouped[c]);
return (
<AuthGuard>
<Navbar />
<BottomNav />
<main className="pt-20 pb-20 px-4 max-w-3xl mx-auto">
<h1 className="text-2xl font-bold text-zinc-100 mb-6">
Generation Rules
</h1>
<p className="text-zinc-400 text-sm mb-8">
These guardrails are enforced during workout generation to ensure
quality and coherence.
</p>
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
) : error ? (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
) : (
<div className="space-y-6">
{sortedCategories.map((category) => (
<div
key={category}
className="rounded-xl border border-zinc-800 bg-zinc-900/50 overflow-hidden"
>
<div className="px-4 py-3 bg-zinc-800/50 border-b border-zinc-800">
<h2 className="text-sm font-semibold text-zinc-200 uppercase tracking-wide">
{CATEGORY_LABELS[category] || category}
</h2>
</div>
<div className="divide-y divide-zinc-800/50">
{grouped[category].map(([key, rule]) => (
<div
key={key}
className="px-4 py-3 flex items-center justify-between gap-4"
>
<div className="min-w-0">
<p className="text-sm text-zinc-200">
{rule.description}
</p>
<p className="text-xs text-zinc-500 mt-0.5 font-mono">
{key}
</p>
</div>
<span className="shrink-0 text-sm font-medium text-[#39FF14] bg-[#39FF14]/10 px-2.5 py-1 rounded-md">
{formatValue(rule.value)}
</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</main>
</AuthGuard>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AuthGuard } from "@/components/auth/AuthGuard";
import { Navbar } from "@/components/layout/Navbar";
import { BottomNav } from "@/components/layout/BottomNav";
import { SupersetCard } from "@/components/workout/SupersetCard";
import { Spinner } from "@/components/ui/Spinner";
import { api } from "@/lib/api";
import type { WorkoutDetail } from "@/lib/types";
function formatTime(seconds: number | null): string {
if (!seconds) return "N/A";
const mins = Math.round(seconds / 60);
if (mins >= 60) {
const h = Math.floor(mins / 60);
const m = mins % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
return `${mins}m`;
}
export default function WorkoutDetailPage({
params,
}: {
params: { workoutId: string };
}) {
const { workoutId } = params;
const [workout, setWorkout] = useState<WorkoutDetail | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.getWorkoutDetail(Number(workoutId))
.then(setWorkout)
.catch((err) => console.error("Failed to fetch workout:", err))
.finally(() => setLoading(false));
}, [workoutId]);
return (
<AuthGuard>
<Navbar />
<BottomNav />
<main className="pt-20 pb-20 px-4 max-w-4xl mx-auto">
<Link
href="/dashboard"
className="inline-flex items-center gap-1 text-sm text-zinc-400 hover:text-zinc-100 transition-colors mb-4"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
Back
</Link>
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
) : !workout ? (
<div className="text-center py-20">
<p className="text-zinc-400">Workout not found.</p>
</div>
) : (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-zinc-100 mb-2">
{workout.name}
</h1>
{workout.description && (
<p className="text-sm text-zinc-400 mb-3">
{workout.description}
</p>
)}
<div className="flex items-center gap-4 text-sm text-zinc-400">
<span className="flex items-center gap-1.5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{formatTime(workout.estimated_time)}
</span>
<span>
{workout.supersets.length} superset
{workout.supersets.length !== 1 ? "s" : ""}
</span>
</div>
</div>
<div className="flex flex-col gap-4">
{[...workout.supersets]
.sort((a, b) => a.order - b.order)
.map((superset, i) => (
<SupersetCard
key={superset.id}
superset={superset}
defaultOpen={i === 0}
/>
))}
</div>
</div>
)}
</main>
</AuthGuard>
);
}