Compare commits
8 Commits
4d1f9126b0
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0401a831b7 | |||
| d1bc895e5e | |||
| 184382f75c | |||
| 38503436e1 | |||
| 4be489d6d3 | |||
| ef3d079ca2 | |||
| 486dcb3773 | |||
| 390aaf556e |
@@ -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`, 1–5); everything else logs **RPE** (`SetLog.rpe`, 6–10). 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" (1–5, Brian MacKenzie) select instead of RPE (6–10); 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).
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) }]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +390,51 @@ export default function SetRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RPE select — always shown */}
|
{/* Avg. watts input */}
|
||||||
|
{showWatts && (
|
||||||
|
<div className="flex-1 min-w-[55px]">
|
||||||
|
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
||||||
|
Avg. watts
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
autoFocus={autoFocus && firstField === "watts"}
|
||||||
|
value={watts}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setWatts(val);
|
||||||
|
emitUpdate({ watts: val });
|
||||||
|
}}
|
||||||
|
placeholder="0"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</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]">
|
<div className="flex-1 min-w-[50px] max-w-[60px]">
|
||||||
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
||||||
RPE
|
RPE
|
||||||
@@ -379,6 +456,7 @@ export default function SetRow({
|
|||||||
<option value="10">10</option>
|
<option value="10">10</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;"
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user