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,35 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { Spinner } from "@/components/ui/Spinner";
interface AuthGuardProps {
children: ReactNode;
}
export function AuthGuard({ children }: AuthGuardProps) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.replace("/login");
}
}, [loading, user, router]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Spinner size="lg" />
</div>
);
}
if (!user) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,137 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const tabs = [
{
href: "/dashboard",
label: "Dashboard",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
</svg>
),
},
{
href: "/plans",
label: "Plans",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
),
},
{
href: "/preferences",
label: "Prefs",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
),
},
{
href: "/rules",
label: "Rules",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
),
},
{
href: "/history",
label: "History",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
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>
),
},
];
export function BottomNav() {
const pathname = usePathname();
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 h-16 pb-safe bg-zinc-900 border-t border-zinc-800 md:hidden">
<div className="flex items-center justify-around h-full">
{tabs.map((tab) => {
const isActive =
pathname === tab.href || pathname.startsWith(tab.href + "/");
return (
<Link
key={tab.href}
href={tab.href}
className={`flex flex-col items-center gap-1 text-xs font-medium transition-colors duration-150 ${
isActive ? "text-[#39FF14]" : "text-zinc-500"
}`}
>
{tab.icon}
<span>{tab.label}</span>
</Link>
);
})}
</div>
</nav>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/Button";
const navLinks = [
{ href: "/dashboard", label: "Dashboard" },
{ href: "/plans", label: "Plans" },
{ href: "/rules", label: "Rules" },
{ href: "/history", label: "History" },
];
export function Navbar() {
const pathname = usePathname();
const router = useRouter();
const { user, logout } = useAuth();
const handleLogout = () => {
logout();
router.push("/login");
};
return (
<nav className="fixed top-0 left-0 right-0 z-50 h-16 bg-zinc-900/80 backdrop-blur border-b border-zinc-800 px-6 flex items-center justify-between">
<Link href="/dashboard" className="text-[#39FF14] font-bold text-xl tracking-tight">
WERKOUT
</Link>
<div className="hidden md:flex items-center gap-6">
{navLinks.map((link) => {
const isActive =
pathname === link.href || pathname.startsWith(link.href + "/");
return (
<Link
key={link.href}
href={link.href}
className={`text-sm font-medium transition-colors duration-150 ${
isActive
? "text-[#39FF14]"
: "text-zinc-400 hover:text-zinc-100"
}`}
>
{link.label}
</Link>
);
})}
</div>
<div className="flex items-center gap-3">
{user && (
<span className="text-sm text-zinc-300 hidden sm:inline">
{user.first_name}
</span>
)}
<Link
href="/preferences"
className={`text-sm font-medium transition-colors duration-150 ${
pathname === "/preferences"
? "text-[#39FF14]"
: "text-zinc-400 hover:text-zinc-100"
}`}
>
Preferences
</Link>
<Button variant="ghost" size="sm" onClick={handleLogout}>
Logout
</Button>
</div>
</nav>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { Slider } from "@/components/ui/Slider";
interface DurationStepProps {
duration: number;
onChange: (min: number) => void;
}
export function DurationStep({ duration, onChange }: DurationStepProps) {
return (
<div>
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
Workout Duration
</h2>
<p className="text-zinc-400 mb-10">
How long do you want each workout to be? This is the total time
including warm-up, working sets, and rest periods.
</p>
{/* Big centered display */}
<div className="flex flex-col items-center justify-center py-12">
<div className="text-7xl font-bold text-accent tabular-nums mb-2">
{duration}
</div>
<div className="text-lg text-zinc-400">minutes</div>
</div>
{/* Slider */}
<div className="max-w-md mx-auto">
<Slider
min={20}
max={90}
value={duration}
onChange={onChange}
step={5}
label="Duration"
unit="min"
/>
</div>
{/* Duration guide */}
<div className="mt-10 grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-sm font-medium text-zinc-300">Quick</div>
<div className="text-xs text-zinc-500">20-30 min</div>
</div>
<div>
<div className="text-sm font-medium text-zinc-300">Standard</div>
<div className="text-xs text-zinc-500">40-60 min</div>
</div>
<div>
<div className="text-sm font-medium text-zinc-300">Extended</div>
<div className="text-xs text-zinc-500">70-90 min</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useState, useEffect } from "react";
import { api } from "@/lib/api";
import { Card } from "@/components/ui/Card";
import { Spinner } from "@/components/ui/Spinner";
import type { Equipment } from "@/lib/types";
interface EquipmentStepProps {
selectedIds: number[];
onChange: (ids: number[]) => void;
}
export function EquipmentStep({ selectedIds, onChange }: EquipmentStepProps) {
const [equipment, setEquipment] = useState<Equipment[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetch() {
try {
const data = await api.getEquipment();
setEquipment(data);
} catch (err) {
console.error("Failed to fetch equipment:", err);
} finally {
setLoading(false);
}
}
fetch();
}, []);
const toggle = (id: number) => {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((i) => i !== id));
} else {
onChange([...selectedIds, id]);
}
};
// Group by category
const grouped = equipment.reduce<Record<string, Equipment[]>>(
(acc, item) => {
const cat = item.category || "Other";
if (!acc[cat]) acc[cat] = [];
acc[cat].push(item);
return acc;
},
{}
);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
);
}
return (
<div>
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
What equipment do you have?
</h2>
<p className="text-zinc-400 mb-6">
Select all the equipment you have access to. This helps us build
workouts tailored to your setup.
</p>
{Object.entries(grouped).map(([category, items]) => (
<div key={category} className="mb-6">
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
{category}
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{items.map((item) => {
const isSelected = selectedIds.includes(item.id);
return (
<Card
key={item.id}
onClick={() => toggle(item.id)}
className={`p-4 text-center transition-all duration-150 ${
isSelected
? "border-[#39FF14] bg-[rgba(57,255,20,0.1)]"
: ""
}`}
>
<span
className={`text-sm font-medium ${
isSelected ? "text-accent" : "text-zinc-100"
}`}
>
{item.name}
</span>
</Card>
);
})}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { api } from "@/lib/api";
import { Spinner } from "@/components/ui/Spinner";
import type { Exercise } from "@/lib/types";
interface ExcludedExercisesStepProps {
selectedIds: number[];
onChange: (ids: number[]) => void;
}
export function ExcludedExercisesStep({
selectedIds,
onChange,
}: ExcludedExercisesStepProps) {
const [exercises, setExercises] = useState<Exercise[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
useEffect(() => {
async function fetchExercises() {
try {
const data = await api.getExercises();
setExercises(data);
} catch (err) {
console.error("Failed to fetch exercises:", err);
} finally {
setLoading(false);
}
}
fetchExercises();
}, []);
const excludedExercises = useMemo(
() => exercises.filter((e) => selectedIds.includes(e.id)),
[exercises, selectedIds]
);
const searchResults = useMemo(() => {
if (search.length < 2) return [];
const query = search.toLowerCase();
return exercises.filter((e) =>
e.name.toLowerCase().includes(query)
);
}, [exercises, search]);
const toggle = (id: number) => {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((i) => i !== id));
} else {
onChange([...selectedIds, id]);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
);
}
return (
<div>
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
Exclude Exercises
</h2>
<p className="text-zinc-400 mb-6">
Search for exercises you can&apos;t or won&apos;t do. They&apos;ll never
appear in your generated workouts.
</p>
{/* Excluded chips */}
<div className="mb-4">
{excludedExercises.length === 0 ? (
<p className="text-sm text-zinc-500 italic">
No exercises excluded yet
</p>
) : (
<div className="flex flex-wrap gap-2">
{excludedExercises.map((ex) => (
<button
key={ex.id}
type="button"
onClick={() => toggle(ex.id)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-red-500/15 text-red-400 border border-red-500/30 hover:bg-red-500/25 transition-colors"
>
{ex.name}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
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>
{/* Search input */}
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search exercises..."
className="w-full px-4 py-3 rounded-xl bg-zinc-900 border border-zinc-700/50 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-zinc-500 transition-colors mb-4"
/>
{/* Search results */}
{search.length >= 2 && (
<div className="max-h-80 overflow-y-auto rounded-xl border border-zinc-700/50">
{searchResults.length === 0 ? (
<p className="p-4 text-sm text-zinc-500">
No exercises match &quot;{search}&quot;
</p>
) : (
searchResults.map((ex) => {
const isExcluded = selectedIds.includes(ex.id);
return (
<button
key={ex.id}
type="button"
onClick={() => toggle(ex.id)}
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-zinc-800 last:border-b-0 transition-colors ${
isExcluded
? "bg-red-500/10 hover:bg-red-500/15"
: "bg-zinc-900 hover:bg-zinc-800/50"
}`}
>
<div>
<span
className={`text-sm font-medium ${
isExcluded ? "text-red-400" : "text-zinc-100"
}`}
>
{ex.name}
</span>
{ex.muscle_groups && (
<span className="ml-2 text-xs text-zinc-500">
{ex.muscle_groups}
</span>
)}
</div>
{isExcluded && (
<span className="text-xs text-red-400 font-medium">
Excluded
</span>
)}
</button>
);
})
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { Card } from "@/components/ui/Card";
import { GOAL_LABELS, FITNESS_LEVEL_LABELS } from "@/lib/types";
interface GoalsStepProps {
fitnessLevel: number;
primaryGoal: string;
secondaryGoal: string;
onChange: (data: {
fitness_level?: number;
primary_goal?: string;
secondary_goal?: string;
}) => void;
}
const FITNESS_LEVEL_DESCRIPTIONS: Record<number, string> = {
1: "New to structured training or returning after a long break.",
2: "Consistent training for 6+ months with good form knowledge.",
3: "Years of experience with complex programming and periodization.",
4: "Competitive athlete or highly experienced lifter.",
};
const goalOptions = Object.keys(GOAL_LABELS);
export function GoalsStep({
fitnessLevel,
primaryGoal,
secondaryGoal,
onChange,
}: GoalsStepProps) {
return (
<div>
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
Your Fitness Profile
</h2>
<p className="text-zinc-400 mb-8">
Tell us about your experience level and what you want to achieve.
</p>
{/* Fitness Level */}
<div className="mb-8">
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
Fitness Level
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Object.entries(FITNESS_LEVEL_LABELS).map(([key, label]) => {
const level = Number(key);
const isSelected = fitnessLevel === level;
return (
<Card
key={level}
onClick={() => onChange({ fitness_level: level })}
className={`p-4 transition-all duration-150 ${
isSelected
? "border-[#39FF14] bg-[rgba(57,255,20,0.1)]"
: ""
}`}
>
<div className="flex flex-col gap-1">
<span
className={`text-base font-semibold ${
isSelected ? "text-accent" : "text-zinc-100"
}`}
>
{label}
</span>
<span className="text-sm text-zinc-400">
{FITNESS_LEVEL_DESCRIPTIONS[level]}
</span>
</div>
</Card>
);
})}
</div>
</div>
{/* Primary Goal */}
<div className="mb-6">
<label className="block text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-2">
Primary Goal
</label>
<select
value={primaryGoal}
onChange={(e) => onChange({ primary_goal: e.target.value })}
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-3 text-base focus:outline-none focus:border-accent transition-colors"
>
{goalOptions.map((goal) => (
<option key={goal} value={goal}>
{GOAL_LABELS[goal]}
</option>
))}
</select>
</div>
{/* Secondary Goal */}
<div>
<label className="block text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-2">
Secondary Goal
</label>
<select
value={secondaryGoal}
onChange={(e) => onChange({ secondary_goal: e.target.value })}
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-3 text-base focus:outline-none focus:border-accent transition-colors"
>
<option value="">None</option>
{goalOptions
.filter((g) => g !== primaryGoal)
.map((goal) => (
<option key={goal} value={goal}>
{GOAL_LABELS[goal]}
</option>
))}
</select>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import { Card } from "@/components/ui/Card";
import type { InjuryType } from "@/lib/types";
interface InjuryStepProps {
injuryTypes: InjuryType[];
onChange: (injuries: InjuryType[]) => void;
}
const INJURY_AREAS: { type: string; label: string; icon: string }[] = [
{ type: "knee", label: "Knee", icon: "🦵" },
{ type: "lower_back", label: "Lower Back", icon: "🔻" },
{ type: "upper_back", label: "Upper Back", icon: "🔺" },
{ type: "shoulder", label: "Shoulder", icon: "💪" },
{ type: "hip", label: "Hip", icon: "🦴" },
{ type: "wrist", label: "Wrist", icon: "✋" },
{ type: "ankle", label: "Ankle", icon: "🦶" },
{ type: "neck", label: "Neck", icon: "🧣" },
];
const SEVERITY_OPTIONS: {
value: "mild" | "moderate" | "severe";
label: string;
color: string;
bgColor: string;
borderColor: string;
}[] = [
{
value: "mild",
label: "Mild",
color: "text-yellow-400",
bgColor: "bg-yellow-500/10",
borderColor: "border-yellow-500/50",
},
{
value: "moderate",
label: "Moderate",
color: "text-orange-400",
bgColor: "bg-orange-500/10",
borderColor: "border-orange-500/50",
},
{
value: "severe",
label: "Severe",
color: "text-red-400",
bgColor: "bg-red-500/10",
borderColor: "border-red-500/50",
},
];
export function InjuryStep({ injuryTypes, onChange }: InjuryStepProps) {
const getInjury = (type: string): InjuryType | undefined =>
injuryTypes.find((i) => i.type === type);
const toggleInjury = (type: string) => {
const existing = getInjury(type);
if (existing) {
onChange(injuryTypes.filter((i) => i.type !== type));
} else {
onChange([...injuryTypes, { type, severity: "moderate" }]);
}
};
const setSeverity = (type: string, severity: "mild" | "moderate" | "severe") => {
onChange(
injuryTypes.map((i) => (i.type === type ? { ...i, severity } : i))
);
};
const getSeverityStyle = (type: string) => {
const injury = getInjury(type);
if (!injury) return "";
const opt = SEVERITY_OPTIONS.find((s) => s.value === injury.severity);
return opt ? `${opt.borderColor} ${opt.bgColor}` : "";
};
return (
<div>
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
Any injuries or limitations?
</h2>
<p className="text-zinc-400 mb-6">
Select any body areas with current injuries. We will adjust your
workouts to avoid aggravating them. You can skip this step if you have
no injuries.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{INJURY_AREAS.map((area) => {
const injury = getInjury(area.type);
const isSelected = !!injury;
return (
<div key={area.type}>
<Card
onClick={() => toggleInjury(area.type)}
className={`p-4 text-center transition-all duration-150 ${
isSelected ? getSeverityStyle(area.type) : ""
}`}
>
<div className="flex flex-col items-center gap-1">
<span className="text-2xl">{area.icon}</span>
<span
className={`text-sm font-medium ${
isSelected ? "text-zinc-100" : "text-zinc-100"
}`}
>
{area.label}
</span>
</div>
</Card>
{isSelected && (
<div className="flex gap-1 mt-2">
{SEVERITY_OPTIONS.map((sev) => (
<button
key={sev.value}
type="button"
onClick={() => setSeverity(area.type, sev.value)}
className={`flex-1 text-xs py-1.5 rounded-lg border transition-all duration-150 ${
injury.severity === sev.value
? `${sev.bgColor} ${sev.borderColor} ${sev.color} font-semibold`
: "border-zinc-700 text-zinc-500 hover:text-zinc-300"
}`}
>
{sev.label}
</button>
))}
</div>
)}
</div>
);
})}
</div>
{injuryTypes.length > 0 && (
<div className="mt-6 p-4 rounded-lg bg-zinc-800/50 border border-zinc-700/50">
<p className="text-sm text-zinc-400">
<span className="font-medium text-zinc-300">
{injuryTypes.length} area{injuryTypes.length !== 1 ? "s" : ""} selected.
</span>{" "}
Exercises that could aggravate these areas will be excluded or
modified based on severity level.
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useState, useEffect } from "react";
import { api } from "@/lib/api";
import { Card } from "@/components/ui/Card";
import { Spinner } from "@/components/ui/Spinner";
import type { Muscle } from "@/lib/types";
interface MusclesStepProps {
selectedIds: number[];
onChange: (ids: number[]) => void;
}
export function MusclesStep({ selectedIds, onChange }: MusclesStepProps) {
const [muscles, setMuscles] = useState<Muscle[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetch() {
try {
const data = await api.getMuscles();
setMuscles(data);
} catch (err) {
console.error("Failed to fetch muscles:", err);
} finally {
setLoading(false);
}
}
fetch();
}, []);
const toggle = (id: number) => {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((i) => i !== id));
} else {
onChange([...selectedIds, id]);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
);
}
return (
<div>
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
Target Muscles
</h2>
<p className="text-zinc-400 mb-6">
Select the muscle groups you want to focus on. Leave empty to target all
muscle groups equally.
</p>
<button
type="button"
onClick={() => {
if (selectedIds.length === muscles.length) {
onChange([]);
} else {
onChange(muscles.map((m) => m.id));
}
}}
className="mb-4 text-sm font-medium text-accent hover:underline"
>
{selectedIds.length === muscles.length ? "Deselect All" : "Select All"}
</button>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{muscles.map((muscle) => {
const isSelected = selectedIds.includes(muscle.id);
return (
<Card
key={muscle.id}
onClick={() => toggle(muscle.id)}
className={`p-4 text-center transition-all duration-150 ${
isSelected
? "border-[#39FF14] bg-[rgba(57,255,20,0.1)]"
: ""
}`}
>
<span
className={`text-sm font-medium ${
isSelected ? "text-accent" : "text-zinc-100"
}`}
>
{muscle.name}
</span>
</Card>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { DAY_NAMES } from "@/lib/types";
interface ScheduleStepProps {
daysPerWeek: number;
preferredDays: number[];
onChange: (data: {
days_per_week?: number;
preferred_days?: number[];
}) => void;
}
const DAYS_OPTIONS = [3, 4, 5, 6];
export function ScheduleStep({
daysPerWeek,
preferredDays,
onChange,
}: ScheduleStepProps) {
const toggleDay = (dayIndex: number) => {
if (preferredDays.includes(dayIndex)) {
onChange({
preferred_days: preferredDays.filter((d) => d !== dayIndex),
});
} else {
onChange({
preferred_days: [...preferredDays, dayIndex],
});
}
};
return (
<div>
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
Your Schedule
</h2>
<p className="text-zinc-400 mb-8">
How often do you want to work out, and which days work best for you?
</p>
{/* Days per week */}
<div className="mb-8">
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
Days Per Week
</h3>
<div className="flex gap-3">
{DAYS_OPTIONS.map((num) => {
const isSelected = daysPerWeek === num;
return (
<button
key={num}
onClick={() => onChange({ days_per_week: num })}
className={`flex-1 py-3 rounded-lg text-lg font-bold transition-all duration-150 cursor-pointer ${
isSelected
? "bg-accent text-black"
: "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
}`}
>
{num}
</button>
);
})}
</div>
</div>
{/* Preferred days */}
<div>
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
Preferred Days
</h3>
<div className="flex gap-2">
{DAY_NAMES.map((name, index) => {
const isSelected = preferredDays.includes(index);
return (
<button
key={index}
onClick={() => toggleDay(index)}
className={`flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-150 cursor-pointer ${
isSelected
? "bg-accent text-black"
: "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
}`}
>
{name}
</button>
);
})}
</div>
<p className="text-sm text-zinc-500 mt-3">
Select the days you prefer to train. This helps us schedule rest days
optimally.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useState, useEffect } from "react";
import { api } from "@/lib/api";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Spinner } from "@/components/ui/Spinner";
import type { WorkoutType } from "@/lib/types";
interface WorkoutTypesStepProps {
selectedIds: number[];
onChange: (ids: number[]) => void;
}
const intensityVariant: Record<string, "success" | "warning" | "error"> = {
low: "success",
medium: "warning",
high: "error",
};
export function WorkoutTypesStep({
selectedIds,
onChange,
}: WorkoutTypesStepProps) {
const [workoutTypes, setWorkoutTypes] = useState<WorkoutType[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetch() {
try {
const data = await api.getWorkoutTypes();
setWorkoutTypes(data);
} catch (err) {
console.error("Failed to fetch workout types:", err);
} finally {
setLoading(false);
}
}
fetch();
}, []);
const toggle = (id: number) => {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((i) => i !== id));
} else {
onChange([...selectedIds, id]);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
);
}
return (
<div>
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
Workout Types
</h2>
<p className="text-zinc-400 mb-6">
Select the types of workouts you enjoy. We&apos;ll use these to build
your weekly plans.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{workoutTypes.map((wt) => {
const isSelected = selectedIds.includes(wt.id);
return (
<Card
key={wt.id}
onClick={() => toggle(wt.id)}
className={`p-4 transition-all duration-150 ${
isSelected
? "border-[#39FF14] bg-[rgba(57,255,20,0.1)]"
: ""
}`}
>
<div className="flex items-start justify-between gap-2 mb-1">
<span
className={`text-base font-semibold ${
isSelected ? "text-accent" : "text-zinc-100"
}`}
>
{wt.name}
</span>
<Badge variant={intensityVariant[wt.typical_intensity] || "default"}>
{wt.typical_intensity}
</Badge>
</div>
{wt.description && (
<p className="text-sm text-zinc-400 line-clamp-2">
{wt.description}
</p>
)}
</Card>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,700 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { VideoPlayer } from "@/components/workout/VideoPlayer";
import { api } from "@/lib/api";
import type {
GeneratedWorkout,
WorkoutDetail,
Exercise,
PreviewDay,
PreviewExercise,
} from "@/lib/types";
interface DayCardProps {
// Saved mode
workout?: GeneratedWorkout;
detail?: WorkoutDetail;
onUpdate?: () => void;
// Preview mode
previewDay?: PreviewDay;
previewDayIndex?: number;
onPreviewDayChange?: (dayIndex: number, newDay: PreviewDay) => void;
}
function formatTime(seconds: number): string {
const mins = Math.round(seconds / 60);
if (mins < 60) return `${mins}m`;
const h = Math.floor(mins / 60);
const m = mins % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function EditIcon({ className = "" }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" />
</svg>
);
}
function TrashIcon({ className = "" }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
);
}
function XIcon({ className = "" }: { className?: string }) {
return (
<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" className={className}>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}
function mediaUrl(path: string): string {
if (typeof window === "undefined") return path;
return `${window.location.protocol}//${window.location.hostname}:8001${path}`;
}
function PlayIcon({ className = "" }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className={className}>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
);
}
function VideoModal({ src, title, onClose }: { src: string; title: string; onClose: () => void }) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={onClose}
>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl p-4 w-full max-w-lg flex flex-col gap-3"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-100 truncate">{title}</h3>
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-100 p-1">
<XIcon />
</button>
</div>
<VideoPlayer src={src} />
</div>
</div>
);
}
function RefreshIcon({ className = "" }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 16h5v5" />
</svg>
);
}
// Swap exercise modal — works for both preview and saved mode
function SwapModal({
exerciseId,
currentName,
onSwap,
onClose,
}: {
exerciseId: number;
currentName: string;
onSwap: (newExercise: Exercise) => void;
onClose: () => void;
}) {
const [alternatives, setAlternatives] = useState<Exercise[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.getSimilarExercises(exerciseId)
.then(setAlternatives)
.catch((err) => console.error("Failed to load alternatives:", err))
.finally(() => setLoading(false));
}, [exerciseId]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={onClose}
>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl p-4 w-full max-w-md max-h-[70vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-zinc-100">
Replace: {currentName}
</h3>
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-100 p-1">
<XIcon />
</button>
</div>
{loading ? (
<div className="text-center text-zinc-400 text-sm py-8">Loading alternatives...</div>
) : alternatives.length === 0 ? (
<div className="text-center text-zinc-400 text-sm py-8">No alternatives found.</div>
) : (
<div className="flex flex-col gap-1 overflow-y-auto">
{alternatives.map((ex) => (
<button
key={ex.id}
className="text-left px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors text-sm text-zinc-200"
onClick={() => onSwap(ex)}
>
{ex.name}
{ex.muscle_groups && (
<span className="text-[10px] text-zinc-500 ml-2">
{ex.muscle_groups}
</span>
)}
</button>
))}
</div>
)}
</div>
</div>
);
}
export function DayCard({
workout,
detail: externalDetail,
onUpdate,
previewDay,
previewDayIndex,
onPreviewDayChange,
}: DayCardProps) {
const isPreview = !!previewDay;
// Saved mode state
const [deleting, setDeleting] = useState(false);
const [fetchedDetail, setFetchedDetail] = useState<WorkoutDetail | null>(null);
const [regenerating, setRegenerating] = useState(false);
// Video preview modal state
const [videoPreview, setVideoPreview] = useState<{ src: string; title: string } | null>(null);
// Swap modal state
const [swapTarget, setSwapTarget] = useState<{
exerciseId: number;
name: string;
// For saved mode
supersetExerciseId?: number;
// For preview mode
supersetIndex?: number;
exerciseIndex?: number;
} | null>(null);
// Saved mode: fetch detail if not provided
useEffect(() => {
if (isPreview) return;
if (externalDetail || !workout?.workout || workout.is_rest_day) return;
let cancelled = false;
api.getWorkoutDetail(workout.workout).then((data) => {
if (!cancelled) setFetchedDetail(data);
}).catch((err) => {
console.error(`[DayCard] Failed to fetch detail for workout ${workout.workout}:`, err);
});
return () => { cancelled = true; };
}, [isPreview, workout?.workout, workout?.is_rest_day, externalDetail]);
const detail = externalDetail || fetchedDetail;
// ============================================
// Preview mode handlers
// ============================================
const handlePreviewDeleteDay = () => {
if (previewDayIndex === undefined || !previewDay || !onPreviewDayChange) return;
onPreviewDayChange(previewDayIndex, {
...previewDay,
is_rest_day: true,
focus_area: "Rest Day",
target_muscles: [],
workout_spec: undefined,
});
};
const handlePreviewDeleteSuperset = (ssIdx: number) => {
if (previewDayIndex === undefined || !previewDay?.workout_spec || !onPreviewDayChange) return;
const newSupersets = previewDay.workout_spec.supersets.filter((_, i) => i !== ssIdx);
onPreviewDayChange(previewDayIndex, {
...previewDay,
workout_spec: { ...previewDay.workout_spec, supersets: newSupersets },
});
};
const handlePreviewDeleteExercise = (ssIdx: number, exIdx: number) => {
if (previewDayIndex === undefined || !previewDay?.workout_spec || !onPreviewDayChange) return;
const newSupersets = previewDay.workout_spec.supersets.map((ss, i) => {
if (i !== ssIdx) return ss;
const newExercises = ss.exercises.filter((_, j) => j !== exIdx);
return { ...ss, exercises: newExercises };
}).filter((ss) => ss.exercises.length > 0);
onPreviewDayChange(previewDayIndex, {
...previewDay,
workout_spec: { ...previewDay.workout_spec, supersets: newSupersets },
});
};
const handlePreviewSwapExercise = (ssIdx: number, exIdx: number, ex: PreviewExercise) => {
setSwapTarget({
exerciseId: ex.exercise_id,
name: ex.exercise_name,
supersetIndex: ssIdx,
exerciseIndex: exIdx,
});
};
const handlePreviewSwapConfirm = (newExercise: Exercise) => {
if (!swapTarget || swapTarget.supersetIndex === undefined || swapTarget.exerciseIndex === undefined) return;
if (previewDayIndex === undefined || !previewDay?.workout_spec || !onPreviewDayChange) return;
const ssIdx = swapTarget.supersetIndex;
const exIdx = swapTarget.exerciseIndex;
const newSupersets = previewDay.workout_spec.supersets.map((ss, i) => {
if (i !== ssIdx) return ss;
const newExercises = ss.exercises.map((ex, j) => {
if (j !== exIdx) return ex;
return {
...ex,
exercise_id: newExercise.id,
exercise_name: newExercise.name,
muscle_groups: newExercise.muscle_groups || "",
};
});
return { ...ss, exercises: newExercises };
});
onPreviewDayChange(previewDayIndex, {
...previewDay,
workout_spec: { ...previewDay.workout_spec, supersets: newSupersets },
});
setSwapTarget(null);
};
const handlePreviewRegenerate = async () => {
if (previewDayIndex === undefined || !previewDay || !onPreviewDayChange) return;
setRegenerating(true);
try {
const newDay = await api.previewDay({
target_muscles: previewDay.target_muscles,
focus_area: previewDay.focus_area,
workout_type_id: previewDay.workout_type_id,
date: previewDay.date,
});
onPreviewDayChange(previewDayIndex, newDay);
} catch (err) {
console.error("Failed to regenerate day:", err);
} finally {
setRegenerating(false);
}
};
// ============================================
// Saved mode handlers
// ============================================
const handleDeleteDay = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!confirm("Remove this workout day?")) return;
setDeleting(true);
try {
await api.deleteWorkoutDay(workout!.id);
onUpdate?.();
} catch (err) {
console.error("Failed to delete day:", err);
} finally {
setDeleting(false);
}
};
const handleDeleteSuperset = async (e: React.MouseEvent, supersetId: number) => {
e.preventDefault();
e.stopPropagation();
try {
await api.deleteSuperset(supersetId);
onUpdate?.();
} catch (err) {
console.error("Failed to delete superset:", err);
}
};
const handleDeleteExercise = async (e: React.MouseEvent, seId: number) => {
e.preventDefault();
e.stopPropagation();
try {
await api.deleteSupersetExercise(seId);
onUpdate?.();
} catch (err) {
console.error("Failed to delete exercise:", err);
}
};
const handleSavedSwapExercise = (seId: number, exerciseId: number, name: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setSwapTarget({ supersetExerciseId: seId, exerciseId, name });
};
const handleSavedSwapConfirm = async (newExercise: Exercise) => {
if (!swapTarget?.supersetExerciseId) return;
try {
await api.swapExercise(swapTarget.supersetExerciseId, newExercise.id);
setSwapTarget(null);
onUpdate?.();
} catch (err) {
console.error("Failed to swap exercise:", err);
}
};
// ============================================
// Render: Preview mode
// ============================================
if (isPreview && previewDay) {
if (previewDay.is_rest_day) return null;
const spec = previewDay.workout_spec;
const typeName = previewDay.workout_type_name?.replace(/_/g, " ");
return (
<>
<Card className="p-4 flex flex-col gap-3 relative">
{/* Top-right actions */}
<div className="absolute top-3 right-3 flex items-center gap-1 z-10">
<button
onClick={handlePreviewRegenerate}
disabled={regenerating}
className="p-1 rounded-md text-zinc-500 hover:text-[#39FF14] hover:bg-zinc-800 transition-colors"
title="Regenerate this day"
>
<RefreshIcon className={regenerating ? "animate-spin" : ""} />
</button>
<button
onClick={handlePreviewDeleteDay}
className="p-1 rounded-md text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition-colors"
title="Remove this day"
>
<XIcon />
</button>
</div>
{/* Header */}
<div className="flex items-start justify-between gap-2 pr-14">
<div className="flex-1 min-w-0">
{typeName && (
<Badge variant="accent" className="text-[10px] capitalize mb-1.5">
{typeName}
</Badge>
)}
{previewDay.focus_area && (
<p className="text-sm font-semibold text-zinc-100 break-words">
{previewDay.focus_area}
</p>
)}
</div>
{spec?.estimated_time && (
<span className="text-xs text-zinc-400 font-medium whitespace-nowrap mt-0.5">
{formatTime(spec.estimated_time)}
</span>
)}
</div>
{/* Supersets */}
{spec && spec.supersets.length > 0 && (
<div className="flex flex-col gap-2">
{spec.supersets.map((superset, si) => (
<div key={si} className="bg-zinc-800/50 rounded-lg px-3 py-2 group/superset">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
{superset.name}
</span>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-zinc-500">
{superset.rounds}x
</span>
<button
onClick={() => handlePreviewDeleteSuperset(si)}
className="p-0.5 rounded text-zinc-600 hover:text-red-400 opacity-0 group-hover/superset:opacity-100 transition-opacity"
title="Delete superset"
>
<TrashIcon />
</button>
</div>
</div>
<div className="flex flex-col gap-0.5">
{superset.exercises.map((ex, ei) => (
<div
key={ei}
className="flex items-center justify-between gap-1 group/exercise"
>
<div className="flex items-center gap-1 flex-1 min-w-0">
<span className="text-xs text-zinc-200 break-words">
{ex.exercise_name}
</span>
{ex.video_url && (
<button
onClick={() => setVideoPreview({ src: mediaUrl(ex.video_url!), title: ex.exercise_name })}
className="flex-shrink-0 p-0.5 rounded text-zinc-500 hover:text-[#39FF14] transition-colors"
title="Preview video"
>
<PlayIcon />
</button>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs font-semibold text-[#39FF14] whitespace-nowrap">
{ex.reps ? `${ex.reps} reps` : ex.duration ? `${ex.duration}s` : ""}
</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover/exercise:opacity-100 transition-opacity">
<button
onClick={() => handlePreviewSwapExercise(si, ei, ex)}
className="p-0.5 rounded text-zinc-500 hover:text-[#39FF14]"
title="Swap exercise"
>
<EditIcon />
</button>
<button
onClick={() => handlePreviewDeleteExercise(si, ei)}
className="p-0.5 rounded text-zinc-500 hover:text-red-400"
title="Delete exercise"
>
<TrashIcon />
</button>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Muscle summary fallback */}
{!spec && previewDay.target_muscles.length > 0 && (
<p className="text-xs text-zinc-400 break-words">
{previewDay.target_muscles.join(", ")}
</p>
)}
</Card>
{swapTarget && (
<SwapModal
exerciseId={swapTarget.exerciseId}
currentName={swapTarget.name}
onSwap={handlePreviewSwapConfirm}
onClose={() => setSwapTarget(null)}
/>
)}
{videoPreview && (
<VideoModal
src={videoPreview.src}
title={videoPreview.title}
onClose={() => setVideoPreview(null)}
/>
)}
</>
);
}
// ============================================
// Render: Saved mode
// ============================================
if (!workout || workout.is_rest_day) return null;
const typeName = workout.workout_type_name?.replace(/_/g, " ");
const sortedSupersets = detail
? [...detail.supersets].sort((a, b) => a.order - b.order)
: [];
const cardContent = (
<Card className="p-4 flex flex-col gap-3 relative">
<button
onClick={handleDeleteDay}
disabled={deleting}
className="absolute top-3 right-3 p-1 rounded-md text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition-colors z-10"
title="Remove this day"
>
<XIcon />
</button>
<div className="flex items-start justify-between gap-2 pr-6">
<div className="flex-1 min-w-0">
{typeName && (
<Badge variant="accent" className="text-[10px] capitalize mb-1.5">
{typeName}
</Badge>
)}
{workout.focus_area && (
<p className="text-sm font-semibold text-zinc-100 break-words">
{workout.focus_area}
</p>
)}
</div>
{detail?.estimated_time && (
<span className="text-xs text-zinc-400 font-medium whitespace-nowrap mt-0.5">
{formatTime(detail.estimated_time)}
</span>
)}
</div>
{sortedSupersets.length > 0 && (
<div className="flex flex-col gap-2">
{sortedSupersets.map((superset, si) => {
const sortedExercises = [...superset.exercises].sort(
(a, b) => a.order - b.order
);
return (
<div key={superset.id ?? `s-${si}`} className="bg-zinc-800/50 rounded-lg px-3 py-2 group/superset">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
{superset.name || `Set ${superset.order}`}
</span>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-zinc-500">
{superset.rounds}x
</span>
{superset.id && (
<button
onClick={(e) => handleDeleteSuperset(e, superset.id)}
className="p-0.5 rounded text-zinc-600 hover:text-red-400 opacity-0 group-hover/superset:opacity-100 transition-opacity"
title="Delete superset"
>
<TrashIcon />
</button>
)}
</div>
</div>
<div className="flex flex-col gap-0.5">
{sortedExercises.map((se, ei) => (
<div
key={se.id ?? `e-${si}-${ei}`}
className="flex items-center justify-between gap-1 group/exercise"
>
<div className="flex items-center gap-1 flex-1 min-w-0">
<span className="text-xs text-zinc-200 break-words">
{se.exercise.name}
</span>
{se.exercise.video_url && (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setVideoPreview({ src: mediaUrl(se.exercise.video_url), title: se.exercise.name }); }}
className="flex-shrink-0 p-0.5 rounded text-zinc-500 hover:text-[#39FF14] transition-colors"
title="Preview video"
>
<PlayIcon />
</button>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs font-semibold text-[#39FF14] whitespace-nowrap">
{se.reps ? `${se.reps} reps` : se.duration ? `${se.duration}s` : ""}
</span>
{se.id && (
<div className="flex items-center gap-0.5 opacity-0 group-hover/exercise:opacity-100 transition-opacity">
<button
onClick={handleSavedSwapExercise(se.id, se.exercise.id, se.exercise.name)}
className="p-0.5 rounded text-zinc-500 hover:text-[#39FF14]"
title="Swap exercise"
>
<EditIcon />
</button>
<button
onClick={(e) => handleDeleteExercise(e, se.id)}
className="p-0.5 rounded text-zinc-500 hover:text-red-400"
title="Delete exercise"
>
<TrashIcon />
</button>
</div>
)}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
{!detail && workout.target_muscles.length > 0 && (
<p className="text-xs text-zinc-400 break-words">
{workout.target_muscles.join(", ")}
</p>
)}
{/* Status badges for saved workouts */}
{workout.status === "accepted" && (
<div className="mt-auto pt-1">
<Badge variant="success">Saved</Badge>
</div>
)}
{workout.status === "completed" && (
<div className="mt-auto pt-1">
<Badge variant="accent">Completed</Badge>
</div>
)}
</Card>
);
const modal = swapTarget ? (
<SwapModal
exerciseId={swapTarget.exerciseId}
currentName={swapTarget.name}
onSwap={handleSavedSwapConfirm}
onClose={() => setSwapTarget(null)}
/>
) : null;
const videoModal = videoPreview ? (
<VideoModal
src={videoPreview.src}
title={videoPreview.title}
onClose={() => setVideoPreview(null)}
/>
) : null;
if (workout.workout) {
return (
<>
<Link href={`/workout/${workout.workout}`} className="block">
{cardContent}
</Link>
{modal}
{videoModal}
</>
);
}
return (
<>
{cardContent}
{modal}
{videoModal}
</>
);
}

View File

@@ -0,0 +1,50 @@
import Link from "next/link";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import type { GeneratedWeeklyPlan } from "@/lib/types";
interface PlanCardProps {
plan: GeneratedWeeklyPlan;
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", { month: "short", day: "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 function PlanCard({ plan }: PlanCardProps) {
const workoutDays = plan.generated_workouts.filter((w) => !w.is_rest_day);
const dateRange = `${formatDate(plan.week_start_date)} - ${formatDate(plan.week_end_date)}`;
return (
<Link href={`/plans/${plan.id}`} className="block">
<Card className="p-4 hover:bg-zinc-800/50 transition-colors duration-150">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-zinc-100">{dateRange}</h3>
<Badge variant={getStatusVariant(plan.status)}>
{plan.status}
</Badge>
</div>
<div className="flex items-center gap-4 text-xs text-zinc-400">
<span>{workoutDays.length} workout{workoutDays.length !== 1 ? "s" : ""}</span>
<span>{plan.generation_time_ms}ms</span>
</div>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
interface WeekPickerProps {
selectedMonday: string;
onChange: (monday: string) => void;
}
function getMondayDate(dateStr: string): Date {
const [y, m, d] = dateStr.split("-").map(Number);
return new Date(y, m - 1, d);
}
function formatDate(date: Date): string {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
function formatWeekLabel(dateStr: string): string {
const date = getMondayDate(dateStr);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function ChevronLeft() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 18l-6-6 6-6" />
</svg>
);
}
function ChevronRight() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
);
}
export function WeekPicker({ selectedMonday, onChange }: WeekPickerProps) {
const shiftWeek = (offset: number) => {
const date = getMondayDate(selectedMonday);
date.setDate(date.getDate() + offset * 7);
onChange(formatDate(date));
};
return (
<div className="flex items-center gap-3">
<button
onClick={() => shiftWeek(-1)}
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
aria-label="Previous week"
>
<ChevronLeft />
</button>
<span className="text-sm font-medium text-zinc-200 min-w-[160px] text-center">
Week of {formatWeekLabel(selectedMonday)}
</span>
<button
onClick={() => shiftWeek(1)}
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
aria-label="Next week"
>
<ChevronRight />
</button>
</div>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import { useEffect, useState } from "react";
import { DAY_NAMES } from "@/lib/types";
import type {
GeneratedWeeklyPlan,
WorkoutDetail,
WeeklyPreview,
PreviewDay,
} from "@/lib/types";
import { DayCard } from "@/components/plans/DayCard";
import { api } from "@/lib/api";
interface WeeklyPlanGridProps {
plan?: GeneratedWeeklyPlan;
preview?: WeeklyPreview;
onUpdate?: () => void;
onPreviewChange?: (preview: WeeklyPreview) => void;
}
export function WeeklyPlanGrid({
plan,
preview,
onUpdate,
onPreviewChange,
}: WeeklyPlanGridProps) {
const [workoutDetails, setWorkoutDetails] = useState<
Record<number, WorkoutDetail>
>({});
const [refreshKey, setRefreshKey] = useState(0);
// Saved mode: fetch workout details
const workoutIds =
plan?.generated_workouts
.filter((w) => w.workout && !w.is_rest_day)
.map((w) => w.workout!) ?? [];
useEffect(() => {
if (!plan || workoutIds.length === 0) return;
let cancelled = false;
async function fetchDetails() {
const results = await Promise.allSettled(
workoutIds.map((id) => api.getWorkoutDetail(id))
);
if (cancelled) return;
const details: Record<number, WorkoutDetail> = {};
results.forEach((result, i) => {
if (result.status === "fulfilled") {
details[workoutIds[i]] = result.value;
}
});
setWorkoutDetails(details);
}
fetchDetails();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plan?.id, refreshKey]);
const handleSavedUpdate = () => {
setRefreshKey((k) => k + 1);
onUpdate?.();
};
const handlePreviewDayChange = (dayIndex: number, newDay: PreviewDay) => {
if (!preview || !onPreviewChange) return;
const newDays = [...preview.days];
newDays[dayIndex] = newDay;
onPreviewChange({ ...preview, days: newDays });
};
// Determine items to render
if (preview) {
const trainingDays = preview.days
.map((day, idx) => ({ day, idx }))
.filter((d) => !d.day.is_rest_day);
const pairs: (typeof trainingDays)[] = [];
for (let i = 0; i < trainingDays.length; i += 2) {
pairs.push(trainingDays.slice(i, i + 2));
}
return (
<div>
{/* Desktop: two per row */}
<div className="hidden md:flex flex-col gap-4">
{pairs.map((pair, rowIdx) => (
<div key={rowIdx} className="grid grid-cols-2 gap-4">
{pair.map(({ day, idx }) => (
<div key={idx}>
<div className="text-center text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">
{DAY_NAMES[day.day_of_week]}
</div>
<DayCard
previewDay={day}
previewDayIndex={idx}
onPreviewDayChange={handlePreviewDayChange}
/>
</div>
))}
</div>
))}
</div>
{/* Mobile stack */}
<div className="flex flex-col gap-3 md:hidden">
{trainingDays.map(({ day, idx }) => (
<div key={idx}>
<div className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1">
{DAY_NAMES[day.day_of_week]}
</div>
<DayCard
previewDay={day}
previewDayIndex={idx}
onPreviewDayChange={handlePreviewDayChange}
/>
</div>
))}
</div>
</div>
);
}
// Saved plan mode
if (!plan) return null;
const sortedWorkouts = [...plan.generated_workouts].sort(
(a, b) => a.day_of_week - b.day_of_week
);
const nonRestWorkouts = sortedWorkouts.filter((w) => !w.is_rest_day);
const pairs: (typeof nonRestWorkouts)[] = [];
for (let i = 0; i < nonRestWorkouts.length; i += 2) {
pairs.push(nonRestWorkouts.slice(i, i + 2));
}
return (
<div>
{/* Desktop: two per row */}
<div className="hidden md:flex flex-col gap-4">
{pairs.map((pair, rowIdx) => (
<div key={rowIdx} className="grid grid-cols-2 gap-4">
{pair.map((workout) => (
<div key={workout.id}>
<div className="text-center text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">
{DAY_NAMES[workout.day_of_week]}
</div>
<DayCard
workout={workout}
detail={
workout.workout
? workoutDetails[workout.workout]
: undefined
}
onUpdate={handleSavedUpdate}
/>
</div>
))}
</div>
))}
</div>
{/* Mobile stack */}
<div className="flex flex-col gap-3 md:hidden">
{nonRestWorkouts.map((workout) => (
<div key={workout.id}>
<div className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1">
{DAY_NAMES[workout.day_of_week]}
</div>
<DayCard
workout={workout}
detail={
workout.workout
? workoutDetails[workout.workout]
: undefined
}
onUpdate={handleSavedUpdate}
/>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
type BadgeVariant = "default" | "success" | "warning" | "error" | "accent";
interface BadgeProps {
variant?: BadgeVariant;
children: ReactNode;
className?: string;
}
const variantClasses: Record<BadgeVariant, string> = {
default: "bg-zinc-700 text-zinc-300",
success: "bg-green-500/20 text-green-400",
warning: "bg-amber-500/20 text-amber-400",
error: "bg-red-500/20 text-red-400",
accent: "bg-[rgba(57,255,20,0.1)] text-[#39FF14]",
};
export function Badge({
variant = "default",
children,
className = "",
}: BadgeProps) {
return (
<span
className={`
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${variantClasses[variant]}
${className}
`}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { Spinner } from "@/components/ui/Spinner";
type Variant = "primary" | "secondary" | "danger" | "ghost";
type Size = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
loading?: boolean;
children: ReactNode;
}
const variantClasses: Record<Variant, string> = {
primary:
"bg-accent text-black font-bold hover:bg-accent-hover active:bg-accent-hover",
secondary: "bg-zinc-700 text-zinc-100 hover:bg-zinc-600 active:bg-zinc-500",
danger: "bg-red-500 text-white hover:bg-red-600 active:bg-red-700",
ghost: "bg-transparent text-zinc-100 hover:bg-zinc-800 active:bg-zinc-700",
};
const sizeClasses: Record<Size, string> = {
sm: "px-3 py-1.5 text-sm rounded-lg",
md: "px-5 py-2.5 text-base rounded-lg",
lg: "px-7 py-3.5 text-lg rounded-xl",
};
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = "primary",
size = "md",
loading = false,
disabled,
className = "",
children,
...rest
},
ref
) => {
const isDisabled = disabled || loading;
return (
<button
ref={ref}
disabled={isDisabled}
className={`
inline-flex items-center justify-center gap-2 font-medium
transition-colors duration-150 ease-in-out
${variantClasses[variant]}
${sizeClasses[size]}
${isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
${className}
`}
{...rest}
>
{loading && <Spinner size="sm" />}
{children}
</button>
);
}
);
Button.displayName = "Button";
export { Button };
export type { ButtonProps };

View File

@@ -0,0 +1,23 @@
import type { ReactNode, HTMLAttributes } from "react";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
className?: string;
children: ReactNode;
onClick?: () => void;
}
export function Card({ className = "", children, onClick, ...rest }: CardProps) {
return (
<div
onClick={onClick}
className={`
bg-zinc-900 border border-zinc-700/50 rounded-xl
${onClick ? "hover:bg-zinc-800/50 cursor-pointer transition-colors duration-150" : ""}
${className}
`}
{...rest}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
interface SliderProps {
min: number;
max: number;
value: number;
onChange: (value: number) => void;
step?: number;
label?: string;
unit?: string;
className?: string;
}
export function Slider({
min,
max,
value,
onChange,
step = 1,
label,
unit,
className = "",
}: SliderProps) {
return (
<div className={`flex flex-col gap-2 ${className}`}>
{(label || unit) && (
<div className="flex items-center justify-between">
{label && (
<label className="text-sm font-medium text-zinc-300">
{label}
</label>
)}
<span className="text-sm font-semibold text-accent tabular-nums">
{value}
{unit && <span className="ml-0.5 text-zinc-400">{unit}</span>}
</span>
</div>
)}
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="range-slider w-full h-2 rounded-full appearance-none cursor-pointer bg-zinc-700"
/>
</div>
);
}

View File

@@ -0,0 +1,37 @@
type SpinnerSize = "sm" | "md" | "lg";
interface SpinnerProps {
size?: SpinnerSize;
className?: string;
}
const sizePx: Record<SpinnerSize, number> = {
sm: 16,
md: 24,
lg: 40,
};
const borderWidth: Record<SpinnerSize, number> = {
sm: 2,
md: 3,
lg: 4,
};
export function Spinner({ size = "md", className = "" }: SpinnerProps) {
const px = sizePx[size];
const bw = borderWidth[size];
return (
<span
role="status"
aria-label="Loading"
className={`inline-block animate-spin rounded-full ${className}`}
style={{
width: px,
height: px,
border: `${bw}px solid rgba(63,63,70,0.6)`,
borderTopColor: "#39FF14",
}}
/>
);
}

View File

@@ -0,0 +1,79 @@
import Link from "next/link";
import { Badge } from "@/components/ui/Badge";
import type { SupersetExercise } from "@/lib/types";
function mediaUrl(path: string): string {
if (typeof window === "undefined") return path;
return `${window.location.protocol}//${window.location.hostname}:8001${path}`;
}
interface ExerciseRowProps {
exercise: SupersetExercise;
}
export function ExerciseRow({ exercise }: ExerciseRowProps) {
const ex = exercise.exercise;
const details: string[] = [];
if (exercise.reps) {
details.push(`${exercise.reps} reps`);
}
if (exercise.duration) {
details.push(`${exercise.duration}s`);
}
if (exercise.weight) {
details.push(`${exercise.weight} lbs`);
}
const muscles = ex.muscles?.map((m) => m.name) || [];
return (
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800/50 last:border-b-0">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-zinc-100 truncate">
{ex.name}
</span>
{ex.video_url && (
<Link
href={mediaUrl(ex.video_url)}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 text-[#39FF14] hover:text-[#39FF14]/80 transition-colors"
title="Watch video"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</Link>
)}
</div>
{details.length > 0 && (
<p className="text-xs text-zinc-400 mt-0.5">{details.join(" / ")}</p>
)}
{muscles.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{muscles.map((name) => (
<Badge
key={name}
variant="default"
className="text-[10px] px-1.5 py-0"
>
{name}
</Badge>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { ExerciseRow } from "@/components/workout/ExerciseRow";
import type { Superset } from "@/lib/types";
interface SupersetCardProps {
superset: Superset;
defaultOpen?: boolean;
}
function formatTime(seconds: number | null): string {
if (!seconds) return "";
const mins = Math.round(seconds / 60);
return `${mins}m`;
}
export function SupersetCard({ superset, defaultOpen = false }: SupersetCardProps) {
const [open, setOpen] = useState(defaultOpen);
const sortedExercises = [...superset.exercises].sort(
(a, b) => a.order - b.order
);
const displayName = superset.name || `Superset ${superset.order}`;
return (
<Card className="overflow-hidden">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-zinc-800/50 transition-colors duration-150"
>
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold text-zinc-100">{displayName}</h3>
<Badge variant="accent">{superset.rounds}x</Badge>
{superset.estimated_time && (
<span className="text-xs text-zinc-500">
{formatTime(superset.estimated_time)}
</span>
)}
</div>
<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"
className={`text-zinc-500 transition-transform duration-200 ${
open ? "rotate-180" : ""
}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{open && (
<div className="border-t border-zinc-700/50">
{sortedExercises.map((exercise) => (
<ExerciseRow key={exercise.id} exercise={exercise} />
))}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useEffect, useRef } from "react";
import Hls from "hls.js";
interface VideoPlayerProps {
src: string;
poster?: string;
}
export function VideoPlayer({ src, poster }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const isHLS = src.endsWith(".m3u8");
if (isHLS) {
if (Hls.isSupported()) {
const hls = new Hls();
hlsRef.current = hls;
hls.loadSource(src);
hls.attachMedia(video);
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
// Native HLS support (Safari)
video.src = src;
}
} else {
video.src = src;
}
return () => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
};
}, [src]);
return (
<video
ref={videoRef}
poster={poster}
controls
playsInline
className="w-full rounded-lg bg-zinc-900"
/>
);
}