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:
700
werkout-frontend/components/plans/DayCard.tsx
Normal file
700
werkout-frontend/components/plans/DayCard.tsx
Normal file
@@ -0,0 +1,700 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { VideoPlayer } from "@/components/workout/VideoPlayer";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
GeneratedWorkout,
|
||||
WorkoutDetail,
|
||||
Exercise,
|
||||
PreviewDay,
|
||||
PreviewExercise,
|
||||
} from "@/lib/types";
|
||||
|
||||
interface DayCardProps {
|
||||
// Saved mode
|
||||
workout?: GeneratedWorkout;
|
||||
detail?: WorkoutDetail;
|
||||
onUpdate?: () => void;
|
||||
// Preview mode
|
||||
previewDay?: PreviewDay;
|
||||
previewDayIndex?: number;
|
||||
onPreviewDayChange?: (dayIndex: number, newDay: PreviewDay) => void;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.round(seconds / 60);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function EditIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<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" className={className}>
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<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" className={className}>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function mediaUrl(path: string): string {
|
||||
if (typeof window === "undefined") return path;
|
||||
return `${window.location.protocol}//${window.location.hostname}:8001${path}`;
|
||||
}
|
||||
|
||||
function PlayIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoModal({ src, title, onClose }: { src: string; title: string; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-xl p-4 w-full max-w-lg flex flex-col gap-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 truncate">{title}</h3>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-100 p-1">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<VideoPlayer src={src} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<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" className={className}>
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Swap exercise modal — works for both preview and saved mode
|
||||
function SwapModal({
|
||||
exerciseId,
|
||||
currentName,
|
||||
onSwap,
|
||||
onClose,
|
||||
}: {
|
||||
exerciseId: number;
|
||||
currentName: string;
|
||||
onSwap: (newExercise: Exercise) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [alternatives, setAlternatives] = useState<Exercise[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getSimilarExercises(exerciseId)
|
||||
.then(setAlternatives)
|
||||
.catch((err) => console.error("Failed to load alternatives:", err))
|
||||
.finally(() => setLoading(false));
|
||||
}, [exerciseId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-xl p-4 w-full max-w-md max-h-[70vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">
|
||||
Replace: {currentName}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-100 p-1">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-center text-zinc-400 text-sm py-8">Loading alternatives...</div>
|
||||
) : alternatives.length === 0 ? (
|
||||
<div className="text-center text-zinc-400 text-sm py-8">No alternatives found.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 overflow-y-auto">
|
||||
{alternatives.map((ex) => (
|
||||
<button
|
||||
key={ex.id}
|
||||
className="text-left px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors text-sm text-zinc-200"
|
||||
onClick={() => onSwap(ex)}
|
||||
>
|
||||
{ex.name}
|
||||
{ex.muscle_groups && (
|
||||
<span className="text-[10px] text-zinc-500 ml-2">
|
||||
{ex.muscle_groups}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DayCard({
|
||||
workout,
|
||||
detail: externalDetail,
|
||||
onUpdate,
|
||||
previewDay,
|
||||
previewDayIndex,
|
||||
onPreviewDayChange,
|
||||
}: DayCardProps) {
|
||||
const isPreview = !!previewDay;
|
||||
|
||||
// Saved mode state
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [fetchedDetail, setFetchedDetail] = useState<WorkoutDetail | null>(null);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
|
||||
// Video preview modal state
|
||||
const [videoPreview, setVideoPreview] = useState<{ src: string; title: string } | null>(null);
|
||||
|
||||
// Swap modal state
|
||||
const [swapTarget, setSwapTarget] = useState<{
|
||||
exerciseId: number;
|
||||
name: string;
|
||||
// For saved mode
|
||||
supersetExerciseId?: number;
|
||||
// For preview mode
|
||||
supersetIndex?: number;
|
||||
exerciseIndex?: number;
|
||||
} | null>(null);
|
||||
|
||||
// Saved mode: fetch detail if not provided
|
||||
useEffect(() => {
|
||||
if (isPreview) return;
|
||||
if (externalDetail || !workout?.workout || workout.is_rest_day) return;
|
||||
let cancelled = false;
|
||||
api.getWorkoutDetail(workout.workout).then((data) => {
|
||||
if (!cancelled) setFetchedDetail(data);
|
||||
}).catch((err) => {
|
||||
console.error(`[DayCard] Failed to fetch detail for workout ${workout.workout}:`, err);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isPreview, workout?.workout, workout?.is_rest_day, externalDetail]);
|
||||
|
||||
const detail = externalDetail || fetchedDetail;
|
||||
|
||||
// ============================================
|
||||
// Preview mode handlers
|
||||
// ============================================
|
||||
|
||||
const handlePreviewDeleteDay = () => {
|
||||
if (previewDayIndex === undefined || !previewDay || !onPreviewDayChange) return;
|
||||
onPreviewDayChange(previewDayIndex, {
|
||||
...previewDay,
|
||||
is_rest_day: true,
|
||||
focus_area: "Rest Day",
|
||||
target_muscles: [],
|
||||
workout_spec: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreviewDeleteSuperset = (ssIdx: number) => {
|
||||
if (previewDayIndex === undefined || !previewDay?.workout_spec || !onPreviewDayChange) return;
|
||||
const newSupersets = previewDay.workout_spec.supersets.filter((_, i) => i !== ssIdx);
|
||||
onPreviewDayChange(previewDayIndex, {
|
||||
...previewDay,
|
||||
workout_spec: { ...previewDay.workout_spec, supersets: newSupersets },
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreviewDeleteExercise = (ssIdx: number, exIdx: number) => {
|
||||
if (previewDayIndex === undefined || !previewDay?.workout_spec || !onPreviewDayChange) return;
|
||||
const newSupersets = previewDay.workout_spec.supersets.map((ss, i) => {
|
||||
if (i !== ssIdx) return ss;
|
||||
const newExercises = ss.exercises.filter((_, j) => j !== exIdx);
|
||||
return { ...ss, exercises: newExercises };
|
||||
}).filter((ss) => ss.exercises.length > 0);
|
||||
onPreviewDayChange(previewDayIndex, {
|
||||
...previewDay,
|
||||
workout_spec: { ...previewDay.workout_spec, supersets: newSupersets },
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreviewSwapExercise = (ssIdx: number, exIdx: number, ex: PreviewExercise) => {
|
||||
setSwapTarget({
|
||||
exerciseId: ex.exercise_id,
|
||||
name: ex.exercise_name,
|
||||
supersetIndex: ssIdx,
|
||||
exerciseIndex: exIdx,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreviewSwapConfirm = (newExercise: Exercise) => {
|
||||
if (!swapTarget || swapTarget.supersetIndex === undefined || swapTarget.exerciseIndex === undefined) return;
|
||||
if (previewDayIndex === undefined || !previewDay?.workout_spec || !onPreviewDayChange) return;
|
||||
|
||||
const ssIdx = swapTarget.supersetIndex;
|
||||
const exIdx = swapTarget.exerciseIndex;
|
||||
|
||||
const newSupersets = previewDay.workout_spec.supersets.map((ss, i) => {
|
||||
if (i !== ssIdx) return ss;
|
||||
const newExercises = ss.exercises.map((ex, j) => {
|
||||
if (j !== exIdx) return ex;
|
||||
return {
|
||||
...ex,
|
||||
exercise_id: newExercise.id,
|
||||
exercise_name: newExercise.name,
|
||||
muscle_groups: newExercise.muscle_groups || "",
|
||||
};
|
||||
});
|
||||
return { ...ss, exercises: newExercises };
|
||||
});
|
||||
|
||||
onPreviewDayChange(previewDayIndex, {
|
||||
...previewDay,
|
||||
workout_spec: { ...previewDay.workout_spec, supersets: newSupersets },
|
||||
});
|
||||
setSwapTarget(null);
|
||||
};
|
||||
|
||||
const handlePreviewRegenerate = async () => {
|
||||
if (previewDayIndex === undefined || !previewDay || !onPreviewDayChange) return;
|
||||
setRegenerating(true);
|
||||
try {
|
||||
const newDay = await api.previewDay({
|
||||
target_muscles: previewDay.target_muscles,
|
||||
focus_area: previewDay.focus_area,
|
||||
workout_type_id: previewDay.workout_type_id,
|
||||
date: previewDay.date,
|
||||
});
|
||||
onPreviewDayChange(previewDayIndex, newDay);
|
||||
} catch (err) {
|
||||
console.error("Failed to regenerate day:", err);
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Saved mode handlers
|
||||
// ============================================
|
||||
|
||||
const handleDeleteDay = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!confirm("Remove this workout day?")) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteWorkoutDay(workout!.id);
|
||||
onUpdate?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete day:", err);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSuperset = async (e: React.MouseEvent, supersetId: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.deleteSuperset(supersetId);
|
||||
onUpdate?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete superset:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteExercise = async (e: React.MouseEvent, seId: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.deleteSupersetExercise(seId);
|
||||
onUpdate?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete exercise:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavedSwapExercise = (seId: number, exerciseId: number, name: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSwapTarget({ supersetExerciseId: seId, exerciseId, name });
|
||||
};
|
||||
|
||||
const handleSavedSwapConfirm = async (newExercise: Exercise) => {
|
||||
if (!swapTarget?.supersetExerciseId) return;
|
||||
try {
|
||||
await api.swapExercise(swapTarget.supersetExerciseId, newExercise.id);
|
||||
setSwapTarget(null);
|
||||
onUpdate?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to swap exercise:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Render: Preview mode
|
||||
// ============================================
|
||||
|
||||
if (isPreview && previewDay) {
|
||||
if (previewDay.is_rest_day) return null;
|
||||
|
||||
const spec = previewDay.workout_spec;
|
||||
const typeName = previewDay.workout_type_name?.replace(/_/g, " ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="p-4 flex flex-col gap-3 relative">
|
||||
{/* Top-right actions */}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 z-10">
|
||||
<button
|
||||
onClick={handlePreviewRegenerate}
|
||||
disabled={regenerating}
|
||||
className="p-1 rounded-md text-zinc-500 hover:text-[#39FF14] hover:bg-zinc-800 transition-colors"
|
||||
title="Regenerate this day"
|
||||
>
|
||||
<RefreshIcon className={regenerating ? "animate-spin" : ""} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreviewDeleteDay}
|
||||
className="p-1 rounded-md text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition-colors"
|
||||
title="Remove this day"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2 pr-14">
|
||||
<div className="flex-1 min-w-0">
|
||||
{typeName && (
|
||||
<Badge variant="accent" className="text-[10px] capitalize mb-1.5">
|
||||
{typeName}
|
||||
</Badge>
|
||||
)}
|
||||
{previewDay.focus_area && (
|
||||
<p className="text-sm font-semibold text-zinc-100 break-words">
|
||||
{previewDay.focus_area}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{spec?.estimated_time && (
|
||||
<span className="text-xs text-zinc-400 font-medium whitespace-nowrap mt-0.5">
|
||||
{formatTime(spec.estimated_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supersets */}
|
||||
{spec && spec.supersets.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{spec.supersets.map((superset, si) => (
|
||||
<div key={si} className="bg-zinc-800/50 rounded-lg px-3 py-2 group/superset">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
{superset.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
{superset.rounds}x
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePreviewDeleteSuperset(si)}
|
||||
className="p-0.5 rounded text-zinc-600 hover:text-red-400 opacity-0 group-hover/superset:opacity-100 transition-opacity"
|
||||
title="Delete superset"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{superset.exercises.map((ex, ei) => (
|
||||
<div
|
||||
key={ei}
|
||||
className="flex items-center justify-between gap-1 group/exercise"
|
||||
>
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<span className="text-xs text-zinc-200 break-words">
|
||||
{ex.exercise_name}
|
||||
</span>
|
||||
{ex.video_url && (
|
||||
<button
|
||||
onClick={() => setVideoPreview({ src: mediaUrl(ex.video_url!), title: ex.exercise_name })}
|
||||
className="flex-shrink-0 p-0.5 rounded text-zinc-500 hover:text-[#39FF14] transition-colors"
|
||||
title="Preview video"
|
||||
>
|
||||
<PlayIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-xs font-semibold text-[#39FF14] whitespace-nowrap">
|
||||
{ex.reps ? `${ex.reps} reps` : ex.duration ? `${ex.duration}s` : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover/exercise:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handlePreviewSwapExercise(si, ei, ex)}
|
||||
className="p-0.5 rounded text-zinc-500 hover:text-[#39FF14]"
|
||||
title="Swap exercise"
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePreviewDeleteExercise(si, ei)}
|
||||
className="p-0.5 rounded text-zinc-500 hover:text-red-400"
|
||||
title="Delete exercise"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Muscle summary fallback */}
|
||||
{!spec && previewDay.target_muscles.length > 0 && (
|
||||
<p className="text-xs text-zinc-400 break-words">
|
||||
{previewDay.target_muscles.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{swapTarget && (
|
||||
<SwapModal
|
||||
exerciseId={swapTarget.exerciseId}
|
||||
currentName={swapTarget.name}
|
||||
onSwap={handlePreviewSwapConfirm}
|
||||
onClose={() => setSwapTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{videoPreview && (
|
||||
<VideoModal
|
||||
src={videoPreview.src}
|
||||
title={videoPreview.title}
|
||||
onClose={() => setVideoPreview(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Render: Saved mode
|
||||
// ============================================
|
||||
|
||||
if (!workout || workout.is_rest_day) return null;
|
||||
|
||||
const typeName = workout.workout_type_name?.replace(/_/g, " ");
|
||||
const sortedSupersets = detail
|
||||
? [...detail.supersets].sort((a, b) => a.order - b.order)
|
||||
: [];
|
||||
|
||||
const cardContent = (
|
||||
<Card className="p-4 flex flex-col gap-3 relative">
|
||||
<button
|
||||
onClick={handleDeleteDay}
|
||||
disabled={deleting}
|
||||
className="absolute top-3 right-3 p-1 rounded-md text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition-colors z-10"
|
||||
title="Remove this day"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start justify-between gap-2 pr-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
{typeName && (
|
||||
<Badge variant="accent" className="text-[10px] capitalize mb-1.5">
|
||||
{typeName}
|
||||
</Badge>
|
||||
)}
|
||||
{workout.focus_area && (
|
||||
<p className="text-sm font-semibold text-zinc-100 break-words">
|
||||
{workout.focus_area}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{detail?.estimated_time && (
|
||||
<span className="text-xs text-zinc-400 font-medium whitespace-nowrap mt-0.5">
|
||||
{formatTime(detail.estimated_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sortedSupersets.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sortedSupersets.map((superset, si) => {
|
||||
const sortedExercises = [...superset.exercises].sort(
|
||||
(a, b) => a.order - b.order
|
||||
);
|
||||
return (
|
||||
<div key={superset.id ?? `s-${si}`} className="bg-zinc-800/50 rounded-lg px-3 py-2 group/superset">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
{superset.name || `Set ${superset.order}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
{superset.rounds}x
|
||||
</span>
|
||||
{superset.id && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteSuperset(e, superset.id)}
|
||||
className="p-0.5 rounded text-zinc-600 hover:text-red-400 opacity-0 group-hover/superset:opacity-100 transition-opacity"
|
||||
title="Delete superset"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sortedExercises.map((se, ei) => (
|
||||
<div
|
||||
key={se.id ?? `e-${si}-${ei}`}
|
||||
className="flex items-center justify-between gap-1 group/exercise"
|
||||
>
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<span className="text-xs text-zinc-200 break-words">
|
||||
{se.exercise.name}
|
||||
</span>
|
||||
{se.exercise.video_url && (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setVideoPreview({ src: mediaUrl(se.exercise.video_url), title: se.exercise.name }); }}
|
||||
className="flex-shrink-0 p-0.5 rounded text-zinc-500 hover:text-[#39FF14] transition-colors"
|
||||
title="Preview video"
|
||||
>
|
||||
<PlayIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-xs font-semibold text-[#39FF14] whitespace-nowrap">
|
||||
{se.reps ? `${se.reps} reps` : se.duration ? `${se.duration}s` : ""}
|
||||
</span>
|
||||
{se.id && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover/exercise:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={handleSavedSwapExercise(se.id, se.exercise.id, se.exercise.name)}
|
||||
className="p-0.5 rounded text-zinc-500 hover:text-[#39FF14]"
|
||||
title="Swap exercise"
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDeleteExercise(e, se.id)}
|
||||
className="p-0.5 rounded text-zinc-500 hover:text-red-400"
|
||||
title="Delete exercise"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!detail && workout.target_muscles.length > 0 && (
|
||||
<p className="text-xs text-zinc-400 break-words">
|
||||
{workout.target_muscles.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Status badges for saved workouts */}
|
||||
{workout.status === "accepted" && (
|
||||
<div className="mt-auto pt-1">
|
||||
<Badge variant="success">Saved</Badge>
|
||||
</div>
|
||||
)}
|
||||
{workout.status === "completed" && (
|
||||
<div className="mt-auto pt-1">
|
||||
<Badge variant="accent">Completed</Badge>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const modal = swapTarget ? (
|
||||
<SwapModal
|
||||
exerciseId={swapTarget.exerciseId}
|
||||
currentName={swapTarget.name}
|
||||
onSwap={handleSavedSwapConfirm}
|
||||
onClose={() => setSwapTarget(null)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const videoModal = videoPreview ? (
|
||||
<VideoModal
|
||||
src={videoPreview.src}
|
||||
title={videoPreview.title}
|
||||
onClose={() => setVideoPreview(null)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (workout.workout) {
|
||||
return (
|
||||
<>
|
||||
<Link href={`/workout/${workout.workout}`} className="block">
|
||||
{cardContent}
|
||||
</Link>
|
||||
{modal}
|
||||
{videoModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{cardContent}
|
||||
{modal}
|
||||
{videoModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
werkout-frontend/components/plans/PlanCard.tsx
Normal file
50
werkout-frontend/components/plans/PlanCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { GeneratedWeeklyPlan } from "@/lib/types";
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: GeneratedWeeklyPlan;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function getStatusVariant(
|
||||
status: string
|
||||
): "success" | "warning" | "error" | "default" {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "pending":
|
||||
return "warning";
|
||||
case "failed":
|
||||
return "error";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
export function PlanCard({ plan }: PlanCardProps) {
|
||||
const workoutDays = plan.generated_workouts.filter((w) => !w.is_rest_day);
|
||||
const dateRange = `${formatDate(plan.week_start_date)} - ${formatDate(plan.week_end_date)}`;
|
||||
|
||||
return (
|
||||
<Link href={`/plans/${plan.id}`} className="block">
|
||||
<Card className="p-4 hover:bg-zinc-800/50 transition-colors duration-150">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">{dateRange}</h3>
|
||||
<Badge variant={getStatusVariant(plan.status)}>
|
||||
{plan.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-zinc-400">
|
||||
<span>{workoutDays.length} workout{workoutDays.length !== 1 ? "s" : ""}</span>
|
||||
<span>{plan.generation_time_ms}ms</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
73
werkout-frontend/components/plans/WeekPicker.tsx
Normal file
73
werkout-frontend/components/plans/WeekPicker.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
interface WeekPickerProps {
|
||||
selectedMonday: string;
|
||||
onChange: (monday: string) => void;
|
||||
}
|
||||
|
||||
function getMondayDate(dateStr: string): Date {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function formatWeekLabel(dateStr: string): string {
|
||||
const date = getMondayDate(dateStr);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function ChevronLeft() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronRight() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeekPicker({ selectedMonday, onChange }: WeekPickerProps) {
|
||||
const shiftWeek = (offset: number) => {
|
||||
const date = getMondayDate(selectedMonday);
|
||||
date.setDate(date.getDate() + offset * 7);
|
||||
onChange(formatDate(date));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => shiftWeek(-1)}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
|
||||
aria-label="Previous week"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-zinc-200 min-w-[160px] text-center">
|
||||
Week of {formatWeekLabel(selectedMonday)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => shiftWeek(1)}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
|
||||
aria-label="Next week"
|
||||
>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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