Files
WerkoutAPI/werkout-frontend/components/onboarding/ExcludedExercisesStep.tsx
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

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