From 390aaf556eee9b535166e75aa2f5c9a133300286 Mon Sep 17 00:00:00 2001 From: Keysat Date: Tue, 16 Jun 2026 12:52:59 -0500 Subject: [PATCH] =?UTF-8?q?v1.2.0:4=20=E2=80=94=20make=20avg.=20watts=20a?= =?UTF-8?q?=20first-class=20SetLog=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- proof-of-work/app/api/import/parse/route.ts | 4 ++ proof-of-work/app/api/me/import/route.ts | 2 + .../app/api/settings/export-csv/route.ts | 2 + proof-of-work/app/api/workouts/[id]/route.ts | 2 + .../app/api/workouts/[id]/sets/route.ts | 2 + .../app/api/workouts/import/save/route.ts | 2 + proof-of-work/app/api/workouts/route.ts | 2 + .../app/main/exercises/[id]/page.tsx | 1 + proof-of-work/app/main/import/page-csv.tsx | 18 +++--- proof-of-work/app/main/workouts/[id]/page.tsx | 12 +++- proof-of-work/app/main/workouts/new/page.tsx | 1 + .../components/workouts/ExercisePicker.tsx | 7 ++- proof-of-work/components/workouts/SetRow.tsx | 49 +++++++++++++-- .../components/workouts/WorkoutForm.tsx | 9 ++- proof-of-work/lib/exerciseOptions.ts | 1 + proof-of-work/prisma/schema.prisma | 3 +- proof-of-work/tests/routes-crud.test.ts | 63 +++++++++++++++++++ proof-of-work/types/index.ts | 1 + start9/0.4/docker_entrypoint.sh | 5 ++ start9/0.4/startos/versions/index.ts | 8 ++- start9/0.4/startos/versions/v1.2.0.4.ts | 27 ++++++++ 21 files changed, 202 insertions(+), 19 deletions(-) create mode 100644 start9/0.4/startos/versions/v1.2.0.4.ts diff --git a/proof-of-work/app/api/import/parse/route.ts b/proof-of-work/app/api/import/parse/route.ts index bf8316b..0290275 100644 --- a/proof-of-work/app/api/import/parse/route.ts +++ b/proof-of-work/app/api/import/parse/route.ts @@ -19,6 +19,7 @@ interface ParsedSet { distance?: number; distanceUnit?: string; calories?: number; + watts?: number; rpe?: number; customMetrics?: Record; notes?: string; @@ -136,6 +137,7 @@ export async function POST(request: NextRequest) { "distance_unit", "distanceunit", "calories", + "watts", "rpe", "notes", "custom_metrics_json", @@ -199,6 +201,7 @@ export async function POST(request: NextRequest) { const distance = parseFloatMaybe(row.distance); const distanceUnit = row.distance_unit || row.distanceunit || (distance !== undefined ? "mi" : undefined); const calories = parseIntMaybe(row.calories); + const watts = parseIntMaybe(row.watts); const rpe = parseIntMaybe(row.rpe); const customMetrics: Record = {}; @@ -253,6 +256,7 @@ export async function POST(request: NextRequest) { distance, distanceUnit, calories, + watts, rpe, customMetrics: Object.keys(customMetrics).length > 0 ? customMetrics : undefined, notes: notes || undefined, diff --git a/proof-of-work/app/api/me/import/route.ts b/proof-of-work/app/api/me/import/route.ts index 6bad297..c0be414 100644 --- a/proof-of-work/app/api/me/import/route.ts +++ b/proof-of-work/app/api/me/import/route.ts @@ -55,6 +55,7 @@ const setLogImport = z.object({ distance: z.number().nullable().optional(), distanceUnit: z.string().nullable().optional(), calories: z.number().int().nullable().optional(), + watts: z.number().int().nullable().optional(), customMetrics: z.string().nullable().optional(), notes: z.string().nullable().optional(), // 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, distanceUnit: s.distanceUnit ?? null, calories: s.calories ?? null, + watts: s.watts ?? null, customMetrics: s.customMetrics ?? null, notes: s.notes ?? null, }); diff --git a/proof-of-work/app/api/settings/export-csv/route.ts b/proof-of-work/app/api/settings/export-csv/route.ts index 01ce22c..03f2c3d 100644 --- a/proof-of-work/app/api/settings/export-csv/route.ts +++ b/proof-of-work/app/api/settings/export-csv/route.ts @@ -70,6 +70,7 @@ export async function GET() { "distance", "distanceUnit", "setCalories", + "setWatts", "rpe", "setNotes", "customMetricsJson", @@ -101,6 +102,7 @@ export async function GET() { set.distance ?? "", set.distanceUnit ?? "", set.calories ?? "", + set.watts ?? "", set.rpe ?? "", set.notes ?? "", set.customMetrics ?? "", diff --git a/proof-of-work/app/api/workouts/[id]/route.ts b/proof-of-work/app/api/workouts/[id]/route.ts index 025ebb2..56259a1 100644 --- a/proof-of-work/app/api/workouts/[id]/route.ts +++ b/proof-of-work/app/api/workouts/[id]/route.ts @@ -61,6 +61,7 @@ const setSchema = z.object({ distance: z.number().positive().optional().nullable(), distanceUnit: z.string().optional().nullable(), calories: z.number().int().positive().optional().nullable(), + watts: z.number().int().positive().optional().nullable(), customMetrics: z.record(z.string()).optional().nullable(), notes: z.string().optional().nullable(), }); @@ -156,6 +157,7 @@ export async function PATCH( distance: set.distance ?? undefined, distanceUnit: set.distanceUnit ?? undefined, calories: set.calories ?? undefined, + watts: set.watts ?? undefined, customMetrics: set.customMetrics && Object.keys(set.customMetrics).length > 0 ? JSON.stringify(set.customMetrics) diff --git a/proof-of-work/app/api/workouts/[id]/sets/route.ts b/proof-of-work/app/api/workouts/[id]/sets/route.ts index 6a3dcaf..3cc011a 100644 --- a/proof-of-work/app/api/workouts/[id]/sets/route.ts +++ b/proof-of-work/app/api/workouts/[id]/sets/route.ts @@ -18,6 +18,7 @@ const addSetsSchema = z.object({ distance: z.number().positive().optional(), distanceUnit: z.string().optional(), calories: z.number().int().positive().optional(), + watts: z.number().int().positive().optional(), customMetrics: z.record(z.string()).optional(), notes: z.string().optional(), }) @@ -83,6 +84,7 @@ export async function POST( distance: set.distance, distanceUnit: set.distanceUnit, calories: set.calories, + watts: set.watts, customMetrics: set.customMetrics && Object.keys(set.customMetrics).length > 0 ? JSON.stringify(set.customMetrics) diff --git a/proof-of-work/app/api/workouts/import/save/route.ts b/proof-of-work/app/api/workouts/import/save/route.ts index 019eed8..27b0790 100644 --- a/proof-of-work/app/api/workouts/import/save/route.ts +++ b/proof-of-work/app/api/workouts/import/save/route.ts @@ -13,6 +13,7 @@ const setSchema = z.object({ distance: z.number().positive().optional(), distanceUnit: z.string().optional(), calories: z.number().int().positive().optional(), + watts: z.number().int().positive().optional(), rpe: z.number().int().min(1).max(10).optional(), notes: z.string().optional(), }); @@ -127,6 +128,7 @@ export async function POST(request: Request) { distance: set.distance || null, distanceUnit: set.distanceUnit || null, calories: set.calories || null, + watts: set.watts || null, notes: set.notes || null, }); }); diff --git a/proof-of-work/app/api/workouts/route.ts b/proof-of-work/app/api/workouts/route.ts index 4c51540..2c31f92 100644 --- a/proof-of-work/app/api/workouts/route.ts +++ b/proof-of-work/app/api/workouts/route.ts @@ -30,6 +30,7 @@ const createWorkoutSchema = z.object({ distance: z.number().positive().optional(), distanceUnit: z.string().optional(), calories: z.number().int().positive().optional(), + watts: z.number().int().positive().optional(), customMetrics: z.record(z.string()).optional(), notes: z.string().optional(), }) @@ -178,6 +179,7 @@ export async function POST(request: NextRequest) { distance: set.distance, distanceUnit: set.distanceUnit, calories: set.calories, + watts: set.watts, customMetrics: set.customMetrics && Object.keys(set.customMetrics).length > 0 ? JSON.stringify(set.customMetrics) diff --git a/proof-of-work/app/main/exercises/[id]/page.tsx b/proof-of-work/app/main/exercises/[id]/page.tsx index f969643..b404a17 100644 --- a/proof-of-work/app/main/exercises/[id]/page.tsx +++ b/proof-of-work/app/main/exercises/[id]/page.tsx @@ -57,6 +57,7 @@ const INPUT_FIELD_OPTIONS = [ { value: "duration", label: "Duration" }, { value: "distance", label: "Distance" }, { value: "calories", label: "Calories" }, + { value: "watts", label: "Avg. watts" }, { value: "notes", label: "Notes" }, ]; diff --git a/proof-of-work/app/main/import/page-csv.tsx b/proof-of-work/app/main/import/page-csv.tsx index 4fe651c..7c99c46 100644 --- a/proof-of-work/app/main/import/page-csv.tsx +++ b/proof-of-work/app/main/import/page-csv.tsx @@ -22,6 +22,7 @@ interface ParsedSet { distance?: number; distanceUnit?: string; calories?: number; + watts?: number; rpe?: number; customMetrics?: Record; notes?: string; @@ -392,6 +393,9 @@ export default function ImportCSVPage() { if (typeof set.calories === "number" && !Number.isNaN(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)) { payloadSet.rpe = set.rpe; } @@ -763,7 +767,7 @@ export default function ImportCSVPage() {

CSV columns: date, exercise, set, weight, reps, duration_seconds, - distance, distance_unit, calories, rpe, notes, custom_* + distance, distance_unit, calories, watts, rpe, notes, custom_*

{loading && ( @@ -778,12 +782,12 @@ export default function ImportCSVPage() { CSV Format Example
-              {`date,exercise,set,weight,weight_unit,reps,duration_seconds,distance,distance_unit,calories,rpe,notes,custom_temperature,custom_watts,custom_metrics_json
-2025-02-15,Bench,1,225,lbs,5,,,,,8,good form,,,
-2025-02-15,Bench,2,225,lbs,5,,,,,8,,,,
-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-18,Cold Plunge,1,,, ,180,,,,,felt great,50,,`}
+              {`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,2,225,lbs,5,,,,,,8,,,
+2025-02-16,Squat,1,315,lbs,8,,,,,,9,30kg per leg,,
+2025-02-17,Assault Bike,1,,, ,900,5,mi,120,157,7,,,"{\"resistance\":\"8\"}"
+2025-02-18,Cold Plunge,1,,, ,180,,,,,,felt great,50,`}
             
diff --git a/proof-of-work/app/main/workouts/[id]/page.tsx b/proof-of-work/app/main/workouts/[id]/page.tsx index 93cde96..8b36c52 100644 --- a/proof-of-work/app/main/workouts/[id]/page.tsx +++ b/proof-of-work/app/main/workouts/[id]/page.tsx @@ -23,6 +23,7 @@ function buildSetSummary(set: { durationSeconds?: number | null; distance?: number | null; calories?: number | null; + watts?: number | null; customMetrics?: string | null; }) { 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).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) { try { const custom = JSON.parse((set as any).customMetrics) as Record; 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 {} } diff --git a/proof-of-work/app/main/workouts/new/page.tsx b/proof-of-work/app/main/workouts/new/page.tsx index f3f237f..ea2bda0 100644 --- a/proof-of-work/app/main/workouts/new/page.tsx +++ b/proof-of-work/app/main/workouts/new/page.tsx @@ -53,6 +53,7 @@ export default async function NewWorkoutPage(props: { durationSeconds: set.durationSeconds ?? undefined, distance: set.distance ?? undefined, calories: set.calories ?? undefined, + watts: set.watts ?? undefined, customMetrics, notes: set.notes ?? undefined, }); diff --git a/proof-of-work/components/workouts/ExercisePicker.tsx b/proof-of-work/components/workouts/ExercisePicker.tsx index ac8b283..95216bf 100644 --- a/proof-of-work/components/workouts/ExercisePicker.tsx +++ b/proof-of-work/components/workouts/ExercisePicker.tsx @@ -61,7 +61,7 @@ export default function ExercisePicker({ // Derive custom types/muscles/fields from existing exercises const knownTypeValues = EXERCISE_TYPES.map((t) => t.value); 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 set = new Set(); @@ -506,6 +506,7 @@ export default function ExercisePicker({ { value: "duration", label: "Time" }, { value: "distance", label: "Distance" }, { value: "calories", label: "Calories" }, + { value: "watts", label: "Avg. watts" }, { value: "notes", label: "Notes" }, ...customFieldOptions, ].map((field) => ( @@ -527,7 +528,7 @@ export default function ExercisePicker({ onSubmit={(e) => { e.preventDefault(); 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)) { 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)} onBlur={() => { 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)) { setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]); } diff --git a/proof-of-work/components/workouts/SetRow.tsx b/proof-of-work/components/workouts/SetRow.tsx index e0144a1..8884e23 100644 --- a/proof-of-work/components/workouts/SetRow.tsx +++ b/proof-of-work/components/workouts/SetRow.tsx @@ -10,6 +10,7 @@ export type InputField = | "duration" | "distance" | "calories" + | "watts" | "notes" | string; @@ -24,6 +25,7 @@ export interface SetRowProps { initialDuration?: number; initialDistance?: number; initialCalories?: number; + initialWatts?: number; initialCustomMetrics?: Record; initialLocked?: boolean; autoFocus?: boolean; @@ -35,6 +37,7 @@ export interface SetRowProps { durationSeconds?: number; distance?: number; calories?: number; + watts?: number; customMetrics?: Record; }) => void; onConfirm?: () => void; @@ -46,6 +49,7 @@ export interface SetRowProps { duration?: string; distance?: string; calories?: string; + watts?: string; }) => void; onDelete: () => void; } @@ -61,6 +65,7 @@ export default function SetRow({ initialDuration, initialDistance, initialCalories, + initialWatts, initialCustomMetrics, initialLocked = false, autoFocus = false, @@ -90,9 +95,16 @@ export default function SetRow({ const [duration, setDuration] = useState(secondsToMinuteString(initialDuration)); const [distance, setDistance] = useState(initialDistance?.toString() || ""); const [calories, setCalories] = useState(initialCalories?.toString() || ""); - const [customValues, setCustomValues] = useState>( - initialCustomMetrics || {} + // 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>(() => { + const { watts: _legacyWatts, ...rest } = initialCustomMetrics || {}; + return rest; + }); const [showNotes, setShowNotes] = useState(!!initialNotes); const [locked, setLocked] = useState(initialLocked); @@ -101,6 +113,7 @@ export default function SetRow({ 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) => @@ -111,6 +124,7 @@ export default function SetRow({ "duration", "distance", "calories", + "watts", "notes", ].includes(f) ); @@ -124,6 +138,7 @@ export default function SetRow({ duration?: string; distance?: string; calories?: string; + watts?: string; customMetrics?: Record; }) => { const r = overrides.reps ?? reps; @@ -133,6 +148,7 @@ export default function SetRow({ 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 !== "") @@ -146,13 +162,14 @@ export default function SetRow({ 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, notes, duration, distance, calories, customValues, onUpdate] + [reps, weight, rpe, notes, duration, distance, calories, watts, customValues, onUpdate] ); const handleConfirm = () => { @@ -175,7 +192,7 @@ export default function SetRow({ const handleNextSet = () => { emitUpdate({}); 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 @@ -186,6 +203,7 @@ export default function SetRow({ 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}`); @@ -238,7 +256,7 @@ export default function SetRow({ } // 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 ---------- return ( @@ -357,6 +375,27 @@ export default function SetRow({ )} + {/* Avg. watts input */} + {showWatts && ( +
+ + { + 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" + /> +
+ )} + {/* RPE select — always shown */}