Compare commits

..

2 Commits

Author SHA1 Message Date
Keysat 38503436e1 Update Current state: 1.2.0:5 built + sideloaded (Gear replaces RPE for cardio)
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
2026-06-16 14:49:43 -05:00
Keysat 4be489d6d3 v1.2.0:5 — Gear (breathing, 1-5) replaces RPE as the effort field for cardio
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.
2026-06-16 14:49:15 -05:00
21 changed files with 251 additions and 38 deletions
+3 -3
View File
@@ -106,11 +106,11 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
## Current state ## Current state
Latest version is **1.2.0:4****avg. watts is now a first-class set field**: promotes average watts (assault bike / rower / ski erg) from a free-text `customMetrics` JSON entry to a real nullable `SetLog.watts` column, written through every set path (create / PATCH / add-sets / import-save / account-import) and shown everywhere as "Avg. watts" with a numeric input. This is the **first schema change in the 1.2.0 line**: the column is added by the boot-time additive `ALTER` in `docker_entrypoint.sh` (guarded, idempotent); legacy watts in `customMetrics` stays readable and migrates to the column on next save (no data migration). Internal token/column is `watts` (matches the existing assault-bike `inputFields` token + legacy `customMetrics` key → zero exercise migration); only ever *displayed* as "Avg. watts". **Built + sideloaded** to the StartOS box (`immense-voyage.local`, 2026-06-16, on `master`) as `proof-of-work_x86_64.s9pk` (80M, git `390aaf5`). Verified before build: tsc clean (app + packaging), lint clean (only pre-existing warnings), **223 tests pass**, `next build` succeeds. Registry empty, **publishing parked** (sideload-only via `make install`). Latest version is **1.2.0:5****Gear replaces RPE as the cardio effort field**: cardio exercises now log a breathing "Gear" (15, Brian MacKenzie) select instead of RPE (610); strength keeps RPE. An exercise is cardio when its equipment `type` is "cardio" **or** its `muscleGroups` contains "cardio" (`isCardioExercise` in `lib/exerciseOptions.ts`) — so Assault Bike (type "assault bike") qualifies, as do Box jump & Soccer (both tagged cardio). New nullable `SetLog.gear` column via boot-time guarded `ALTER`; plumbed through all 5 set-write paths, summary/edit views, CSV/JSON import-export. Program/AI **target**-RPE is a separate concept and untouched. **Built + sideloaded** (`immense-voyage.local`, 2026-06-16, `master`) as `proof-of-work_x86_64.s9pk` (80M, git `4be489d`). Verified: tsc clean (app + packaging), lint clean (pre-existing warnings only), **231 tests pass** (incl. gear + `isCardioExercise`), `next build` succeeds. Registry empty, **publishing parked** (sideload-only via `make install`).
Recent prior ships (1.2.0 line): **1.2.0:3** P3 hardening (login timing oracle + `exerciseId` ownership). **1.2.0:2** iOS Safari login/signup first-tap retry (`lib/retryAction.ts`). **1.2.0:1** Next 15 / React 19 upgrade. (1.2.0:13 had no schema/data change.) Confirmed on-box: **1.2.0:4** avg. watts is a first-class `SetLog.watts` column (assault bike fields verified working). Recent prior ships (1.2.0 line): **1.2.0:3** P3 hardening (login timing oracle + `exerciseId` ownership); **1.2.0:2** iOS Safari login first-tap retry; **1.2.0:1** Next 15 / React 19 upgrade.
**Pending on-box check:** confirm 1.2.0:4 boots clean in StartOS → Logs — on the existing-data upgrade path the entrypoint logs `adding missing column SetLog.watts` (one-shot; no-op thereafter), no permission errors — and that logging an Assault Bike set with Avg. watts saves and displays as `157 W`. Still also pending from earlier ships: the 1.2.0:3 non-root boot (entrypoint logs `launching … as nextjs`, app writes `/data` as uid 1001 — also clears the long-standing 1.1.0:9 non-root check), **and** the 1.2.0:2 Safari first-tap proof (log in from Safari on iPhone/iPad; first Sign In tap works — `-1005` in Web Inspector confirms the retry path, anything else points at a proxy↔container keep-alive mismatch). **Pending on-box check:** confirm 1.2.0:5 boots clean in StartOS → Logs — existing-data upgrade logs `adding missing column SetLog.gear` (one-shot; no-op thereafter), no permission errors — and that an Assault Bike set shows a **Gear** (15) select, not RPE, and saves/displays as `Gear N`. Still pending from earlier ships: the 1.2.0:3 non-root boot (entrypoint logs `launching … as nextjs`, app writes `/data` as uid 1001 — also clears the long-standing 1.1.0:9 non-root check), **and** the 1.2.0:2 Safari first-tap proof (first Sign In tap works — `-1005` in Web Inspector confirms the retry path).
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history). Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).
@@ -21,6 +21,7 @@ interface ParsedSet {
calories?: number; calories?: number;
watts?: number; watts?: number;
rpe?: number; rpe?: number;
gear?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
notes?: string; notes?: string;
} }
@@ -139,6 +140,7 @@ export async function POST(request: NextRequest) {
"calories", "calories",
"watts", "watts",
"rpe", "rpe",
"gear",
"notes", "notes",
"custom_metrics_json", "custom_metrics_json",
"custommetricsjson", "custommetricsjson",
@@ -203,6 +205,7 @@ export async function POST(request: NextRequest) {
const calories = parseIntMaybe(row.calories); const calories = parseIntMaybe(row.calories);
const watts = parseIntMaybe(row.watts); const watts = parseIntMaybe(row.watts);
const rpe = parseIntMaybe(row.rpe); const rpe = parseIntMaybe(row.rpe);
const gear = parseIntMaybe(row.gear);
const customMetrics: Record<string, string> = {}; const customMetrics: Record<string, string> = {};
const customJson = row.custom_metrics_json || row.custommetricsjson; const customJson = row.custom_metrics_json || row.custommetricsjson;
@@ -258,6 +261,7 @@ export async function POST(request: NextRequest) {
calories, calories,
watts, watts,
rpe, rpe,
gear,
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
@@ -51,6 +51,7 @@ const setLogImport = z.object({
weight: z.number().nullable().optional(), weight: z.number().nullable().optional(),
weightUnit: z.string().optional(), weightUnit: z.string().optional(),
rpe: z.number().int().nullable().optional(), rpe: z.number().int().nullable().optional(),
gear: z.number().int().nullable().optional(),
durationSeconds: z.number().int().nullable().optional(), durationSeconds: z.number().int().nullable().optional(),
distance: z.number().nullable().optional(), distance: z.number().nullable().optional(),
distanceUnit: z.string().nullable().optional(), distanceUnit: z.string().nullable().optional(),
@@ -202,6 +203,7 @@ export async function POST(request: NextRequest) {
weight: s.weight ?? null, weight: s.weight ?? null,
weightUnit: s.weightUnit ?? 'lbs', weightUnit: s.weightUnit ?? 'lbs',
rpe: s.rpe ?? null, rpe: s.rpe ?? null,
gear: s.gear ?? null,
durationSeconds: s.durationSeconds ?? null, durationSeconds: s.durationSeconds ?? null,
distance: s.distance ?? null, distance: s.distance ?? null,
distanceUnit: s.distanceUnit ?? null, distanceUnit: s.distanceUnit ?? null,
@@ -72,6 +72,7 @@ export async function GET() {
"setCalories", "setCalories",
"setWatts", "setWatts",
"rpe", "rpe",
"setGear",
"setNotes", "setNotes",
"customMetricsJson", "customMetricsJson",
]; ];
@@ -104,6 +105,7 @@ export async function GET() {
set.calories ?? "", set.calories ?? "",
set.watts ?? "", set.watts ?? "",
set.rpe ?? "", set.rpe ?? "",
set.gear ?? "",
set.notes ?? "", set.notes ?? "",
set.customMetrics ?? "", set.customMetrics ?? "",
]; ];
@@ -57,6 +57,7 @@ const setSchema = z.object({
weight: z.number().optional().nullable(), weight: z.number().optional().nullable(),
weightUnit: z.string().default("lbs"), weightUnit: z.string().default("lbs"),
rpe: z.number().int().min(1).max(10).optional().nullable(), 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(), durationSeconds: z.number().int().positive().optional().nullable(),
distance: z.number().positive().optional().nullable(), distance: z.number().positive().optional().nullable(),
distanceUnit: z.string().optional().nullable(), distanceUnit: z.string().optional().nullable(),
@@ -153,6 +154,7 @@ export async function PATCH(
weight: set.weight ?? undefined, weight: set.weight ?? undefined,
weightUnit: set.weightUnit, weightUnit: set.weightUnit,
rpe: set.rpe ?? undefined, rpe: set.rpe ?? undefined,
gear: set.gear ?? undefined,
durationSeconds: set.durationSeconds ?? undefined, durationSeconds: set.durationSeconds ?? undefined,
distance: set.distance ?? undefined, distance: set.distance ?? undefined,
distanceUnit: set.distanceUnit ?? undefined, distanceUnit: set.distanceUnit ?? undefined,
@@ -14,6 +14,7 @@ const addSetsSchema = z.object({
weight: z.number().optional(), weight: z.number().optional(),
weightUnit: z.string().default("lbs"), weightUnit: z.string().default("lbs"),
rpe: z.number().int().min(1).max(10).optional(), rpe: z.number().int().min(1).max(10).optional(),
gear: z.number().int().min(1).max(5).optional(),
durationSeconds: z.number().int().positive().optional(), durationSeconds: z.number().int().positive().optional(),
distance: z.number().positive().optional(), distance: z.number().positive().optional(),
distanceUnit: z.string().optional(), distanceUnit: z.string().optional(),
@@ -80,6 +81,7 @@ export async function POST(
weight: set.weight, weight: set.weight,
weightUnit: set.weightUnit, weightUnit: set.weightUnit,
rpe: set.rpe, rpe: set.rpe,
gear: set.gear,
durationSeconds: set.durationSeconds, durationSeconds: set.durationSeconds,
distance: set.distance, distance: set.distance,
distanceUnit: set.distanceUnit, distanceUnit: set.distanceUnit,
@@ -15,6 +15,7 @@ const setSchema = z.object({
calories: z.number().int().positive().optional(), calories: z.number().int().positive().optional(),
watts: 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(),
gear: z.number().int().min(1).max(5).optional(),
notes: z.string().optional(), notes: z.string().optional(),
}); });
@@ -124,6 +125,7 @@ export async function POST(request: Request) {
weight: set.weight || null, weight: set.weight || null,
weightUnit: set.weightUnit || "lbs", weightUnit: set.weightUnit || "lbs",
rpe: set.rpe || null, rpe: set.rpe || null,
gear: set.gear || null,
durationSeconds: set.durationSeconds || null, durationSeconds: set.durationSeconds || null,
distance: set.distance || null, distance: set.distance || null,
distanceUnit: set.distanceUnit || null, distanceUnit: set.distanceUnit || null,
+2
View File
@@ -26,6 +26,7 @@ const createWorkoutSchema = z.object({
weight: z.number().positive().optional(), weight: z.number().positive().optional(),
weightUnit: z.string().default("lbs"), weightUnit: z.string().default("lbs"),
rpe: z.number().int().min(1).max(10).optional(), rpe: z.number().int().min(1).max(10).optional(),
gear: z.number().int().min(1).max(5).optional(),
durationSeconds: z.number().int().positive().optional(), durationSeconds: z.number().int().positive().optional(),
distance: z.number().positive().optional(), distance: z.number().positive().optional(),
distanceUnit: z.string().optional(), distanceUnit: z.string().optional(),
@@ -175,6 +176,7 @@ export async function POST(request: NextRequest) {
weight: set.weight, weight: set.weight,
weightUnit: set.weightUnit, weightUnit: set.weightUnit,
rpe: set.rpe, rpe: set.rpe,
gear: set.gear,
durationSeconds: set.durationSeconds, durationSeconds: set.durationSeconds,
distance: set.distance, distance: set.distance,
distanceUnit: set.distanceUnit, distanceUnit: set.distanceUnit,
+11 -7
View File
@@ -24,6 +24,7 @@ interface ParsedSet {
calories?: number; calories?: number;
watts?: number; watts?: number;
rpe?: number; rpe?: number;
gear?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
notes?: string; notes?: string;
} }
@@ -399,6 +400,9 @@ export default function ImportCSVPage() {
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;
} }
if (typeof set.gear === "number" && !Number.isNaN(set.gear)) {
payloadSet.gear = set.gear;
}
if ( if (
set.customMetrics && set.customMetrics &&
typeof set.customMetrics === "object" && typeof set.customMetrics === "object" &&
@@ -767,7 +771,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, watts, rpe, notes, custom_* distance, distance_unit, calories, watts, rpe, gear, notes, custom_*
</p> </p>
</div> </div>
{loading && ( {loading && (
@@ -782,12 +786,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,watts,rpe,notes,custom_temperature,custom_metrics_json {`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,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,157,7,,,"{\"resistance\":\"8\"}" 2025-02-17,Assault Bike,1,,, ,900,5,mi,120,157,,4,,,"{\"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>
@@ -19,6 +19,7 @@ function buildSetSummary(set: {
weightUnit?: string | null; weightUnit?: string | null;
reps?: number | null; reps?: number | null;
rpe?: number | null; rpe?: number | null;
gear?: number | null;
notes?: string | null; notes?: string | null;
durationSeconds?: number | null; durationSeconds?: number | null;
distance?: number | null; distance?: number | null;
@@ -53,7 +54,8 @@ function buildSetSummary(set: {
} }
} catch {} } 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); if (set.notes) parts.push(set.notes);
return parts.length > 0 ? parts.join(" · ") : "No data"; return parts.length > 0 ? parts.join(" · ") : "No data";
} }
@@ -50,6 +50,7 @@ export default async function NewWorkoutPage(props: {
reps: set.reps ?? undefined, reps: set.reps ?? undefined,
weight: set.weight ?? undefined, weight: set.weight ?? undefined,
rpe: set.rpe ?? undefined, rpe: set.rpe ?? undefined,
gear: set.gear ?? undefined,
durationSeconds: set.durationSeconds ?? undefined, durationSeconds: set.durationSeconds ?? undefined,
distance: set.distance ?? undefined, distance: set.distance ?? undefined,
calories: set.calories ?? undefined, calories: set.calories ?? undefined,
+64 -25
View File
@@ -18,9 +18,12 @@ export interface SetRowProps {
setNumber: number; setNumber: number;
inputFields?: InputField[]; inputFields?: InputField[];
weightUnit?: string; weightUnit?: string;
/** Cardio sets log breathing "Gear" (1-5) instead of RPE (6-10). */
isCardio?: boolean;
initialReps?: number; initialReps?: number;
initialWeight?: number; initialWeight?: number;
initialRpe?: number; initialRpe?: number;
initialGear?: number;
initialNotes?: string; initialNotes?: string;
initialDuration?: number; initialDuration?: number;
initialDistance?: number; initialDistance?: number;
@@ -33,6 +36,7 @@ export interface SetRowProps {
reps?: number; reps?: number;
weight?: number; weight?: number;
rpe?: number; rpe?: number;
gear?: number;
notes?: string; notes?: string;
durationSeconds?: number; durationSeconds?: number;
distance?: number; distance?: number;
@@ -45,6 +49,7 @@ export interface SetRowProps {
weight?: string; weight?: string;
reps?: string; reps?: string;
rpe?: string; rpe?: string;
gear?: string;
notes?: string; notes?: string;
duration?: string; duration?: string;
distance?: string; distance?: string;
@@ -58,9 +63,11 @@ export default function SetRow({
setNumber, setNumber,
inputFields = ["sets", "reps", "weight"], inputFields = ["sets", "reps", "weight"],
weightUnit = "lbs", weightUnit = "lbs",
isCardio = false,
initialReps, initialReps,
initialWeight, initialWeight,
initialRpe, initialRpe,
initialGear,
initialNotes, initialNotes,
initialDuration, initialDuration,
initialDistance, initialDistance,
@@ -91,6 +98,7 @@ export default function SetRow({
const [reps, setReps] = useState(initialReps?.toString() || ""); const [reps, setReps] = useState(initialReps?.toString() || "");
const [weight, setWeight] = useState(initialWeight?.toString() || ""); const [weight, setWeight] = useState(initialWeight?.toString() || "");
const [rpe, setRpe] = useState(initialRpe?.toString() || ""); const [rpe, setRpe] = useState(initialRpe?.toString() || "");
const [gear, setGear] = useState(initialGear?.toString() || "");
const [notes, setNotes] = useState(initialNotes || ""); const [notes, setNotes] = useState(initialNotes || "");
const [duration, setDuration] = useState(secondsToMinuteString(initialDuration)); const [duration, setDuration] = useState(secondsToMinuteString(initialDuration));
const [distance, setDistance] = useState(initialDistance?.toString() || ""); const [distance, setDistance] = useState(initialDistance?.toString() || "");
@@ -134,6 +142,7 @@ export default function SetRow({
reps?: string; reps?: string;
weight?: string; weight?: string;
rpe?: string; rpe?: string;
gear?: string;
notes?: string; notes?: string;
duration?: string; duration?: string;
distance?: string; distance?: string;
@@ -144,6 +153,7 @@ export default function SetRow({
const r = overrides.reps ?? reps; const r = overrides.reps ?? reps;
const w = overrides.weight ?? weight; const w = overrides.weight ?? weight;
const p = overrides.rpe ?? rpe; const p = overrides.rpe ?? rpe;
const gr = overrides.gear ?? gear;
const n = overrides.notes ?? notes; const n = overrides.notes ?? notes;
const dur = overrides.duration ?? duration; const dur = overrides.duration ?? duration;
const dist = overrides.distance ?? distance; const dist = overrides.distance ?? distance;
@@ -158,6 +168,7 @@ export default function SetRow({
reps: r ? parseInt(r) : undefined, reps: r ? parseInt(r) : undefined,
weight: w ? parseFloat(w) : undefined, weight: w ? parseFloat(w) : undefined,
rpe: p ? parseInt(p) : undefined, rpe: p ? parseInt(p) : undefined,
gear: gr ? parseInt(gr) : undefined,
notes: n || undefined, notes: n || undefined,
durationSeconds: minuteStringToSeconds(dur), durationSeconds: minuteStringToSeconds(dur),
distance: dist ? parseFloat(dist) : undefined, distance: dist ? parseFloat(dist) : undefined,
@@ -169,7 +180,7 @@ export default function SetRow({
: undefined, : undefined,
}); });
}, },
[reps, weight, rpe, notes, duration, distance, calories, watts, customValues, onUpdate] [reps, weight, rpe, gear, notes, duration, distance, calories, watts, customValues, onUpdate]
); );
const handleConfirm = () => { const handleConfirm = () => {
@@ -192,7 +203,7 @@ export default function SetRow({
const handleNextSet = () => { const handleNextSet = () => {
emitUpdate({}); emitUpdate({});
setLocked(true); 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 // Build a summary string for the locked view
@@ -208,7 +219,11 @@ export default function SetRow({
const value = customValues[field]; const value = customValues[field];
if (value) parts.push(`${field}: ${value}`); 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); if (showNotesField && notes) parts.push(notes);
return parts.length > 0 ? parts.join(" · ") : "No data"; return parts.length > 0 ? parts.join(" · ") : "No data";
}; };
@@ -396,28 +411,52 @@ export default function SetRow({
</div> </div>
)} )}
{/* RPE select — always shown */} {/* Effort select — Gear (1-5, breathing gear) for cardio, else RPE (6-10) */}
<div className="flex-1 min-w-[50px] max-w-[60px]"> {isCardio ? (
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5"> <div className="flex-1 min-w-[50px] max-w-[60px]">
RPE <label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
</label> Gear
<select </label>
value={rpe} <select
onChange={(e) => { value={gear}
const val = e.target.value; onChange={(e) => {
setRpe(val); const val = e.target.value;
emitUpdate({ rpe: val }); setGear(val);
}} emitUpdate({ gear: val });
className="w-full px-1.5 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20" }}
> className="w-full px-1.5 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
<option value="">-</option> >
<option value="6">6</option> <option value="">-</option>
<option value="7">7</option> <option value="1">1</option>
<option value="8">8</option> <option value="2">2</option>
<option value="9">9</option> <option value="3">3</option>
<option value="10">10</option> <option value="4">4</option>
</select> <option value="5">5</option>
</div> </select>
</div>
) : (
<div className="flex-1 min-w-[50px] max-w-[60px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
RPE
</label>
<select
value={rpe}
onChange={(e) => {
const val = e.target.value;
setRpe(val);
emitUpdate({ rpe: val });
}}
className="w-full px-1.5 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
>
<option value="">-</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
</select>
</div>
)}
{/* Next set button — confirm + add new pre-filled set */} {/* Next set button — confirm + add new pre-filled set */}
{onNextSet && ( {onNextSet && (
@@ -7,6 +7,7 @@ import { ChevronDown, ChevronUp, Loader, Trash2, Plus, Save, Pencil, Check, Arro
import ExercisePicker from "./ExercisePicker"; import ExercisePicker from "./ExercisePicker";
import SetRow, { InputField } from "./SetRow"; import SetRow, { InputField } from "./SetRow";
import { formatSetsSummary } from "@/lib/formatSets"; import { formatSetsSummary } from "@/lib/formatSets";
import { isCardioExercise } from "@/lib/exerciseOptions";
// --------------- Exercise History Popup --------------- // --------------- Exercise History Popup ---------------
type HistoryEntry = { type HistoryEntry = {
@@ -232,6 +233,7 @@ interface ExerciseWithSets {
reps?: number; reps?: number;
weight?: number; weight?: number;
rpe?: number; rpe?: number;
gear?: number;
durationSeconds?: number; durationSeconds?: number;
distance?: number; distance?: number;
calories?: number; calories?: number;
@@ -257,6 +259,7 @@ export interface EditWorkoutData {
reps?: number; reps?: number;
weight?: number; weight?: number;
rpe?: number; rpe?: number;
gear?: number;
durationSeconds?: number; durationSeconds?: number;
distance?: number; distance?: number;
calories?: number; calories?: number;
@@ -344,6 +347,7 @@ export default function WorkoutForm({
weight: s.weight, weight: s.weight,
weightUnit: (e.exercise as any).defaultWeightUnit || "lbs", weightUnit: (e.exercise as any).defaultWeightUnit || "lbs",
rpe: s.rpe, rpe: s.rpe,
gear: s.gear,
durationSeconds: s.durationSeconds, durationSeconds: s.durationSeconds,
distance: s.distance, distance: s.distance,
distanceUnit: s.distance !== undefined ? "mi" : undefined, distanceUnit: s.distance !== undefined ? "mi" : undefined,
@@ -507,6 +511,7 @@ export default function WorkoutForm({
reps?: number; reps?: number;
weight?: number; weight?: number;
rpe?: number; rpe?: number;
gear?: number;
notes?: string; notes?: string;
durationSeconds?: number; durationSeconds?: number;
distance?: number; distance?: number;
@@ -559,6 +564,7 @@ export default function WorkoutForm({
weight?: string; weight?: string;
reps?: string; reps?: string;
rpe?: string; rpe?: string;
gear?: string;
notes?: string; notes?: string;
duration?: string; duration?: string;
distance?: string; distance?: string;
@@ -580,6 +586,7 @@ export default function WorkoutForm({
weight: currentValues.weight ? parseFloat(currentValues.weight) : undefined, weight: currentValues.weight ? parseFloat(currentValues.weight) : undefined,
reps: undefined, // User typically changes reps per set reps: undefined, // User typically changes reps per set
rpe: currentValues.rpe ? parseInt(currentValues.rpe) : undefined, rpe: currentValues.rpe ? parseInt(currentValues.rpe) : undefined,
gear: currentValues.gear ? parseInt(currentValues.gear) : undefined,
notes: currentValues.notes || undefined, notes: currentValues.notes || undefined,
forceEdit: true, // Start in edit mode even though weight is pre-filled forceEdit: true, // Start in edit mode even though weight is pre-filled
}, },
@@ -856,9 +863,11 @@ export default function WorkoutForm({
setNumber={set.setNumber} setNumber={set.setNumber}
inputFields={parseInputFields(item.exercise)} inputFields={parseInputFields(item.exercise)}
weightUnit={(item.exercise as any).defaultWeightUnit || "lbs"} weightUnit={(item.exercise as any).defaultWeightUnit || "lbs"}
isCardio={isCardioExercise(item.exercise)}
initialReps={set.reps} initialReps={set.reps}
initialWeight={set.weight} initialWeight={set.weight}
initialRpe={set.rpe} initialRpe={set.rpe}
initialGear={set.gear}
initialDuration={set.durationSeconds} initialDuration={set.durationSeconds}
initialDistance={set.distance} initialDistance={set.distance}
initialCalories={set.calories} initialCalories={set.calories}
+16
View File
@@ -131,3 +131,19 @@ export function deriveTrackingFieldOptions(exercises: Exercise[]): Option[] {
export function displayLabel(value: string): string { export function displayLabel(value: string): string {
return titleCaseToken(value); 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"
);
}
+2 -1
View File
@@ -116,7 +116,8 @@ model SetLog {
reps Int? reps Int?
weight Float? weight Float?
weightUnit String @default("lbs") 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) durationSeconds Int? // for timed exercises (assault bike, jump rope, planks)
distance Float? // for distance-based exercises distance Float? // for distance-based exercises
distanceUnit String? // "mi", "km", "m" distanceUnit String? // "mi", "km", "m"
+28
View File
@@ -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);
});
});
+61
View File
@@ -339,6 +339,45 @@ describe('POST /api/workouts', () => {
expect(stored?.watts).toBe(157); 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 () => { 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({
@@ -466,6 +505,28 @@ describe('PATCH /api/workouts/[id]', () => {
const stored = await prisma.setLog.findFirst({ where: { workoutId: workout.id } }); const stored = await prisma.setLog.findFirst({ where: { workoutId: workout.id } });
expect(stored?.watts).toBe(180); 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', () => { describe('POST /api/workouts/import/save', () => {
+1
View File
@@ -91,6 +91,7 @@ export type ParsedSet = {
calories?: number | null; calories?: number | null;
watts?: number | null; watts?: number | null;
rpe?: number | null; rpe?: number | null;
gear?: number | null;
notes?: string | null; notes?: string | null;
}; };
+5
View File
@@ -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;" sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN watts INTEGER;"
fi 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 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;"
+6 -1
View File
@@ -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_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' 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. * 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 * column, added by the boot-time additive ALTER). Written through
* every set path; legacy watts in customMetrics stays readable and * every set path; legacy watts in customMetrics stays readable and
* migrates on next save. * 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({ export const versionGraph = VersionGraph.of({
current: v_1_2_0_4, current: v_1_2_0_5,
other: [ other: [
v_1_0_0_1, v_1_0_0_1,
v_1_0_0_2, v_1_0_0_2,
@@ -99,5 +103,6 @@ export const versionGraph = VersionGraph.of({
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, v_1_2_0_3,
v_1_2_0_4,
], ],
}) })
+25
View File
@@ -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,
},
})