- 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>
168 lines
5.1 KiB
TypeScript
168 lines
5.1 KiB
TypeScript
"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't or won't do. They'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 "{search}"
|
|
</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>
|
|
);
|
|
}
|