Files
Trey t 03681c532d Unraid deployment fixes and generator improvements
- Add Next.js rewrites to proxy API calls through same origin (fixes login/media on werkout.treytartt.com)
- Fix mediaUrl() in DayCard and ExerciseRow to use relative paths in production
- Add proxyTimeout for long-running workout generation endpoints
- Add CSRF trusted origin for treytartt.com
- Split docker-compose into production (Unraid) and dev configs
- Show display_name and descriptions on workout type cards
- Generator: rules engine improvements, movement enforcement, exercise selector updates
- Add new test files for rules drift, workout research generation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:25:45 -06:00

716 lines
25 KiB
TypeScript

"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;
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
return `${window.location.protocol}//${window.location.hostname}:8001${path}`;
}
return 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,
plan_id: previewDay.plan_id,
});
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>
{previewDay.warnings && previewDay.warnings.length > 0 && (
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-2 text-xs text-yellow-200">
<p className="font-semibold mb-1">Warnings</p>
<ul className="list-disc list-inside space-y-0.5">
{previewDay.warnings.map((w, idx) => (
<li key={idx}>{w}</li>
))}
</ul>
</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}
</>
);
}