Files
proof-of-work/workout-planner/components/workouts/SetRow.tsx
T
2026-02-28 09:27:26 -06:00

407 lines
14 KiB
TypeScript

"use client";
import { Trash2, Check, Pencil, CornerDownLeft } from "lucide-react";
import { useState, useCallback } from "react";
export type InputField = "sets" | "reps" | "weight" | "duration" | "distance" | "calories" | "notes";
export interface SetRowProps {
setNumber: number;
inputFields?: InputField[];
weightUnit?: string;
initialReps?: number;
initialWeight?: number;
initialRpe?: number;
initialNotes?: string;
initialDuration?: number;
initialDistance?: number;
initialCalories?: number;
initialLocked?: boolean;
autoFocus?: boolean;
onUpdate: (data: {
reps?: number;
weight?: number;
rpe?: number;
notes?: string;
durationSeconds?: number;
distance?: number;
calories?: number;
}) => void;
onConfirm?: () => void;
onNextSet?: (currentValues: {
weight?: string;
reps?: string;
rpe?: string;
notes?: string;
duration?: string;
distance?: string;
calories?: string;
}) => void;
onDelete: () => void;
}
export default function SetRow({
setNumber,
inputFields = ["sets", "reps", "weight"],
weightUnit = "lbs",
initialReps,
initialWeight,
initialRpe,
initialNotes,
initialDuration,
initialDistance,
initialCalories,
initialLocked = false,
autoFocus = false,
onUpdate,
onConfirm,
onNextSet,
onDelete,
}: SetRowProps) {
const [reps, setReps] = useState(initialReps?.toString() || "");
const [weight, setWeight] = useState(initialWeight?.toString() || "");
const [rpe, setRpe] = useState(initialRpe?.toString() || "");
const [notes, setNotes] = useState(initialNotes || "");
const [duration, setDuration] = useState(initialDuration?.toString() || "");
const [distance, setDistance] = useState(initialDistance?.toString() || "");
const [calories, setCalories] = useState(initialCalories?.toString() || "");
const [showNotes, setShowNotes] = useState(!!initialNotes);
const [locked, setLocked] = useState(initialLocked);
const showReps = inputFields.includes("reps");
const showWeight = inputFields.includes("weight");
const showDuration = inputFields.includes("duration");
const showDistance = inputFields.includes("distance");
const showCalories = inputFields.includes("calories");
const showNotesField = inputFields.includes("notes");
const emitUpdate = useCallback(
(overrides: {
reps?: string;
weight?: string;
rpe?: string;
notes?: string;
duration?: string;
distance?: string;
calories?: string;
}) => {
const r = overrides.reps ?? reps;
const w = overrides.weight ?? weight;
const p = overrides.rpe ?? rpe;
const n = overrides.notes ?? notes;
const dur = overrides.duration ?? duration;
const dist = overrides.distance ?? distance;
const cal = overrides.calories ?? calories;
onUpdate({
reps: r ? parseInt(r) : undefined,
weight: w ? parseFloat(w) : undefined,
rpe: p ? parseInt(p) : undefined,
notes: n || undefined,
durationSeconds: dur ? parseInt(dur) : undefined,
distance: dist ? parseFloat(dist) : undefined,
calories: cal ? parseInt(cal) : undefined,
});
},
[reps, weight, rpe, notes, duration, distance, calories, onUpdate]
);
const handleConfirm = () => {
emitUpdate({});
setLocked(true);
onConfirm?.();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleConfirm();
}
};
const handleUnlock = () => {
setLocked(false);
};
const handleNextSet = () => {
emitUpdate({});
setLocked(true);
onNextSet?.({ weight, reps, rpe, notes, duration, distance, calories });
};
// Build a summary string for the locked view
const buildSummary = () => {
const parts: string[] = [];
if (showWeight && weight) parts.push(`${weight} ${weightUnit}`);
if (showReps && reps) parts.push(`${reps} reps`);
if (showDuration && duration) parts.push(`${duration}s`);
if (showDistance && distance) parts.push(`${distance} mi`);
if (showCalories && calories) parts.push(`${calories} cal`);
if (rpe) parts.push(`RPE ${rpe}`);
if (showNotesField && notes) parts.push(notes);
return parts.length > 0 ? parts.join(" · ") : "No data";
};
// ---------- LOCKED VIEW ----------
if (locked) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
{/* Set number badge */}
<div className="bg-white text-black rounded-full w-6 h-6 flex items-center justify-center font-semibold text-xs flex-shrink-0">
{setNumber}
</div>
{/* Summary text */}
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-300 truncate">{buildSummary()}</p>
{!showNotesField && notes && (
<p className="text-[10px] text-zinc-500 truncate mt-0.5">{notes}</p>
)}
</div>
{/* Edit (unlock) button */}
<button
type="button"
onClick={handleUnlock}
className="p-1.5 rounded-md flex-shrink-0 transition-colors text-zinc-400 hover:text-white hover:bg-zinc-800"
aria-label="Edit set"
>
<Pencil className="w-4 h-4" />
</button>
{/* Delete button */}
<button
type="button"
onClick={onDelete}
className="p-1.5 text-red-500 hover:bg-red-950/30 rounded-md flex-shrink-0 active:bg-red-900/30"
aria-label="Delete set"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
}
// Determine which field gets autoFocus
const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : null;
// ---------- EDIT VIEW ----------
return (
<div className="space-y-1.5" onKeyDown={handleKeyDown}>
<div className="flex items-end gap-1.5">
{/* Set number badge */}
<div className="bg-white text-black rounded-full w-6 h-6 flex items-center justify-center font-semibold text-xs flex-shrink-0 mb-0.5">
{setNumber}
</div>
{/* Weight input */}
{showWeight && (
<div className="flex-1 min-w-[55px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Weight
</label>
<input
type="number"
step="0.5"
autoFocus={autoFocus && firstField === "weight"}
value={weight}
onChange={(e) => {
const val = e.target.value;
setWeight(val);
emitUpdate({ weight: val });
}}
placeholder="0"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* Reps input */}
{showReps && (
<div className="flex-1 min-w-[55px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Reps
</label>
<input
type="number"
autoFocus={autoFocus && firstField === "reps"}
value={reps}
onChange={(e) => {
const val = e.target.value;
setReps(val);
emitUpdate({ reps: val });
}}
placeholder="0"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* Duration input (seconds) */}
{showDuration && (
<div className="flex-1 min-w-[55px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Time (s)
</label>
<input
type="number"
autoFocus={autoFocus && firstField === "duration"}
value={duration}
onChange={(e) => {
const val = e.target.value;
setDuration(val);
emitUpdate({ duration: val });
}}
placeholder="0"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* Distance input */}
{showDistance && (
<div className="flex-1 min-w-[55px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Dist (mi)
</label>
<input
type="number"
step="0.1"
autoFocus={autoFocus && firstField === "distance"}
value={distance}
onChange={(e) => {
const val = e.target.value;
setDistance(val);
emitUpdate({ distance: val });
}}
placeholder="0"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* Calories input */}
{showCalories && (
<div className="flex-1 min-w-[55px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Cals
</label>
<input
type="number"
autoFocus={autoFocus && firstField === "calories"}
value={calories}
onChange={(e) => {
const val = e.target.value;
setCalories(val);
emitUpdate({ calories: val });
}}
placeholder="0"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* RPE select — always shown */}
<div className="flex-1 min-w-[50px] max-w-[60px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
RPE
</label>
<select
value={rpe}
onChange={(e) => {
const val = e.target.value;
setRpe(val);
emitUpdate({ rpe: val });
}}
className="w-full px-1.5 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
>
<option value="">-</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
</select>
</div>
{/* Next set button — confirm + add new pre-filled set */}
{onNextSet && (
<button
type="button"
onClick={handleNextSet}
className="p-1.5 rounded-md flex-shrink-0 transition-colors text-zinc-400 hover:text-blue-400 hover:bg-blue-950/30"
aria-label="Save and start next set"
>
<CornerDownLeft className="w-4 h-4" />
</button>
)}
{/* Confirm button */}
<button
type="button"
onClick={handleConfirm}
className="p-1.5 rounded-md flex-shrink-0 transition-colors text-zinc-400 hover:text-green-400 hover:bg-green-950/30"
aria-label="Confirm set"
>
<Check className="w-4 h-4" />
</button>
{/* Delete button */}
<button
type="button"
onClick={onDelete}
className="p-1.5 text-red-500 hover:bg-red-950/30 rounded-md flex-shrink-0 active:bg-red-900/30"
aria-label="Delete set"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Notes — always visible when configured as input field */}
{showNotesField && (
<div className="ml-8">
<input
type="text"
value={notes}
onChange={(e) => {
const val = e.target.value;
setNotes(val);
emitUpdate({ notes: val });
}}
placeholder="e.g. weighted vest, ankle weights..."
className="w-full px-2 py-1 border border-zinc-700 rounded-md text-xs bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* Toggle notes button (fallback when notes is not a configured input field) */}
{!showNotesField && showNotes && (
<div className="ml-8">
<input
type="text"
value={notes}
onChange={(e) => {
const val = e.target.value;
setNotes(val);
emitUpdate({ notes: val });
}}
placeholder="Add notes (optional)"
className="w-full px-2 py-1 border border-zinc-700 rounded-md text-xs bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{!showNotesField && !showNotes && (
<button
type="button"
onClick={() => setShowNotes(true)}
className="text-[10px] text-zinc-500 hover:text-zinc-300 ml-8"
>
+ Add notes
</button>
)}
</div>
);
}