From 4be489d6d3c3e67daa8e369fcdb76563831e4cda Mon Sep 17 00:00:00 2001 From: Keysat Date: Tue, 16 Jun 2026 14:49:15 -0500 Subject: [PATCH] =?UTF-8?q?v1.2.0:5=20=E2=80=94=20Gear=20(breathing,=201-5?= =?UTF-8?q?)=20replaces=20RPE=20as=20the=20effort=20field=20for=20cardio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- 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 + proof-of-work/app/main/import/page-csv.tsx | 18 ++-- proof-of-work/app/main/workouts/[id]/page.tsx | 4 +- proof-of-work/app/main/workouts/new/page.tsx | 1 + proof-of-work/components/workouts/SetRow.tsx | 89 +++++++++++++------ .../components/workouts/WorkoutForm.tsx | 9 ++ proof-of-work/lib/exerciseOptions.ts | 16 ++++ proof-of-work/prisma/schema.prisma | 3 +- proof-of-work/tests/cardio.test.ts | 28 ++++++ proof-of-work/tests/routes-crud.test.ts | 61 +++++++++++++ proof-of-work/types/index.ts | 1 + start9/0.4/docker_entrypoint.sh | 5 ++ start9/0.4/startos/versions/index.ts | 7 +- start9/0.4/startos/versions/v1.2.0.5.ts | 25 ++++++ 20 files changed, 248 insertions(+), 35 deletions(-) create mode 100644 proof-of-work/tests/cardio.test.ts create mode 100644 start9/0.4/startos/versions/v1.2.0.5.ts diff --git a/proof-of-work/app/api/import/parse/route.ts b/proof-of-work/app/api/import/parse/route.ts index 0290275..0e282dd 100644 --- a/proof-of-work/app/api/import/parse/route.ts +++ b/proof-of-work/app/api/import/parse/route.ts @@ -21,6 +21,7 @@ interface ParsedSet { calories?: number; watts?: number; rpe?: number; + gear?: number; customMetrics?: Record; notes?: string; } @@ -139,6 +140,7 @@ export async function POST(request: NextRequest) { "calories", "watts", "rpe", + "gear", "notes", "custom_metrics_json", "custommetricsjson", @@ -203,6 +205,7 @@ export async function POST(request: NextRequest) { const calories = parseIntMaybe(row.calories); const watts = parseIntMaybe(row.watts); const rpe = parseIntMaybe(row.rpe); + const gear = parseIntMaybe(row.gear); const customMetrics: Record = {}; const customJson = row.custom_metrics_json || row.custommetricsjson; @@ -258,6 +261,7 @@ export async function POST(request: NextRequest) { calories, watts, rpe, + gear, 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 c0be414..2b1cd71 100644 --- a/proof-of-work/app/api/me/import/route.ts +++ b/proof-of-work/app/api/me/import/route.ts @@ -51,6 +51,7 @@ const setLogImport = z.object({ weight: z.number().nullable().optional(), weightUnit: z.string().optional(), rpe: z.number().int().nullable().optional(), + gear: z.number().int().nullable().optional(), durationSeconds: z.number().int().nullable().optional(), distance: z.number().nullable().optional(), distanceUnit: z.string().nullable().optional(), @@ -202,6 +203,7 @@ export async function POST(request: NextRequest) { weight: s.weight ?? null, weightUnit: s.weightUnit ?? 'lbs', rpe: s.rpe ?? null, + gear: s.gear ?? null, durationSeconds: s.durationSeconds ?? null, distance: s.distance ?? null, distanceUnit: s.distanceUnit ?? 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 03f2c3d..132d606 100644 --- a/proof-of-work/app/api/settings/export-csv/route.ts +++ b/proof-of-work/app/api/settings/export-csv/route.ts @@ -72,6 +72,7 @@ export async function GET() { "setCalories", "setWatts", "rpe", + "setGear", "setNotes", "customMetricsJson", ]; @@ -104,6 +105,7 @@ export async function GET() { set.calories ?? "", set.watts ?? "", set.rpe ?? "", + set.gear ?? "", 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 56259a1..3811867 100644 --- a/proof-of-work/app/api/workouts/[id]/route.ts +++ b/proof-of-work/app/api/workouts/[id]/route.ts @@ -57,6 +57,7 @@ const setSchema = z.object({ weight: z.number().optional().nullable(), weightUnit: z.string().default("lbs"), rpe: z.number().int().min(1).max(10).optional().nullable(), + gear: z.number().int().min(1).max(5).optional().nullable(), durationSeconds: z.number().int().positive().optional().nullable(), distance: z.number().positive().optional().nullable(), distanceUnit: z.string().optional().nullable(), @@ -153,6 +154,7 @@ export async function PATCH( weight: set.weight ?? undefined, weightUnit: set.weightUnit, rpe: set.rpe ?? undefined, + gear: set.gear ?? undefined, durationSeconds: set.durationSeconds ?? undefined, distance: set.distance ?? undefined, distanceUnit: set.distanceUnit ?? undefined, 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 3cc011a..3db6807 100644 --- a/proof-of-work/app/api/workouts/[id]/sets/route.ts +++ b/proof-of-work/app/api/workouts/[id]/sets/route.ts @@ -14,6 +14,7 @@ const addSetsSchema = z.object({ weight: z.number().optional(), weightUnit: z.string().default("lbs"), rpe: z.number().int().min(1).max(10).optional(), + gear: z.number().int().min(1).max(5).optional(), durationSeconds: z.number().int().positive().optional(), distance: z.number().positive().optional(), distanceUnit: z.string().optional(), @@ -80,6 +81,7 @@ export async function POST( weight: set.weight, weightUnit: set.weightUnit, rpe: set.rpe, + gear: set.gear, durationSeconds: set.durationSeconds, distance: set.distance, distanceUnit: set.distanceUnit, 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 27b0790..19ebe65 100644 --- a/proof-of-work/app/api/workouts/import/save/route.ts +++ b/proof-of-work/app/api/workouts/import/save/route.ts @@ -15,6 +15,7 @@ const setSchema = z.object({ calories: z.number().int().positive().optional(), watts: z.number().int().positive().optional(), rpe: z.number().int().min(1).max(10).optional(), + gear: z.number().int().min(1).max(5).optional(), notes: z.string().optional(), }); @@ -124,6 +125,7 @@ export async function POST(request: Request) { weight: set.weight || null, weightUnit: set.weightUnit || "lbs", rpe: set.rpe || null, + gear: set.gear || null, durationSeconds: set.durationSeconds || null, distance: set.distance || null, distanceUnit: set.distanceUnit || null, diff --git a/proof-of-work/app/api/workouts/route.ts b/proof-of-work/app/api/workouts/route.ts index 2c31f92..e5b77d0 100644 --- a/proof-of-work/app/api/workouts/route.ts +++ b/proof-of-work/app/api/workouts/route.ts @@ -26,6 +26,7 @@ const createWorkoutSchema = z.object({ weight: z.number().positive().optional(), weightUnit: z.string().default("lbs"), rpe: z.number().int().min(1).max(10).optional(), + gear: z.number().int().min(1).max(5).optional(), durationSeconds: z.number().int().positive().optional(), distance: z.number().positive().optional(), distanceUnit: z.string().optional(), @@ -175,6 +176,7 @@ export async function POST(request: NextRequest) { weight: set.weight, weightUnit: set.weightUnit, rpe: set.rpe, + gear: set.gear, durationSeconds: set.durationSeconds, distance: set.distance, distanceUnit: set.distanceUnit, diff --git a/proof-of-work/app/main/import/page-csv.tsx b/proof-of-work/app/main/import/page-csv.tsx index 7c99c46..a750c5a 100644 --- a/proof-of-work/app/main/import/page-csv.tsx +++ b/proof-of-work/app/main/import/page-csv.tsx @@ -24,6 +24,7 @@ interface ParsedSet { calories?: number; watts?: number; rpe?: number; + gear?: number; customMetrics?: Record; notes?: string; } @@ -399,6 +400,9 @@ export default function ImportCSVPage() { if (typeof set.rpe === "number" && !Number.isNaN(set.rpe)) { payloadSet.rpe = set.rpe; } + if (typeof set.gear === "number" && !Number.isNaN(set.gear)) { + payloadSet.gear = set.gear; + } if ( set.customMetrics && typeof set.customMetrics === "object" && @@ -767,7 +771,7 @@ export default function ImportCSVPage() {

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

{loading && ( @@ -782,12 +786,12 @@ export default function ImportCSVPage() { CSV Format Example
-              {`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,`}
+              {`date,exercise,set,weight,weight_unit,reps,duration_seconds,distance,distance_unit,calories,watts,rpe,gear,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,,4,,,"{\"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 8b36c52..38680ef 100644 --- a/proof-of-work/app/main/workouts/[id]/page.tsx +++ b/proof-of-work/app/main/workouts/[id]/page.tsx @@ -19,6 +19,7 @@ function buildSetSummary(set: { weightUnit?: string | null; reps?: number | null; rpe?: number | null; + gear?: number | null; notes?: string | null; durationSeconds?: number | null; distance?: number | null; @@ -53,7 +54,8 @@ function buildSetSummary(set: { } } catch {} } - if (set.rpe) parts.push(`RPE ${set.rpe}`); + if (set.gear) parts.push(`Gear ${set.gear}`); + else if (set.rpe) parts.push(`RPE ${set.rpe}`); if (set.notes) parts.push(set.notes); return parts.length > 0 ? parts.join(" · ") : "No data"; } diff --git a/proof-of-work/app/main/workouts/new/page.tsx b/proof-of-work/app/main/workouts/new/page.tsx index ea2bda0..c69da17 100644 --- a/proof-of-work/app/main/workouts/new/page.tsx +++ b/proof-of-work/app/main/workouts/new/page.tsx @@ -50,6 +50,7 @@ export default async function NewWorkoutPage(props: { reps: set.reps ?? undefined, weight: set.weight ?? undefined, rpe: set.rpe ?? undefined, + gear: set.gear ?? undefined, durationSeconds: set.durationSeconds ?? undefined, distance: set.distance ?? undefined, calories: set.calories ?? undefined, diff --git a/proof-of-work/components/workouts/SetRow.tsx b/proof-of-work/components/workouts/SetRow.tsx index 8884e23..598a16a 100644 --- a/proof-of-work/components/workouts/SetRow.tsx +++ b/proof-of-work/components/workouts/SetRow.tsx @@ -18,9 +18,12 @@ 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; @@ -33,6 +36,7 @@ export interface SetRowProps { reps?: number; weight?: number; rpe?: number; + gear?: number; notes?: string; durationSeconds?: number; distance?: number; @@ -45,6 +49,7 @@ export interface SetRowProps { weight?: string; reps?: string; rpe?: string; + gear?: string; notes?: string; duration?: string; distance?: string; @@ -58,9 +63,11 @@ export default function SetRow({ setNumber, inputFields = ["sets", "reps", "weight"], weightUnit = "lbs", + isCardio = false, initialReps, initialWeight, initialRpe, + initialGear, initialNotes, initialDuration, initialDistance, @@ -91,6 +98,7 @@ export default function SetRow({ 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() || ""); @@ -134,6 +142,7 @@ export default function SetRow({ reps?: string; weight?: string; rpe?: string; + gear?: string; notes?: string; duration?: string; distance?: string; @@ -144,6 +153,7 @@ export default function SetRow({ 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; @@ -158,6 +168,7 @@ export default function SetRow({ 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, @@ -169,7 +180,7 @@ export default function SetRow({ : undefined, }); }, - [reps, weight, rpe, notes, duration, distance, calories, watts, customValues, onUpdate] + [reps, weight, rpe, gear, notes, duration, distance, calories, watts, customValues, onUpdate] ); const handleConfirm = () => { @@ -192,7 +203,7 @@ export default function SetRow({ const handleNextSet = () => { emitUpdate({}); setLocked(true); - onNextSet?.({ weight, reps, rpe, notes, duration, distance, calories, watts }); + onNextSet?.({ weight, reps, rpe, gear, notes, duration, distance, calories, watts }); }; // Build a summary string for the locked view @@ -208,7 +219,11 @@ export default function SetRow({ const value = customValues[field]; if (value) parts.push(`${field}: ${value}`); } - if (rpe) parts.push(`RPE ${rpe}`); + 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"; }; @@ -396,28 +411,52 @@ export default function SetRow({ )} - {/* RPE select — always shown */} -
- - -
+ {/* Effort select — Gear (1-5, breathing gear) for cardio, else RPE (6-10) */} + {isCardio ? ( +
+ + +
+ ) : ( +
+ + +
+ )} {/* Next set button — confirm + add new pre-filled set */} {onNextSet && ( diff --git a/proof-of-work/components/workouts/WorkoutForm.tsx b/proof-of-work/components/workouts/WorkoutForm.tsx index 255022f..14de800 100644 --- a/proof-of-work/components/workouts/WorkoutForm.tsx +++ b/proof-of-work/components/workouts/WorkoutForm.tsx @@ -7,6 +7,7 @@ import { ChevronDown, ChevronUp, Loader, Trash2, Plus, Save, Pencil, Check, Arro import ExercisePicker from "./ExercisePicker"; import SetRow, { InputField } from "./SetRow"; import { formatSetsSummary } from "@/lib/formatSets"; +import { isCardioExercise } from "@/lib/exerciseOptions"; // --------------- Exercise History Popup --------------- type HistoryEntry = { @@ -232,6 +233,7 @@ interface ExerciseWithSets { reps?: number; weight?: number; rpe?: number; + gear?: number; durationSeconds?: number; distance?: number; calories?: number; @@ -257,6 +259,7 @@ export interface EditWorkoutData { reps?: number; weight?: number; rpe?: number; + gear?: number; durationSeconds?: number; distance?: number; calories?: number; @@ -344,6 +347,7 @@ export default function WorkoutForm({ weight: s.weight, weightUnit: (e.exercise as any).defaultWeightUnit || "lbs", rpe: s.rpe, + gear: s.gear, durationSeconds: s.durationSeconds, distance: s.distance, distanceUnit: s.distance !== undefined ? "mi" : undefined, @@ -507,6 +511,7 @@ export default function WorkoutForm({ reps?: number; weight?: number; rpe?: number; + gear?: number; notes?: string; durationSeconds?: number; distance?: number; @@ -559,6 +564,7 @@ export default function WorkoutForm({ weight?: string; reps?: string; rpe?: string; + gear?: string; notes?: string; duration?: string; distance?: string; @@ -580,6 +586,7 @@ export default function WorkoutForm({ weight: currentValues.weight ? parseFloat(currentValues.weight) : undefined, reps: undefined, // User typically changes reps per set rpe: currentValues.rpe ? parseInt(currentValues.rpe) : undefined, + gear: currentValues.gear ? parseInt(currentValues.gear) : undefined, notes: currentValues.notes || undefined, forceEdit: true, // Start in edit mode even though weight is pre-filled }, @@ -856,9 +863,11 @@ export default function WorkoutForm({ setNumber={set.setNumber} inputFields={parseInputFields(item.exercise)} weightUnit={(item.exercise as any).defaultWeightUnit || "lbs"} + isCardio={isCardioExercise(item.exercise)} initialReps={set.reps} initialWeight={set.weight} initialRpe={set.rpe} + initialGear={set.gear} initialDuration={set.durationSeconds} initialDistance={set.distance} initialCalories={set.calories} diff --git a/proof-of-work/lib/exerciseOptions.ts b/proof-of-work/lib/exerciseOptions.ts index a4e04dc..4480ec2 100644 --- a/proof-of-work/lib/exerciseOptions.ts +++ b/proof-of-work/lib/exerciseOptions.ts @@ -131,3 +131,19 @@ export function deriveTrackingFieldOptions(exercises: Exercise[]): Option[] { export function displayLabel(value: string): string { return titleCaseToken(value); } + +/** + * Cardio exercises log breathing "Gear" (1-5) instead of RPE (6-10) as their + * effort field. An exercise counts as cardio if its equipment type is "cardio" + * or it carries the "cardio" muscle group (e.g. Assault Bike, type + * "assault bike", is tagged cardio). + */ +export function isCardioExercise(exercise: { + type?: string | null; + muscleGroups?: string | null; +}): boolean { + if (normalizeValue(exercise.type || "") === "cardio") return true; + return parseJsonArray(exercise.muscleGroups ?? null).some( + (group) => normalizeValue(group) === "cardio" + ); +} diff --git a/proof-of-work/prisma/schema.prisma b/proof-of-work/prisma/schema.prisma index 228eaa6..6482f5b 100644 --- a/proof-of-work/prisma/schema.prisma +++ b/proof-of-work/prisma/schema.prisma @@ -116,7 +116,8 @@ model SetLog { reps Int? weight Float? weightUnit String @default("lbs") - rpe Int? // Rate of Perceived Exertion (1-10) + rpe Int? // Rate of Perceived Exertion (1-10) — non-cardio effort + gear Int? // breathing "gear" (1-5, Brian MacKenzie) — cardio effort durationSeconds Int? // for timed exercises (assault bike, jump rope, planks) distance Float? // for distance-based exercises distanceUnit String? // "mi", "km", "m" diff --git a/proof-of-work/tests/cardio.test.ts b/proof-of-work/tests/cardio.test.ts new file mode 100644 index 0000000..aeda74c --- /dev/null +++ b/proof-of-work/tests/cardio.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { isCardioExercise } from '@/lib/exerciseOptions'; + +describe('isCardioExercise', () => { + it('treats type "cardio" as cardio', () => { + expect(isCardioExercise({ type: 'cardio', muscleGroups: '["cardio"]' })).toBe(true); + }); + + it('treats the cardio muscle group as cardio even when type differs (Assault Bike)', () => { + expect( + isCardioExercise({ type: 'assault bike', muscleGroups: '["cardio","legs","back","shoulders"]' }) + ).toBe(true); + }); + + it('is case/whitespace-insensitive on the muscle group', () => { + expect(isCardioExercise({ type: 'other', muscleGroups: '[" Cardio "]' })).toBe(true); + }); + + it('treats strength work (no cardio signal) as non-cardio', () => { + expect(isCardioExercise({ type: 'barbell', muscleGroups: '["back","biceps"]' })).toBe(false); + }); + + it('handles missing/empty fields without throwing', () => { + expect(isCardioExercise({})).toBe(false); + expect(isCardioExercise({ type: null, muscleGroups: null })).toBe(false); + expect(isCardioExercise({ type: '', muscleGroups: 'not json' })).toBe(false); + }); +}); diff --git a/proof-of-work/tests/routes-crud.test.ts b/proof-of-work/tests/routes-crud.test.ts index bd4c6c0..12c0d32 100644 --- a/proof-of-work/tests/routes-crud.test.ts +++ b/proof-of-work/tests/routes-crud.test.ts @@ -339,6 +339,45 @@ describe('POST /api/workouts', () => { expect(stored?.watts).toBe(157); }); + it('persists gear (cardio breathing effort) on a set', 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: '["cardio"]', + 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, gear: 3 }], + }), + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.setLogs[0].gear).toBe(3); + const stored = await prisma.setLog.findFirst({ where: { workoutId: body.id } }); + expect(stored?.gear).toBe(3); + }); + + it('rejects gear outside 1-5 via Zod with 400', 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: '["cardio"]' }, + }); + getCurrentUserMock.mockResolvedValue(alice); + const res = await postWorkout( + jsonReq('http://x/api/workouts', { + sets: [{ exerciseId: bike.id, setNumber: 1, durationSeconds: 600, gear: 7 }], + }), + ); + expect(res.status).toBe(400); + }); + it('rejects negative reps via Zod with 400', async () => { const alice = await makeUser({ email: 'a@x' }); const bench = await prisma.exercise.create({ @@ -466,6 +505,28 @@ describe('PATCH /api/workouts/[id]', () => { const stored = await prisma.setLog.findFirst({ where: { workoutId: workout.id } }); expect(stored?.watts).toBe(180); }); + + it('persists gear 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: '["cardio"]' }, + }); + 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, gear: 4 }] }, + { 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?.gear).toBe(4); + }); }); describe('POST /api/workouts/import/save', () => { diff --git a/proof-of-work/types/index.ts b/proof-of-work/types/index.ts index 2c26ddf..32c07ca 100644 --- a/proof-of-work/types/index.ts +++ b/proof-of-work/types/index.ts @@ -91,6 +91,7 @@ export type ParsedSet = { calories?: number | null; watts?: number | null; rpe?: number | null; + gear?: number | null; notes?: string | null; }; diff --git a/start9/0.4/docker_entrypoint.sh b/start9/0.4/docker_entrypoint.sh index b4f53c9..000480a 100755 --- a/start9/0.4/docker_entrypoint.sh +++ b/start9/0.4/docker_entrypoint.sh @@ -77,6 +77,11 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN watts INTEGER;" fi + if ! sqlite3 "$DB_PATH" "PRAGMA table_info('SetLog');" 2>/dev/null | grep -q "|gear|"; then + log "adding missing column SetLog.gear" + sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN gear INTEGER;" + fi + if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|deletedAt|"; then log "adding missing column Workout.deletedAt" sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN deletedAt DATETIME;" diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts index 58e1d9b..1bfdfe5 100644 --- a/start9/0.4/startos/versions/index.ts +++ b/start9/0.4/startos/versions/index.ts @@ -19,6 +19,7 @@ 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_3 } from './v1.2.0.3' import { v_1_2_0_4 } from './v1.2.0.4' +import { v_1_2_0_5 } from './v1.2.0.5' /** * Version graph for the `proof-of-work` package. @@ -76,9 +77,12 @@ import { v_1_2_0_4 } from './v1.2.0.4' * column, added by the boot-time additive ALTER). Written through * every set path; legacy watts in customMetrics stays readable and * migrates on next save. + * v1.2.0:5 — Gear (breathing, 1-5, Brian MacKenzie) replaces RPE as the effort + * field for cardio exercises (type "cardio" or "cardio" muscle + * group); strength keeps RPE. New SetLog.gear column via boot ALTER. */ export const versionGraph = VersionGraph.of({ - current: v_1_2_0_4, + current: v_1_2_0_5, other: [ v_1_0_0_1, v_1_0_0_2, @@ -99,5 +103,6 @@ export const versionGraph = VersionGraph.of({ v_1_2_0_1, v_1_2_0_2, v_1_2_0_3, + v_1_2_0_4, ], }) diff --git a/start9/0.4/startos/versions/v1.2.0.5.ts b/start9/0.4/startos/versions/v1.2.0.5.ts new file mode 100644 index 0000000..1eef2e7 --- /dev/null +++ b/start9/0.4/startos/versions/v1.2.0.5.ts @@ -0,0 +1,25 @@ +import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' + +/** + * v1.2.0:5 — Gear (breathing, 1-5) replaces RPE as the cardio effort field (2026-06-16). + * + * Cardio exercises now log a breathing "Gear" (1-5, per Brian MacKenzie) + * instead of RPE (6-10) as their effort field; non-cardio keeps RPE. An + * exercise counts as cardio if its equipment type is "cardio" or it carries + * the "cardio" muscle group (so Assault Bike, type "assault bike", qualifies). + * + * Additive schema change: the new nullable SetLog.gear column is added by the + * boot-time guarded ALTER in docker_entrypoint.sh (migration stays empty, like + * every other column add). Existing rpe data is untouched and still displays. + */ +export const v_1_2_0_5 = VersionInfo.of({ + version: '1.2.0:5', + releaseNotes: { + en_US: + 'Cardio exercises (assault bike, rower, ski erg, running, etc.) now log a breathing "Gear" (1-5) instead of RPE as their effort field. Strength exercises still use RPE. No data changes.', + }, + migrations: { + up: async () => {}, + down: IMPOSSIBLE, + }, +})