Files
Trey t 1c61b80731 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>
2026-02-22 20:07:40 -06:00

247 lines
7.5 KiB
TypeScript

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