v1.2.0:4 — make avg. watts a first-class SetLog field

Average watts (assault bike, rower, ski erg) was a free-text entry stuffed
into the per-set customMetrics JSON blob. Promote it to a real nullable
column, SetLog.watts, written through every set path (create / PATCH /
add-sets / import-save / account-import) and shown everywhere as
"Avg. watts" with a proper numeric input.

The column is added by the boot-time guarded ALTER in docker_entrypoint.sh
(additive, idempotent), so the version migration stays empty. Existing data
is untouched: legacy watts values remain readable from customMetrics and
migrate to the column the next time a set is saved.
This commit is contained in:
Keysat
2026-06-16 12:52:59 -05:00
parent 4d1f9126b0
commit 390aaf556e
21 changed files with 202 additions and 19 deletions
@@ -19,6 +19,7 @@ interface ParsedSet {
distance?: number; distance?: number;
distanceUnit?: string; distanceUnit?: string;
calories?: number; calories?: number;
watts?: number;
rpe?: number; rpe?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
notes?: string; notes?: string;
@@ -136,6 +137,7 @@ export async function POST(request: NextRequest) {
"distance_unit", "distance_unit",
"distanceunit", "distanceunit",
"calories", "calories",
"watts",
"rpe", "rpe",
"notes", "notes",
"custom_metrics_json", "custom_metrics_json",
@@ -199,6 +201,7 @@ export async function POST(request: NextRequest) {
const distance = parseFloatMaybe(row.distance); const distance = parseFloatMaybe(row.distance);
const distanceUnit = row.distance_unit || row.distanceunit || (distance !== undefined ? "mi" : undefined); const distanceUnit = row.distance_unit || row.distanceunit || (distance !== undefined ? "mi" : undefined);
const calories = parseIntMaybe(row.calories); const calories = parseIntMaybe(row.calories);
const watts = parseIntMaybe(row.watts);
const rpe = parseIntMaybe(row.rpe); const rpe = parseIntMaybe(row.rpe);
const customMetrics: Record<string, string> = {}; const customMetrics: Record<string, string> = {};
@@ -253,6 +256,7 @@ export async function POST(request: NextRequest) {
distance, distance,
distanceUnit, distanceUnit,
calories, calories,
watts,
rpe, rpe,
customMetrics: Object.keys(customMetrics).length > 0 ? customMetrics : undefined, customMetrics: Object.keys(customMetrics).length > 0 ? customMetrics : undefined,
notes: notes || undefined, notes: notes || undefined,
+2
View File
@@ -55,6 +55,7 @@ const setLogImport = z.object({
distance: z.number().nullable().optional(), distance: z.number().nullable().optional(),
distanceUnit: z.string().nullable().optional(), distanceUnit: z.string().nullable().optional(),
calories: z.number().int().nullable().optional(), calories: z.number().int().nullable().optional(),
watts: z.number().int().nullable().optional(),
customMetrics: z.string().nullable().optional(), customMetrics: z.string().nullable().optional(),
notes: z.string().nullable().optional(), notes: z.string().nullable().optional(),
// The exported set carries an exerciseId pointing into the export's // The exported set carries an exerciseId pointing into the export's
@@ -205,6 +206,7 @@ export async function POST(request: NextRequest) {
distance: s.distance ?? null, distance: s.distance ?? null,
distanceUnit: s.distanceUnit ?? null, distanceUnit: s.distanceUnit ?? null,
calories: s.calories ?? null, calories: s.calories ?? null,
watts: s.watts ?? null,
customMetrics: s.customMetrics ?? null, customMetrics: s.customMetrics ?? null,
notes: s.notes ?? null, notes: s.notes ?? null,
}); });
@@ -70,6 +70,7 @@ export async function GET() {
"distance", "distance",
"distanceUnit", "distanceUnit",
"setCalories", "setCalories",
"setWatts",
"rpe", "rpe",
"setNotes", "setNotes",
"customMetricsJson", "customMetricsJson",
@@ -101,6 +102,7 @@ export async function GET() {
set.distance ?? "", set.distance ?? "",
set.distanceUnit ?? "", set.distanceUnit ?? "",
set.calories ?? "", set.calories ?? "",
set.watts ?? "",
set.rpe ?? "", set.rpe ?? "",
set.notes ?? "", set.notes ?? "",
set.customMetrics ?? "", set.customMetrics ?? "",
@@ -61,6 +61,7 @@ const setSchema = z.object({
distance: z.number().positive().optional().nullable(), distance: z.number().positive().optional().nullable(),
distanceUnit: z.string().optional().nullable(), distanceUnit: z.string().optional().nullable(),
calories: z.number().int().positive().optional().nullable(), calories: z.number().int().positive().optional().nullable(),
watts: z.number().int().positive().optional().nullable(),
customMetrics: z.record(z.string()).optional().nullable(), customMetrics: z.record(z.string()).optional().nullable(),
notes: z.string().optional().nullable(), notes: z.string().optional().nullable(),
}); });
@@ -156,6 +157,7 @@ export async function PATCH(
distance: set.distance ?? undefined, distance: set.distance ?? undefined,
distanceUnit: set.distanceUnit ?? undefined, distanceUnit: set.distanceUnit ?? undefined,
calories: set.calories ?? undefined, calories: set.calories ?? undefined,
watts: set.watts ?? undefined,
customMetrics: customMetrics:
set.customMetrics && Object.keys(set.customMetrics).length > 0 set.customMetrics && Object.keys(set.customMetrics).length > 0
? JSON.stringify(set.customMetrics) ? JSON.stringify(set.customMetrics)
@@ -18,6 +18,7 @@ const addSetsSchema = z.object({
distance: z.number().positive().optional(), distance: z.number().positive().optional(),
distanceUnit: z.string().optional(), distanceUnit: z.string().optional(),
calories: z.number().int().positive().optional(), calories: z.number().int().positive().optional(),
watts: z.number().int().positive().optional(),
customMetrics: z.record(z.string()).optional(), customMetrics: z.record(z.string()).optional(),
notes: z.string().optional(), notes: z.string().optional(),
}) })
@@ -83,6 +84,7 @@ export async function POST(
distance: set.distance, distance: set.distance,
distanceUnit: set.distanceUnit, distanceUnit: set.distanceUnit,
calories: set.calories, calories: set.calories,
watts: set.watts,
customMetrics: customMetrics:
set.customMetrics && Object.keys(set.customMetrics).length > 0 set.customMetrics && Object.keys(set.customMetrics).length > 0
? JSON.stringify(set.customMetrics) ? JSON.stringify(set.customMetrics)
@@ -13,6 +13,7 @@ const setSchema = z.object({
distance: z.number().positive().optional(), distance: z.number().positive().optional(),
distanceUnit: z.string().optional(), distanceUnit: z.string().optional(),
calories: z.number().int().positive().optional(), calories: z.number().int().positive().optional(),
watts: z.number().int().positive().optional(),
rpe: z.number().int().min(1).max(10).optional(), rpe: z.number().int().min(1).max(10).optional(),
notes: z.string().optional(), notes: z.string().optional(),
}); });
@@ -127,6 +128,7 @@ export async function POST(request: Request) {
distance: set.distance || null, distance: set.distance || null,
distanceUnit: set.distanceUnit || null, distanceUnit: set.distanceUnit || null,
calories: set.calories || null, calories: set.calories || null,
watts: set.watts || null,
notes: set.notes || null, notes: set.notes || null,
}); });
}); });
+2
View File
@@ -30,6 +30,7 @@ const createWorkoutSchema = z.object({
distance: z.number().positive().optional(), distance: z.number().positive().optional(),
distanceUnit: z.string().optional(), distanceUnit: z.string().optional(),
calories: z.number().int().positive().optional(), calories: z.number().int().positive().optional(),
watts: z.number().int().positive().optional(),
customMetrics: z.record(z.string()).optional(), customMetrics: z.record(z.string()).optional(),
notes: z.string().optional(), notes: z.string().optional(),
}) })
@@ -178,6 +179,7 @@ export async function POST(request: NextRequest) {
distance: set.distance, distance: set.distance,
distanceUnit: set.distanceUnit, distanceUnit: set.distanceUnit,
calories: set.calories, calories: set.calories,
watts: set.watts,
customMetrics: customMetrics:
set.customMetrics && Object.keys(set.customMetrics).length > 0 set.customMetrics && Object.keys(set.customMetrics).length > 0
? JSON.stringify(set.customMetrics) ? JSON.stringify(set.customMetrics)
@@ -57,6 +57,7 @@ const INPUT_FIELD_OPTIONS = [
{ value: "duration", label: "Duration" }, { value: "duration", label: "Duration" },
{ value: "distance", label: "Distance" }, { value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" }, { value: "calories", label: "Calories" },
{ value: "watts", label: "Avg. watts" },
{ value: "notes", label: "Notes" }, { value: "notes", label: "Notes" },
]; ];
+11 -7
View File
@@ -22,6 +22,7 @@ interface ParsedSet {
distance?: number; distance?: number;
distanceUnit?: string; distanceUnit?: string;
calories?: number; calories?: number;
watts?: number;
rpe?: number; rpe?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
notes?: string; notes?: string;
@@ -392,6 +393,9 @@ export default function ImportCSVPage() {
if (typeof set.calories === "number" && !Number.isNaN(set.calories)) { if (typeof set.calories === "number" && !Number.isNaN(set.calories)) {
payloadSet.calories = set.calories; payloadSet.calories = set.calories;
} }
if (typeof set.watts === "number" && !Number.isNaN(set.watts)) {
payloadSet.watts = set.watts;
}
if (typeof set.rpe === "number" && !Number.isNaN(set.rpe)) { if (typeof set.rpe === "number" && !Number.isNaN(set.rpe)) {
payloadSet.rpe = set.rpe; payloadSet.rpe = set.rpe;
} }
@@ -763,7 +767,7 @@ export default function ImportCSVPage() {
</p> </p>
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500">
CSV columns: date, exercise, set, weight, reps, duration_seconds, CSV columns: date, exercise, set, weight, reps, duration_seconds,
distance, distance_unit, calories, rpe, notes, custom_* distance, distance_unit, calories, watts, rpe, notes, custom_*
</p> </p>
</div> </div>
{loading && ( {loading && (
@@ -778,12 +782,12 @@ export default function ImportCSVPage() {
CSV Format Example CSV Format Example
</h3> </h3>
<pre className="text-xs text-zinc-400 overflow-x-auto bg-zinc-950 p-4 rounded border border-zinc-800"> <pre className="text-xs text-zinc-400 overflow-x-auto bg-zinc-950 p-4 rounded border border-zinc-800">
{`date,exercise,set,weight,weight_unit,reps,duration_seconds,distance,distance_unit,calories,rpe,notes,custom_temperature,custom_watts,custom_metrics_json {`date,exercise,set,weight,weight_unit,reps,duration_seconds,distance,distance_unit,calories,watts,rpe,notes,custom_temperature,custom_metrics_json
2025-02-15,Bench,1,225,lbs,5,,,,,8,good form,,, 2025-02-15,Bench,1,225,lbs,5,,,,,,8,good form,,
2025-02-15,Bench,2,225,lbs,5,,,,,8,,,, 2025-02-15,Bench,2,225,lbs,5,,,,,,8,,,
2025-02-16,Squat,1,315,lbs,8,,,,,9,30kg per leg,,, 2025-02-16,Squat,1,315,lbs,8,,,,,,9,30kg per leg,,
2025-02-17,Assault Bike,1,,, ,900,5,mi,120,7,,,"{\"resistance\":\"8\"}" 2025-02-17,Assault Bike,1,,, ,900,5,mi,120,157,7,,,"{\"resistance\":\"8\"}"
2025-02-18,Cold Plunge,1,,, ,180,,,,,felt great,50,,`} 2025-02-18,Cold Plunge,1,,, ,180,,,,,,felt great,50,`}
</pre> </pre>
</div> </div>
</div> </div>
+11 -1
View File
@@ -23,6 +23,7 @@ function buildSetSummary(set: {
durationSeconds?: number | null; durationSeconds?: number | null;
distance?: number | null; distance?: number | null;
calories?: number | null; calories?: number | null;
watts?: number | null;
customMetrics?: string | null; customMetrics?: string | null;
}) { }) {
const parts: string[] = []; const parts: string[] = [];
@@ -35,11 +36,20 @@ function buildSetSummary(set: {
} }
if ((set as any).distance) parts.push(`${(set as any).distance} mi`); if ((set as any).distance) parts.push(`${(set as any).distance} mi`);
if ((set as any).calories) parts.push(`${(set as any).calories} cal`); if ((set as any).calories) parts.push(`${(set as any).calories} cal`);
if ((set as any).watts) parts.push(`${(set as any).watts} W`);
if ((set as any).customMetrics) { if ((set as any).customMetrics) {
try { try {
const custom = JSON.parse((set as any).customMetrics) as Record<string, string>; const custom = JSON.parse((set as any).customMetrics) as Record<string, string>;
for (const [k, v] of Object.entries(custom)) { for (const [k, v] of Object.entries(custom)) {
if (v) parts.push(`${k}: ${v}`); if (!v) continue;
// Watts is now a first-class column. Legacy sets still carry it under
// customMetrics — render it the same way (and skip if the column
// already supplied it) so old and new sets read identically.
if (k === "watts") {
if (!(set as any).watts) parts.push(`${v} W`);
continue;
}
parts.push(`${k}: ${v}`);
} }
} catch {} } catch {}
} }
@@ -53,6 +53,7 @@ export default async function NewWorkoutPage(props: {
durationSeconds: set.durationSeconds ?? undefined, durationSeconds: set.durationSeconds ?? undefined,
distance: set.distance ?? undefined, distance: set.distance ?? undefined,
calories: set.calories ?? undefined, calories: set.calories ?? undefined,
watts: set.watts ?? undefined,
customMetrics, customMetrics,
notes: set.notes ?? undefined, notes: set.notes ?? undefined,
}); });
@@ -61,7 +61,7 @@ export default function ExercisePicker({
// Derive custom types/muscles/fields from existing exercises // Derive custom types/muscles/fields from existing exercises
const knownTypeValues = EXERCISE_TYPES.map((t) => t.value); const knownTypeValues = EXERCISE_TYPES.map((t) => t.value);
const knownMuscleValues = MUSCLE_GROUPS.map((g) => g.toLowerCase()); const knownMuscleValues = MUSCLE_GROUPS.map((g) => g.toLowerCase());
const knownFieldValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"]; const knownFieldValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
const derivedCustomTypes = useMemo(() => { const derivedCustomTypes = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
@@ -506,6 +506,7 @@ export default function ExercisePicker({
{ value: "duration", label: "Time" }, { value: "duration", label: "Time" },
{ value: "distance", label: "Distance" }, { value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" }, { value: "calories", label: "Calories" },
{ value: "watts", label: "Avg. watts" },
{ value: "notes", label: "Notes" }, { value: "notes", label: "Notes" },
...customFieldOptions, ...customFieldOptions,
].map((field) => ( ].map((field) => (
@@ -527,7 +528,7 @@ export default function ExercisePicker({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
const val = newFieldText.trim().toLowerCase(); const val = newFieldText.trim().toLowerCase();
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"]; const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) { if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]); setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
} }
@@ -545,7 +546,7 @@ export default function ExercisePicker({
onChange={(e) => setNewFieldText(e.target.value)} onChange={(e) => setNewFieldText(e.target.value)}
onBlur={() => { onBlur={() => {
const val = newFieldText.trim().toLowerCase(); const val = newFieldText.trim().toLowerCase();
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"]; const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) { if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]); setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
} }
+44 -5
View File
@@ -10,6 +10,7 @@ export type InputField =
| "duration" | "duration"
| "distance" | "distance"
| "calories" | "calories"
| "watts"
| "notes" | "notes"
| string; | string;
@@ -24,6 +25,7 @@ export interface SetRowProps {
initialDuration?: number; initialDuration?: number;
initialDistance?: number; initialDistance?: number;
initialCalories?: number; initialCalories?: number;
initialWatts?: number;
initialCustomMetrics?: Record<string, string>; initialCustomMetrics?: Record<string, string>;
initialLocked?: boolean; initialLocked?: boolean;
autoFocus?: boolean; autoFocus?: boolean;
@@ -35,6 +37,7 @@ export interface SetRowProps {
durationSeconds?: number; durationSeconds?: number;
distance?: number; distance?: number;
calories?: number; calories?: number;
watts?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
}) => void; }) => void;
onConfirm?: () => void; onConfirm?: () => void;
@@ -46,6 +49,7 @@ export interface SetRowProps {
duration?: string; duration?: string;
distance?: string; distance?: string;
calories?: string; calories?: string;
watts?: string;
}) => void; }) => void;
onDelete: () => void; onDelete: () => void;
} }
@@ -61,6 +65,7 @@ export default function SetRow({
initialDuration, initialDuration,
initialDistance, initialDistance,
initialCalories, initialCalories,
initialWatts,
initialCustomMetrics, initialCustomMetrics,
initialLocked = false, initialLocked = false,
autoFocus = false, autoFocus = false,
@@ -90,9 +95,16 @@ export default function SetRow({
const [duration, setDuration] = useState(secondsToMinuteString(initialDuration)); const [duration, setDuration] = useState(secondsToMinuteString(initialDuration));
const [distance, setDistance] = useState(initialDistance?.toString() || ""); const [distance, setDistance] = useState(initialDistance?.toString() || "");
const [calories, setCalories] = useState(initialCalories?.toString() || ""); const [calories, setCalories] = useState(initialCalories?.toString() || "");
const [customValues, setCustomValues] = useState<Record<string, string>>( // Watts is now a first-class field. Legacy sets stored it under the
initialCustomMetrics || {} // 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 [showNotes, setShowNotes] = useState(!!initialNotes);
const [locked, setLocked] = useState(initialLocked); const [locked, setLocked] = useState(initialLocked);
@@ -101,6 +113,7 @@ export default function SetRow({
const showDuration = inputFields.includes("duration"); const showDuration = inputFields.includes("duration");
const showDistance = inputFields.includes("distance"); const showDistance = inputFields.includes("distance");
const showCalories = inputFields.includes("calories"); const showCalories = inputFields.includes("calories");
const showWatts = inputFields.includes("watts");
const showNotesField = inputFields.includes("notes"); const showNotesField = inputFields.includes("notes");
const customFields = inputFields.filter( const customFields = inputFields.filter(
(f) => (f) =>
@@ -111,6 +124,7 @@ export default function SetRow({
"duration", "duration",
"distance", "distance",
"calories", "calories",
"watts",
"notes", "notes",
].includes(f) ].includes(f)
); );
@@ -124,6 +138,7 @@ export default function SetRow({
duration?: string; duration?: string;
distance?: string; distance?: string;
calories?: string; calories?: string;
watts?: string;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
}) => { }) => {
const r = overrides.reps ?? reps; const r = overrides.reps ?? reps;
@@ -133,6 +148,7 @@ export default function SetRow({
const dur = overrides.duration ?? duration; const dur = overrides.duration ?? duration;
const dist = overrides.distance ?? distance; const dist = overrides.distance ?? distance;
const cal = overrides.calories ?? calories; const cal = overrides.calories ?? calories;
const wt = overrides.watts ?? watts;
const cm = overrides.customMetrics ?? customValues; const cm = overrides.customMetrics ?? customValues;
const cleanedCustomMetrics = Object.fromEntries( const cleanedCustomMetrics = Object.fromEntries(
Object.entries(cm).filter(([, value]) => value !== "") Object.entries(cm).filter(([, value]) => value !== "")
@@ -146,13 +162,14 @@ export default function SetRow({
durationSeconds: minuteStringToSeconds(dur), durationSeconds: minuteStringToSeconds(dur),
distance: dist ? parseFloat(dist) : undefined, distance: dist ? parseFloat(dist) : undefined,
calories: cal ? parseInt(cal) : undefined, calories: cal ? parseInt(cal) : undefined,
watts: wt ? parseInt(wt) : undefined,
customMetrics: customMetrics:
Object.keys(cleanedCustomMetrics).length > 0 Object.keys(cleanedCustomMetrics).length > 0
? cleanedCustomMetrics ? cleanedCustomMetrics
: undefined, : undefined,
}); });
}, },
[reps, weight, rpe, notes, duration, distance, calories, customValues, onUpdate] [reps, weight, rpe, notes, duration, distance, calories, watts, customValues, onUpdate]
); );
const handleConfirm = () => { const handleConfirm = () => {
@@ -175,7 +192,7 @@ export default function SetRow({
const handleNextSet = () => { const handleNextSet = () => {
emitUpdate({}); emitUpdate({});
setLocked(true); setLocked(true);
onNextSet?.({ weight, reps, rpe, notes, duration, distance, calories }); onNextSet?.({ weight, reps, rpe, notes, duration, distance, calories, watts });
}; };
// Build a summary string for the locked view // Build a summary string for the locked view
@@ -186,6 +203,7 @@ export default function SetRow({
if (showDuration && duration) parts.push(`${duration} min`); if (showDuration && duration) parts.push(`${duration} min`);
if (showDistance && distance) parts.push(`${distance} mi`); if (showDistance && distance) parts.push(`${distance} mi`);
if (showCalories && calories) parts.push(`${calories} cal`); if (showCalories && calories) parts.push(`${calories} cal`);
if (showWatts && watts) parts.push(`${watts} W`);
for (const field of customFields) { for (const field of customFields) {
const value = customValues[field]; const value = customValues[field];
if (value) parts.push(`${field}: ${value}`); if (value) parts.push(`${field}: ${value}`);
@@ -238,7 +256,7 @@ export default function SetRow({
} }
// Determine which field gets autoFocus // Determine which field gets autoFocus
const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : null; const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : showWatts ? "watts" : null;
// ---------- EDIT VIEW ---------- // ---------- EDIT VIEW ----------
return ( return (
@@ -357,6 +375,27 @@ export default function SetRow({
</div> </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>
)}
{/* RPE select — always shown */} {/* RPE select — always shown */}
<div className="flex-1 min-w-[50px] max-w-[60px]"> <div className="flex-1 min-w-[50px] max-w-[60px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5"> <label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
@@ -235,6 +235,7 @@ interface ExerciseWithSets {
durationSeconds?: number; durationSeconds?: number;
distance?: number; distance?: number;
calories?: number; calories?: number;
watts?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
notes?: string; notes?: string;
forceEdit?: boolean; // When true, start in edit mode even if data is pre-filled forceEdit?: boolean; // When true, start in edit mode even if data is pre-filled
@@ -259,6 +260,7 @@ export interface EditWorkoutData {
durationSeconds?: number; durationSeconds?: number;
distance?: number; distance?: number;
calories?: number; calories?: number;
watts?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
notes?: string; notes?: string;
}>; }>;
@@ -346,6 +348,7 @@ export default function WorkoutForm({
distance: s.distance, distance: s.distance,
distanceUnit: s.distance !== undefined ? "mi" : undefined, distanceUnit: s.distance !== undefined ? "mi" : undefined,
calories: s.calories, calories: s.calories,
watts: s.watts,
customMetrics: s.customMetrics, customMetrics: s.customMetrics,
notes: s.notes, notes: s.notes,
})) }))
@@ -508,6 +511,7 @@ export default function WorkoutForm({
durationSeconds?: number; durationSeconds?: number;
distance?: number; distance?: number;
calories?: number; calories?: number;
watts?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
} }
) => { ) => {
@@ -858,6 +862,7 @@ export default function WorkoutForm({
initialDuration={set.durationSeconds} initialDuration={set.durationSeconds}
initialDistance={set.distance} initialDistance={set.distance}
initialCalories={set.calories} initialCalories={set.calories}
initialWatts={set.watts}
initialCustomMetrics={set.customMetrics} initialCustomMetrics={set.customMetrics}
initialNotes={set.notes} initialNotes={set.notes}
initialLocked={ initialLocked={
@@ -869,6 +874,7 @@ export default function WorkoutForm({
set.durationSeconds || set.durationSeconds ||
set.distance || set.distance ||
set.calories || set.calories ||
set.watts ||
(set.customMetrics && (set.customMetrics &&
Object.values(set.customMetrics).some((v) => v)) Object.values(set.customMetrics).some((v) => v))
) )
@@ -880,7 +886,8 @@ export default function WorkoutForm({
!set.weight && !set.weight &&
!set.durationSeconds && !set.durationSeconds &&
!set.distance && !set.distance &&
!set.calories) !set.calories &&
!set.watts)
} }
onUpdate={(data) => onUpdate={(data) =>
handleUpdateSet( handleUpdateSet(
+1
View File
@@ -46,6 +46,7 @@ export const BASE_TRACKING_FIELDS: Option[] = [
{ value: "duration", label: "Time" }, { value: "duration", label: "Time" },
{ value: "distance", label: "Distance" }, { value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" }, { value: "calories", label: "Calories" },
{ value: "watts", label: "Avg. watts" },
{ value: "notes", label: "Notes" }, { value: "notes", label: "Notes" },
]; ];
+2 -1
View File
@@ -121,7 +121,8 @@ model SetLog {
distance Float? // for distance-based exercises distance Float? // for distance-based exercises
distanceUnit String? // "mi", "km", "m" distanceUnit String? // "mi", "km", "m"
calories Int? // for cardio machines that report calories calories Int? // for cardio machines that report calories
customMetrics String? // JSON map for dynamic custom metrics (e.g. {"watts":"157"}) watts Int? // average watts for cardio machines (assault bike, rower, ski erg)
customMetrics String? // JSON map for dynamic custom metrics (legacy watts lived here as {"watts":"157"})
notes String? notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
+63
View File
@@ -302,6 +302,43 @@ describe('POST /api/workouts', () => {
expect(body.setLogs[1].rpe).toBe(8); expect(body.setLogs[1].rpe).toBe(8);
}); });
it('persists avg. watts as a first-class set field (assault bike)', async () => {
const alice = await makeUser({ email: 'a@x' });
const bike = await prisma.exercise.create({
data: {
userId: alice.id,
name: 'Assault Bike',
type: 'assault bike',
muscleGroups: '[]',
inputFields: '["sets","duration","distance","calories","watts","notes"]',
},
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await postWorkout(
jsonReq('http://x/api/workouts', {
name: 'Conditioning',
sets: [
{
exerciseId: bike.id,
setNumber: 1,
durationSeconds: 600,
distance: 2.5,
distanceUnit: 'mi',
calories: 120,
watts: 157,
},
],
}),
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.setLogs).toHaveLength(1);
expect(body.setLogs[0].watts).toBe(157);
// And it round-trips out of the DB, not just the response.
const stored = await prisma.setLog.findFirst({ where: { workoutId: body.id } });
expect(stored?.watts).toBe(157);
});
it('rejects negative reps via Zod with 400', async () => { it('rejects negative reps via Zod with 400', async () => {
const alice = await makeUser({ email: 'a@x' }); const alice = await makeUser({ email: 'a@x' });
const bench = await prisma.exercise.create({ const bench = await prisma.exercise.create({
@@ -403,6 +440,32 @@ describe('PATCH /api/workouts/[id]', () => {
// The guard runs before the set-replace transaction. // The guard runs before the set-replace transaction.
expect(await prisma.setLog.count()).toBe(0); expect(await prisma.setLog.count()).toBe(0);
}); });
it('persists avg. watts when replacing sets via PATCH', async () => {
const alice = await makeUser({ email: 'a@x' });
const bike = await prisma.exercise.create({
data: { userId: alice.id, name: 'Assault Bike', type: 'assault bike', muscleGroups: '[]' },
});
const workout = await prisma.workout.create({
data: { userId: alice.id, date: new Date(), name: 'Cond' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await patchWorkout(
jsonReq(
'http://x/api/workouts/' + workout.id,
{
sets: [
{ exerciseId: bike.id, setNumber: 1, durationSeconds: 600, watts: 180 },
],
},
{ method: 'PATCH' },
),
{ params: Promise.resolve({ id: workout.id }) },
);
expect(res.status).toBe(200);
const stored = await prisma.setLog.findFirst({ where: { workoutId: workout.id } });
expect(stored?.watts).toBe(180);
});
}); });
describe('POST /api/workouts/import/save', () => { describe('POST /api/workouts/import/save', () => {
+1
View File
@@ -89,6 +89,7 @@ export type ParsedSet = {
distance?: number | null; distance?: number | null;
distanceUnit?: string | null; distanceUnit?: string | null;
calories?: number | null; calories?: number | null;
watts?: number | null;
rpe?: number | null; rpe?: number | null;
notes?: string | null; notes?: string | null;
}; };
+5
View File
@@ -72,6 +72,11 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN customMetrics TEXT;" sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN customMetrics TEXT;"
fi fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('SetLog');" 2>/dev/null | grep -q "|watts|"; then
log "adding missing column SetLog.watts"
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN watts INTEGER;"
fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|deletedAt|"; then if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|deletedAt|"; then
log "adding missing column Workout.deletedAt" log "adding missing column Workout.deletedAt"
sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN deletedAt DATETIME;" sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN deletedAt DATETIME;"
+7 -1
View File
@@ -18,6 +18,7 @@ import { v_1_1_0_9 } from './v1.1.0.9'
import { v_1_2_0_1 } from './v1.2.0.1' import { v_1_2_0_1 } from './v1.2.0.1'
import { v_1_2_0_2 } from './v1.2.0.2' import { v_1_2_0_2 } from './v1.2.0.2'
import { v_1_2_0_3 } from './v1.2.0.3' import { v_1_2_0_3 } from './v1.2.0.3'
import { v_1_2_0_4 } from './v1.2.0.4'
/** /**
* Version graph for the `proof-of-work` package. * Version graph for the `proof-of-work` package.
@@ -71,9 +72,13 @@ import { v_1_2_0_3 } from './v1.2.0.3'
* bcrypt on unknown email) and enforce exerciseId ownership on * bcrypt on unknown email) and enforce exerciseId ownership on
* workout create/PATCH/add-sets + CSV-import-save (shared * workout create/PATCH/add-sets + CSV-import-save (shared
* lib/exerciseOwnership). No schema/data change. * lib/exerciseOwnership). No schema/data change.
* v1.2.0:4 — Avg. watts promoted to a first-class set field (SetLog.watts
* column, added by the boot-time additive ALTER). Written through
* every set path; legacy watts in customMetrics stays readable and
* migrates on next save.
*/ */
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_1_2_0_3, current: v_1_2_0_4,
other: [ other: [
v_1_0_0_1, v_1_0_0_1,
v_1_0_0_2, v_1_0_0_2,
@@ -93,5 +98,6 @@ export const versionGraph = VersionGraph.of({
v_1_1_0_9, v_1_1_0_9,
v_1_2_0_1, v_1_2_0_1,
v_1_2_0_2, v_1_2_0_2,
v_1_2_0_3,
], ],
}) })
+27
View File
@@ -0,0 +1,27 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:4 — Avg. watts as a first-class set field (2026-06-16).
*
* Average watts (assault bike, rower, ski erg) used to be a free-text entry
* stuffed into the per-set customMetrics JSON blob. It's now a real nullable
* column, SetLog.watts, written through every set path (create / PATCH /
* add-sets / import-save / account-import) and shown everywhere as
* "Avg. watts" with a proper numeric input.
*
* Additive schema change: the SetLog.watts column is added by the boot-time
* guarded ALTER in docker_entrypoint.sh (so this migration stays empty, like
* every other column add). Existing data is untouched — legacy watts values
* remain readable from customMetrics and migrate to the column on next save.
*/
export const v_1_2_0_4 = VersionInfo.of({
version: '1.2.0:4',
releaseNotes: {
en_US:
'Average watts is now a first-class field for cardio machines (assault bike, rower, ski erg) — a proper numeric "Avg. watts" input instead of a free-text custom metric. Existing data is preserved.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})