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

185 lines
5.4 KiB
TypeScript

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