Files
proof-of-work/proof-of-work/components/workouts/SetRow.tsx
T
Keysat 4be489d6d3 v1.2.0:5 — Gear (breathing, 1-5) replaces RPE as the effort field for cardio
Cardio exercises now log a breathing "Gear" (1-5, per Brian MacKenzie)
instead of RPE (6-10) as their effort field; strength keeps RPE. An exercise
counts as cardio when its equipment type is "cardio" or it carries the
"cardio" muscle group (isCardioExercise in lib/exerciseOptions), so the
Assault Bike (type "assault bike") qualifies.

New nullable SetLog.gear column added by the boot-time guarded ALTER in
docker_entrypoint.sh (additive, idempotent); plumbed through all 5 set-write
paths, the summary/edit views, and CSV/JSON import-export. Existing rpe data
is untouched and still displays. Program/AI target-RPE is unaffected.
2026-06-16 14:49:15 -05:00

566 lines
19 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"
| "watts"
| "notes"
| string;
export interface SetRowProps {
setNumber: number;
inputFields?: InputField[];
weightUnit?: string;
/** Cardio sets log breathing "Gear" (1-5) instead of RPE (6-10). */
isCardio?: boolean;
initialReps?: number;
initialWeight?: number;
initialRpe?: number;
initialGear?: number;
initialNotes?: string;
initialDuration?: number;
initialDistance?: number;
initialCalories?: number;
initialWatts?: number;
initialCustomMetrics?: Record<string, string>;
initialLocked?: boolean;
autoFocus?: boolean;
onUpdate: (data: {
reps?: number;
weight?: number;
rpe?: number;
gear?: number;
notes?: string;
durationSeconds?: number;
distance?: number;
calories?: number;
watts?: number;
customMetrics?: Record<string, string>;
}) => void;
onConfirm?: () => void;
onNextSet?: (currentValues: {
weight?: string;
reps?: string;
rpe?: string;
gear?: string;
notes?: string;
duration?: string;
distance?: string;
calories?: string;
watts?: string;
}) => void;
onDelete: () => void;
}
export default function SetRow({
setNumber,
inputFields = ["sets", "reps", "weight"],
weightUnit = "lbs",
isCardio = false,
initialReps,
initialWeight,
initialRpe,
initialGear,
initialNotes,
initialDuration,
initialDistance,
initialCalories,
initialWatts,
initialCustomMetrics,
initialLocked = false,
autoFocus = false,
onUpdate,
onConfirm,
onNextSet,
onDelete,
}: SetRowProps) {
const secondsToMinuteString = (seconds?: number) => {
if (seconds === undefined || seconds === null) return "";
const minutes = seconds / 60;
const rounded = Math.round(minutes * 10) / 10;
return Number.isInteger(rounded) ? String(Math.trunc(rounded)) : String(rounded);
};
const minuteStringToSeconds = (minutes: string) => {
if (!minutes) return undefined;
const parsed = parseFloat(minutes);
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
return Math.round(parsed * 60);
};
const [reps, setReps] = useState(initialReps?.toString() || "");
const [weight, setWeight] = useState(initialWeight?.toString() || "");
const [rpe, setRpe] = useState(initialRpe?.toString() || "");
const [gear, setGear] = useState(initialGear?.toString() || "");
const [notes, setNotes] = useState(initialNotes || "");
const [duration, setDuration] = useState(secondsToMinuteString(initialDuration));
const [distance, setDistance] = useState(initialDistance?.toString() || "");
const [calories, setCalories] = useState(initialCalories?.toString() || "");
// Watts is now a first-class field. Legacy sets stored it under the
// customMetrics "watts" key — seed from there so old data shows up, and
// strip it from customValues so it isn't also rendered in the custom grid.
const [watts, setWatts] = useState(
initialWatts?.toString() || initialCustomMetrics?.watts || ""
);
const [customValues, setCustomValues] = useState<Record<string, string>>(() => {
const { watts: _legacyWatts, ...rest } = initialCustomMetrics || {};
return rest;
});
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 showWatts = inputFields.includes("watts");
const showNotesField = inputFields.includes("notes");
const customFields = inputFields.filter(
(f) =>
![
"sets",
"reps",
"weight",
"duration",
"distance",
"calories",
"watts",
"notes",
].includes(f)
);
const emitUpdate = useCallback(
(overrides: {
reps?: string;
weight?: string;
rpe?: string;
gear?: string;
notes?: string;
duration?: string;
distance?: string;
calories?: string;
watts?: string;
customMetrics?: Record<string, string>;
}) => {
const r = overrides.reps ?? reps;
const w = overrides.weight ?? weight;
const p = overrides.rpe ?? rpe;
const gr = overrides.gear ?? gear;
const n = overrides.notes ?? notes;
const dur = overrides.duration ?? duration;
const dist = overrides.distance ?? distance;
const cal = overrides.calories ?? calories;
const wt = overrides.watts ?? watts;
const cm = overrides.customMetrics ?? customValues;
const cleanedCustomMetrics = Object.fromEntries(
Object.entries(cm).filter(([, value]) => value !== "")
);
onUpdate({
reps: r ? parseInt(r) : undefined,
weight: w ? parseFloat(w) : undefined,
rpe: p ? parseInt(p) : undefined,
gear: gr ? parseInt(gr) : undefined,
notes: n || undefined,
durationSeconds: minuteStringToSeconds(dur),
distance: dist ? parseFloat(dist) : undefined,
calories: cal ? parseInt(cal) : undefined,
watts: wt ? parseInt(wt) : undefined,
customMetrics:
Object.keys(cleanedCustomMetrics).length > 0
? cleanedCustomMetrics
: undefined,
});
},
[reps, weight, rpe, gear, notes, duration, distance, calories, watts, customValues, 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, gear, notes, duration, distance, calories, watts });
};
// 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} min`);
if (showDistance && distance) parts.push(`${distance} mi`);
if (showCalories && calories) parts.push(`${calories} cal`);
if (showWatts && watts) parts.push(`${watts} W`);
for (const field of customFields) {
const value = customValues[field];
if (value) parts.push(`${field}: ${value}`);
}
if (isCardio) {
if (gear) parts.push(`Gear ${gear}`);
} else 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" : showWatts ? "watts" : 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 (min)
</label>
<input
type="number"
step="0.1"
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>
)}
{/* Avg. watts input */}
{showWatts && (
<div className="flex-1 min-w-[55px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Avg. watts
</label>
<input
type="number"
autoFocus={autoFocus && firstField === "watts"}
value={watts}
onChange={(e) => {
const val = e.target.value;
setWatts(val);
emitUpdate({ watts: 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>
)}
{/* Effort select — Gear (1-5, breathing gear) for cardio, else RPE (6-10) */}
{isCardio ? (
<div className="flex-1 min-w-[50px] max-w-[60px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Gear
</label>
<select
value={gear}
onChange={(e) => {
const val = e.target.value;
setGear(val);
emitUpdate({ gear: 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="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</div>
) : (
<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>
{/* Dynamic custom metrics configured on the exercise (e.g., watts) */}
{customFields.length > 0 && (
<div className="ml-8 grid grid-cols-2 gap-1.5">
{customFields.map((field) => (
<div key={field}>
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
{field.charAt(0).toUpperCase() + field.slice(1)}
</label>
<input
type="text"
value={customValues[field] || ""}
onChange={(e) => {
const val = e.target.value;
setCustomValues((prev) => {
const next = { ...prev, [field]: val };
emitUpdate({ customMetrics: next });
return next;
});
}}
placeholder="-"
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>
))}
</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>
);
}