Compare commits

...

6 Commits

Author SHA1 Message Date
Keysat 0401a831b7 Document read-only on-box verification via start-cli
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Record the installed-version / package logs / attach+sqlite3 commands used to
verify ALTERs and persisted data on the box, so future sessions verify
directly instead of deferring to the StartOS web UI.
2026-06-16 16:37:54 -05:00
Keysat d1bc895e5e Log Safari first-tap login bug as a known bug with diagnosis
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
1.2.0:2's retryOnTransportError does not fix the mobile-Safari first-login
failure (reproduced on 1.2.0:5: first tap errors, second works). Record the
diagnosis and the gating next step (capture the first request's error code:
-1005 -> client delayed retry; 502/503 -> Node keep-alive tuning) so a future
session resumes from here. Correct the now-stale Current state line.
2026-06-16 16:04:12 -05:00
Keysat 184382f75c Record Gear/RPE effort convention; mark 1.2.0:5 confirmed on-box
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Add the cardio-effort (Gear vs RPE) convention, and tighten Current state:
1.2.0:5 + 1.2.0:4 ALTERs and non-root boot verified on the box via start-cli
(installed-version, package logs, and an app-DB read showing a saved gear=1
Assault Bike set). Only the 1.2.0:2 Safari first-tap check remains open.
2026-06-16 15:44:47 -05:00
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
Keysat ef3d079ca2 Record first-class-set-metric convention + CSV round-trip backlog
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Capture the ~17-touchpoint recipe for promoting a set metric to a column
(the watts precedent) so the next one doesn't need a repo-wide grep, and log
the pre-existing CSV export/import header-name asymmetry as backlog.
2026-06-16 14:07:03 -05:00
22 changed files with 267 additions and 38 deletions
+10 -3
View File
@@ -63,6 +63,11 @@ Build/sideload the s9pk (from `start9/0.4/`): `make x86` then `make install`. Ta
Both `install` and `publish` read host/registry config from `~/.startos/config.yaml`, which is **not in the repo** — verify against the live setup, not from a checkout.
**Verify on-box state read-only via `start-cli`** (the same host config) instead of punting to the StartOS web UI — used this way to confirm the 1.2.0:4/:5 ALTERs and a persisted set:
- `start-cli package installed-version proof-of-work` — what version the box actually runs.
- `start-cli package logs proof-of-work --limit N | grep -iE "adding missing column|as nextjs|error"` — confirms boot ALTERs ran (each logs once) and the non-root launch.
- `start-cli package attach proof-of-work -- sqlite3 /data/app.db "<SELECT …>"` — read-only query of the live app DB (e.g. confirm a new column exists / a value persisted). **SELECT only**; never mutate prod data this way.
Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds, uploads to FileBrowser, registers) — separate from the generic `make publish`. Unpublish: `~/.proof-of-work/unpublish.sh`.
`npm run db:seed` (= `tsx prisma/seed.ts`) seeds **only the `InstanceSettings` singleton** — deliberately NO users and NO curated library (the library attaches at admin-creation and via the boot-time ensure). It is **live, not dead** — invoked at Docker image-build time (`start9/0.4/Dockerfile`) and the local-dev first-run path. `npm run create-admin` (= `tsx scripts/create-admin.ts`) is the local-dev equivalent of the StartOS "Set admin credentials" action: creates the first admin + seeds their library; `--force` to reset/promote an existing account. Runtime first-boot/upgrade seeding is handled separately by `docker_entrypoint.sh`.
@@ -72,6 +77,8 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
- **Versioning is ExVer**: `1.1.0:4` (note the colon). Every release = a new `start9/0.4/startos/versions/vMAJOR.MINOR.PATCH.N.ts` file, imported into `versions/index.ts` and promoted to `current` (previous `current` moves into `other[]`).
- **Bump the version BEFORE building the s9pk** — Start9 0.4 won't recognize a rebuild as an update otherwise.
- **Schema changes are additive ALTERs in `docker_entrypoint.sh`**, guarded by `PRAGMA table_info` checks. Keep `schema.prisma` in sync as the mirror, but the entrypoint is what migrates live `/data`. Never write a destructive migration.
- **Adding a first-class numeric set metric** (precedent: `watts`, 1.2.0:4): mirror `calories` end-to-end — `schema.prisma` column + `prisma generate`; guarded additive `ALTER` in `docker_entrypoint.sh`; zod field + insert in all **5 set-write paths** (`workouts` POST, `workouts/[id]` PATCH, `workouts/[id]/sets`, `workouts/import/save`, `me/import`); `SetRow.tsx` (`show*`/state/`emitUpdate`/`buildSummary`/`firstField`/input) + `WorkoutForm.tsx` (set interfaces, `buildPayload`, `handleUpdateSet`, `initial*` prop, has-data checks); read summary in `app/main/workouts/[id]/page.tsx` + edit mapping in `app/main/workouts/new/page.tsx`; CSV export/parse + `page-csv` payload; field-option label lists (`lib/exerciseOptions.ts`, `app/main/exercises/[id]/page.tsx`, `ExercisePicker.tsx`). The `inputFields` token == the column name; the human label lives in those option lists (token `watts` → "Avg. watts"). `me/export` rides the 1:1 Prisma dump automatically. Add a round-trip test in `tests/routes-crud.test.ts`.
- **Logged-set effort is Gear or RPE, by cardio-ness** (1.2.0:5): the effort select is always shown (not an `inputFields` token). Cardio exercises log breathing **Gear** (`SetLog.gear`, 15); everything else logs **RPE** (`SetLog.rpe`, 610). The switch is `isCardioExercise(exercise)` (`lib/exerciseOptions.ts`): `type === "cardio"` OR `muscleGroups` contains "cardio". `SetRow` takes an `isCardio` prop (from `WorkoutForm`) and renders one; both are always emitted (the hidden one stays empty). Distinct from program/AI **target**-RPE (`ProgramExercise.rpe`), which is unrelated and unaffected.
- **Commit subject** = `vX.Y.Z:N — short summary`, imperative, body explains the *why*.
- **Git remote is self-hosted** (a private Start9 registry + a FileBrowser artifact host), NOT GitHub. The actual registry/file-host URLs are constants in `~/.proof-of-work/{publish,unpublish}.sh`; FileBrowser creds live in `~/.keysat/filebrowser.env` (outside the repo, gitignored). Default branch is `master`.
- **Authorization tiers**: whole-instance routes (`settings/{export,import}-db`) are **admin-only** (`!user.isAdmin → 403`); per-user data routes scope by `user.id`. Custom-URL AI providers (Ollama, OpenAI-compatible — anything with `requiresBaseUrl`) are **admin-only** (SSRF surface); fixed-URL cloud providers (claude/openai/gemini) stay per-user. Gate the server route AND hide the control in the UI.
@@ -105,11 +112,11 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
## 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 (2026-06-16, via `start-cli`):** box runs `1.2.0:5`; entrypoint logged `adding missing column SetLog.gear` and (earlier boot) `SetLog.watts`, each once; app launches `as nextjs` with no permission errors (clears the 1.2.0:3 / long-standing 1.1.0:9 non-root check). App DB shows an Assault Bike set saved with `gear=1` and no `rpe` — Gear select renders + persists for cardio, RPE for strength. 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).
**No on-box checks pending.** Known bug (tracked in `ROADMAP.md` → Known bugs): the **1.2.0:2** Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced on 1.2.0:5, first tap shows "An unexpected error occurred", second tap works. Diagnosis captured; the fix is gated on one data point — the first failed request's error code from Safari Web Inspector (`-1005` → client delayed-retry; `502`/`503` → Node keep-alive tuning).
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).
+9
View File
@@ -2,6 +2,14 @@
Longer-term backlog. Near-term state + next steps live in `AGENTS.md` → Current state.
## Known bugs
- **Mobile-Safari first-login-tap fails ("An unexpected error occurred"); second tap works.** Reproduced on iPhone/iPad Safari against 1.2.0:5 (desktop Safari untested — user declined). The first Sign In tap fails, a second manual tap succeeds. **1.2.0:2's `retryOnTransportError` does NOT fix it.** Diagnosis so far: `LoginForm` only surfaces that error when *both* the initial action call and its in-tap retry throw, so the immediate retry isn't escaping the bad connection — only a fresh user-initiated tap does. Box app logs show no server-side error/500/reset around the attempt, so it's a transport-layer failure, not an app bug.
- **Gating data (do this first):** capture the first failed request's error in Safari Web Inspector (iOS→Mac, Network/Console tab). The code picks the fix:
- `-1005` "The network connection was lost" → client-side stale keep-alive socket. Fix = a *delayed* retry (let Safari tear down the dead socket before retrying), not the current instant one.
- `502`/`503` → StartOS-proxy↔Node keep-alive mismatch (Node closing idle conns the proxy reuses). Fix = raise Node `keepAliveTimeout`/`headersTimeout` server-side; a client retry only masks it.
- Files: `lib/retryAction.ts`, `app/auth/login/LoginForm.tsx`, `app/auth/signup/SignupForm.tsx`.
## AI quality
- Tiered prompt formatting (also the immediate next step): JSON-Schema output enforcement via Ollama `format` and OpenAI `response_format`; pipe-separated library rows; XML-tagged prompt sections; Ollama-only few-shot example; stable prefix first for prompt-cache hits.
@@ -25,6 +33,7 @@ Done in 1.1.0:9 (P2 batch): input-validation 500s → 400 (`lib/http.ts readJson
- Adherence tracking: compare logged workouts against the planned `ProgramDay` (the `programDayId` link already exists).
- Per-user export/import polish and scheduled backups.
- CSV export↔import round-trip: export writes `setX`-prefixed headers (`setCalories`/`setWatts`/`setNotes`) the importer doesn't read (it expects `calories`/`watts`/`notes`), so the app's own CSV export silently drops those on re-import (calories long-standing; watts since 1.2.0:4). Fix by aligning export header names with the parser, or adding the prefixed names as `knownColumns` aliases. (JSON account export/import round-trips fine.)
- Charts/progress views over history (the data and 1RM estimates already exist).
## Hygiene
@@ -21,6 +21,7 @@ interface ParsedSet {
calories?: number;
watts?: number;
rpe?: number;
gear?: number;
customMetrics?: Record<string, string>;
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<string, string> = {};
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,
});
+2
View File
@@ -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,
@@ -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 ?? "",
];
@@ -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,
@@ -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,
@@ -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,
+2
View File
@@ -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,
+11 -7
View File
@@ -24,6 +24,7 @@ interface ParsedSet {
calories?: number;
watts?: number;
rpe?: number;
gear?: number;
customMetrics?: Record<string, string>;
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() {
</p>
<p className="text-sm text-zinc-500">
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>
</div>
{loading && (
@@ -782,12 +786,12 @@ export default function ImportCSVPage() {
CSV Format Example
</h3>
<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
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,`}
</pre>
</div>
</div>
@@ -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";
}
@@ -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,
+43 -4
View File
@@ -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,7 +411,30 @@ export default function SetRow({
</div>
)}
{/* RPE select — always shown */}
{/* Effort select — Gear (1-5, breathing gear) for cardio, else RPE (6-10) */}
{isCardio ? (
<div className="flex-1 min-w-[50px] max-w-[60px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Gear
</label>
<select
value={gear}
onChange={(e) => {
const val = e.target.value;
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"
>
<option value="">-</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</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
@@ -418,6 +456,7 @@ export default function SetRow({
<option value="10">10</option>
</select>
</div>
)}
{/* Next set button — confirm + add new pre-filled set */}
{onNextSet && (
@@ -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}
+16
View File
@@ -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"
);
}
+2 -1
View File
@@ -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"
+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);
});
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', () => {
+1
View File
@@ -91,6 +91,7 @@ export type ParsedSet = {
calories?: number | null;
watts?: number | null;
rpe?: number | null;
gear?: number | 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;"
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;"
+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_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,
],
})
+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,
},
})