- 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>
247 lines
7.5 KiB
TypeScript
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'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's get started!
|
|
</p>
|
|
<Button
|
|
variant="primary"
|
|
size="lg"
|
|
loading={generating}
|
|
onClick={handleGenerate}
|
|
>
|
|
Generate Plan
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</AuthGuard>
|
|
);
|
|
}
|