Compare commits

...

8 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
Keysat 486dcb3773 Update Current state: 1.2.0:4 built + sideloaded (avg. watts first-class field)
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 12:58:48 -05:00
Keysat 390aaf556e v1.2.0:4 — make avg. watts a first-class SetLog field
Average watts (assault bike, rower, ski erg) was a free-text entry stuffed
into the per-set customMetrics JSON blob. Promote it to a real nullable
column, SetLog.watts, written through every set path (create / PATCH /
add-sets / import-save / account-import) and shown everywhere as
"Avg. watts" with a proper numeric input.

The column is added by the boot-time guarded ALTER in docker_entrypoint.sh
(additive, idempotent), so the version migration stays empty. Existing data
is untouched: legacy watts values remain readable from customMetrics and
migrate to the column the next time a set is saved.
2026-06-16 12:52:59 -05:00
25 changed files with 459 additions and 47 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. 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`. 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`. `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[]`). - **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. - **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. - **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*. - **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`. - **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. - **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 ## Current state
Latest version is **1.2.0:3****P3 hardening**: closes the login timing oracle (`verifyPasswordOrDummy` — unknown email now spends one bcrypt too) and enforces `exerciseId` ownership on all workout-write routes + both program routes via the shared `lib/exerciseOwnership.ts` (see Conventions). **Built + sideloaded** to the StartOS box (`immense-voyage.local`, 2026-06-15, on `master`) as `proof-of-work_x86_64.s9pk` (80M, git `f540a47`). Verified before build: tsc clean (app + packaging), lint clean (only pre-existing warnings), **221 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`).
Also shipped this session: **1.2.0:2** — iOS Safari login/signup first-tap retry (`lib/retryAction.ts`; see Conventions). Prior: **1.2.0:1** Next 15 / React 19 upgrade. No schema/data change in any of the three. **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 (one pass covers all):** confirm 1.2.0:3 boots clean in StartOS → Logs (entrypoint logs `launching … as nextjs`, app writes `/data` as uid 1001, no permission errors — also clears the long-standing 1.1.0:9 non-root check), **and** the Safari first-tap proof from 1.2.0:2 — log in from Safari on iPhone/iPad and confirm the *first* Sign In tap works. If it still occasionally fails, grab the Web Inspector error (iPad→Mac): `-1005` confirms the retry is right; 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). 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. 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 ## 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. - 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). - Adherence tracking: compare logged workouts against the planned `ProgramDay` (the `programDayId` link already exists).
- Per-user export/import polish and scheduled backups. - 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). - Charts/progress views over history (the data and 1RM estimates already exist).
## Hygiene ## Hygiene
@@ -19,7 +19,9 @@ interface ParsedSet {
distance?: number; distance?: number;
distanceUnit?: string; distanceUnit?: string;
calories?: number; calories?: number;
watts?: number;
rpe?: number; rpe?: number;
gear?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
notes?: string; notes?: string;
} }
@@ -136,7 +138,9 @@ export async function POST(request: NextRequest) {
"distance_unit", "distance_unit",
"distanceunit", "distanceunit",
"calories", "calories",
"watts",
"rpe", "rpe",
"gear",
"notes", "notes",
"custom_metrics_json", "custom_metrics_json",
"custommetricsjson", "custommetricsjson",
@@ -199,7 +203,9 @@ export async function POST(request: NextRequest) {
const distance = parseFloatMaybe(row.distance); const distance = parseFloatMaybe(row.distance);
const distanceUnit = row.distance_unit || row.distanceunit || (distance !== undefined ? "mi" : undefined); const distanceUnit = row.distance_unit || row.distanceunit || (distance !== undefined ? "mi" : undefined);
const calories = parseIntMaybe(row.calories); const calories = parseIntMaybe(row.calories);
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;
@@ -253,7 +259,9 @@ export async function POST(request: NextRequest) {
distance, distance,
distanceUnit, distanceUnit,
calories, calories,
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,
}); });
+4
View File
@@ -51,10 +51,12 @@ 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(),
calories: z.number().int().nullable().optional(), calories: z.number().int().nullable().optional(),
watts: z.number().int().nullable().optional(),
customMetrics: z.string().nullable().optional(), customMetrics: z.string().nullable().optional(),
notes: z.string().nullable().optional(), notes: z.string().nullable().optional(),
// The exported set carries an exerciseId pointing into the export's // The exported set carries an exerciseId pointing into the export's
@@ -201,10 +203,12 @@ 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,
calories: s.calories ?? null, calories: s.calories ?? null,
watts: s.watts ?? null,
customMetrics: s.customMetrics ?? null, customMetrics: s.customMetrics ?? null,
notes: s.notes ?? null, notes: s.notes ?? null,
}); });
@@ -70,7 +70,9 @@ export async function GET() {
"distance", "distance",
"distanceUnit", "distanceUnit",
"setCalories", "setCalories",
"setWatts",
"rpe", "rpe",
"setGear",
"setNotes", "setNotes",
"customMetricsJson", "customMetricsJson",
]; ];
@@ -101,7 +103,9 @@ export async function GET() {
set.distance ?? "", set.distance ?? "",
set.distanceUnit ?? "", set.distanceUnit ?? "",
set.calories ?? "", set.calories ?? "",
set.watts ?? "",
set.rpe ?? "", set.rpe ?? "",
set.gear ?? "",
set.notes ?? "", set.notes ?? "",
set.customMetrics ?? "", set.customMetrics ?? "",
]; ];
@@ -57,10 +57,12 @@ 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(),
calories: z.number().int().positive().optional().nullable(), calories: z.number().int().positive().optional().nullable(),
watts: z.number().int().positive().optional().nullable(),
customMetrics: z.record(z.string()).optional().nullable(), customMetrics: z.record(z.string()).optional().nullable(),
notes: z.string().optional().nullable(), notes: z.string().optional().nullable(),
}); });
@@ -152,10 +154,12 @@ 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,
calories: set.calories ?? undefined, calories: set.calories ?? undefined,
watts: set.watts ?? undefined,
customMetrics: customMetrics:
set.customMetrics && Object.keys(set.customMetrics).length > 0 set.customMetrics && Object.keys(set.customMetrics).length > 0
? JSON.stringify(set.customMetrics) ? JSON.stringify(set.customMetrics)
@@ -14,10 +14,12 @@ 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(),
calories: z.number().int().positive().optional(), calories: z.number().int().positive().optional(),
watts: z.number().int().positive().optional(),
customMetrics: z.record(z.string()).optional(), customMetrics: z.record(z.string()).optional(),
notes: z.string().optional(), notes: z.string().optional(),
}) })
@@ -79,10 +81,12 @@ 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,
calories: set.calories, calories: set.calories,
watts: set.watts,
customMetrics: customMetrics:
set.customMetrics && Object.keys(set.customMetrics).length > 0 set.customMetrics && Object.keys(set.customMetrics).length > 0
? JSON.stringify(set.customMetrics) ? JSON.stringify(set.customMetrics)
@@ -13,7 +13,9 @@ const setSchema = z.object({
distance: z.number().positive().optional(), distance: z.number().positive().optional(),
distanceUnit: z.string().optional(), distanceUnit: z.string().optional(),
calories: z.number().int().positive().optional(), calories: 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(),
}); });
@@ -123,10 +125,12 @@ 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,
calories: set.calories || null, calories: set.calories || null,
watts: set.watts || null,
notes: set.notes || null, notes: set.notes || null,
}); });
}); });
+4
View File
@@ -26,10 +26,12 @@ 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(),
calories: z.number().int().positive().optional(), calories: z.number().int().positive().optional(),
watts: z.number().int().positive().optional(),
customMetrics: z.record(z.string()).optional(), customMetrics: z.record(z.string()).optional(),
notes: z.string().optional(), notes: z.string().optional(),
}) })
@@ -174,10 +176,12 @@ 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,
calories: set.calories, calories: set.calories,
watts: set.watts,
customMetrics: customMetrics:
set.customMetrics && Object.keys(set.customMetrics).length > 0 set.customMetrics && Object.keys(set.customMetrics).length > 0
? JSON.stringify(set.customMetrics) ? JSON.stringify(set.customMetrics)
@@ -57,6 +57,7 @@ const INPUT_FIELD_OPTIONS = [
{ value: "duration", label: "Duration" }, { value: "duration", label: "Duration" },
{ value: "distance", label: "Distance" }, { value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" }, { value: "calories", label: "Calories" },
{ value: "watts", label: "Avg. watts" },
{ value: "notes", label: "Notes" }, { value: "notes", label: "Notes" },
]; ];
+15 -7
View File
@@ -22,7 +22,9 @@ interface ParsedSet {
distance?: number; distance?: number;
distanceUnit?: string; distanceUnit?: string;
calories?: number; calories?: number;
watts?: number;
rpe?: number; rpe?: number;
gear?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
notes?: string; notes?: string;
} }
@@ -392,9 +394,15 @@ export default function ImportCSVPage() {
if (typeof set.calories === "number" && !Number.isNaN(set.calories)) { if (typeof set.calories === "number" && !Number.isNaN(set.calories)) {
payloadSet.calories = set.calories; payloadSet.calories = set.calories;
} }
if (typeof set.watts === "number" && !Number.isNaN(set.watts)) {
payloadSet.watts = set.watts;
}
if (typeof set.rpe === "number" && !Number.isNaN(set.rpe)) { 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" &&
@@ -763,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, rpe, notes, custom_* distance, distance_unit, calories, watts, rpe, gear, notes, custom_*
</p> </p>
</div> </div>
{loading && ( {loading && (
@@ -778,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,rpe,notes,custom_temperature,custom_watts,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,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>
+14 -2
View File
@@ -19,10 +19,12 @@ 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;
calories?: number | null; calories?: number | null;
watts?: number | null;
customMetrics?: string | null; customMetrics?: string | null;
}) { }) {
const parts: string[] = []; const parts: string[] = [];
@@ -35,15 +37,25 @@ function buildSetSummary(set: {
} }
if ((set as any).distance) parts.push(`${(set as any).distance} mi`); if ((set as any).distance) parts.push(`${(set as any).distance} mi`);
if ((set as any).calories) parts.push(`${(set as any).calories} cal`); if ((set as any).calories) parts.push(`${(set as any).calories} cal`);
if ((set as any).watts) parts.push(`${(set as any).watts} W`);
if ((set as any).customMetrics) { if ((set as any).customMetrics) {
try { try {
const custom = JSON.parse((set as any).customMetrics) as Record<string, string>; const custom = JSON.parse((set as any).customMetrics) as Record<string, string>;
for (const [k, v] of Object.entries(custom)) { for (const [k, v] of Object.entries(custom)) {
if (v) parts.push(`${k}: ${v}`); if (!v) continue;
// Watts is now a first-class column. Legacy sets still carry it under
// customMetrics — render it the same way (and skip if the column
// already supplied it) so old and new sets read identically.
if (k === "watts") {
if (!(set as any).watts) parts.push(`${v} W`);
continue;
}
parts.push(`${k}: ${v}`);
} }
} catch {} } 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,9 +50,11 @@ 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,
watts: set.watts ?? undefined,
customMetrics, customMetrics,
notes: set.notes ?? undefined, notes: set.notes ?? undefined,
}); });
@@ -61,7 +61,7 @@ export default function ExercisePicker({
// Derive custom types/muscles/fields from existing exercises // Derive custom types/muscles/fields from existing exercises
const knownTypeValues = EXERCISE_TYPES.map((t) => t.value); const knownTypeValues = EXERCISE_TYPES.map((t) => t.value);
const knownMuscleValues = MUSCLE_GROUPS.map((g) => g.toLowerCase()); const knownMuscleValues = MUSCLE_GROUPS.map((g) => g.toLowerCase());
const knownFieldValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"]; const knownFieldValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
const derivedCustomTypes = useMemo(() => { const derivedCustomTypes = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
@@ -506,6 +506,7 @@ export default function ExercisePicker({
{ value: "duration", label: "Time" }, { value: "duration", label: "Time" },
{ value: "distance", label: "Distance" }, { value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" }, { value: "calories", label: "Calories" },
{ value: "watts", label: "Avg. watts" },
{ value: "notes", label: "Notes" }, { value: "notes", label: "Notes" },
...customFieldOptions, ...customFieldOptions,
].map((field) => ( ].map((field) => (
@@ -527,7 +528,7 @@ export default function ExercisePicker({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
const val = newFieldText.trim().toLowerCase(); const val = newFieldText.trim().toLowerCase();
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"]; const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) { if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]); setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
} }
@@ -545,7 +546,7 @@ export default function ExercisePicker({
onChange={(e) => setNewFieldText(e.target.value)} onChange={(e) => setNewFieldText(e.target.value)}
onBlur={() => { onBlur={() => {
const val = newFieldText.trim().toLowerCase(); const val = newFieldText.trim().toLowerCase();
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"]; const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) { if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]); setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
} }
+106 -28
View File
@@ -10,6 +10,7 @@ export type InputField =
| "duration" | "duration"
| "distance" | "distance"
| "calories" | "calories"
| "watts"
| "notes" | "notes"
| string; | string;
@@ -17,13 +18,17 @@ 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;
initialCalories?: number; initialCalories?: number;
initialWatts?: number;
initialCustomMetrics?: Record<string, string>; initialCustomMetrics?: Record<string, string>;
initialLocked?: boolean; initialLocked?: boolean;
autoFocus?: boolean; autoFocus?: boolean;
@@ -31,10 +36,12 @@ 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;
calories?: number; calories?: number;
watts?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
}) => void; }) => void;
onConfirm?: () => void; onConfirm?: () => void;
@@ -42,10 +49,12 @@ 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;
calories?: string; calories?: string;
watts?: string;
}) => void; }) => void;
onDelete: () => void; onDelete: () => void;
} }
@@ -54,13 +63,16 @@ 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,
initialCalories, initialCalories,
initialWatts,
initialCustomMetrics, initialCustomMetrics,
initialLocked = false, initialLocked = false,
autoFocus = false, autoFocus = false,
@@ -86,13 +98,21 @@ 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() || "");
const [calories, setCalories] = useState(initialCalories?.toString() || ""); const [calories, setCalories] = useState(initialCalories?.toString() || "");
const [customValues, setCustomValues] = useState<Record<string, string>>( // Watts is now a first-class field. Legacy sets stored it under the
initialCustomMetrics || {} // customMetrics "watts" key — seed from there so old data shows up, and
// strip it from customValues so it isn't also rendered in the custom grid.
const [watts, setWatts] = useState(
initialWatts?.toString() || initialCustomMetrics?.watts || ""
); );
const [customValues, setCustomValues] = useState<Record<string, string>>(() => {
const { watts: _legacyWatts, ...rest } = initialCustomMetrics || {};
return rest;
});
const [showNotes, setShowNotes] = useState(!!initialNotes); const [showNotes, setShowNotes] = useState(!!initialNotes);
const [locked, setLocked] = useState(initialLocked); const [locked, setLocked] = useState(initialLocked);
@@ -101,6 +121,7 @@ export default function SetRow({
const showDuration = inputFields.includes("duration"); const showDuration = inputFields.includes("duration");
const showDistance = inputFields.includes("distance"); const showDistance = inputFields.includes("distance");
const showCalories = inputFields.includes("calories"); const showCalories = inputFields.includes("calories");
const showWatts = inputFields.includes("watts");
const showNotesField = inputFields.includes("notes"); const showNotesField = inputFields.includes("notes");
const customFields = inputFields.filter( const customFields = inputFields.filter(
(f) => (f) =>
@@ -111,6 +132,7 @@ export default function SetRow({
"duration", "duration",
"distance", "distance",
"calories", "calories",
"watts",
"notes", "notes",
].includes(f) ].includes(f)
); );
@@ -120,19 +142,23 @@ 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;
calories?: string; calories?: string;
watts?: string;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
}) => { }) => {
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;
const cal = overrides.calories ?? calories; const cal = overrides.calories ?? calories;
const wt = overrides.watts ?? watts;
const cm = overrides.customMetrics ?? customValues; const cm = overrides.customMetrics ?? customValues;
const cleanedCustomMetrics = Object.fromEntries( const cleanedCustomMetrics = Object.fromEntries(
Object.entries(cm).filter(([, value]) => value !== "") Object.entries(cm).filter(([, value]) => value !== "")
@@ -142,17 +168,19 @@ 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,
calories: cal ? parseInt(cal) : undefined, calories: cal ? parseInt(cal) : undefined,
watts: wt ? parseInt(wt) : undefined,
customMetrics: customMetrics:
Object.keys(cleanedCustomMetrics).length > 0 Object.keys(cleanedCustomMetrics).length > 0
? cleanedCustomMetrics ? cleanedCustomMetrics
: undefined, : undefined,
}); });
}, },
[reps, weight, rpe, notes, duration, distance, calories, customValues, onUpdate] [reps, weight, rpe, gear, notes, duration, distance, calories, watts, customValues, onUpdate]
); );
const handleConfirm = () => { const handleConfirm = () => {
@@ -175,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 }); 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
@@ -186,11 +214,16 @@ export default function SetRow({
if (showDuration && duration) parts.push(`${duration} min`); if (showDuration && duration) parts.push(`${duration} min`);
if (showDistance && distance) parts.push(`${distance} mi`); if (showDistance && distance) parts.push(`${distance} mi`);
if (showCalories && calories) parts.push(`${calories} cal`); if (showCalories && calories) parts.push(`${calories} cal`);
if (showWatts && watts) parts.push(`${watts} W`);
for (const field of customFields) { for (const field of customFields) {
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";
}; };
@@ -238,7 +271,7 @@ export default function SetRow({
} }
// Determine which field gets autoFocus // Determine which field gets autoFocus
const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : null; const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : showWatts ? "watts" : null;
// ---------- EDIT VIEW ---------- // ---------- EDIT VIEW ----------
return ( return (
@@ -357,28 +390,73 @@ export default function SetRow({
</div> </div>
)} )}
{/* RPE select — always shown */} {/* Avg. watts input */}
<div className="flex-1 min-w-[50px] max-w-[60px]"> {showWatts && (
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5"> <div className="flex-1 min-w-[55px]">
RPE <label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
</label> Avg. watts
<select </label>
value={rpe} <input
onChange={(e) => { type="number"
const val = e.target.value; autoFocus={autoFocus && firstField === "watts"}
setRpe(val); value={watts}
emitUpdate({ rpe: val }); onChange={(e) => {
}} const val = e.target.value;
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" setWatts(val);
> emitUpdate({ watts: val });
<option value="">-</option> }}
<option value="6">6</option> placeholder="0"
<option value="7">7</option> className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
<option value="8">8</option> />
<option value="9">9</option> </div>
<option value="10">10</option> )}
</select>
</div> {/* 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
</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,9 +233,11 @@ 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;
watts?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
notes?: string; notes?: string;
forceEdit?: boolean; // When true, start in edit mode even if data is pre-filled forceEdit?: boolean; // When true, start in edit mode even if data is pre-filled
@@ -256,9 +259,11 @@ 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;
watts?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
notes?: string; notes?: string;
}>; }>;
@@ -342,10 +347,12 @@ 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,
calories: s.calories, calories: s.calories,
watts: s.watts,
customMetrics: s.customMetrics, customMetrics: s.customMetrics,
notes: s.notes, notes: s.notes,
})) }))
@@ -504,10 +511,12 @@ 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;
calories?: number; calories?: number;
watts?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
} }
) => { ) => {
@@ -555,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;
@@ -576,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
}, },
@@ -852,12 +863,15 @@ 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}
initialWatts={set.watts}
initialCustomMetrics={set.customMetrics} initialCustomMetrics={set.customMetrics}
initialNotes={set.notes} initialNotes={set.notes}
initialLocked={ initialLocked={
@@ -869,6 +883,7 @@ export default function WorkoutForm({
set.durationSeconds || set.durationSeconds ||
set.distance || set.distance ||
set.calories || set.calories ||
set.watts ||
(set.customMetrics && (set.customMetrics &&
Object.values(set.customMetrics).some((v) => v)) Object.values(set.customMetrics).some((v) => v))
) )
@@ -880,7 +895,8 @@ export default function WorkoutForm({
!set.weight && !set.weight &&
!set.durationSeconds && !set.durationSeconds &&
!set.distance && !set.distance &&
!set.calories) !set.calories &&
!set.watts)
} }
onUpdate={(data) => onUpdate={(data) =>
handleUpdateSet( handleUpdateSet(
+17
View File
@@ -46,6 +46,7 @@ export const BASE_TRACKING_FIELDS: Option[] = [
{ value: "duration", label: "Time" }, { value: "duration", label: "Time" },
{ value: "distance", label: "Distance" }, { value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" }, { value: "calories", label: "Calories" },
{ value: "watts", label: "Avg. watts" },
{ value: "notes", label: "Notes" }, { value: "notes", label: "Notes" },
]; ];
@@ -130,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"
);
}
+4 -2
View File
@@ -116,12 +116,14 @@ 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"
calories Int? // for cardio machines that report calories calories Int? // for cardio machines that report calories
customMetrics String? // JSON map for dynamic custom metrics (e.g. {"watts":"157"}) watts Int? // average watts for cardio machines (assault bike, rower, ski erg)
customMetrics String? // JSON map for dynamic custom metrics (legacy watts lived here as {"watts":"157"})
notes String? notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
+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);
});
});
+124
View File
@@ -302,6 +302,82 @@ describe('POST /api/workouts', () => {
expect(body.setLogs[1].rpe).toBe(8); expect(body.setLogs[1].rpe).toBe(8);
}); });
it('persists avg. watts as a first-class set field (assault bike)', 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: '[]',
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,
distance: 2.5,
distanceUnit: 'mi',
calories: 120,
watts: 157,
},
],
}),
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.setLogs).toHaveLength(1);
expect(body.setLogs[0].watts).toBe(157);
// And it round-trips out of the DB, not just the response.
const stored = await prisma.setLog.findFirst({ where: { workoutId: body.id } });
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({
@@ -403,6 +479,54 @@ describe('PATCH /api/workouts/[id]', () => {
// The guard runs before the set-replace transaction. // The guard runs before the set-replace transaction.
expect(await prisma.setLog.count()).toBe(0); expect(await prisma.setLog.count()).toBe(0);
}); });
it('persists avg. watts 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: '[]' },
});
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, watts: 180 },
],
},
{ 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?.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', () => {
+2
View File
@@ -89,7 +89,9 @@ export type ParsedSet = {
distance?: number | null; distance?: number | null;
distanceUnit?: string | null; distanceUnit?: string | null;
calories?: number | null; calories?: number | null;
watts?: number | null;
rpe?: number | null; rpe?: number | null;
gear?: number | null;
notes?: string | null; notes?: string | null;
}; };
+10
View File
@@ -72,6 +72,16 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN customMetrics TEXT;" sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN customMetrics TEXT;"
fi fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('SetLog');" 2>/dev/null | grep -q "|watts|"; then
log "adding missing column SetLog.watts"
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 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;"
+12 -1
View File
@@ -18,6 +18,8 @@ import { v_1_1_0_9 } from './v1.1.0.9'
import { v_1_2_0_1 } from './v1.2.0.1' 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_5 } from './v1.2.0.5'
/** /**
* Version graph for the `proof-of-work` package. * Version graph for the `proof-of-work` package.
@@ -71,9 +73,16 @@ import { v_1_2_0_3 } from './v1.2.0.3'
* bcrypt on unknown email) and enforce exerciseId ownership on * bcrypt on unknown email) and enforce exerciseId ownership on
* workout create/PATCH/add-sets + CSV-import-save (shared * workout create/PATCH/add-sets + CSV-import-save (shared
* lib/exerciseOwnership). No schema/data change. * lib/exerciseOwnership). No schema/data change.
* v1.2.0:4 — Avg. watts promoted to a first-class set field (SetLog.watts
* 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({ export const versionGraph = VersionGraph.of({
current: v_1_2_0_3, 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,
@@ -93,5 +102,7 @@ export const versionGraph = VersionGraph.of({
v_1_1_0_9, v_1_1_0_9,
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_4,
], ],
}) })
+27
View File
@@ -0,0 +1,27 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:4 — Avg. watts as a first-class set field (2026-06-16).
*
* Average watts (assault bike, rower, ski erg) used to be a free-text entry
* stuffed into the per-set customMetrics JSON blob. It's now a real nullable
* column, SetLog.watts, written through every set path (create / PATCH /
* add-sets / import-save / account-import) and shown everywhere as
* "Avg. watts" with a proper numeric input.
*
* Additive schema change: the SetLog.watts column is added by the boot-time
* guarded ALTER in docker_entrypoint.sh (so this migration stays empty, like
* every other column add). Existing data is untouched — legacy watts values
* remain readable from customMetrics and migrate to the column on next save.
*/
export const v_1_2_0_4 = VersionInfo.of({
version: '1.2.0:4',
releaseNotes: {
en_US:
'Average watts is now a first-class field for cardio machines (assault bike, rower, ski erg) — a proper numeric "Avg. watts" input instead of a free-text custom metric. Existing data is preserved.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})
+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,
},
})