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,
+ },
+})