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:
184
werkout-frontend/components/plans/WeeklyPlanGrid.tsx
Normal file
184
werkout-frontend/components/plans/WeeklyPlanGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user