"use client"; import { useState, useCallback, useRef, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Exercise } from "@prisma/client"; import { ChevronDown, ChevronUp, Loader, Trash2, Plus, Save, Pencil, Check, ArrowUp, ArrowDown, Clock, X } from "lucide-react"; import ExercisePicker from "./ExercisePicker"; import SetRow, { InputField } from "./SetRow"; import { formatSetsSummary } from "@/lib/formatSets"; // --------------- Exercise History Popup --------------- function ExerciseHistoryPopup({ exerciseId, onClose, }: { exerciseId: string; onClose: () => void; }) { const [history, setHistory] = useState< Array<{ workout: { id: string; date: string; name?: string }; sets: Array<{ weight?: number; reps?: number; weightUnit?: string }> }> >([]); const [loading, setLoading] = useState(true); const popupRef = useRef(null); useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch(`/api/exercises/${exerciseId}`); if (res.ok) { const data = await res.json(); if (!cancelled) setHistory(data.history || []); } } catch {} if (!cancelled) setLoading(false); })(); return () => { cancelled = true; }; }, [exerciseId]); // Close on outside click useEffect(() => { const handler = (e: MouseEvent) => { if (popupRef.current && !popupRef.current.contains(e.target as Node)) { onClose(); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [onClose]); return (
Recent History
{loading ? (
) : history.length === 0 ? (

No history yet

) : (
{history.slice(0, 10).map((entry) => { const d = new Date(entry.workout.date); const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); const summary = formatSetsSummary(entry.sets); return (
{dateStr} · {entry.sets.length} set{entry.sets.length !== 1 ? "s" : ""}
{summary && (

{summary}

)}
); })}
)}
); } function parseInputFields(exercise: Exercise): InputField[] { try { const raw = (exercise as any).inputFields; if (raw && typeof raw === "string") { return JSON.parse(raw); } } catch {} return ["sets", "reps", "weight"]; } interface ExerciseWithSets { exercise: Exercise; sets: Array<{ setNumber: number; reps?: number; weight?: number; rpe?: number; notes?: string; forceEdit?: boolean; // When true, start in edit mode even if data is pre-filled }>; } export interface EditWorkoutData { id: string; name: string; date: string; // ISO string durationMinutes?: number | null; difficulty?: number | null; caloriesBurned?: number | null; notes?: string | null; exercises: Array<{ exercise: Exercise; sets: Array<{ setNumber: number; reps?: number; weight?: number; rpe?: number; notes?: string; }>; }>; } interface WorkoutFormProps { exercises: Exercise[]; recentlyUsedExercises?: string[]; editWorkout?: EditWorkoutData; } export default function WorkoutForm({ exercises: initialExercises, recentlyUsedExercises = [], editWorkout, }: WorkoutFormProps) { const router = useRouter(); const [loading, setLoading] = useState(false); const [exercises, setExercises] = useState(initialExercises); const [workoutName, setWorkoutName] = useState(editWorkout?.name || ""); const [workoutDate, setWorkoutDate] = useState(() => { if (editWorkout?.date) { return new Date(editWorkout.date).toISOString().split("T")[0]; } return new Date().toISOString().split("T")[0]; }); const [duration, setDuration] = useState(editWorkout?.durationMinutes?.toString() || ""); const [difficulty, setDifficulty] = useState(editWorkout?.difficulty?.toString() || ""); const [workoutCalories, setWorkoutCalories] = useState(editWorkout?.caloriesBurned?.toString() || ""); const [notes, setNotes] = useState(editWorkout?.notes || ""); const [notesLocked, setNotesLocked] = useState(!!editWorkout?.notes); const [addedExercises, setAddedExercises] = useState( editWorkout?.exercises || [] ); const [expandedExercise, setExpandedExercise] = useState(null); const [historyPopupExercise, setHistoryPopupExercise] = useState(null); // Header lock state (name + date lock after first save, or immediately for edits) const [headerLocked, setHeaderLocked] = useState(!!editWorkout); // Auto-save state — if editing, start with existing workout ID const [savedWorkoutId, setSavedWorkoutId] = useState(editWorkout?.id || null); const [autoSaving, setAutoSaving] = useState(false); const [showSavedFlash, setShowSavedFlash] = useState(false); const savingRef = useRef(false); const savedFlashTimer = useRef(null); // Flash "Saved ✓" briefly after each successful save const triggerSavedFlash = useCallback(() => { setShowSavedFlash(true); if (savedFlashTimer.current) clearTimeout(savedFlashTimer.current); savedFlashTimer.current = setTimeout(() => setShowSavedFlash(false), 2000); }, []); // Cleanup timer on unmount useEffect(() => { return () => { if (savedFlashTimer.current) clearTimeout(savedFlashTimer.current); }; }, []); // ---------- Build payload from current state ---------- const buildPayload = useCallback( (currentExercises?: ExerciseWithSets[]) => { const exs = currentExercises ?? addedExercises; return { name: workoutName, durationMinutes: duration ? parseInt(duration) : undefined, difficulty: difficulty ? parseInt(difficulty) : undefined, caloriesBurned: workoutCalories ? parseInt(workoutCalories) : undefined, notes: notes || undefined, date: new Date(workoutDate + "T12:00:00").toISOString(), sets: exs.flatMap((e) => e.sets.map((s) => ({ exerciseId: e.exercise.id, setNumber: s.setNumber, reps: s.reps, weight: s.weight, weightUnit: (e.exercise as any).defaultWeightUnit || "lbs", rpe: s.rpe, notes: s.notes, })) ), }; }, [workoutName, workoutDate, duration, difficulty, workoutCalories, notes, addedExercises] ); // ---------- Auto-save: create or update ---------- const autoSave = useCallback( async (overrideExercises?: ExerciseWithSets[]) => { if (savingRef.current) return; savingRef.current = true; setAutoSaving(true); try { const payload = buildPayload(overrideExercises); if (!savedWorkoutId) { // First save — POST to create const response = await fetch("/api/workouts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (response.ok) { const created = await response.json(); setSavedWorkoutId(created.id); triggerSavedFlash(); setHeaderLocked(true); } } else { // Subsequent save — PATCH to update const response = await fetch(`/api/workouts/${savedWorkoutId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, durationMinutes: payload.durationMinutes ?? null, difficulty: payload.difficulty ?? null, caloriesBurned: payload.caloriesBurned ?? null, notes: payload.notes ?? null, }), }); if (response.ok) { triggerSavedFlash(); setHeaderLocked(true); } } } catch (error) { console.error("Auto-save failed:", error); } finally { savingRef.current = false; setAutoSaving(false); } }, [buildPayload, savedWorkoutId, triggerSavedFlash] ); // ---------- Exercise handlers ---------- const handleAddExercise = (exercise: Exercise) => { if (addedExercises.some((e) => e.exercise.id === exercise.id)) { setExpandedExercise(exercise.id); return; } setAddedExercises((prev) => [ ...prev, { exercise, sets: [{ setNumber: 1, reps: undefined, weight: undefined }], }, ]); setExpandedExercise(exercise.id); }; const handleExerciseCreated = (exercise: Exercise) => { setExercises((prev) => [...prev, exercise]); }; const handleMoveExercise = (exerciseId: string, direction: "up" | "down") => { setAddedExercises((prev) => { const idx = prev.findIndex((e) => e.exercise.id === exerciseId); if (idx < 0) return prev; const swapIdx = direction === "up" ? idx - 1 : idx + 1; if (swapIdx < 0 || swapIdx >= prev.length) return prev; const updated = [...prev]; [updated[idx], updated[swapIdx]] = [updated[swapIdx], updated[idx]]; return updated; }); }; const handleRemoveExercise = (exerciseId: string) => { setAddedExercises((prev) => { const updated = prev.filter((e) => e.exercise.id !== exerciseId); // Auto-save after removing exercise setTimeout(() => autoSave(updated), 0); return updated; }); if (expandedExercise === exerciseId) setExpandedExercise(null); }; const handleAddSet = (exerciseId: string) => { setAddedExercises((prev) => prev.map((e) => { if (e.exercise.id === exerciseId) { const nextSetNumber = Math.max(...e.sets.map((s) => s.setNumber), 0) + 1; return { ...e, sets: [ ...e.sets, { setNumber: nextSetNumber, reps: undefined, weight: undefined }, ], }; } return e; }) ); }; const handleRemoveSet = (exerciseId: string, setNumber: number) => { setAddedExercises((prev) => { const updated = prev.map((e) => { if (e.exercise.id === exerciseId) { return { ...e, sets: e.sets.filter((s) => s.setNumber !== setNumber), }; } return e; }); // Auto-save after removing set setTimeout(() => autoSave(updated), 0); return updated; }); }; const handleUpdateSet = ( exerciseId: string, setNumber: number, data: { reps?: number; weight?: number; rpe?: number; notes?: string } ) => { setAddedExercises((prev) => prev.map((e) => { if (e.exercise.id === exerciseId) { return { ...e, sets: e.sets.map((s) => s.setNumber === setNumber ? { ...s, ...data } : s ), }; } return e; }) ); }; // Called when user confirms a set (check icon) — triggers auto-save // Also clears forceEdit so the set stays locked when the exercise is collapsed/re-expanded const handleSetConfirmed = (exerciseId?: string, setNumber?: number) => { if (exerciseId && setNumber !== undefined) { setAddedExercises((prev) => prev.map((e) => { if (e.exercise.id === exerciseId) { return { ...e, sets: e.sets.map((s) => s.setNumber === setNumber ? { ...s, forceEdit: false } : s ), }; } return e; }) ); } // Small delay to let state settle after the SetRow's emitUpdate setTimeout(() => autoSave(), 50); }; // Called when user taps "next set" arrow — confirm current set + add new pre-filled set const handleNextSet = ( exerciseId: string, currentValues: { weight?: string; reps?: string; rpe?: string; notes?: string; duration?: string; distance?: string; calories?: string; } ) => { setAddedExercises((prev) => prev.map((e) => { if (e.exercise.id === exerciseId) { const nextSetNumber = Math.max(...e.sets.map((s) => s.setNumber), 0) + 1; return { ...e, // Clear forceEdit on all existing sets (they're confirmed now) sets: [ ...e.sets.map((s) => (s.forceEdit ? { ...s, forceEdit: false } : s)), { setNumber: nextSetNumber, weight: currentValues.weight ? parseFloat(currentValues.weight) : undefined, reps: undefined, // User typically changes reps per set rpe: currentValues.rpe ? parseInt(currentValues.rpe) : undefined, notes: currentValues.notes || undefined, forceEdit: true, // Start in edit mode even though weight is pre-filled }, ], }; } return e; }) ); // Auto-save after confirming the current set setTimeout(() => autoSave(), 50); }; // Called when user saves notes — triggers auto-save const handleNotesSave = () => { setNotesLocked(true); setTimeout(() => autoSave(), 50); }; // ---------- Save and Close ---------- const handleSaveAndClose = async (e: React.FormEvent) => { e.preventDefault(); if (addedExercises.length === 0) { alert("Please add at least one exercise"); return; } setLoading(true); try { // Wait for any in-flight auto-save to finish to avoid race conditions // (both would delete-all-sets + recreate simultaneously) while (savingRef.current) { await new Promise((r) => setTimeout(r, 100)); } // Prevent auto-saves from starting while we do the final save savingRef.current = true; const payload = buildPayload(); if (!savedWorkoutId) { // Never saved before — create const response = await fetch("/api/workouts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) throw new Error("Failed to save workout"); } else { // Already saved — final update const response = await fetch(`/api/workouts/${savedWorkoutId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, durationMinutes: payload.durationMinutes ?? null, difficulty: payload.difficulty ?? null, caloriesBurned: payload.caloriesBurned ?? null, notes: payload.notes ?? null, }), }); if (!response.ok) throw new Error("Failed to save workout"); } // Navigate back: to detail page if editing, otherwise to list if (editWorkout) { router.push(`/main/workouts/${savedWorkoutId || editWorkout.id}`); } else { router.push("/main/workouts"); } } catch (error) { console.error("Error saving workout:", error); alert("Failed to save workout. Please try again."); } finally { savingRef.current = false; setLoading(false); } }; return (
{ // Prevent Enter from submitting form when editing inputs if (e.key === "Enter") { const target = e.target as HTMLElement; const tag = target.tagName.toLowerCase(); // Allow submit only from the submit button itself if (tag === "input" || tag === "textarea" || tag === "select") { e.preventDefault(); } } }} className="space-y-6" > {/* Auto-save indicator — fixed height to prevent layout shift */}
{autoSaving ? ( <> Saving... ) : ( <> Saved )}
{/* Workout name & date */} {headerLocked ? (

{workoutName || "Unnamed Workout"}

{new Date(workoutDate + "T12:00:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric", })}

) : ( <>
setWorkoutName(e.target.value)} placeholder="- -" className="w-full px-4 py-3 border border-zinc-700 rounded-lg bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600" />
setWorkoutDate(e.target.value)} className="w-full px-4 py-3 border border-zinc-700 rounded-lg bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 [color-scheme:dark]" />
)} {/* Exercises */}

Exercises

{/* Added exercises */} {addedExercises.length > 0 && (
{addedExercises.map((item, exIdx) => (
{/* Exercise header — compact with reorder */}
{/* Reorder buttons */} {addedExercises.length > 1 && (
)} {/* History popup toggle */}
{/* Exercise history popup */} {historyPopupExercise === item.exercise.id && ( setHistoryPopupExercise(null)} /> )} {/* Exercise body (expanded) */} {expandedExercise === item.exercise.id && (
{item.sets.map((set, idx) => ( handleUpdateSet( item.exercise.id, set.setNumber, data ) } onConfirm={() => handleSetConfirmed(item.exercise.id, set.setNumber)} onNextSet={(vals) => handleNextSet(item.exercise.id, vals)} onDelete={() => handleRemoveSet(item.exercise.id, set.setNumber) } /> ))}
)}
))}
)} {/* Inline exercise search */}
{/* Post-workout: Notes, Duration, Calories, Difficulty */}

Post-Workout

{/* Notes — lockable */}
{notes && ( )}
{notesLocked ? ( ) : (