- 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>
185 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|