Compare commits

...

13 Commits

Author SHA1 Message Date
Keysat 2b0abad68e v1.2.0:6 — AI "generate today's workout" from a brain-dump
CI / proof-of-work (Next.js app) (push) Waiting to run
CI / start9/0.4 (StartOS package code) (push) Waiting to run
Add a single-session AI flow alongside program generation: describe a
workout in plain words and get a ready-to-log workout back — exercises
with suggested weights, target reps, and set counts grounded in the
user's recent history. The suggestion can be inline-edited or refined
by sending a follow-up instruction back to the model, then "Use this
workout" pre-fills the normal New Workout form (nothing persists until
the user saves through the regular path).

Why reuse, not fork: the existing program-generation spine (detached
background runner, SSE streaming, lenient-JSON preview, 5 providers,
history context, library name->id mapping) already does the hard parts.
A new AIGeneration.kind discriminant ("program" | "workout", default
"program" via boot-time guarded ALTER) selects the parser and keeps the
ephemeral workout rows out of the program-shaped AI history. Refine is a
fresh generation seeded with the prior suggestion (validated through the
same schema before it re-enters the prompt).

Hand-off is sessionStorage -> /main/workouts/new?from=ai -> AiWorkoutPrefill,
which expands each suggestion into N sets and maps effort by cardio-ness
(Gear for cardio, RPE for strength). EditWorkoutData.id is now optional so
the prefill CREATEs rather than PATCHing a nonexistent id. The AI suggests
each weight in that exercise's effective logging unit (the library JSON
carries a per-exercise unit) so the stored number and unit never diverge.

Built + sideloaded to immense-voyage.local as 1.2.0:6; on-box ALTER and
non-root launch confirmed via start-cli. tsc clean (app + packaging),
251 tests pass, next build + s9pk build succeed.
2026-06-19 10:59:12 -05:00
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
Keysat 4d1f9126b0 Update Current state: 1.2.0:3 built + sideloaded; record session patterns
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Current state now reflects 1.2.0:3 (P3 hardening) built + sideloaded
(git f540a47, 221 tests). Add durable conventions for the three patterns
established this session: cross-user exerciseId ownership
(lib/exerciseOwnership), login timing-oracle avoidance
(verifyPasswordOrDummy), and the iOS-Safari auth-form retry
(lib/retryAction). ROADMAP: move the shipped P3 items (timing oracle,
exerciseId ownership) and the Next 15 bump into the Done lines.
2026-06-15 18:33:16 -05:00
Keysat f540a473ef v1.2.0:3 — close login timing oracle, enforce exerciseId ownership on workout writes
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Two P3 multi-user hardening fixes from the 2026-06-13 full-eval.

Login timing oracle: both login paths (the UI server action and
POST /api/auth) returned immediately on an unknown email but ran
bcrypt.compare when the email matched a user, so response latency
revealed which emails have accounts. New verifyPasswordOrDummy() in
lib/auth runs bcrypt against a fixed dummy hash when there is no user,
so every attempt spends exactly one bcrypt; the two error branches in
each route collapse into one.

exerciseId ownership: exercises are per-user, but the workout
create / PATCH (set-replace) / add-sets and CSV import-save routes wrote
SetLogs from a client-supplied exerciseId with no ownership check —
letting a user attach another user's exercise to their own workout,
which leaks that exercise's name/notes on fetch and wires up a
cross-user onDelete: Cascade link. All four now reject unowned ids with
400 via the shared lib/exerciseOwnership helper; the pre-existing inline
checks in both programs routes are refactored onto the same helper.

App-code only — no schema, no API contract change, no data migration.
2026-06-15 18:30:08 -05:00
Keysat 00a4b704e8 Update Current state: 1.2.0:2 built + sideloaded
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Record the Safari first-tap retry release as built + sideloaded, refresh
the pending on-box check (now also the first-tap proof from Safari), and
bump the local-verification numbers (213 tests).

