4be489d6d3
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.
566 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|