Also folds in the pending doc-name alignment left in the working tree:
the AGENTS.md inbox tag and the EVALUATION.md title now read
"proof-of-work" instead of the old "Workout-log".
2026-06-15 16:44:58 -05:00
Keysat 0178f8f5cc v1.2.0:2 — retry login/signup server action once on transport failure
iOS Safari reuses a keep-alive socket the server closed while the login
form sat idle during typing, so the first Sign In / Create account POST
dies instantly with NSURLErrorNetworkConnectionLost ("The network
connection was lost"). That rejects the server-action call, hitting the
client-side catch in LoginForm/SignupForm and showing "An unexpected
error occurred"; the second tap lands on a fresh connection and works.

Add lib/retryAction.ts: retryOnTransportError() retries the action once
only when the call throws. A returned { error } (bad password, rate
limit) is a real result and passes straight through. A lost-on-a-stale-
socket POST never reached the server, so retrying it once is safe.
2026-06-15 16:44:33 -05:00
58 changed files with 2528 additions and 123 deletions
+18 -8
View File
@@ -3,7 +3,7 @@
Self-hosted multi-user workout logger (Next.js app) packaged as a StartOS 0.4 `s9pk`, published to a private Start9 registry. Self-hosted multi-user workout logger (Next.js app) packaged as a StartOS 0.4 `s9pk`, published to a private Start9 registry.
> **Inbox check:** At session start, if `~/Projects/standards/INBOX.md` exists, scan it for > **Inbox check:** At session start, if `~/Projects/standards/INBOX.md` exists, scan it for
> items tagged `(Workout-log)` and surface them before proposing next steps; triage with `/triage`. > items tagged `(proof-of-work)` and surface them before proposing next steps; triage with `/triage`.
## Stack (versions that matter) ## Stack (versions that matter)
@@ -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,13 +77,18 @@ 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.
- **Cross-user id ownership**: any route writing `SetLog`s or `ProgramExercise`s from a **client-supplied `exerciseId`** must validate it via `findUnownedExerciseIds(userId, ids)` (`lib/exerciseOwnership.ts`) → `400 { error, details: bad }`. Unknown vs. foreign ids are deliberately indistinguishable (no existence oracle). Applied to all workout-write routes (create/PATCH/add-sets/import-save) + both program routes. (Server-*derived* ids, e.g. program-day `start`, are already owned — no check needed.)
- **Login must not leak account existence**: both login paths (`app/auth/login/actions.ts`, `app/api/auth/route.ts`) call `verifyPasswordOrDummy` (`lib/auth.ts`) so an unknown email spends the same bcrypt as a real one. Never reintroduce an early `if (!user) return` *before* the compare — that's the timing oracle.
- **Malformed JSON body must return 400, not 500.** Routes whose catch maps `instanceof z.ZodError → 400` parse via `readJsonBody(request)` (`lib/http.ts` — throws a `ZodError` on bad JSON, so the existing branch handles it with no catch change). `safeParse`-style routes (`me/import`, `admin/signups`) wrap `request.json()` in an explicit `try/catch → 400`. (AI/admin routes using `.catch(() => ({}))` are a third, pre-existing pattern — unify if you touch them.) - **Malformed JSON body must return 400, not 500.** Routes whose catch maps `instanceof z.ZodError → 400` parse via `readJsonBody(request)` (`lib/http.ts` — throws a `ZodError` on bad JSON, so the existing branch handles it with no catch change). `safeParse`-style routes (`me/import`, `admin/signups`) wrap `request.json()` in an explicit `try/catch → 400`. (AI/admin routes using `.catch(() => ({}))` are a third, pre-existing pattern — unify if you touch them.)
- **Next 15 dynamic APIs are async — `await` them.** Route-handler context `params`, page/layout `params` + `searchParams`, and `cookies()`/`headers()` are all Promises. Established idiom (keeps handler bodies untouched): `[id]` routes take `context: { params: Promise<{…}> }` then `const params = await context.params`; server pages take `props` then `const params = await props.params` / `const searchParams = await props.searchParams`. Route tests pass `params: Promise.resolve({…})`. All routes are dynamic, so the Next 15 "uncached by default" change is a no-op here. - **Next 15 dynamic APIs are async — `await` them.** Route-handler context `params`, page/layout `params` + `searchParams`, and `cookies()`/`headers()` are all Promises. Established idiom (keeps handler bodies untouched): `[id]` routes take `context: { params: Promise<{…}> }` then `const params = await context.params`; server pages take `props` then `const params = await props.params` / `const searchParams = await props.searchParams`. Route tests pass `params: Promise.resolve({…})`. All routes are dynamic, so the Next 15 "uncached by default" change is a no-op here.
- **The container runs the Node server as non-root.** `docker_entrypoint.sh` runs as root only to prep `/data` (seed, ALTERs, library reconcile), then `chown -R nextjs:nodejs "$DATA_DIR"` and `exec su-exec nextjs:nodejs node /app/server.js` (su-exec added in the Dockerfile runner stage). Any new entrypoint step that needs root must run *before* that final line. - **The container runs the Node server as non-root.** `docker_entrypoint.sh` runs as root only to prep `/data` (seed, ALTERs, library reconcile), then `chown -R nextjs:nodejs "$DATA_DIR"` and `exec su-exec nextjs:nodejs node /app/server.js` (su-exec added in the Dockerfile runner stage). Any new entrypoint step that needs root must run *before* that final line.
- Tests live in `proof-of-work/tests/`; mock server-action deps with `vi.hoisted()` + `vi.mock`. - **Auth forms retry their server action once on a transport failure**: `LoginForm`/`SignupForm` wrap the action in `retryOnTransportError` (`lib/retryAction.ts`). iOS Safari drops the first POST on a stale keep-alive socket (`NSURLErrorNetworkConnectionLost`, "The network connection was lost") → the client catch showed "An unexpected error occurred". Retry only on a *thrown* error; a returned `{ error }` is a real result and passes through.
- Tests live in `proof-of-work/tests/`; mock server-action deps with `vi.hoisted()` + `vi.mock`. Route tests run against a real temp SQLite DB (`tests/helpers/db.ts`) with `getCurrentUser` mocked.
- **Before editing the AI subsystem (`proof-of-work/lib/ai/**` or the generate/generations routes), read `docs/guides/ai-subsystem.md`** — provider abstraction, SSE/lenient-JSON, pricing/model menus, and the background-runner architecture live there. - **Before editing the AI subsystem (`proof-of-work/lib/ai/**` or the generate/generations routes), read `docs/guides/ai-subsystem.md`** — provider abstraction, SSE/lenient-JSON, pricing/model menus, and the background-runner architecture live there.
## Always ## Always
@@ -102,18 +112,18 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
## Current state ## Current state
Latest version is **1.2.0:1**the **Next.js 14→15 / React 18→19** upgrade (the remaining P1; closes the Next framework RSC + middleware-bypass CVEs). **Built + sideloaded** to the StartOS box (`immense-voyage.local`, 2026-06-13, on `master`) as `proof-of-work_x86_64.s9pk` (80M, git `f487204`). Verified locally before build: tsc + lint clean, **209 tests pass**, `next build` succeeds, standalone bundle traces the Prisma engine. Registry empty, **publishing parked** (sideload-only via `make install`). Latest version is **1.2.0:6****AI "generate today's workout"**: a new AI flow alongside program generation. Describe one session in plain words → a streamed, ready-to-log workout (exercises + suggested weights/reps/set-counts grounded in 90-day history) → inline-edit + a **Refine** box that round-trips changes back to the LLM → **Use this workout** pre-fills the normal New Workout form (nothing persists until you save). Reuses the whole generation spine (detached runner / SSE / lenient-JSON / 5 providers / `historyContext`) via a new **`AIGeneration.kind`** discriminant (`"program" | "workout"`, default "program"); the runner picks the parser by kind and stores JSON in the reused `parsedProgram` column. Workout rows are **ephemeral** (the saved Workout is the durable record) so they're filtered out of the program-shaped AI History (`kind:'program'`). Refine = a fresh generation seeded with the prior suggestion JSON (validated via `aiWorkoutSchema` → REVISION mode in `workoutPrompt.ts`). Hand-off is sessionStorage → `/main/workouts/new?from=ai``AiWorkoutPrefill` (`workoutDraft.ts::buildPrefillExercises`: expands to N sets, cardio→Gear / strength→RPE via `isCardioExercise`, drops unmapped ids). `EditWorkoutData.id` is now optional so the prefill **creates** (not PATCHes). AI suggests each weight in that exercise's effective unit (library JSON carries per-exercise `unit` = `defaultWeightUnit || "lbs"`, matching what `WorkoutForm.buildPayload` stores). New `AIGeneration.kind` column via boot-time guarded `ALTER`. New files: `lib/ai/workoutSchema.ts`, `workoutPrompt.ts`, `workoutDraft.ts`, `api/ai/generate-workout/route.ts`, `components/ai/GenerateWorkoutClient.tsx`, `components/workouts/AiWorkoutPrefill.tsx`, `app/main/ai/generate-workout/page.tsx`. **Built + sideloaded** (`immense-voyage.local`, 2026-06-19, `master`) as `proof-of-work_x86_64.s9pk` (80M). Verified: tsc clean (app + packaging), lint clean (pre-existing warnings only), **251 tests pass** (incl. `parseAIWorkout`, `buildPrefillExercises` gear/RPE mapping, generate-workout route auth/validation), `next build` succeeds. Registry empty, **publishing parked** (sideload-only via `make install`). See `docs/guides/ai-subsystem.md` → "Two generation kinds".
**Pending on-box check:** confirm 1.2.0:1 boots clean in StartOS → Logs (this supersedes the still-unconfirmed 1.1.0:9 non-root clean-boot check — same Logs verification: entrypoint logs `launching … as nextjs`, app writes `/data` as uid 1001 with no permission errors). **Confirmed on-box (2026-06-19, via `start-cli`):** box runs `1.2.0:6`; entrypoint logged `adding AIGeneration.kind (default 'program')` once, then launched `as nextjs` with no errors (clears the long-standing non-root check); read-only `SELECT` confirms the `AIGeneration.kind` column exists and the existing generation row backfilled to `program`. Recent prior ships (1.2.0 line): **1.2.0:5** Gear replaces RPE for cardio; **1.2.0:4** watts as first-class set field; **1.2.0:3** P3 hardening (login timing oracle + `exerciseId` ownership).
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). **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 through 1.2.0:5 (the workout feature didn't touch auth), 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).
Done this session: **Next 15 / React 19 upgrade.** Async-`params`/`searchParams` migration across 10 `[id]` route files + 4 server pages (uniform `await` re-derive idiom — see Conventions). Deps: `next` 15.5.x, `react`/`react-dom` 19.x, `eslint-config-next` 15.5.x, `lucide-react` → 1.x, `next-themes` → 0.4.x (the latter two bumped for React-19 peers). `next.config.js`/middleware unchanged; no schema/data change. Residual `npm audit` items are dev/build-only tooling (esbuild/tsx, picomatch, bundled postcss) — **not in the runtime image; do NOT `audit fix --force`** (npm wrongly suggests downgrading to `next@9`). Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, **single-workout generation + refine**, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).
Next steps (priority order): Next steps (priority order):
1. **P3 hardening batch** (`ROADMAP.md` → Security & hardening): login timing oracle, CSP `unsafe-eval`, `/api/health` info disclosure, rate-limit map leak, `exerciseId` ownership on workout PATCH/sets POST, 30-day sessions, text max-length. Also unify the 3rd JSON-parse pattern in `programs/[id]/days/[dayId]/start`. 1. **Finish the P3 hardening batch** (`ROADMAP.md` → Security & hardening timing oracle + exerciseId ownership now DONE): CSP `unsafe-eval`, `/api/health` info disclosure, rate-limit map leak, configurable/shorter sessions (currently 30-day), text max-length. Also unify the 3rd JSON-parse pattern (`try{json}catch{→{}}`) in `programs/[id]/days/[dayId]/start`.
2. Tiered AI prompt formatting (`ROADMAP.md` → AI quality). 2. Tiered AI prompt formatting (`ROADMAP.md` → AI quality).
3. (Later) **Next 15→16** when ready — `next lint` is deprecated in 15.5 (removed in 16), plus Next 16's own breaking changes; do it as its own tested bump. 3. (Later) **Next 15→16** when ready — `next lint` deprecated in 15.5 (removed in 16) + Next 16 breaking changes; its own tested bump.
Open/parked: rate-limit per-IP correctness depends on the StartOS proxy forwarding real client IPs (unverified on the box). `publish.sh` Step-3 registry no-op (parked w/ publishing). Community-registry 4 blockers (`ROADMAP.md` → Packaging). Open/parked: rate-limit per-IP correctness depends on the StartOS proxy forwarding real client IPs (unverified on the box). `publish.sh` Step-3 registry no-op (parked w/ publishing). Community-registry 4 blockers (`ROADMAP.md` → Packaging).
+1 -1
View File
@@ -1,4 +1,4 @@
# Evaluation — proof-of-work (Workout-log) — 2026-06-13 # Evaluation — proof-of-work — 2026-06-13
Intent: A self-hosted, multi-user workout planner and logger (Next.js 14 App Router + server actions/SSE, Prisma/SQLite, bcrypt auth) with an AI program-suggestion subsystem (5 LLM providers, background generation), packaged as a StartOS 0.4 s9pk. Intent: A self-hosted, multi-user workout planner and logger (Next.js 14 App Router + server actions/SSE, Prisma/SQLite, bcrypt auth) with an AI program-suggestion subsystem (5 LLM providers, background generation), packaged as a StartOS 0.4 s9pk.
+11 -2
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.
@@ -9,10 +17,10 @@ Longer-term backlog. Near-term state + next steps live in `AGENTS.md` → Curren
## Security & hardening (from 2026-06-13 full-eval; full detail + file:line in `EVALUATION.md`) ## Security & hardening (from 2026-06-13 full-eval; full detail + file:line in `EVALUATION.md`)
- **Next.js 14→15 major bump** (CVEs: RSC DoS, WS-upgrade SSRF, App Router XSS). Own tested change — breaking App Router/caching semantics, needs its own build + sideload verification.
- **Still open — verify on the box:** whether the StartOS proxy forwards real client IPs to the app. The rate limiter now keys on the rightmost (trusted-proxy) `X-Forwarded-For` entry; if the proxy instead makes every client look like one IP, the per-IP cap collapses to a single global bucket. Confirm with live headers. - **Still open — verify on the box:** whether the StartOS proxy forwards real client IPs to the app. The rate limiter now keys on the rightmost (trusted-proxy) `X-Forwarded-For` entry; if the proxy instead makes every client look like one IP, the per-IP cap collapses to a single global bucket. Confirm with live headers.
- P3 hardening batch: login timing oracle (dummy bcrypt on unknown email), CSP `unsafe-eval` vs comment, `/api/health` info disclosure, rate-limit map leak, `exerciseId` ownership unchecked on workout PATCH/sets POST, 30-day sessions, no text max-length. Also unify the 3rd JSON-parse pattern in `programs/[id]/days/[dayId]/start` (`try{json}catch{→{}}`). - P3 hardening batch (remaining): CSP `unsafe-eval` vs comment, `/api/health` info disclosure, rate-limit map leak, configurable/shorter sessions (currently 30-day), no text max-length. Also unify the 3rd JSON-parse pattern in `programs/[id]/days/[dayId]/start` (`try{json}catch{→{}}`).
Done in 1.2.0:1:3: Next 14→15 / React 18→19 bump (1.2.0:1, closed RSC DoS / WS-upgrade SSRF / App Router XSS + middleware-bypass CVEs); iOS-Safari login first-tap retry (1.2.0:2); login timing oracle closed + `exerciseId` ownership enforced on all workout-write & program routes (1.2.0:3).
Done in 1.1.0:9 (P2 batch): input-validation 500s → 400 (`lib/http.ts readJsonBody` + explicit guards); `POST /api/auth` rate-limited; XFF anti-spoof; container drops root via su-exec. Done in 1.1.0:9 (P2 batch): input-validation 500s → 400 (`lib/http.ts readJsonBody` + explicit guards); `POST /api/auth` rate-limited; XFF anti-spoof; container drops root via su-exec.
## Packaging / distribution ## Packaging / distribution
@@ -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
+23
View File
@@ -20,6 +20,29 @@ generate/generations route handlers). Whole-repo rules live in `AGENTS.md`.
- Multi-config: `AIConfigProfile` rows per user; `UserPreferences.activeAIConfigId` - Multi-config: `AIConfigProfile` rows per user; `UserPreferences.activeAIConfigId`
points at the active one and is mirrored into the legacy `ai*` columns for back-compat. points at the active one and is mirrored into the legacy `ai*` columns for back-compat.
## Two generation kinds (`AIGeneration.kind`)
The runner spine is shared by two output shapes, discriminated by `AIGeneration.kind`
("program" | "workout", default "program"). The runner picks the parser by kind and
stores the JSON in the (reused) `parsedProgram` column.
- **program** (`kind: 'program'`) — `generate/route.ts``programSchema.ts`
(`PROGRAM_OUTPUT_SHAPE` / `parseAIProgram`). Applied to DB rows via `apply.ts`.
Shown in AI · History (which filters `kind: 'program'`).
- **workout** (`kind: 'workout'`) — `generate-workout/route.ts` (uses
`workoutPrompt.ts` + `workoutSchema.ts`: `WORKOUT_OUTPUT_SHAPE` / `parseAIWorkout`).
A single day's session. **No server-side apply**: the client (`GenerateWorkoutClient.tsx`)
stashes the reviewed suggestion in `sessionStorage` and routes to
`/main/workouts/new?from=ai`, where `AiWorkoutPrefill.tsx` expands it (via
`workoutDraft.ts::buildPrefillExercises`) and pre-fills the normal `WorkoutForm`
nothing persists until the user saves through the regular workout path.
**Refine = a new workout generation** seeded with the prior suggestion JSON
(`priorWorkout` in the route body → REVISION mode in `workoutPrompt.ts`). These rows
are ephemeral, so they're excluded from the program-shaped AI · History.
- Adding a new kind: extend the union in `KickoffOpts`, add a parser + output-shape,
branch the parser selection in `generationRunner.ts`, and decide whether it belongs in
History (filtered by kind).
## Provider abstraction ## Provider abstraction
- Each provider yields an async iterable of `GenerateChunk` (`text` / `usage` / `done` / - Each provider yields an async iterable of `GenerateChunk` (`text` / `usage` / `done` /
@@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { WORKOUT_OUTPUT_SHAPE, aiWorkoutSchema } from '@/lib/ai/workoutSchema';
import {
buildHistorySummary,
formatHistoryContext,
} from '@/lib/ai/historyContext';
import { buildWorkoutSystemPrompt } from '@/lib/ai/workoutPrompt';
import { kickoffGeneration } from '@/lib/ai/generationRunner';
/**
* POST /api/ai/generate-workout
*
* Kicks off a background runner (kind="workout") that streams a single
* day's workout suggestion, and returns the generation id. The caller
* subscribes via GET /api/ai/generations/[id]/stream (SSE) — the same
* spine as program generation.
*
* Body:
* { userInput: string, includeHistory?: boolean, priorWorkout?: AIWorkout }
*
* `priorWorkout` switches the prompt into REVISION mode: userInput is the
* change instruction and the model re-emits the full revised workout.
*
* Response:
* 201 { id: "...generationId..." }
* 400 { error: "..." }
*/
const bodySchema = z.object({
userInput: z.string().min(1),
includeHistory: z.boolean().optional().default(false),
// The current suggestion, when refining. Validated against the same
// shape the model emits so we only ever feed it well-formed JSON.
priorWorkout: aiWorkoutSchema.optional().nullable(),
});
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid body', details: parsed.error.errors },
{ status: 400 },
);
}
const prefs = await prisma.userPreferences.findUnique({
where: { userId: user.id },
});
if (!prefs?.aiProvider || !prefs?.aiModel) {
return NextResponse.json(
{
error:
'AI is not configured. Open Settings → AI integration and pick a provider + model.',
},
{ status: 400 },
);
}
const weightUnit = (prefs.defaultWeightUnit as 'lbs' | 'kg') || 'lbs';
// Library for the prompt. We include each exercise's effective logging
// unit (`defaultWeightUnit || "lbs"` — the exact unit the saved workout
// will store, see WorkoutForm.buildPayload) so the model suggests the
// weight NUMBER in that unit. Without this the model would assume the
// user's global preferred unit, which diverges for per-exercise unit
// overrides (e.g. kettlebells in kg) and silently mislabels the weight.
const exercises = await prisma.exercise.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
type: true,
muscleGroups: true,
defaultWeightUnit: true,
},
});
const libraryJson = JSON.stringify(
exercises.map((e) => ({
id: e.id,
name: e.name,
type: e.type,
unit: e.defaultWeightUnit || 'lbs',
muscleGroups: (() => {
try {
return JSON.parse(e.muscleGroups);
} catch {
return [];
}
})(),
})),
);
// History context if requested.
let historyBlock = '';
if (parsed.data.includeHistory) {
const summary = await buildHistorySummary(prisma, user.id);
historyBlock = formatHistoryContext(summary);
}
const isLocalModel = prefs.aiProvider === 'ollama';
const priorWorkoutJson = parsed.data.priorWorkout
? JSON.stringify(parsed.data.priorWorkout)
: undefined;
const basePrompt = buildWorkoutSystemPrompt({
weightUnit,
hasHistoryContext: parsed.data.includeHistory,
isLocalModel,
priorWorkoutJson,
});
const systemPrompt = `${basePrompt}
# OUTPUT SHAPE
${WORKOUT_OUTPUT_SHAPE}
# LIBRARY (use these exerciseIds; do not invent ids)
${libraryJson}${historyBlock}`;
const id = await kickoffGeneration({
prisma,
userId: user.id,
kind: 'workout',
templateId: null,
templateName: priorWorkoutJson ? 'Workout (refine)' : 'Workout',
userInput: parsed.data.userInput,
systemPrompt,
userPrompt: parsed.data.userInput,
provider: prefs.aiProvider,
model: prefs.aiModel,
apiKey: prefs.aiApiKey,
baseUrl: prefs.aiBaseUrl,
});
return NextResponse.json({ id }, { status: 201 });
}
@@ -147,6 +147,7 @@ ${libraryJson}${historyBlock}`;
const id = await kickoffGeneration({ const id = await kickoffGeneration({
prisma, prisma,
userId: user.id, userId: user.id,
kind: 'program',
templateId: template?.id ?? null, templateId: template?.id ?? null,
templateName: template?.name ?? null, templateName: template?.name ?? null,
userInput: parsed.data.userInput, userInput: parsed.data.userInput,
@@ -16,7 +16,8 @@ export async function GET(request: NextRequest) {
const offset = Math.max(parseInt(sp.get('offset') || '0'), 0); const offset = Math.max(parseInt(sp.get('offset') || '0'), 0);
const rows = await prisma.aIGeneration.findMany({ const rows = await prisma.aIGeneration.findMany({
where: { userId: user.id }, // Program history only; workout-kind rows are ephemeral (see history page).
where: { userId: user.id, kind: 'program' },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: limit + 1, take: limit + 1,
skip: offset, skip: offset,
+8 -11
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod'; import { z } from 'zod';
import { verifyPassword, createSession } from '@/lib/auth'; import { verifyPasswordOrDummy, createSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { readJsonBody } from '@/lib/http'; import { readJsonBody } from '@/lib/http';
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit'; import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
@@ -33,17 +33,14 @@ export async function POST(request: NextRequest) {
where: { email }, where: { email },
}); });
if (!user) { // Always run a bcrypt compare (against a dummy hash when the email is
return NextResponse.json( // unknown) so response time doesn't reveal whether an account exists.
{ error: 'Invalid email or password' }, const isValid = await verifyPasswordOrDummy(
{ status: 401 } password,
); user?.passwordHash ?? null,
} );
// Verify the password if (!user || !isValid) {
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid email or password' }, { error: 'Invalid email or password' },
{ status: 401 } { status: 401 }
@@ -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,
}); });
+10 -14
View File
@@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http"; import { readJsonBody } from "@/lib/http";
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
import { getProgramById } from "@/lib/db/programs"; import { getProgramById } from "@/lib/db/programs";
/** /**
@@ -86,25 +87,20 @@ export async function PATCH(
const body = await readJsonBody(request); const body = await readJsonBody(request);
const validated = patchSchema.parse(body); const validated = patchSchema.parse(body);
// If replacing the tree, verify exercise ownership. // If replacing the tree, verify exercise ownership
// (see lib/exerciseOwnership).
if (validated.weeks) { if (validated.weeks) {
const allExerciseIds = new Set<string>(); const allExerciseIds = new Set<string>();
for (const w of validated.weeks) for (const w of validated.weeks)
for (const d of w.days) for (const d of w.days)
for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId); for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId);
if (allExerciseIds.size > 0) {
const owned = await prisma.exercise.findMany({ const bad = await findUnownedExerciseIds(user.id, allExerciseIds);
where: { userId: user.id, id: { in: Array.from(allExerciseIds) } }, if (bad.length > 0) {
select: { id: true }, return NextResponse.json(
}); { error: "Some exerciseIds don't exist in your library", details: bad },
const ownedIds = new Set(owned.map((e) => e.id)); { status: 400 },
const bad = Array.from(allExerciseIds).filter((id) => !ownedIds.has(id)); );
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 },
);
}
} }
} }
+9 -14
View File
@@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http"; import { readJsonBody } from "@/lib/http";
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
import { getPrograms } from "@/lib/db/programs"; import { getPrograms } from "@/lib/db/programs";
/** /**
@@ -65,25 +66,19 @@ export async function POST(request: NextRequest) {
const body = await readJsonBody(request); const body = await readJsonBody(request);
const validated = createProgramSchema.parse(body); const validated = createProgramSchema.parse(body);
// Verify any referenced exerciseIds belong to this user. // Verify any referenced exerciseIds belong to this user
// (see lib/exerciseOwnership).
const allExerciseIds = new Set<string>(); const allExerciseIds = new Set<string>();
for (const w of validated.weeks) for (const w of validated.weeks)
for (const d of w.days) for (const d of w.days)
for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId); for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId);
if (allExerciseIds.size > 0) { const bad = await findUnownedExerciseIds(user.id, allExerciseIds);
const owned = await prisma.exercise.findMany({ if (bad.length > 0) {
where: { userId: user.id, id: { in: Array.from(allExerciseIds) } }, return NextResponse.json(
select: { id: true }, { error: "Some exerciseIds don't exist in your library", details: bad },
}); { status: 400 },
const ownedIds = new Set(owned.map((e) => e.id)); );
const bad = Array.from(allExerciseIds).filter((id) => !ownedIds.has(id));
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 },
);
}
} }
const program = await prisma.$transaction(async (tx) => { const program = await prisma.$transaction(async (tx) => {
@@ -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 ?? "",
]; ];
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http"; import { readJsonBody } from "@/lib/http";
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
import { z } from "zod"; import { z } from "zod";
// GET: Get workout by ID // GET: Get workout by ID
@@ -56,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(),
}); });
@@ -101,6 +104,21 @@ export async function PATCH(
const body = await readJsonBody(request); const body = await readJsonBody(request);
const validated = updateWorkoutSchema.parse(body); const validated = updateWorkoutSchema.parse(body);
// When replacing sets, every referenced exercise must belong to this
// user (see lib/exerciseOwnership).
if (validated.sets) {
const bad = await findUnownedExerciseIds(
user.id,
validated.sets.map((s) => s.exerciseId),
);
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 }
);
}
}
const workoutData: Record<string, unknown> = {}; const workoutData: Record<string, unknown> = {};
if (validated.name !== undefined) workoutData.name = validated.name; if (validated.name !== undefined) workoutData.name = validated.name;
if (validated.notes !== undefined) workoutData.notes = validated.notes || null; if (validated.notes !== undefined) workoutData.notes = validated.notes || null;
@@ -136,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)
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http"; import { readJsonBody } from "@/lib/http";
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
import { z } from "zod"; import { z } from "zod";
const addSetsSchema = z.object({ const addSetsSchema = z.object({
@@ -13,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(),
}) })
@@ -51,6 +54,15 @@ export async function POST(
const body = await readJsonBody(request); const body = await readJsonBody(request);
const validated = addSetsSchema.parse(body); const validated = addSetsSchema.parse(body);
// The exercise must belong to this user (see lib/exerciseOwnership).
const bad = await findUnownedExerciseIds(user.id, [validated.exerciseId]);
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 }
);
}
// Delete existing sets for this exercise in this workout (replace mode) // Delete existing sets for this exercise in this workout (replace mode)
await prisma.setLog.deleteMany({ await prisma.setLog.deleteMany({
where: { where: {
@@ -69,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)
@@ -3,6 +3,7 @@ import { z } from "zod";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http"; import { readJsonBody } from "@/lib/http";
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
const setSchema = z.object({ const setSchema = z.object({
reps: z.number().int().positive().optional(), reps: z.number().int().positive().optional(),
@@ -12,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(),
}); });
@@ -44,6 +47,22 @@ export async function POST(request: Request) {
const body = await readJsonBody(request); const body = await readJsonBody(request);
const validated = saveImportSchema.parse(body); const validated = saveImportSchema.parse(body);
// An explicitly-matched `existingExerciseId` must belong to this user;
// name-matched and newly-created exercises are owned by construction
// (see lib/exerciseOwnership).
const claimedIds = validated.workouts.flatMap((w) =>
w.exercises
.map((e) => e.existingExerciseId)
.filter((id): id is string => !!id)
);
const bad = await findUnownedExerciseIds(user.id, claimedIds);
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 }
);
}
// Load all user exercises for matching // Load all user exercises for matching
const existingExercises = await prisma.exercise.findMany({ const existingExercises = await prisma.exercise.findMany({
where: { userId: user.id }, where: { userId: user.id },
@@ -106,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,
}); });
}); });
+18
View File
@@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http"; import { readJsonBody } from "@/lib/http";
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
// Schema now supports creating empty workouts (just date) or with sets // Schema now supports creating empty workouts (just date) or with sets
const createWorkoutSchema = z.object({ const createWorkoutSchema = z.object({
@@ -25,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(),
}) })
@@ -140,6 +143,19 @@ export async function POST(request: NextRequest) {
const body = await readJsonBody(request); const body = await readJsonBody(request);
const validated = createWorkoutSchema.parse(body); const validated = createWorkoutSchema.parse(body);
// Every referenced exercise must belong to this user (see
// lib/exerciseOwnership).
const bad = await findUnownedExerciseIds(
user.id,
validated.sets.map((s) => s.exerciseId),
);
if (bad.length > 0) {
return NextResponse.json(
{ error: "Some exerciseIds don't exist in your library", details: bad },
{ status: 400 }
);
}
const workoutDate = validated.date ? new Date(validated.date) : new Date(); const workoutDate = validated.date ? new Date(validated.date) : new Date();
const createData: Prisma.WorkoutCreateInput = { const createData: Prisma.WorkoutCreateInput = {
@@ -160,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)
+4 -1
View File
@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { loginAction } from './actions'; import { loginAction } from './actions';
import { retryOnTransportError } from '@/lib/retryAction';
export default function LoginForm() { export default function LoginForm() {
const router = useRouter(); const router = useRouter();
@@ -17,7 +18,9 @@ export default function LoginForm() {
setLoading(true); setLoading(true);
try { try {
const result = await loginAction(email, password); const result = await retryOnTransportError(() =>
loginAction(email, password)
);
if (result.error) { if (result.error) {
setError(result.error); setError(result.error);
+8 -8
View File
@@ -2,7 +2,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { cookies, headers } from 'next/headers'; import { cookies, headers } from 'next/headers';
import { verifyPassword, createSession } from '@/lib/auth'; import { verifyPasswordOrDummy, createSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit'; import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
@@ -24,14 +24,14 @@ export async function loginAction(email: string, password: string) {
where: { email }, where: { email },
}); });
if (!user) { // Always run a bcrypt compare (against a dummy hash when the email is
return { error: 'Invalid email or password' }; // unknown) so response time doesn't reveal whether an account exists.
} const isValid = await verifyPasswordOrDummy(
password,
user?.passwordHash ?? null,
);
// Verify the password if (!user || !isValid) {
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
return { error: 'Invalid email or password' }; return { error: 'Invalid email or password' };
} }
+4 -1
View File
@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { signupAction } from './actions'; import { signupAction } from './actions';
import { retryOnTransportError } from '@/lib/retryAction';
export default function SignupForm() { export default function SignupForm() {
const router = useRouter(); const router = useRouter();
@@ -19,7 +20,9 @@ export default function SignupForm() {
setLoading(true); setLoading(true);
try { try {
const result = await signupAction(email, password, passwordConfirm, name); const result = await retryOnTransportError(() =>
signupAction(email, password, passwordConfirm, name)
);
if (result.error) { if (result.error) {
setError(result.error); setError(result.error);
setLoading(false); setLoading(false);
@@ -0,0 +1,70 @@
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import GenerateWorkoutClient from '@/components/ai/GenerateWorkoutClient';
export const dynamic = 'force-dynamic';
export default async function GenerateWorkoutPage() {
const user = await getCurrentUser();
if (!user) redirect('/auth/login');
const [exercises, prefs, workoutCount] = await Promise.all([
prisma.exercise.findMany({
where: { userId: user.id },
select: { id: true, name: true, type: true },
orderBy: [{ type: 'asc' }, { name: 'asc' }],
}),
prisma.userPreferences.findUnique({
where: { userId: user.id },
select: { aiProvider: true, aiModel: true },
}),
prisma.workout.count({
where: { userId: user.id, deletedAt: null },
}),
]);
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="border-b border-zinc-800">
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center gap-3">
<Link
href="/main/ai"
className="text-zinc-400 hover:text-white"
aria-label="Back to AI"
>
<ChevronLeft className="w-5 h-5" />
</Link>
<h1 className="text-2xl sm:text-3xl font-bold text-white">
AI · Today&apos;s workout
</h1>
</div>
</div>
<div className="max-w-3xl mx-auto px-4 py-6">
{!aiConfigured ? (
<div className="bg-amber-950/30 border border-amber-900 rounded p-5 text-sm text-amber-200">
<p className="font-bold text-amber-100 mb-2">AI is not configured.</p>
<p>
Pick a provider, model, and (if needed) API key in{' '}
<Link href="/main/settings" className="underline hover:text-amber-100">
Settings AI integration
</Link>{' '}
before you can generate a workout.
</p>
</div>
) : (
<GenerateWorkoutClient
exercises={exercises}
providerLabel={prefs!.aiProvider!}
modelLabel={prefs!.aiModel!}
workoutCount={workoutCount}
/>
)}
</div>
</div>
);
}
@@ -34,7 +34,8 @@ export default async function GenerationDetailPage(props: {
const [row, exercises] = await Promise.all([ const [row, exercises] = await Promise.all([
prisma.aIGeneration.findFirst({ prisma.aIGeneration.findFirst({
where: { id: params.id, userId: user.id }, // Program history only — workout-kind rows aren't shown here.
where: { id: params.id, userId: user.id, kind: 'program' },
}), }),
prisma.exercise.findMany({ prisma.exercise.findMany({
where: { userId: user.id }, where: { userId: user.id },
+4 -1
View File
@@ -12,7 +12,10 @@ export default async function HistoryPage() {
if (!user) redirect('/auth/login'); if (!user) redirect('/auth/login');
const rows = await prisma.aIGeneration.findMany({ const rows = await prisma.aIGeneration.findMany({
where: { userId: user.id }, // Program history only. Single-workout generations (kind="workout")
// are ephemeral — the durable record is the saved Workout — so they
// don't belong in this program-shaped list/detail.
where: { userId: user.id, kind: 'program' },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 25, take: 25,
select: { select: {
+9 -1
View File
@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Sparkles, ListChecks, History } from 'lucide-react'; import { Sparkles, ListChecks, History, Dumbbell } from 'lucide-react';
import { getCurrentUser } from '@/lib/auth'; import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
@@ -17,6 +17,14 @@ export default async function AIIndexPage() {
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel; const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
const cards = [ const cards = [
{
href: '/main/ai/generate-workout',
icon: Dumbbell,
title: "Today's workout",
blurb:
'Describe today\'s session in plain words and get a ready-to-log workout — exercises with suggested weights and reps from your history. Refine it, then pre-fill the log.',
disabled: !aiConfigured,
},
{ {
href: '/main/ai/generate', href: '/main/ai/generate',
icon: Sparkles, icon: Sparkles,
@@ -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>
+2 -1
View File
@@ -44,7 +44,8 @@ const navLinks: NavLink[] = [
label: 'AI', label: 'AI',
icon: Sparkles, icon: Sparkles,
subItems: [ subItems: [
{ href: '/main/ai/generate', label: 'Generate' }, { href: '/main/ai/generate-workout', label: "Today's workout" },
{ href: '/main/ai/generate', label: 'Generate program' },
{ href: '/main/ai/history', label: 'History' }, { href: '/main/ai/history', label: 'History' },
{ href: '/main/ai/templates', label: 'Templates' }, { href: '/main/ai/templates', label: 'Templates' },
], ],
+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";
} }
+18 -6
View File
@@ -5,6 +5,7 @@ import { getCurrentUser } from "@/lib/auth";
import { getExercises } from "@/lib/db/exercises"; import { getExercises } from "@/lib/db/exercises";
import { getWorkoutById } from "@/lib/db/workouts"; import { getWorkoutById } from "@/lib/db/workouts";
import WorkoutForm, { EditWorkoutData } from "@/components/workouts/WorkoutForm"; import WorkoutForm, { EditWorkoutData } from "@/components/workouts/WorkoutForm";
import AiWorkoutPrefill from "@/components/workouts/AiWorkoutPrefill";
export const metadata = { export const metadata = {
title: "Log Workout", title: "Log Workout",
@@ -12,7 +13,7 @@ export const metadata = {
}; };
export default async function NewWorkoutPage(props: { export default async function NewWorkoutPage(props: {
searchParams: Promise<{ edit?: string }>; searchParams: Promise<{ edit?: string; from?: string }>;
}) { }) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const user = await getCurrentUser(); const user = await getCurrentUser();
@@ -22,6 +23,11 @@ export default async function NewWorkoutPage(props: {
const exercises = await getExercises(user.id); const exercises = await getExercises(user.id);
// Coming from the AI "today's workout" flow: the suggestion is in
// sessionStorage (client-only), so a client wrapper reads it and
// pre-fills the form. No ?edit fetch here.
const fromAi = searchParams.from === "ai";
// If ?edit=WORKOUT_ID, fetch existing workout for editing // If ?edit=WORKOUT_ID, fetch existing workout for editing
let editWorkout: EditWorkoutData | undefined; let editWorkout: EditWorkoutData | undefined;
if (searchParams.edit) { if (searchParams.edit) {
@@ -50,9 +56,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,
}); });
@@ -93,11 +101,15 @@ export default async function NewWorkoutPage(props: {
{/* Form */} {/* Form */}
<div className="max-w-2xl mx-auto px-4 py-6 pb-12"> <div className="max-w-2xl mx-auto px-4 py-6 pb-12">
<WorkoutForm {fromAi ? (
exercises={exercises} <AiWorkoutPrefill exercises={exercises} />
recentlyUsedExercises={[]} ) : (
editWorkout={editWorkout} <WorkoutForm
/> exercises={exercises}
recentlyUsedExercises={[]}
editWorkout={editWorkout}
/>
)}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,626 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2, Sparkles } from 'lucide-react';
import { lenientJsonParse } from '@/lib/ai/lenientJson';
import { estimateCost, formatCost } from '@/lib/ai/pricing';
import type { AiWorkoutDraft } from '@/lib/ai/workoutDraft';
interface LibraryExercise {
id: string;
name: string;
type: string;
}
// AI output shape — mirrors lib/ai/workoutSchema.ts (AIWorkout).
interface AIWorkoutExercise {
exerciseId: string | null;
exerciseName: string;
order: number;
sets?: number | null;
reps?: number | null;
suggestedWeight?: number | null;
suggestedWeightUnit?: 'lbs' | 'kg' | null;
rpe?: number | null;
gear?: number | null;
durationSeconds?: number | null;
notes?: string | null;
}
interface AIWorkout {
name: string;
notes?: string | null;
exercises: AIWorkoutExercise[];
}
// The ephemeral draft we hand to the New Workout form via sessionStorage.
export const AI_WORKOUT_DRAFT_KEY = 'ai-workout-draft';
type Phase =
| { kind: 'idle' }
| { kind: 'streaming'; raw: string; lastPartial: Partial<AIWorkout> | null }
| { kind: 'failed'; raw: string; message: string };
export default function GenerateWorkoutClient({
exercises,
providerLabel,
modelLabel,
workoutCount,
}: {
exercises: LibraryExercise[];
providerLabel: string;
modelLabel: string;
workoutCount: number;
}) {
const router = useRouter();
const [userInput, setUserInput] = useState('');
const [includeHistory, setIncludeHistory] = useState(workoutCount >= 1);
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
// The editable suggestion once parsed. Lifted to the parent so the
// Refine action can send the user's current edits back as the prior
// workout. null until the first successful parse.
const [workout, setWorkout] = useState<AIWorkout | null>(null);
// Refine instruction lives here (not in WorkoutPreview) because the
// preview unmounts while streaming — keeping it in the parent means a
// failed refine doesn't lose what the user typed; we clear it only on
// a successful regeneration.
const [refineInput, setRefineInput] = useState('');
const [tokens, setTokens] = useState<{ in?: number; out?: number; durationMs?: number }>({});
const closeStreamRef = useRef<(() => void) | null>(null);
const streaming = phase.kind === 'streaming';
/**
* Run a generation. `priorWorkout` present → REVISION mode: `input`
* is the change instruction and the model re-emits the full workout.
*/
const runGeneration = async (input: string, priorWorkout?: AIWorkout) => {
if (!input.trim()) return;
setPhase({ kind: 'streaming', raw: '', lastPartial: null });
setTokens({});
let id: string;
try {
const res = await fetch('/api/ai/generate-workout', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
userInput: input,
includeHistory,
priorWorkout: priorWorkout ?? null,
}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
setPhase({ kind: 'failed', raw: '', message: body.error ?? `HTTP ${res.status}` });
return;
}
id = body.id;
} catch (e) {
setPhase({ kind: 'failed', raw: '', message: (e as Error).message });
return;
}
attachStream(id);
};
const attachStream = (id: string) => {
const es = new EventSource(`/api/ai/generations/${id}/stream`);
closeStreamRef.current = () => es.close();
let raw = '';
let lastPartial: Partial<AIWorkout> | null = null;
es.addEventListener('text', (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
raw += data.delta;
const next = lenientJsonParse(raw) as Partial<AIWorkout> | null;
if (next) lastPartial = next; // sticky — kills flicker between parses
setPhase({ kind: 'streaming', raw, lastPartial });
});
es.addEventListener('usage', (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
setTokens((t) => ({ ...t, in: data.tokensIn, out: data.tokensOut }));
});
es.addEventListener('complete', async (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
es.close();
closeStreamRef.current = null;
setTokens((t) => ({
...t,
in: data.tokensIn ?? t.in,
out: data.tokensOut ?? t.out,
durationMs: data.durationMs,
}));
if (data.parsedOk) {
const r = await fetch(`/api/ai/generations/${id}`);
if (r.ok) {
const gen = await r.json();
if (gen.parsedProgram) {
setWorkout(JSON.parse(gen.parsedProgram) as AIWorkout);
setRefineInput(''); // consumed — clear only on success
setPhase({ kind: 'idle' });
return;
}
}
}
setPhase({
kind: 'failed',
raw,
message: data.errorMessage ?? 'Failed to parse model output.',
});
});
es.onerror = () => {
if (es.readyState === EventSource.CLOSED) {
closeStreamRef.current = null;
setPhase((p) =>
p.kind === 'streaming'
? {
kind: 'failed',
raw: p.raw,
message:
'Stream disconnected. The generation may still be running — check AI · History.',
}
: p,
);
}
};
};
// Warn before unload while streaming (the runner keeps going server-side).
useEffect(() => {
if (!streaming) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [streaming]);
// Detach on unmount; the server keeps generating regardless.
useEffect(() => () => closeStreamRef.current?.(), []);
const costStr = useMemo(() => {
if (tokens.in == null || tokens.out == null) return null;
return formatCost(
estimateCost({
provider: providerLabel,
model: modelLabel,
tokensIn: tokens.in,
tokensOut: tokens.out,
}),
);
}, [providerLabel, modelLabel, tokens.in, tokens.out]);
const showResult = streaming || phase.kind === 'failed' || workout != null;
return (
<div className="space-y-6">
<div className="text-xs text-zinc-500 uppercase tracking-wider">
Provider: <span className="text-zinc-300">{providerLabel}</span>
{' · '}Model: <span className="text-zinc-300">{modelLabel}</span>
</div>
<section className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
<Field label="Describe today's workout">
<textarea
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
placeholder="e.g. Upper body, focus on shoulders. Overhead press, 4 working sets. Abs: landmine rotation, oblique cable, landmine hollow-body hold. Pull-ups, biceps and triceps."
rows={6}
className={inputClass}
disabled={streaming}
/>
</Field>
<label className="flex items-start gap-2 text-xs text-zinc-300">
<input
type="checkbox"
checked={includeHistory}
onChange={(e) => setIncludeHistory(e.target.checked)}
disabled={streaming || workoutCount === 0}
className="mt-0.5"
/>
<span>
Use my history to suggest weights{' '}
<span className="text-zinc-500">
({workoutCount === 0
? 'no workouts logged yet — disabled'
: 'last 90 days · recent working weights per exercise'}
)
</span>
</span>
</label>
<button
type="button"
onClick={() => runGeneration(userInput)}
disabled={!userInput.trim() || streaming}
className="inline-flex items-center gap-2 px-5 py-2 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500"
>
<Sparkles className="w-4 h-4" />
{workout ? 'Regenerate' : 'Generate workout'}
</button>
</section>
{showResult && (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
{streaming ? 'Generating…' : 'Suggested workout'}
</h2>
<span className="text-[11px] text-zinc-500 uppercase tracking-wider">
{tokens.in != null && (
<>
{tokens.in} in · {tokens.out ?? '?'} out
</>
)}
{costStr && <> · {costStr}</>}
{tokens.durationMs != null && (
<> · {(tokens.durationMs / 1000).toFixed(1)}s</>
)}
</span>
</div>
{streaming && (
<>
{phase.lastPartial ? (
<PartialPreview partial={phase.lastPartial} />
) : (
<div className="text-xs text-zinc-500 italic flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Waiting for the first parseable JSON
</div>
)}
<details className="text-xs text-zinc-500">
<summary className="cursor-pointer">Raw stream</summary>
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 font-mono text-[11px] text-zinc-400 max-h-80 overflow-auto whitespace-pre-wrap mt-2">
{phase.raw || '(waiting for first token…)'}
<Loader2 className="inline w-3 h-3 animate-spin ml-2" />
</div>
</details>
</>
)}
{phase.kind === 'failed' && (
<>
<div className="bg-red-950/40 border border-red-900 rounded p-3 text-sm text-red-300">
{phase.message}
</div>
{phase.raw && (
<details className="text-xs text-zinc-500">
<summary className="cursor-pointer">Raw response</summary>
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 mt-2 whitespace-pre-wrap">
{phase.raw}
</pre>
</details>
)}
</>
)}
{!streaming && workout && (
<WorkoutPreview
workout={workout}
setWorkout={(updater) => setWorkout((w) => (w ? updater(w) : w))}
exercises={exercises}
refineInput={refineInput}
setRefineInput={setRefineInput}
onRefine={() => runGeneration(refineInput, workout)}
onUse={(draft) => {
sessionStorage.setItem(AI_WORKOUT_DRAFT_KEY, JSON.stringify(draft));
router.push('/main/workouts/new?from=ai');
}}
/>
)}
</section>
)}
</div>
);
}
function WorkoutPreview({
workout,
setWorkout,
exercises,
refineInput,
setRefineInput,
onRefine,
onUse,
}: {
workout: AIWorkout;
setWorkout: (updater: (w: AIWorkout) => AIWorkout) => void;
exercises: LibraryExercise[];
refineInput: string;
setRefineInput: (v: string) => void;
onRefine: () => void;
onUse: (draft: AiWorkoutDraft) => void;
}) {
const [error, setError] = useState<string | null>(null);
const exerciseLookup = useMemo(
() => new Map(exercises.map((e) => [e.id, e])),
[exercises],
);
const unresolvedCount = useMemo(
() =>
workout.exercises.filter(
(ex) => !ex.exerciseId || !exerciseLookup.has(ex.exerciseId),
).length,
[workout, exerciseLookup],
);
const updateExercise = (
idx: number,
patch: Partial<AIWorkoutExercise>,
) => {
setWorkout((w) => {
const next = structuredClone(w);
next.exercises[idx] = { ...next.exercises[idx], ...patch };
return next;
});
};
const removeExercise = (idx: number) => {
setWorkout((w) => {
const next = structuredClone(w);
next.exercises.splice(idx, 1);
next.exercises.forEach((ex, i) => (ex.order = i));
return next;
});
};
const numOrNull = (v: string) => {
if (v.trim() === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
const handleUse = () => {
if (unresolvedCount > 0) {
setError(`Map or remove the ${unresolvedCount} unknown exercise(s) first.`);
return;
}
setError(null);
onUse({
name: workout.name,
notes: workout.notes ?? undefined,
exercises: workout.exercises.map((ex) => ({
exerciseId: ex.exerciseId!,
sets: ex.sets && ex.sets > 0 ? ex.sets : 3,
reps: ex.reps ?? undefined,
suggestedWeight: ex.suggestedWeight ?? undefined,
suggestedWeightUnit: ex.suggestedWeightUnit ?? undefined,
rpe: ex.rpe ?? undefined,
gear: ex.gear ?? undefined,
durationSeconds: ex.durationSeconds ?? undefined,
notes: ex.notes ?? undefined,
})),
});
};
return (
<div className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
<div>
<input
value={workout.name}
onChange={(e) => setWorkout((w) => ({ ...w, name: e.target.value }))}
className="text-lg font-bold text-white bg-transparent border-b border-transparent hover:border-zinc-700 focus:border-zinc-500 focus:outline-none w-full"
/>
{workout.notes && (
<p className="text-sm text-zinc-400 mt-1 italic">{workout.notes}</p>
)}
<p className="text-xs text-zinc-500 mt-1">
{workout.exercises.length} exercise
{workout.exercises.length === 1 ? '' : 's'}
</p>
</div>
{unresolvedCount > 0 && (
<div className="rounded bg-amber-950/30 border border-amber-900 px-3 py-2 text-xs text-amber-200">
{unresolvedCount} exercise(s) the AI couldn&apos;t map to your library.
Pick a replacement or remove them before using this workout.
</div>
)}
<ul className="space-y-2">
{workout.exercises.map((ex, idx) => {
const isUnknown = !ex.exerciseId || !exerciseLookup.has(ex.exerciseId);
const lib = ex.exerciseId ? exerciseLookup.get(ex.exerciseId) : null;
return (
<li
key={idx}
className={`rounded p-3 ${
isUnknown
? 'bg-amber-950/30 border border-amber-900'
: 'bg-zinc-950 border border-zinc-800'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="text-white text-sm">
{lib?.name ?? ex.exerciseName}
{isUnknown && (
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-400">
not in library
</span>
)}
</div>
<button
type="button"
onClick={() => removeExercise(idx)}
className="text-xs text-red-400 hover:text-red-300 px-1"
title="Remove from workout"
>
</button>
</div>
{isUnknown && (
<select
value={ex.exerciseId ?? ''}
onChange={(e) =>
updateExercise(idx, { exerciseId: e.target.value || null })
}
className="mt-2 w-full text-xs px-2 py-1 rounded border border-amber-900 bg-zinc-900 text-white"
>
<option value="">Map to existing exercise</option>
{exercises.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.name} ({opt.type})
</option>
))}
</select>
)}
<div className="mt-2 grid grid-cols-3 gap-2">
<NumField
label="Sets"
value={ex.sets}
onChange={(v) => updateExercise(idx, { sets: v })}
/>
<NumField
label="Reps"
value={ex.reps}
onChange={(v) => updateExercise(idx, { reps: v })}
/>
<NumField
label={`Weight${ex.suggestedWeightUnit ? ` (${ex.suggestedWeightUnit})` : ''}`}
value={ex.suggestedWeight}
step="any"
onChange={(v) => updateExercise(idx, { suggestedWeight: v })}
/>
</div>
{ex.notes && (
<div className="text-xs text-zinc-400 mt-2 italic">{ex.notes}</div>
)}
</li>
);
})}
</ul>
<div className="border-t border-zinc-800 pt-4 space-y-3">
<Field label="Refine (send a change back to the AI)">
<div className="flex gap-2">
<input
value={refineInput}
onChange={(e) => setRefineInput(e.target.value)}
onKeyDown={(e) => {
// Cleared by the parent only on a successful regeneration, so
// a failed refine keeps what the user typed.
if (e.key === 'Enter' && refineInput.trim()) onRefine();
}}
placeholder="e.g. make overhead press 5 sets; swap the oblique exercise"
className={inputClass}
/>
<button
type="button"
onClick={() => {
if (refineInput.trim()) onRefine();
}}
disabled={!refineInput.trim()}
className="shrink-0 px-4 py-2 rounded bg-zinc-700 text-white font-bold text-xs uppercase tracking-wider hover:bg-zinc-600 disabled:bg-zinc-800 disabled:text-zinc-600"
>
Refine
</button>
</div>
</Field>
{error && (
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
{error}
</div>
)}
<button
type="button"
onClick={handleUse}
disabled={unresolvedCount > 0 || workout.exercises.length === 0}
className="px-5 py-2 rounded bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider hover:bg-emerald-600 disabled:bg-zinc-700 disabled:text-zinc-500"
>
Use this workout
</button>
<p className="text-[11px] text-zinc-500">
Opens a pre-filled workout nothing is saved until you save it there.
</p>
</div>
</div>
);
function NumField({
label,
value,
step,
onChange,
}: {
label: string;
value?: number | null;
step?: string;
onChange: (v: number | null) => void;
}) {
return (
<label className="block">
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider block mb-0.5">
{label}
</span>
<input
type="number"
inputMode="decimal"
step={step}
value={value ?? ''}
onChange={(e) => onChange(numOrNull(e.target.value))}
className="w-full px-2 py-1 text-sm rounded border border-zinc-700 bg-zinc-800 text-white focus:outline-none focus:ring-1 focus:ring-white/30"
/>
</label>
);
}
}
const inputClass =
'w-full px-3 py-2 text-sm rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/30';
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
{label}
</span>
{children}
</label>
);
}
function PartialPreview({ partial }: { partial: Partial<AIWorkout> }) {
const exercises = (partial.exercises as AIWorkoutExercise[] | undefined) ?? [];
return (
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Loader2 className="w-3 h-3 animate-spin text-zinc-500" />
<span className="text-zinc-400">
Building workout{' '}
{partial.name && (
<span className="text-white font-semibold">{partial.name}</span>
)}
</span>
</div>
{exercises.length > 0 && (
<ul className="text-xs text-zinc-300 space-y-1">
{exercises.map((ex, i) => (
<li key={i}>
<span className="text-zinc-500">{(ex?.order ?? i) + 1}.</span>{' '}
{ex?.exerciseName ?? '…'}
{ex?.sets ? (
<span className="text-zinc-500">
{' '}
· {ex.sets}×{ex.reps ?? '?'}
{ex.suggestedWeight != null
? ` @ ${ex.suggestedWeight}${ex.suggestedWeightUnit ?? ''}`
: ''}
</span>
) : null}
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,70 @@
'use client';
import { useEffect, useState } from 'react';
import { Exercise } from '@prisma/client';
import WorkoutForm, { EditWorkoutData } from './WorkoutForm';
import { AI_WORKOUT_DRAFT_KEY } from '@/components/ai/GenerateWorkoutClient';
import { buildPrefillExercises, type AiWorkoutDraft } from '@/lib/ai/workoutDraft';
/**
* Reads the ephemeral AI workout draft from sessionStorage (stashed by
* GenerateWorkoutClient before navigating to /main/workouts/new?from=ai),
* expands each suggested exercise into N pre-filled SetLogs, and renders
* the normal WorkoutForm. Nothing is persisted until the user saves
* through the regular workout path.
*
* The draft has no workout id, so WorkoutForm's first save CREATEs.
* Effort follows the app convention: cardio → gear (1-5), else → rpe.
*
* If the draft is missing (e.g. a refresh cleared it), we fall back to a
* blank form so the page is never broken.
*/
export default function AiWorkoutPrefill({
exercises,
}: {
exercises: Exercise[];
}) {
// Read + build once on mount via a lazy initializer. This stays PURE
// (no sessionStorage mutation) so React's StrictMode double-invoke is
// safe — both passes read the same draft. The one-shot removal happens
// in the effect below, after the value is captured.
const [editWorkout] = useState<EditWorkoutData | undefined>(() => {
if (typeof window === 'undefined') return undefined;
const raw = sessionStorage.getItem(AI_WORKOUT_DRAFT_KEY);
if (!raw) return undefined;
let draft: AiWorkoutDraft;
try {
draft = JSON.parse(raw);
} catch {
return undefined;
}
const builtExercises: EditWorkoutData['exercises'] =
buildPrefillExercises(draft, exercises);
if (builtExercises.length === 0) return undefined;
return {
// No id → first save CREATEs a new workout.
name: draft.name || '',
date: new Date().toISOString(),
notes: draft.notes,
exercises: builtExercises,
};
});
// Clear the one-shot draft after mount so a manual reload starts blank.
// removeItem is idempotent, so StrictMode's double-run is harmless.
useEffect(() => {
sessionStorage.removeItem(AI_WORKOUT_DRAFT_KEY);
}, []);
return (
<WorkoutForm
exercises={exercises}
recentlyUsedExercises={[]}
editWorkout={editWorkout}
/>
);
}
@@ -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
@@ -242,7 +245,10 @@ interface ExerciseWithSets {
} }
export interface EditWorkoutData { export interface EditWorkoutData {
id: string; /// Existing workout id when editing. Omitted for a pre-filled NEW
/// workout (e.g. an AI suggestion) so `savedWorkoutId` starts null and
/// the first save CREATEs instead of PATCHing a nonexistent id.
id?: string;
name: string; name: string;
date: string; // ISO string date: string; // ISO string
durationMinutes?: number | null; durationMinutes?: number | null;
@@ -256,9 +262,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 +350,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 +514,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 +567,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 +589,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
}, },
@@ -644,9 +658,11 @@ export default function WorkoutForm({
if (!response.ok) throw new Error("Failed to save workout"); if (!response.ok) throw new Error("Failed to save workout");
} }
// Navigate back: to detail page if editing, otherwise to list // Navigate back: to detail page if we have a workout id (editing, or
if (editWorkout) { // an AI-prefilled workout that has now been created), otherwise to list.
router.push(`/main/workouts/${savedWorkoutId || editWorkout.id}`); const detailId = savedWorkoutId || editWorkout?.id;
if (detailId) {
router.push(`/main/workouts/${detailId}`);
} else { } else {
router.push("/main/workouts"); router.push("/main/workouts");
} }
@@ -852,12 +868,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 +888,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 +900,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(
+8 -2
View File
@@ -34,6 +34,7 @@
import type { PrismaClient } from '@prisma/client'; import type { PrismaClient } from '@prisma/client';
import { getProvider } from './providers'; import { getProvider } from './providers';
import { parseAIProgram } from './programSchema'; import { parseAIProgram } from './programSchema';
import { parseAIWorkout } from './workoutSchema';
export interface GenerationDelta { export interface GenerationDelta {
type: 'text' | 'usage' | 'complete' | 'error'; type: 'text' | 'usage' | 'complete' | 'error';
@@ -114,6 +115,9 @@ export function subscribe(
export interface KickoffOpts { export interface KickoffOpts {
prisma: PrismaClient; prisma: PrismaClient;
userId: string; userId: string;
/** "program" (multi-week) or "workout" (single day). Selects the
* output parser and is persisted on the row. */
kind: 'program' | 'workout';
templateId: string | null; templateId: string | null;
templateName: string | null; templateName: string | null;
userInput: string; userInput: string;
@@ -139,6 +143,7 @@ export async function kickoffGeneration(opts: KickoffOpts): Promise<string> {
const generation = await opts.prisma.aIGeneration.create({ const generation = await opts.prisma.aIGeneration.create({
data: { data: {
userId: opts.userId, userId: opts.userId,
kind: opts.kind,
templateId: opts.templateId, templateId: opts.templateId,
templateName: opts.templateName, templateName: opts.templateName,
userInput: opts.userInput, userInput: opts.userInput,
@@ -248,10 +253,11 @@ async function runGeneration(generationId: string, opts: KickoffOpts) {
let parsedJson: string | null = null; let parsedJson: string | null = null;
let parseErr: string | null = null; let parseErr: string | null = null;
if (!providerError && raw) { if (!providerError && raw) {
const r = parseAIProgram(raw); const r =
opts.kind === 'workout' ? parseAIWorkout(raw) : parseAIProgram(raw);
if (r.ok) { if (r.ok) {
parsedOk = true; parsedOk = true;
parsedJson = JSON.stringify(r.program); parsedJson = JSON.stringify('workout' in r ? r.workout : r.program);
} else { } else {
parseErr = r.reason; parseErr = r.reason;
} }
+78
View File
@@ -0,0 +1,78 @@
import { isCardioExercise } from '@/lib/exerciseOptions';
/**
* The ephemeral draft the "today's workout" flow hands to the New Workout
* form (via sessionStorage). One entry per exercise, with a working set
* count plus a single target weight/reps that we expand into N identical
* pre-filled sets. Shared by the producer (GenerateWorkoutClient) and the
* consumer (AiWorkoutPrefill) so the shape stays in sync.
*/
export interface AiWorkoutDraftExercise {
exerciseId: string;
sets: number;
reps?: number;
suggestedWeight?: number;
suggestedWeightUnit?: 'lbs' | 'kg';
rpe?: number;
gear?: number;
durationSeconds?: number;
notes?: string;
}
export interface AiWorkoutDraft {
name: string;
notes?: string;
exercises: AiWorkoutDraftExercise[];
}
export interface PrefillSet {
setNumber: number;
reps?: number;
weight?: number;
rpe?: number;
gear?: number;
durationSeconds?: number;
notes?: string;
}
export interface PrefillExercise<E> {
exercise: E;
sets: PrefillSet[];
}
/** Default working sets when the model omits a positive count. */
const DEFAULT_SET_COUNT = 3;
/**
* Expand a draft into pre-filled exercises against the user's library.
*
* - Exercises whose `exerciseId` isn't in the library are dropped (the
* preview forces the user to map them first, so this is just defensive).
* - Each exercise becomes `sets` identical SetLogs seeded with the
* suggested weight/reps.
* - Effort follows the app convention: cardio logs `gear` (1-5), every
* other exercise logs `rpe`. We keep only the matching one so a stray
* value on the wrong kind never reaches the form.
* - The coaching note rides only on the first set (avoids N copies).
*/
export function buildPrefillExercises<
E extends { id: string; type?: string | null; muscleGroups?: string | null },
>(draft: AiWorkoutDraft, exercises: E[]): PrefillExercise<E>[] {
const byId = new Map(exercises.map((e) => [e.id, e]));
const out: PrefillExercise<E>[] = [];
for (const d of draft.exercises) {
const exercise = byId.get(d.exerciseId);
if (!exercise) continue;
const cardio = isCardioExercise(exercise);
const setCount = d.sets && d.sets > 0 ? d.sets : DEFAULT_SET_COUNT;
const sets: PrefillSet[] = Array.from({ length: setCount }, (_, i) => ({
setNumber: i + 1,
reps: d.reps,
weight: d.suggestedWeight,
rpe: cardio ? undefined : d.rpe,
gear: cardio ? d.gear : undefined,
durationSeconds: d.durationSeconds,
notes: i === 0 ? d.notes : undefined,
}));
out.push({ exercise, sets });
}
return out;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* System-prompt builder for the "generate today's workout" flow. The
* sibling of systemPromptBase.ts (which targets multi-week programs).
*
* Job: force the single-workout JSON contract, ground suggested weights
* in the user's history, and respect the app's Gear-vs-RPE effort
* convention (cardio logs breathing Gear 1-5; everything else logs
* RPE 6-10).
*/
export interface WorkoutPromptOpts {
/** "lbs" | "kg" — default suggestedWeightUnit when the model omits one. */
weightUnit: 'lbs' | 'kg';
/** Whether the user's workout history is included in the prompt. */
hasHistoryContext: boolean;
/** True when the model is local (Ollama) — needs blunter, shorter rules. */
isLocalModel: boolean;
/** When refining, the prior suggestion's JSON. Present → revision mode. */
priorWorkoutJson?: string;
}
export function buildWorkoutSystemPrompt(opts: WorkoutPromptOpts): string {
const lines: string[] = [];
lines.push(
'# ROLE',
'',
"You are a strength & conditioning coach building ONE training session for today from the user's brain-dump. Turn their loose description into a concrete, ready-to-log workout.",
'',
'# OUTPUT CONTRACT (mandatory)',
'',
'1. Reply with EXACTLY ONE JSON object matching the OUTPUT SHAPE. No prose before or after. No ```json fences.',
'2. Every exercise must use an `exerciseId` from the LIBRARY block. NEVER invent ids. If nothing fits, pick the closest match and explain the substitution in `notes`.',
'3. Honor what the user asked for: include the exercises they named, with the set counts / emphasis they specified. Add sensible accessory work only if they asked you to fill out a body part (e.g. "let\'s do biceps and triceps").',
`4. Every resistance exercise MUST have a \`suggestedWeight\` (a number) and a target \`reps\`. Cardio, stretching, and bodyweight exercises set \`suggestedWeight\` to null.`,
`5. Express \`suggestedWeight\` in THAT exercise's \`unit\` from the LIBRARY block, and set \`suggestedWeightUnit\` to match it (default "${opts.weightUnit}" if none is shown). Don't convert — give the number in the exercise's own unit.`,
'6. `sets` is the number of working sets to pre-fill (e.g. the user\'s "4 working sets" → sets: 4).',
'7. EFFORT: for CARDIO exercises set `gear` (1-5 breathing gear) and leave `rpe` null. For everything else set `rpe` (1-10) and leave `gear` null.',
'8. Use `durationSeconds` instead of `reps` for timed work (holds, carries, intervals).',
'9. `notes` is for a short coaching cue — one sentence, optional.',
'10. Keep it to a single realistic session (typically 3-8 exercises). Do NOT invent multiple days or weeks — this is ONE workout.',
);
if (opts.hasHistoryContext) {
lines.push(
'',
'# USING THE HISTORY BLOCK',
'',
"The HISTORY block below summarizes the user's last 90 days. Use it to:",
'- Set `suggestedWeight` near their recent working weights for that exercise, NOT round numbers from nowhere.',
'- Nudge progressive overload where appropriate (small jump if a lift is moving; hold or deload if STAGNANT).',
'- Match the rep ranges and effort they tend to train at.',
"- If an exercise they named has no history, estimate conservatively and say so in `notes`.",
);
} else {
lines.push(
'',
'# WEIGHT GUIDANCE WITHOUT HISTORY',
'',
`Without prior data, set conservative \`suggestedWeight\` values (round gym increments; 5${opts.weightUnit} jumps, 2.5${opts.weightUnit} for small accessories) and add a coaching note like "adjust to leave 2-3 reps in reserve" so the user knows it's a starting estimate.`,
);
}
if (opts.priorWorkoutJson) {
lines.push(
'',
'# REVISION MODE',
'',
'The user already has the workout below and wants you to change it. Apply their requested change and re-emit the COMPLETE revised workout as one JSON object (not a diff). Keep everything they did not ask to change.',
'',
'CURRENT WORKOUT:',
opts.priorWorkoutJson,
);
}
if (opts.isLocalModel) {
lines.push(
'',
'# LOCAL MODEL REMINDER',
'',
'You are running locally with limited reasoning. Build the simplest valid single-session workout that matches the request. Do not overthink. JSON only.',
);
}
return lines.join('\n');
}
+124
View File
@@ -0,0 +1,124 @@
import { z } from 'zod';
import { extractJson } from './programSchema';
/**
* The shape we ask LLMs to produce for a SINGLE day's workout (the
* "generate today's workout" flow). Distinct from the multi-week
* AIProgram in programSchema.ts.
*
* This does NOT map onto a DB table directly: the user reviews/edits the
* suggestion, then it pre-populates the normal New Workout form (nothing
* is persisted until they save through the regular workout path). So the
* shape is optimized for "pre-fill a logger" not "INSERT a Program".
*
* Per exercise we ask for a working `sets` count plus a single target
* `reps` / `suggestedWeight` — the hand-off expands that into N identical
* pre-filled SetLogs. (No warmup/ramping distinction in v1.)
*
* `exerciseId` is nullable: the model picks from the user's library when
* it can, but may suggest something not in the library (the preview
* prompts the user to map it). `exerciseName` is REQUIRED as the display
* label + fuzzy-match fallback.
*/
export const aiWorkoutExerciseSchema = z.object({
exerciseId: z.string().nullable(),
exerciseName: z.string().min(1),
order: z.number().int().nonnegative(),
/// Number of working sets to pre-fill. Defaults to 3 in the hand-off
/// if the model omits it.
sets: z.number().int().positive().optional().nullable(),
/// Target reps per set (the user overwrites with what they actually
/// did). Omit for time/distance-based work.
reps: z.number().int().positive().optional().nullable(),
/// Suggested working weight. Null for cardio / bodyweight / stretching.
suggestedWeight: z.number().nonnegative().optional().nullable(),
/// "lbs" | "kg". Optional; hand-off falls back to the user's
/// defaultWeightUnit when null.
suggestedWeightUnit: z.enum(['lbs', 'kg']).optional().nullable(),
/// Strength effort (1-10). The hand-off keeps this only for non-cardio
/// exercises (cardio uses `gear`).
rpe: z.number().int().min(1).max(10).optional().nullable(),
/// Cardio breathing gear (1-5). The hand-off keeps this only for
/// cardio exercises (strength uses `rpe`).
gear: z.number().int().min(1).max(5).optional().nullable(),
/// Target duration in seconds for time-based work (e.g. a hold).
durationSeconds: z.number().int().positive().optional().nullable(),
notes: z.string().optional().nullable(),
});
export const aiWorkoutSchema = z.object({
name: z.string().min(1),
notes: z.string().optional().nullable(),
exercises: z.array(aiWorkoutExerciseSchema),
});
export type AIWorkout = z.infer<typeof aiWorkoutSchema>;
export type AIWorkoutExercise = z.infer<typeof aiWorkoutExerciseSchema>;
/**
* JSON-schema-ish doc pasted into the system prompt so the model knows
* the exact shape to emit (same approach as PROGRAM_OUTPUT_SHAPE — not a
* provider "structured output" mode, since Ollama support is uneven).
*/
export const WORKOUT_OUTPUT_SHAPE = `{
"name": "<string, e.g. Upper Body — Shoulder Focus>",
"notes": "<string, optional, one-line session summary>",
"exercises": [
{
"exerciseId": "<string — REQUIRED — must be an id from the LIBRARY block. If no library exercise fits, pick the closest match and explain in notes; do NOT invent ids.>",
"exerciseName": "<string, the canonical name from the library>",
"order": <int >= 0>,
"sets": <int >= 1, number of working sets>,
"reps": <int, target reps per set; omit for time/distance work>,
"suggestedWeight": <number, working weight in the exercise's LIBRARY \`unit\`; omit/null for cardio, bodyweight, stretching>,
"suggestedWeightUnit": "<\\"lbs\\" | \\"kg\\", optional — match the exercise's \`unit\` from the LIBRARY>",
"rpe": <int 1-10, strength effort; use for NON-cardio exercises>,
"gear": <int 1-5, cardio breathing gear; use for CARDIO exercises instead of rpe>,
"durationSeconds": <int, optional, for timed holds/intervals>,
"notes": "<string, optional, short coaching cue>"
}
]
}`;
/**
* Parse + validate a model's raw response into an AIWorkout. Returns a
* clean workout or a structured error. Mirrors parseAIProgram.
*/
export function parseAIWorkout(
raw: string,
):
| { ok: true; workout: AIWorkout }
| { ok: false; reason: string; json?: string } {
const json = extractJson(raw);
if (!json) {
return {
ok: false,
reason: 'Could not find a JSON object in the response.',
};
}
let obj: unknown;
try {
obj = JSON.parse(json);
} catch (e) {
return {
ok: false,
reason: `JSON parse error: ${(e as Error).message}`,
json,
};
}
const result = aiWorkoutSchema.safeParse(obj);
if (!result.success) {
return {
ok: false,
reason:
'JSON did not match the expected shape: ' +
result.error.errors
.slice(0, 5)
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join('; '),
json,
};
}
return { ok: true, workout: result.data };
}
+27
View File
@@ -24,6 +24,33 @@ export async function verifyPassword(
return bcrypt.compare(password, hash); return bcrypt.compare(password, hash);
} }
/**
* A valid bcrypt hash (cost 10, matching real password hashes) of a
* throwaway string. Not a secret — it verifies no real password. Its only
* job is to give the no-such-user login path something to bcrypt against.
*/
const DUMMY_PASSWORD_HASH =
"$2b$10$4Q3ukhdLWRqxvYHp4JezhuSPskBFVXvewuUhhfUML64nh4xBuYyPC";
/**
* Verify a password against a user's hash, or against a fixed dummy hash
* when `hash` is null (no user matched the email). Either way exactly one
* bcrypt.compare runs, so an unknown-email attempt costs the same wall
* time as a real one — closing the timing oracle that would otherwise let
* an attacker enumerate which emails have accounts. The null-hash path
* always returns false.
*/
export async function verifyPasswordOrDummy(
password: string,
hash: string | null,
): Promise<boolean> {
if (hash === null) {
await bcrypt.compare(password, DUMMY_PASSWORD_HASH);
return false;
}
return bcrypt.compare(password, hash);
}
/** /**
* Create a session token for a user (30-day expiration). * Create a session token for a user (30-day expiration).
* *
+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"
);
}
+32
View File
@@ -0,0 +1,32 @@
import { prisma } from "./prisma";
/**
* Return the subset of `exerciseIds` that do NOT belong to `userId`.
*
* Exercises are per-user (`Exercise.userId`, `@@unique([userId, name])`) —
* even curated-library entries are copied per account. Routes that write
* SetLogs from a client-supplied exerciseId must check ownership first;
* otherwise a user could attach another user's exercise to their own
* workout, which leaks that exercise's name/notes back on fetch and wires
* up a cross-user `onDelete: Cascade` dependency.
*
* Unknown ids and ids owned by someone else are deliberately
* indistinguishable in the result, so a caller's 400 can't be used to
* probe which exerciseIds exist. An empty input returns an empty array
* (no query).
*/
export async function findUnownedExerciseIds(
userId: string,
exerciseIds: Iterable<string>,
): Promise<string[]> {
const ids = Array.from(new Set(exerciseIds));
if (ids.length === 0) return [];
const owned = await prisma.exercise.findMany({
where: { userId, id: { in: ids } },
select: { id: true },
});
const ownedIds = new Set(owned.map((e) => e.id));
return ids.filter((id) => !ownedIds.has(id));
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Run a server action, retrying it ONCE if the call rejects at the
* transport layer.
*
* iOS Safari (and Safari generally) frequently drops the first POST sent
* on a keep-alive socket that the server closed while the connection sat
* idle — e.g. while the user typed their credentials. The request fails
* instantly with `NSURLErrorNetworkConnectionLost` ("The network
* connection was lost", -1005); a retry lands on a fresh connection and
* succeeds. This is why a first login/signup tap shows "An unexpected
* error occurred" and the second tap works.
*
* Only a *thrown* rejection is retried. A server action that returns a
* value — including an application-level `{ error }` ("Invalid email or
* password", a rate-limit message) — is a real result and passes
* straight through untouched. A lost-on-a-stale-socket POST never
* reached the server, so retrying it once is safe.
*/
export async function retryOnTransportError<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch {
return await fn();
}
}
+12 -3
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())
@@ -420,13 +422,20 @@ model AIGeneration {
userInput String userInput String
systemPrompt String systemPrompt String
userPrompt String userPrompt String
/// What this generation produces: "program" (multi-week Program) or
/// "workout" (a single day's workout the user pre-fills the log from).
/// Drives which parser the runner uses and which UI consumes the row.
/// Defaults to "program" so legacy rows read correctly post-migration.
kind String @default("program")
/// Streamed-so-far text. Updated periodically by the background /// Streamed-so-far text. Updated periodically by the background
/// generator so navigating-away clients can resume display via /// generator so navigating-away clients can resume display via
/// polling. Final value matches `rawResponse` once status flips /// polling. Final value matches `rawResponse` once status flips
/// to 'completed' or 'failed'. /// to 'completed' or 'failed'.
progressText String? progressText String?
rawResponse String? rawResponse String?
parsedProgram String? // JSON.stringify of the parsed structure /// JSON.stringify of the parsed structure. An AIProgram when
/// kind="program", an AIWorkout when kind="workout".
parsedProgram String?
provider String provider String
model String model String
tokensIn Int? tokensIn Int?
+103
View File
@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { buildPrefillExercises, type AiWorkoutDraft } from '@/lib/ai/workoutDraft';
// Minimal library shape buildPrefillExercises needs (id + cardio inputs).
const lib = [
{ id: 'press', type: 'barbell', muscleGroups: '["shoulders"]' },
{ id: 'bike', type: 'cardio', muscleGroups: '[]' },
// Tagged cardio via muscleGroups even though the equipment type isn't.
{ id: 'boxjump', type: 'bodyweight', muscleGroups: '["legs","cardio"]' },
];
describe('buildPrefillExercises', () => {
it('expands a strength exercise into N sets with weight+reps and RPE only', () => {
const draft: AiWorkoutDraft = {
name: 'Push',
exercises: [
{
exerciseId: 'press',
sets: 4,
reps: 6,
suggestedWeight: 95,
rpe: 8,
gear: 3, // wrong-kind value — must be dropped for non-cardio
},
],
};
const [ex] = buildPrefillExercises(draft, lib);
expect(ex.sets).toHaveLength(4);
expect(ex.sets.map((s) => s.setNumber)).toEqual([1, 2, 3, 4]);
for (const s of ex.sets) {
expect(s.weight).toBe(95);
expect(s.reps).toBe(6);
expect(s.rpe).toBe(8);
expect(s.gear).toBeUndefined();
}
});
it('uses gear (not rpe) for a cardio exercise', () => {
const draft: AiWorkoutDraft = {
name: 'Conditioning',
exercises: [
{ exerciseId: 'bike', sets: 1, durationSeconds: 600, gear: 3, rpe: 8 },
],
};
const [ex] = buildPrefillExercises(draft, lib);
expect(ex.sets[0].gear).toBe(3);
expect(ex.sets[0].rpe).toBeUndefined();
expect(ex.sets[0].durationSeconds).toBe(600);
});
it('treats an exercise tagged "cardio" in muscleGroups as cardio', () => {
const draft: AiWorkoutDraft = {
name: 'Plyo',
exercises: [{ exerciseId: 'boxjump', sets: 3, reps: 5, rpe: 7, gear: 2 }],
};
const [ex] = buildPrefillExercises(draft, lib);
expect(ex.sets[0].gear).toBe(2);
expect(ex.sets[0].rpe).toBeUndefined();
});
it('defaults to 3 sets when the count is missing or non-positive', () => {
const draft: AiWorkoutDraft = {
name: 'X',
exercises: [
{ exerciseId: 'press', sets: 0, reps: 5, suggestedWeight: 100 },
],
};
const [ex] = buildPrefillExercises(draft, lib);
expect(ex.sets).toHaveLength(3);
});
it('drops exercises whose id is not in the library', () => {
const draft: AiWorkoutDraft = {
name: 'X',
exercises: [
{ exerciseId: 'ghost', sets: 3, reps: 5 },
{ exerciseId: 'press', sets: 2, reps: 5, suggestedWeight: 100 },
],
};
const out = buildPrefillExercises(draft, lib);
expect(out).toHaveLength(1);
expect(out[0].exercise.id).toBe('press');
});
it('puts the coaching note on the first set only', () => {
const draft: AiWorkoutDraft = {
name: 'X',
exercises: [
{
exerciseId: 'press',
sets: 3,
reps: 5,
suggestedWeight: 100,
notes: 'brace hard',
},
],
};
const [ex] = buildPrefillExercises(draft, lib);
expect(ex.sets[0].notes).toBe('brace hard');
expect(ex.sets[1].notes).toBeUndefined();
expect(ex.sets[2].notes).toBeUndefined();
});
});
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { parseAIWorkout } from '@/lib/ai/workoutSchema';
describe('parseAIWorkout', () => {
const valid = {
name: 'Upper — Shoulders',
notes: 'Overhead press focus',
exercises: [
{
exerciseId: 'cabc',
exerciseName: 'Overhead Press',
order: 0,
sets: 4,
reps: 6,
suggestedWeight: 95,
suggestedWeightUnit: 'lbs',
rpe: 8,
},
{
exerciseId: 'cdef',
exerciseName: 'Assault Bike',
order: 1,
sets: 1,
durationSeconds: 600,
gear: 3,
},
],
};
it('accepts a valid single workout', () => {
const r = parseAIWorkout(JSON.stringify(valid));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.workout.name).toBe('Upper — Shoulders');
expect(r.workout.exercises).toHaveLength(2);
expect(r.workout.exercises[0].suggestedWeight).toBe(95);
expect(r.workout.exercises[1].gear).toBe(3);
}
});
it('accepts null exerciseId for unresolved exercises', () => {
const variant = structuredClone(valid);
variant.exercises[0].exerciseId = null as unknown as string;
const r = parseAIWorkout(JSON.stringify(variant));
expect(r.ok).toBe(true);
});
it('strips markdown fences and commentary', () => {
const wrapped =
"Here's today's session:\n\n```json\n" +
JSON.stringify(valid) +
'\n```\n\nEnjoy!';
const r = parseAIWorkout(wrapped);
expect(r.ok).toBe(true);
});
it('rejects when no JSON is present', () => {
const r = parseAIWorkout('the model just said hi');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/Could not find/);
});
it('rejects a parse-level syntax error inside balanced braces', () => {
const r = parseAIWorkout('{ "name": "x", }');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/parse error/i);
});
it('rejects when the shape is wrong (missing exercises)', () => {
const r = parseAIWorkout(JSON.stringify({ name: 'X' }));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/shape/);
});
it('rejects an out-of-range gear', () => {
const variant = structuredClone(valid);
variant.exercises[1].gear = 9; // gear is 1-5
const r = parseAIWorkout(JSON.stringify(variant));
expect(r.ok).toBe(false);
});
it('rejects an empty exercise name', () => {
const variant = structuredClone(valid);
variant.exercises[0].exerciseName = '';
const r = parseAIWorkout(JSON.stringify(variant));
expect(r.ok).toBe(false);
});
});
+30 -1
View File
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword } from '@/lib/auth'; import {
hashPassword,
verifyPassword,
verifyPasswordOrDummy,
} from '@/lib/auth';
// Pure-function bits of lib/auth.ts (no Prisma, no cookies). // Pure-function bits of lib/auth.ts (no Prisma, no cookies).
@@ -27,3 +31,28 @@ describe('hashPassword / verifyPassword', () => {
expect(hash.startsWith('$2')).toBe(true); expect(hash.startsWith('$2')).toBe(true);
}); });
}); });
describe('verifyPasswordOrDummy', () => {
it('verifies a correct password against a real hash', async () => {
const hash = await hashPassword('hunter2');
expect(await verifyPasswordOrDummy('hunter2', hash)).toBe(true);
});
it('rejects a wrong password against a real hash', async () => {
const hash = await hashPassword('hunter2');
expect(await verifyPasswordOrDummy('wrong', hash)).toBe(false);
});
it('returns false without throwing when there is no user (null hash)', async () => {
expect(await verifyPasswordOrDummy('anything', null)).toBe(false);
});
it('still spends bcrypt time on the null-hash path (timing-oracle guard)', async () => {
// A real cost-10 bcrypt.compare is tens of ms; a path that skipped
// bcrypt would return in well under 1ms. 5ms is a safe lower bound,
// so this fails if someone removes the dummy compare.
const start = Date.now();
await verifyPasswordOrDummy('anything', null);
expect(Date.now() - start).toBeGreaterThan(5);
});
});
+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);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest';
import { retryOnTransportError } from '@/lib/retryAction';
describe('retryOnTransportError', () => {
it('returns the result without retrying when the call succeeds', async () => {
const fn = vi.fn().mockResolvedValue({ success: true });
const result = await retryOnTransportError(fn);
expect(result).toEqual({ success: true });
expect(fn).toHaveBeenCalledTimes(1);
});
it('passes through an application-level { error } without retrying', async () => {
// A returned error (bad password, rate limit) is a real result, not a
// transport failure — it must not trigger a retry.
const fn = vi.fn().mockResolvedValue({ error: 'Invalid email or password' });
const result = await retryOnTransportError(fn);
expect(result).toEqual({ error: 'Invalid email or password' });
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries once on a thrown transport error and returns the second result', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new TypeError('The network connection was lost'))
.mockResolvedValueOnce({ success: true });
const result = await retryOnTransportError(fn);
expect(result).toEqual({ success: true });
expect(fn).toHaveBeenCalledTimes(2);
});
it('rejects after a single retry when both attempts throw', async () => {
const fn = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
await expect(retryOnTransportError(fn)).rejects.toThrow('Failed to fetch');
expect(fn).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
const { getCurrentUserMock } = vi.hoisted(() => ({
getCurrentUserMock: vi.fn(),
}));
vi.mock('@/lib/auth', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, getCurrentUser: getCurrentUserMock };
});
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
import { POST } from '@/app/api/ai/generate-workout/route';
const URL = 'http://x/api/ai/generate-workout';
function jsonReq(body: unknown): NextRequest {
return new NextRequest(URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
} as ConstructorParameters<typeof NextRequest>[1]);
}
function rawReq(rawBody: string): NextRequest {
return new NextRequest(URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: rawBody,
} as ConstructorParameters<typeof NextRequest>[1]);
}
async function makeUser(email: string) {
return prisma.user.create({
data: { email, passwordHash: 'fake', isAdmin: false },
});
}
beforeEach(async () => {
await prisma.aIGeneration.deleteMany();
await prisma.userPreferences.deleteMany();
await prisma.user.deleteMany();
getCurrentUserMock.mockReset();
});
describe('POST /api/ai/generate-workout — auth + validation', () => {
// These all return BEFORE the background runner is kicked off, so no
// real provider call happens. We deliberately don't exercise the 201
// path (it would spawn a detached generation).
it('401 when unauthenticated', async () => {
getCurrentUserMock.mockResolvedValue(null);
const res = await POST(jsonReq({ userInput: 'upper body' }));
expect(res.status).toBe(401);
});
it('400 on malformed JSON (not 500)', async () => {
const user = await makeUser('a@x');
getCurrentUserMock.mockResolvedValue(user);
const res = await POST(rawReq('{ not valid json'));
expect(res.status).toBe(400);
});
it('400 when userInput is missing', async () => {
const user = await makeUser('b@x');
getCurrentUserMock.mockResolvedValue(user);
const res = await POST(jsonReq({ includeHistory: true }));
expect(res.status).toBe(400);
});
it('400 when userInput is empty', async () => {
const user = await makeUser('c@x');
getCurrentUserMock.mockResolvedValue(user);
const res = await POST(jsonReq({ userInput: '' }));
expect(res.status).toBe(400);
});
it('400 with a malformed priorWorkout (fails the shared schema)', async () => {
const user = await makeUser('d@x');
getCurrentUserMock.mockResolvedValue(user);
// priorWorkout missing required `exercises` array → schema rejects.
const res = await POST(
jsonReq({ userInput: 'tweak it', priorWorkout: { name: 'X' } }),
);
expect(res.status).toBe(400);
});
it('400 when the user has no AI provider configured', async () => {
const user = await makeUser('e@x');
getCurrentUserMock.mockResolvedValue(user);
// Valid body, but no UserPreferences row → not configured.
const res = await POST(jsonReq({ userInput: 'upper body, shoulders' }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/not configured/i);
});
});
+233
View File
@@ -13,6 +13,9 @@ import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { GET as getExercises, POST as postExercise } from '@/app/api/exercises/route'; import { GET as getExercises, POST as postExercise } from '@/app/api/exercises/route';
import { POST as postWorkout, GET as getWorkouts } from '@/app/api/workouts/route'; import { POST as postWorkout, GET as getWorkouts } from '@/app/api/workouts/route';
import { POST as postSets } from '@/app/api/workouts/[id]/sets/route';
import { PATCH as patchWorkout } from '@/app/api/workouts/[id]/route';
import { POST as postImportSave } from '@/app/api/workouts/import/save/route';
// `NextRequest` accepts a slightly stricter RequestInit (no `signal: // `NextRequest` accepts a slightly stricter RequestInit (no `signal:
// null`), so cast the standard RequestInit to the constructor's // null`), so cast the standard RequestInit to the constructor's
@@ -299,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({
@@ -321,4 +400,158 @@ describe('POST /api/workouts', () => {
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/invalid/i); expect(body.error).toMatch(/invalid/i);
}); });
it("rejects a set referencing another user's exerciseId with 400", async () => {
const alice = await makeUser({ email: 'a@x' });
const bob = await makeUser({ email: 'b@x' });
const bobExercise = await prisma.exercise.create({
data: { userId: bob.id, name: 'Bob Squat', type: 'barbell', muscleGroups: '[]' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await postWorkout(
jsonReq('http://x/api/workouts', {
name: 'Steal attempt',
sets: [
{ exerciseId: bobExercise.id, setNumber: 1, reps: 5, weightUnit: 'lbs' },
],
}),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/library/i);
// Nothing was written — the guard runs before any create.
expect(await prisma.workout.count()).toBe(0);
expect(await prisma.setLog.count()).toBe(0);
});
});
describe('POST /api/workouts/[id]/sets', () => {
it("rejects adding sets for another user's exerciseId with 400", async () => {
const alice = await makeUser({ email: 'a@x' });
const bob = await makeUser({ email: 'b@x' });
const aliceWorkout = await prisma.workout.create({
data: { userId: alice.id, date: new Date(), name: 'Leg Day' },
});
const bobExercise = await prisma.exercise.create({
data: { userId: bob.id, name: 'Bob Squat', type: 'barbell', muscleGroups: '[]' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await postSets(
jsonReq('http://x/api/workouts/' + aliceWorkout.id + '/sets', {
exerciseId: bobExercise.id,
sets: [{ setNumber: 1, reps: 5, weightUnit: 'lbs' }],
}),
{ params: Promise.resolve({ id: aliceWorkout.id }) },
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/library/i);
expect(await prisma.setLog.count()).toBe(0);
});
});
describe('PATCH /api/workouts/[id]', () => {
it("rejects replacing sets with another user's exerciseId (400, nothing written)", async () => {
const alice = await makeUser({ email: 'a@x' });
const bob = await makeUser({ email: 'b@x' });
const aliceWorkout = await prisma.workout.create({
data: { userId: alice.id, date: new Date(), name: 'Day' },
});
const bobExercise = await prisma.exercise.create({
data: { userId: bob.id, name: 'Bob Squat', type: 'barbell', muscleGroups: '[]' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await patchWorkout(
jsonReq(
'http://x/api/workouts/' + aliceWorkout.id,
{
sets: [
{ exerciseId: bobExercise.id, setNumber: 1, reps: 5, weightUnit: 'lbs' },
],
},
{ method: 'PATCH' },
),
{ params: Promise.resolve({ id: aliceWorkout.id }) },
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/library/i);
// The guard runs before the set-replace transaction.
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', () => {
it("rejects an existingExerciseId owned by another user (400)", async () => {
const alice = await makeUser({ email: 'a@x' });
const bob = await makeUser({ email: 'b@x' });
const bobExercise = await prisma.exercise.create({
data: { userId: bob.id, name: 'Bob Squat', type: 'barbell', muscleGroups: '[]' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await postImportSave(
jsonReq('http://x/api/workouts/import/save', {
workouts: [
{
date: new Date().toISOString(),
exercises: [
{ name: 'Squat', existingExerciseId: bobExercise.id, sets: [{ reps: 5 }] },
],
},
],
}),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/library/i);
expect(await prisma.workout.count()).toBe(0);
});
}); });
+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;
}; };
+17
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;"
@@ -211,6 +221,13 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
sqlite3 "$DB_PATH" "ALTER TABLE AIGeneration ADD COLUMN durationMs INTEGER;" sqlite3 "$DB_PATH" "ALTER TABLE AIGeneration ADD COLUMN durationMs INTEGER;"
fi fi
# v1.2.0:6: single-workout generation. `kind` discriminates program vs
# workout rows; defaults to "program" so existing rows read correctly.
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('AIGeneration');" 2>/dev/null | grep -q "|kind|"; then
log "adding AIGeneration.kind (default 'program')"
sqlite3 "$DB_PATH" "ALTER TABLE AIGeneration ADD COLUMN kind TEXT NOT NULL DEFAULT 'program';"
fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('ProgramExercise');" 2>/dev/null | grep -q "|suggestedWeight|"; then if ! sqlite3 "$DB_PATH" "PRAGMA table_info('ProgramExercise');" 2>/dev/null | grep -q "|suggestedWeight|"; then
log "adding ProgramExercise.suggestedWeight + suggestedWeightUnit" log "adding ProgramExercise.suggestedWeight + suggestedWeightUnit"
sqlite3 "$DB_PATH" "ALTER TABLE ProgramExercise ADD COLUMN suggestedWeight REAL;" sqlite3 "$DB_PATH" "ALTER TABLE ProgramExercise ADD COLUMN suggestedWeight REAL;"
+31 -1
View File
@@ -16,6 +16,11 @@ import { v_1_1_0_7 } from './v1.1.0.7'
import { v_1_1_0_8 } from './v1.1.0.8' import { v_1_1_0_8 } from './v1.1.0.8'
import { v_1_1_0_9 } from './v1.1.0.9' 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_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'
import { v_1_2_0_6 } from './v1.2.0.6'
/** /**
* Version graph for the `proof-of-work` package. * Version graph for the `proof-of-work` package.
@@ -61,9 +66,29 @@ import { v_1_2_0_1 } from './v1.2.0.1'
* v1.2.0:1 — Next.js 14 -> 15 / React 18 -> 19 upgrade. Closes the Next * v1.2.0:1 — Next.js 14 -> 15 / React 18 -> 19 upgrade. Closes the Next
* framework RSC + middleware-bypass CVEs; async-params migration * framework RSC + middleware-bypass CVEs; async-params migration
* across all [id] routes + server pages. No schema/data change. * across all [id] routes + server pages. No schema/data change.
* v1.2.0:2 — Login/signup first-tap retry: iOS Safari drops the first
* server-action POST on a stale keep-alive socket
* (NSURLErrorNetworkConnectionLost); retry once on transport
* failure. Client-only, no schema/data change.
* v1.2.0:3 — P3 hardening: close the login timing oracle (dummy-hash
* bcrypt on unknown email) and enforce exerciseId ownership on
* workout create/PATCH/add-sets + CSV-import-save (shared
* 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.
* v1.2.0:6 — AI "generate today's workout": describe a single session and get
* a ready-to-log workout (suggested weights/reps from history),
* inline-edit + refine-with-AI, then pre-fill the workout log.
* Reuses the generation spine via a new AIGeneration.kind
* discriminant (boot ALTER, default "program"). No data changes.
*/ */
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_1_2_0_1, current: v_1_2_0_6,
other: [ other: [
v_1_0_0_1, v_1_0_0_1,
v_1_0_0_2, v_1_0_0_2,
@@ -81,5 +106,10 @@ export const versionGraph = VersionGraph.of({
v_1_1_0_7, v_1_1_0_7,
v_1_1_0_8, v_1_1_0_8,
v_1_1_0_9, v_1_1_0_9,
v_1_2_0_1,
v_1_2_0_2,
v_1_2_0_3,
v_1_2_0_4,
v_1_2_0_5,
], ],
}) })
+32
View File
@@ -0,0 +1,32 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:2 — Login/signup first-tap retry on Safari (2026-06-15).
*
* Fixes a long-standing "first Sign In fails with 'An unexpected error
* occurred', second works" report from iOS Safari. The error string is
* the client-side catch in LoginForm/SignupForm — i.e. the server-action
* POST itself rejected at the transport layer, not any login-logic path
* (those return a clean { error }). Cause: iOS Safari reuses a keep-alive
* socket the server closed while the form sat idle during typing, so the
* first POST dies instantly with NSURLErrorNetworkConnectionLost ("The
* network connection was lost"); a retry lands on a fresh connection.
*
* Fix is client-only: a shared retryOnTransportError() helper retries the
* action ONCE when the call throws (a returned { error } is a real result
* and passes straight through). A stale-socket POST never reached the
* server, so the retry is safe.
*
* App-code only — no schema, no API contract change, no data migration.
*/
export const v_1_2_0_2 = VersionInfo.of({
version: '1.2.0:2',
releaseNotes: {
en_US:
'Fixes the occasional "An unexpected error occurred" on the first Sign In / Create account tap (most common in Safari on iPhone/iPad) — the form now retries automatically, so logging in works on the first try. No data changes.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})
+35
View File
@@ -0,0 +1,35 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:3 — P3 hardening: login timing oracle + exerciseId ownership (2026-06-15).
*
* Two multi-user hardening fixes from the 2026-06-13 full-eval P3 batch:
*
* 1. Login timing oracle. Both login paths (the UI server action and
* POST /api/auth) returned immediately when no user matched the email,
* but ran bcrypt.compare when one did — so response latency revealed
* which emails have accounts. Now an unknown email is compared against
* a fixed dummy hash (lib/auth verifyPasswordOrDummy), so every attempt
* spends one bcrypt regardless.
*
* 2. exerciseId ownership. Exercises are per-user, but the workout
* create/PATCH/add-sets and CSV-import-save routes wrote SetLogs from a
* client-supplied exerciseId without checking ownership — letting a user
* attach another user's exercise to their own workout (leaking its
* name/notes on fetch + a cross-user cascade-delete link). All four now
* reject unowned ids with 400 via the shared lib/exerciseOwnership
* helper (the same check programs-create already did, now centralized).
*
* App-code only — no schema, no API contract change, no data migration.
*/
export const v_1_2_0_3 = VersionInfo.of({
version: '1.2.0:3',
releaseNotes: {
en_US:
'Security hardening: login no longer leaks (via response timing) whether an email has an account, and workouts can only reference exercises from your own library. No data changes.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})
+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,
},
})
+32
View File
@@ -0,0 +1,32 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:6 — AI "generate today's workout" (2026-06-19).
*
* A new AI flow alongside program generation: describe a single session in
* plain words and get a ready-to-log workout back — exercises with suggested
* weights + target reps + set counts grounded in recent history. Inline-edit
* it, send a follow-up to refine it via the model, then "Use this workout" to
* pre-fill the normal New Workout form (nothing persists until you save).
*
* Reuses the existing generation spine (detached runner / SSE / lenient JSON /
* providers / history context) via a new AIGeneration.kind discriminant
* ("program" | "workout"). Single-workout rows are ephemeral and excluded from
* the program-shaped AI history.
*
* Additive schema change: the new AIGeneration.kind column (default "program")
* is added by the boot-time guarded ALTER in docker_entrypoint.sh, so this
* migration stays empty like every other column add. Existing rows read as
* "program"; no data changes.
*/
export const v_1_2_0_6 = VersionInfo.of({
version: '1.2.0:6',
releaseNotes: {
en_US:
'New: AI "Today\'s workout". Describe a session in plain words and get a ready-to-log workout with suggested weights and reps from your history. Edit it, refine it with the AI, then pre-fill the workout log. No data changes.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})