Compare commits
18 Commits
00a4b704e8
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d4557304a5 | |||
| 891bf09d7e | |||
| 794070a1d8 | |||
| 91b5b04d97 | |||
| b2587d767b | |||
| 7fda9ceb7e | |||
| a36ca12318 | |||
| 2b0abad68e | |||
| 0401a831b7 | |||
| d1bc895e5e | |||
| 184382f75c | |||
| 38503436e1 | |||
| 4be489d6d3 | |||
| ef3d079ca2 | |||
| 486dcb3773 | |||
| 390aaf556e | |||
| 4d1f9126b0 | |||
| f540a473ef |
@@ -5,6 +5,9 @@ Self-hosted multi-user workout logger (Next.js app) packaged as a StartOS 0.4 `s
|
|||||||
> **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 `(proof-of-work)` 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`.
|
||||||
|
|
||||||
|
> **Design:** before building or changing any user-facing UI, read `design/DESIGN.md` and
|
||||||
|
> `design/tokens.tokens.json` and conform to them.
|
||||||
|
|
||||||
## Stack (versions that matter)
|
## Stack (versions that matter)
|
||||||
|
|
||||||
- **Next.js 15** (App Router, server components + server actions, SSE streaming) — dynamic request APIs are async (see Conventions)
|
- **Next.js 15** (App Router, server components + server actions, SSE streaming) — dynamic request APIs are async (see Conventions)
|
||||||
@@ -23,7 +26,7 @@ proof-of-work/ ← the Next.js app (THIS is where you run npm)
|
|||||||
app/main/ ← authed UI; navigation.tsx = sidebar
|
app/main/ ← authed UI; navigation.tsx = sidebar
|
||||||
components/ ← React components (workouts/, ai/, settings/)
|
components/ ← React components (workouts/, ai/, settings/)
|
||||||
lib/ai/ ← AI subsystem (see below)
|
lib/ai/ ← AI subsystem (see below)
|
||||||
lib/ai/providers/ ← claude.ts openai.ts gemini.ts ollama.ts + index.ts (getProvider; openai.ts exports both openai + openai-compatible = 5 registered providers)
|
lib/ai/providers/ ← claude.ts openai.ts gemini.ts ollama.ts sparkcontrol.ts + index.ts (getProvider; openai.ts exports both openai + openai-compatible = 6 registered providers)
|
||||||
prisma/schema.prisma ← schema (mirror; real DB migrates via entrypoint ALTERs)
|
prisma/schema.prisma ← schema (mirror; real DB migrates via entrypoint ALTERs)
|
||||||
prisma/*.seed.json ← curated exercise library + AI templates (reconciled each boot)
|
prisma/*.seed.json ← curated exercise library + AI templates (reconciled each boot)
|
||||||
tests/ ← Vitest specs (ai-*.test.ts, routes-*.test.ts, ...)
|
tests/ ← Vitest specs (ai-*.test.ts, routes-*.test.ts, ...)
|
||||||
@@ -31,6 +34,7 @@ start9/0.4/ ← StartOS packaging wrapper
|
|||||||
docker_entrypoint.sh ← boot: first-boot seed, additive ALTERs, library reconcile
|
docker_entrypoint.sh ← boot: first-boot seed, additive ALTERs, library reconcile
|
||||||
Makefile / s9pk.mk ← s9pk build (ARCHES := x86)
|
Makefile / s9pk.mk ← s9pk build (ARCHES := x86)
|
||||||
startos/versions/ ← one file per ExVer version + index.ts (the version graph)
|
startos/versions/ ← one file per ExVer version + index.ts (the version graph)
|
||||||
|
design/ ← UI design contract: DESIGN.md + tokens.tokens.json (read before UI work); brand/ inspiration/
|
||||||
~/.proof-of-work/ ← publish.sh + unpublish.sh (NOT in repo; self-hosted registry)
|
~/.proof-of-work/ ← publish.sh + unpublish.sh (NOT in repo; self-hosted registry)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -63,6 +67,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 +81,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`, 1–5); everything else logs **RPE** (`SetLog.rpe`, 6–10). The switch is `isCardioExercise(exercise)` (`lib/exerciseOptions.ts`): `type === "cardio"` OR `muscleGroups` contains "cardio". `SetRow` takes an `isCardio` prop (from `WorkoutForm`) and renders one; both are always emitted (the hidden one stays empty). Distinct from program/AI **target**-RPE (`ProgramExercise.rpe`), which is unrelated and unaffected.
|
||||||
- **Commit subject** = `vX.Y.Z:N — short summary`, imperative, body explains the *why*.
|
- **Commit subject** = `vX.Y.Z:N — short summary`, imperative, body explains the *why*.
|
||||||
- **Git remote is self-hosted** (a private Start9 registry + a FileBrowser artifact host), NOT GitHub. The actual registry/file-host URLs are constants in `~/.proof-of-work/{publish,unpublish}.sh`; FileBrowser creds live in `~/.keysat/filebrowser.env` (outside the repo, gitignored). Default branch is `master`.
|
- **Git remote is self-hosted** (a private Start9 registry + a FileBrowser artifact host), NOT GitHub. The actual registry/file-host URLs are constants in `~/.proof-of-work/{publish,unpublish}.sh`; FileBrowser creds live in `~/.keysat/filebrowser.env` (outside the repo, gitignored). Default branch is `master`.
|
||||||
- **Authorization tiers**: whole-instance routes (`settings/{export,import}-db`) are **admin-only** (`!user.isAdmin → 403`); per-user data routes scope by `user.id`. Custom-URL AI providers (Ollama, OpenAI-compatible — anything with `requiresBaseUrl`) are **admin-only** (SSRF surface); fixed-URL cloud providers (claude/openai/gemini) stay per-user. Gate the server route AND hide the control in the UI.
|
- **Authorization tiers**: whole-instance routes (`settings/{export,import}-db`) are **admin-only** (`!user.isAdmin → 403`); per-user data routes scope by `user.id`. Custom-URL AI providers (Ollama, OpenAI-compatible — anything with `requiresBaseUrl`) are **admin-only** (SSRF surface); fixed-URL cloud providers (claude/openai/gemini) stay per-user. Gate the server route AND hide the control in the UI.
|
||||||
|
- **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,20 +116,26 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
Latest version is **1.2.0:2** — **login/signup first-tap retry** for iOS Safari (Safari drops the first server-action POST on a stale keep-alive socket → `NSURLErrorNetworkConnectionLost` → client catch showed "An unexpected error occurred"; the new `lib/retryAction.ts` retries the action once on a *thrown* transport failure, while a returned `{ error }` passes through). **Built + sideloaded** to the StartOS box (`immense-voyage.local`, 2026-06-15, on `master`) as `proof-of-work_x86_64.s9pk` (80M, git `0178f8f`). Verified locally before build: tsc clean (app + packaging), lint clean (only pre-existing warnings), **213 tests pass**, `next build` succeeds. Registry empty, **publishing parked** (sideload-only via `make install`).
|
Latest version is **1.2.0:9** — the **SparkControl + local-model arc is complete and confirmed working on-box** (smoke-tested with `RedHatAI/Qwen3.6-35B-A3B-NVFP4`: connected, model auto-detected, `$0`/FREE). Three ships:
|
||||||
|
- **:7** added **SparkControl (local)**, a 6th AI provider — the operator's own self-hosted local-inference gateway. OpenAI-compatible (reuses `generateOpenAIStyle`), **keyless** (`requireApiKey:false` → no `Authorization` header), reached over the **internal same-box address** `http://spark-control.startos:9999/v1` (plain HTTP, no TLS/cert-skip), model auto-detected via `/api/endpoints` (`app/api/ai/sparkcontrol/model`, admin-only + SSRF-guarded). Plus a **base-URL footgun fix**: a custom URL could attach to a fixed-URL provider (claude/openai/gemini) and be silently ignored — both config write paths now null `baseUrl` for non-custom-URL providers + the form clears it on provider change.
|
||||||
|
- **:8** made AI parsing **decimal-tolerant** — `looseInt` (`programSchema.ts`, used by `workoutSchema.ts`) rounds a float (e.g. a half-step `rpe:7.5`) before the zod `.int()` check, on every integer field in both schemas. A local model had failed the whole parse on one decimal.
|
||||||
|
- **:9** added **fuzzy exercise→library matching** (`lib/ai/exerciseMatch.ts`): when the model's `exerciseId` misses, normalize the name (strip `(barbell)` etc.) and auto-map **unique confident** matches in both generate flows (`Overhead Press` → `Overhead Press (barbell)`); ambiguous names stay manual.
|
||||||
|
|
||||||
Prior shipped: **1.2.0:1** — Next.js 14→15 / React 18→19 upgrade (closed the Next RSC + middleware-bypass CVEs; async-params migration). No schema/data change in either release.
|
Whole arc is **client/parse-only — no schema/data change** (`AIConfigProfile.provider` is free-text). Mechanics in `docs/guides/ai-subsystem.md` (provider abstraction + "adding a provider" fan-out checklist). **Built + sideloaded** (`immense-voyage.local`, 2026-06-19, `master`) as `proof-of-work_x86_64.s9pk` (80M); tsc clean (app + packaging), lint clean (pre-existing only), **274 tests pass**, `next build` + s9pk build succeed. Registry empty, **publishing parked** (sideload-only via `make install`).
|
||||||
|
|
||||||
**Pending on-box check:** confirm 1.2.0:2 boots clean in StartOS → Logs, **and** the real first-tap proof — log in from Safari on iPhone/iPad and confirm the *first* Sign In tap now works (the retry can't be unit-tested against a live stale socket). If a first tap still occasionally fails, grab the Safari Web Inspector error (iPad→Mac) to confirm it's `-1005` vs a proxy↔container keep-alive mismatch. This also still covers the 1.1.0:9 non-root clean-boot check (entrypoint logs `launching … as nextjs`, app writes `/data` as uid 1001 with no permission errors).
|
**Design contract established this session (2026-06-19, committed `7fda9ce`, no UI code changed):** the `design/` folder now holds the durable contract — `DESIGN.md` (9-section brief) + `tokens.tokens.json` (DTCG) + `brand/palette.css` + `inspiration/` provenance. From a Case-B *document-as-is* extract of the as-built dark UI: **monochrome gym-brutalist** (`#0A0A0A` canvas, zinc-only neutral, white primary button, Bebas-uppercase/tracked headings, flat border-based depth), plus two owner calls — **red elevated to the single brand accent `#DC2626`** and a **two-tier radius** (4px controls / 8px containers). AGENTS.md carries the read-before-UI Design line; `ROADMAP.md` → **Design** holds the `design-checker` cleanup backlog (gray→zinc, green→emerald, yellow→amber, `rounded-md`→`rounded`, overlay-only shadows, and a shared `<Button>`). **Read `design/DESIGN.md` before any UI work.**
|
||||||
|
|
||||||
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).
|
**Confirmed on-box (2026-06-19, via `start-cli`):** box runs `1.2.0:9`; launched `as nextjs` with no errors, "Ready in 222ms", and (correctly) **no migration ran** (none of :7–:9 add a column). Operator is generating workouts through SparkControl successfully. Recent prior ships (1.2.0 line): **1.2.0:6** AI "today's workout"; **1.2.0:5** Gear replaces RPE for cardio; **1.2.0:4** watts as first-class set field.
|
||||||
|
|
||||||
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`).
|
**On-box follow-up:** the operator is now on the SparkControl config; the old misconfigured `gemini`+`baseUrl` profile (the empty-response trigger) is no longer active — delete it at leisure (cosmetic). 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).
|
||||||
|
|
||||||
|
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (6 providers incl. **SparkControl**, multi-config, background generation, single-workout generation + refine, history detail, cost/duration, Ollama + SparkControl 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. **Design cleanup batch** (`ROADMAP.md` → Design): conform the code to the just-shipped contract — mechanical palette unifications (gray→zinc ×21, green→emerald ×10, yellow→amber ×13), radius/shadow fixes, then the durable one: extract a shared `<Button>` (the empty `components/common/` is why the white-button drift spread across 44 inline copies). All cosmetic, none ship-blocking.
|
||||||
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. Tiered AI prompt formatting (`ROADMAP.md` → AI quality).
|
||||||
|
4. (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).
|
||||||
|
|
||||||
|
|||||||
+25
-2
@@ -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,8 +33,23 @@ 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).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Cleanup backlog from the 2026-06-19 design-contract extract (`design/DESIGN.md` + `tokens.tokens.json`). The contract documents the *intended* system; these are the spots where the code diverges. All cosmetic/mechanical — none ship-blocking. Found by the `design-checker` agent (re-run it for fresh file:line lists).
|
||||||
|
|
||||||
|
- **Unify off-palette neutrals → zinc** (21 hits, 12 files): every `hover:bg-gray-100`/`active:bg-gray-200`/`hover:text-gray-200` on the white primary button → `zinc-200`/`zinc-300`. Canonical hover is `zinc-200` (`color.action.primary-hover-bg`). Worst: `app/main/workouts/page.tsx` (4), auth `LoginForm`/`SignupForm`, `dashboard/page.tsx`, `programs/page.tsx`.
|
||||||
|
- **Unify success hue → emerald** (10 hits): `green-*` → `emerald-*`. Worst: `components/settings/SettingsForm.tsx` (6), `WorkoutForm.tsx:696`, `SetRow.tsx:477`, `ExerciseCard.tsx:12`.
|
||||||
|
- **Unify warning hue → amber** (13 hits): `yellow-*` → `amber-*`. Worst: `app/main/import/page-csv.tsx` (6), `SettingsForm.tsx` (6), `ExerciseCard.tsx:14`.
|
||||||
|
- **No solid red button** (1): `components/settings/DangerZone.tsx:101` uses `bg-red-700` — convert to the ghost/wash destructive treatment (`text-red-400 border-red-800 hover:bg-red-950/30`). (Red elsewhere — 65 wash/outline/text uses — already conforms.)
|
||||||
|
- **Radius two-tier (4px control / 8px container)**: `rounded-md` (6px, 27 hits, mostly `SetRow.tsx` ×16 + `WorkoutForm.tsx` ×9) → `rounded`; `rounded-lg` on primary *buttons* (5 hits in `workouts/page.tsx`, `dashboard/page.tsx`) → `rounded`. (`rounded-full` on the FAB is fine.)
|
||||||
|
- **Shadows are overlay-only**: drop `shadow-2xl` on the static login/signup cards (`app/auth/{login,signup}/page.tsx:23`) and `shadow-lg` on the FAB + desktop CTA (`workouts/page.tsx:163,174`) — depth = bg layering + border.
|
||||||
|
- **Sub-scale font sizes** (10 hits): `text-[10px]`/`text-[11px]` (below the 12px `text-xs` floor) → `text-xs`.
|
||||||
|
- **Extract a shared `<Button>`** (`components/common/` is empty): the white primary pattern is inlined 44× across 21 files — the structural reason the `gray-100` drift spread everywhere. A single component is the durable fix and the single point to enforce the contract. (Biggest item; do after the mechanical sweeps.)
|
||||||
|
- **Token wiring decision**: `design/brand/palette.css` (`--pow-*` vars) isn't imported anywhere. For a Tailwind app the class names already *are* the tokens (hexes match the zinc/emerald/amber ramp), so the pragmatic path is to keep using Tailwind names and treat `palette.css`/`tokens.tokens.json` as the canonical cross-reference (and for any raw-CSS context). Alternatively import `palette.css` at the root and migrate the ~30 inlined `bg-[#0A0A0A]` canvas hexes to `var(--pow-bg-canvas)`. Decide before doing a hex→var sweep.
|
||||||
|
|
||||||
## Hygiene
|
## Hygiene
|
||||||
|
|
||||||
- Delete the legacy `start9/0.4/workout-log_x86_64.s9pk` build artifact; drop unused `bcryptjs` from `start9/0.4/package.json`.
|
- Delete the legacy `start9/0.4/workout-log_x86_64.s9pk` build artifact; drop unused `bcryptjs` from `start9/0.4/package.json`.
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# Proof of Work — Design Brief
|
||||||
|
|
||||||
|
The durable brand brief for Proof of Work's user-facing UI. Read this and
|
||||||
|
[`tokens.tokens.json`](./tokens.tokens.json) before building or changing any UI, and conform
|
||||||
|
to them. Machine-readable values live in the tokens file and
|
||||||
|
[`brand/palette.css`](./brand/palette.css); this file is the *why* and the rules.
|
||||||
|
|
||||||
|
> **Provenance.** Established 2026-06-19 by a **document-as-is extract** (Case B) of the
|
||||||
|
> as-built UI — there were no prior brand guidelines; the look grew in the code. Values were
|
||||||
|
> harvested by frequency census of the Tailwind classes in `proof-of-work/app` +
|
||||||
|
> `proof-of-work/components` and reconciled with the owner. The only owner-driven *elevations*
|
||||||
|
> over the literal as-built state: red promoted from error-only to a **brand accent**
|
||||||
|
> (canonical `#DC2626`), and a documented two-tier radius rule. See
|
||||||
|
> [`inspiration/README.md`](./inspiration/README.md) for what served as the reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visual theme
|
||||||
|
|
||||||
|
**Monochrome gym-brutalist.** A near-black canvas, a single cool-gray (zinc) neutral ramp,
|
||||||
|
white as the primary voice, and **red as the one accent** — the heat in an otherwise
|
||||||
|
black-and-white system. The feeling is a piece of gym equipment: heavy, blunt, high-contrast,
|
||||||
|
no ornament. The product's voice ("Track. Lift. Dominate.") shows up visually as
|
||||||
|
**UPPERCASE, wide-tracked, condensed display type** and flat, edge-defined surfaces.
|
||||||
|
|
||||||
|
It is **not** soft, pastel, glassy, or playful. No gradients-as-decoration, no drop-shadow
|
||||||
|
depth, no rounded-pill friendliness, no multi-hue palette. Restraint is the brand: color is
|
||||||
|
rare and earns its place.
|
||||||
|
|
||||||
|
## 2. Color palette
|
||||||
|
|
||||||
|
The system is **one neutral ramp (zinc) + white/black + four semantic hues**, on a near-black
|
||||||
|
canvas. Red is both the **brand accent** and the **error/destructive** hue, told apart by
|
||||||
|
*treatment*, never by a second red (see §7).
|
||||||
|
|
||||||
|
**Canvas & surfaces** (depth is built by stepping *up* in lightness, not by shadow):
|
||||||
|
|
||||||
|
| Role | Token | Hex | Notes |
|
||||||
|
|------|-------|-----|-------|
|
||||||
|
| Canvas (app background) | `color.bg.canvas` | `#0A0A0A` | The PWA `theme_color`; `<body>` bg. The anchor. |
|
||||||
|
| Surface (cards, panels) | `color.bg.surface` | `#18181B` | zinc-900 — the default raised surface. |
|
||||||
|
| Surface raised (inputs, chips, hover) | `color.bg.raised` | `#27272A` | zinc-800 — controls and the next step up. |
|
||||||
|
| Surface inset (rare deep wells) | `color.bg.inset` | `#09090B` | zinc-950 — slightly below canvas. |
|
||||||
|
|
||||||
|
**Borders** (the primary depth cue alongside bg layering):
|
||||||
|
|
||||||
|
| Role | Token | Hex |
|
||||||
|
|------|-------|-----|
|
||||||
|
| Subtle (default hairline) | `color.border.subtle` | `#27272A` (zinc-800) |
|
||||||
|
| Default | `color.border.default` | `#3F3F46` (zinc-700) |
|
||||||
|
| Strong (emphasis/hover) | `color.border.strong` | `#52525B` (zinc-600) |
|
||||||
|
|
||||||
|
**Text** (on the dark canvas):
|
||||||
|
|
||||||
|
| Role | Token | Hex |
|
||||||
|
|------|-------|-----|
|
||||||
|
| Primary | `color.text.primary` | `#FFFFFF` |
|
||||||
|
| Secondary | `color.text.secondary` | `#A1A1AA` (zinc-400) |
|
||||||
|
| Muted | `color.text.muted` | `#71717A` (zinc-500) |
|
||||||
|
| Subtle / disabled | `color.text.subtle` | `#52525B` (zinc-600) |
|
||||||
|
| Inverted (on white surfaces) | `color.text.inverted` | `#000000` |
|
||||||
|
|
||||||
|
**Accent / semantic.** The accent and error share `#DC2626`; success/warning/info round out
|
||||||
|
the state palette. On the dark canvas the *text/icon* tint is the lighter -400 step; fills and
|
||||||
|
edges use the -600 step; washes use a translucent dark step.
|
||||||
|
|
||||||
|
| Role | Token | Hex | Use |
|
||||||
|
|------|-------|-----|-----|
|
||||||
|
| **Accent / error (canonical red)** | `color.accent.red` | `#DC2626` (red-600) | Brand emphasis fills/edges **and** destructive intent. |
|
||||||
|
| Accent hover/pressed | `color.accent.red-strong` | `#B91C1C` (red-700) | |
|
||||||
|
| Red text/icon on dark | `color.accent.red-text` | `#F87171` (red-400) | Error text, destructive links, accent labels. |
|
||||||
|
| Red border | `color.accent.red-border` | `#991B1B` (red-800) | Error/destructive outlines. |
|
||||||
|
| Red wash (bg) | `color.accent.red-wash` | `rgba(127,29,29,.30)` (red-900/30) | Error banners, destructive hover. |
|
||||||
|
| Success | `color.state.success` | `#34D399` (emerald-400) text / `#059669` (emerald-600) fill | PRs, saved, positive deltas. |
|
||||||
|
| Warning | `color.state.warning` | `#FBBF24` (amber-400) text / `#78350F` (amber-900) edge | Cautions, cost/limit notices. |
|
||||||
|
| Info | `color.state.info` | `#60A5FA` (blue-400) text / `#172554` (blue-950) wash | Neutral notices (used sparingly). |
|
||||||
|
|
||||||
|
**Primary action color is not a hue — it's white.** The primary button is `#FFFFFF` bg /
|
||||||
|
`#000000` text (see §4). White, not red, is the loudest thing on screen.
|
||||||
|
|
||||||
|
## 3. Typography
|
||||||
|
|
||||||
|
Two families, both already wired as CSS variables in `app/layout.tsx`:
|
||||||
|
|
||||||
|
- **Display — Bebas Neue** (`var(--font-display)`): condensed, all-caps by nature. Used for
|
||||||
|
**all headings (h1–h3), buttons, and labels**, always `text-transform: uppercase` with
|
||||||
|
`letter-spacing: 0.05em` (Tailwind `tracking-wider`). This UPPERCASE + tracking pairing is
|
||||||
|
the single strongest brand signal — ~115 uses in the as-built UI. Don't set body copy in it.
|
||||||
|
- **Body — Space Grotesk** (`var(--font-sans)`): all running text, form values, data, numbers.
|
||||||
|
Use `tabular-nums` for stat/metric columns.
|
||||||
|
|
||||||
|
**Type scale** (Tailwind rem, the app is data-dense so the small end dominates):
|
||||||
|
|
||||||
|
| Token | Size | Typical use |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `font.size.xs` | 12px | Labels, meta, table cells (the workhorse — ~240 uses) |
|
||||||
|
| `font.size.sm` | 14px | Default body / form text (~175 uses) |
|
||||||
|
| `font.size.base` | 16px | Comfortable body |
|
||||||
|
| `font.size.lg` | 18px | Sub-headings |
|
||||||
|
| `font.size.xl` | 20px | |
|
||||||
|
| `font.size.2xl` | 24px | Section headings (Bebas) |
|
||||||
|
| `font.size.3xl` | 30px | Page headings (Bebas) |
|
||||||
|
| `font.size.4xl` | 36px | Hero / display (Bebas) |
|
||||||
|
|
||||||
|
**Weights:** 400 normal, 500 medium (default for emphasis), 600 semibold, 700 bold. Bebas
|
||||||
|
ships a single weight; weight contrast lives in the body font.
|
||||||
|
|
||||||
|
## 4. Component styling
|
||||||
|
|
||||||
|
- **Primary button** — `bg-white text-black`, `font-bold uppercase tracking-wider`,
|
||||||
|
`hover:bg-zinc-200`, `disabled:bg-zinc-700 disabled:text-zinc-500`. Radius 4px (`rounded`),
|
||||||
|
padding ≈ `px-5 py-2` (inline) / `py-3` (full-width). **This is the signature; keep it white.**
|
||||||
|
- **Secondary / accent (ghost) button** — transparent bg, `border` + text in the accent red
|
||||||
|
(`#DC2626` border, `text-red-400`), uppercase tracking. For secondary emphasis (e.g.
|
||||||
|
"Refine"). Never a *solid* red fill (see §7).
|
||||||
|
- **Destructive button** — same ghost treatment in red (`text-red-400`/`text-red-500`,
|
||||||
|
`hover:bg-red-950/30`) or a red-wash block. Destructive is red-as-*outline/wash*, so it never
|
||||||
|
competes with the white primary.
|
||||||
|
- **Cards / panels** — `bg-zinc-900`, `border border-zinc-800`, radius 8px (`rounded-lg`),
|
||||||
|
padding `p-4`. Accent a card by adding a **left edge** `border-l-4` in `#DC2626`.
|
||||||
|
- **Inputs / selects** — `bg-zinc-800` (or `bg-zinc-900`), `border border-zinc-700`, radius 4px,
|
||||||
|
white text, `placeholder` in zinc-500, focus ring `ring-white/20`–`ring-white/30`.
|
||||||
|
- **Badges / chips** — uppercase, `text-xs`, tracked; semantic ones use the wash pattern
|
||||||
|
(tinted text + matching translucent bg + matching border at ~45% — e.g. a red "PR" badge:
|
||||||
|
`text-red-400 bg-red-900/30 border border-red-800`).
|
||||||
|
- **Active nav / selected state** — accent red: `text-red-400` + a `border-b-2` underline or a
|
||||||
|
left indicator in `#DC2626`; inactive items in `text-zinc-500`.
|
||||||
|
- **Error / alert banners** — `bg-red-900/30 border border-red-800 text-red-400`, radius 8px.
|
||||||
|
|
||||||
|
## 5. Layout
|
||||||
|
|
||||||
|
- **Mobile-first PWA**, portrait-primary, installable. Most styling is unprefixed (mobile);
|
||||||
|
`sm:` is the dominant breakpoint, `md:` introduces the desktop sidebar.
|
||||||
|
- **App shell:** a fixed **240px sidebar** on `md+` (`md:pl-[var(--sidebar-width)]` via the
|
||||||
|
`.app-content` utility) and a **64px bottom nav** on mobile. Top nav height 64px. These three
|
||||||
|
dimensions are CSS vars in `globals.css` (`--sidebar-width`, `--nav-height`,
|
||||||
|
`--bottom-nav-height`) — reuse them, don't hardcode.
|
||||||
|
- **Spacing** follows Tailwind's 4px scale. Dense by intent: `p-4` cards, `px-4 py-3` controls,
|
||||||
|
`gap-2`/`gap-3` between elements.
|
||||||
|
- **Content** is the focus; chrome is minimal. Single-column on mobile; the sidebar is the only
|
||||||
|
persistent chrome on desktop.
|
||||||
|
|
||||||
|
## 6. Depth & elevation
|
||||||
|
|
||||||
|
**Flat by design — depth comes from background layering + 1px borders, not shadows.** The
|
||||||
|
surface ladder (`#0A0A0A` canvas → `zinc-900` → `zinc-800`) plus zinc-700/800 hairlines *is*
|
||||||
|
the elevation system. Shadows are reserved for **truly floating overlays only** (modals,
|
||||||
|
popovers, dropdowns) — `shadow-lg`/`shadow-xl`/`shadow-2xl`. Never put a shadow on a static
|
||||||
|
card; raise it with bg + border instead.
|
||||||
|
|
||||||
|
## 7. Do's and don'ts
|
||||||
|
|
||||||
|
**Do**
|
||||||
|
- Keep the **primary button white** (`bg-white text-black`). It's the brand's loudest element.
|
||||||
|
- Use **UPPERCASE + `tracking-wider` Bebas** for headings, buttons, and labels.
|
||||||
|
- Reach for **zinc** for every neutral — backgrounds, borders, secondary text.
|
||||||
|
- Use **`#DC2626` red as the single accent**: active states, emphasis edges, key deltas, links,
|
||||||
|
the destructive intent. Make it rare and deliberate.
|
||||||
|
- Build depth with **bg steps + borders**; keep surfaces flat.
|
||||||
|
- Use the **wash pattern** for semantic blocks (tinted text + translucent bg + matching border).
|
||||||
|
|
||||||
|
**Don't**
|
||||||
|
- ❌ Don't make a **solid red button** — it collides with the white primary *and* with the red
|
||||||
|
destructive meaning. Red buttons are ghost/outline/wash only. (Red as a solid *fill* is for
|
||||||
|
small accents — nav indicators, edges, badges — not full buttons.)
|
||||||
|
- ❌ Don't introduce a **second neutral** (gray/slate/stone) — zinc only. (`gray-100` strays
|
||||||
|
exist in the code; they're cleanup, not precedent.)
|
||||||
|
- ❌ Don't introduce a **second red, second green, or second yellow** — `#DC2626` red, `emerald`
|
||||||
|
success, `amber` warning. (`green-*`/`yellow-*` strays are cleanup.)
|
||||||
|
- ❌ Don't add **decorative shadows or gradients**. Flat only.
|
||||||
|
- ❌ Don't set **body text in Bebas Neue**, or leave headings/labels lowercase.
|
||||||
|
- ❌ Don't add a **new arbitrary radius** — controls are 4px, containers 8px (see §8 below).
|
||||||
|
|
||||||
|
## 8. Responsive behavior
|
||||||
|
|
||||||
|
- **Mobile-first**: author the mobile layout unprefixed, layer desktop with `sm:`/`md:`.
|
||||||
|
- The **bottom nav** is the mobile primary navigation; the **240px sidebar** replaces it at
|
||||||
|
`md+`. The main content reserves the sidebar via `md:pl-[var(--sidebar-width)]`.
|
||||||
|
- Touch targets stay ≥ 44px tall on mobile; the dense `text-xs`/`text-sm` scale is for
|
||||||
|
*information density*, not for shrinking tap targets.
|
||||||
|
- Viewport is locked (`maximum-scale=1, user-scalable=no`) — this is an app, not a document.
|
||||||
|
|
||||||
|
**Radius scale (canonical):**
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| `radius.control` | 4px (`rounded`) | Buttons, inputs, chips, small elements |
|
||||||
|
| `radius.container` | 8px (`rounded-lg`) | Cards, panels, modals, banners |
|
||||||
|
| `radius.full` | 9999px (`rounded-full`) | Pills, avatars, dots |
|
||||||
|
|
||||||
|
(`rounded-md`/6px exists in the code as a third value; treat it as drift toward one of the two.)
|
||||||
|
|
||||||
|
## 9. Agent prompt guide
|
||||||
|
|
||||||
|
When building or editing UI in `proof-of-work/`:
|
||||||
|
|
||||||
|
> Build it **monochrome-first**: near-black canvas (`#0A0A0A`), **zinc** for every neutral,
|
||||||
|
> **white** for primary text and the primary button (`bg-white text-black font-bold uppercase
|
||||||
|
> tracking-wider`). Headings/labels/buttons are **Bebas Neue, UPPERCASE, `tracking-wider`**;
|
||||||
|
> body is **Space Grotesk**, dense (`text-xs`/`text-sm`). The **only accent is red `#DC2626`** —
|
||||||
|
> use it sparingly for active states, emphasis edges (`border-l-4`), key deltas, links, and
|
||||||
|
> destructive intent; **never as a solid button fill**. Build depth with **background layering +
|
||||||
|
> 1px zinc borders**, not shadows (shadows are for floating overlays only). Radius: **4px**
|
||||||
|
> controls, **8px** containers. Pull exact values from `design/tokens.tokens.json` /
|
||||||
|
> `design/brand/palette.css` rather than re-deriving hexes; reuse the layout CSS vars
|
||||||
|
> (`--sidebar-width`, `--nav-height`, `--bottom-nav-height`). Stay in the system — no second
|
||||||
|
> neutral, no second red/green/yellow, no decorative gradients or shadows.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<image href="/icons/gemini-kettlebell.png" width="1024" height="1024"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 146 B |
Binary file not shown.
|
After Width: | Height: | Size: 593 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Proof of Work — canonical design tokens as CSS custom properties.
|
||||||
|
* Generated-by-hand mirror of design/tokens.tokens.json (the source of truth).
|
||||||
|
* Import once at the app root so surfaces reference var(--pow-*) instead of inlining hexes.
|
||||||
|
* Hexes are the literal Tailwind values the as-built UI already uses (neutral ramp = zinc).
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Backgrounds / surfaces (depth = stepping up in lightness, not shadow) */
|
||||||
|
--pow-bg-canvas: #0A0A0A; /* app background; PWA theme_color */
|
||||||
|
--pow-bg-surface: #18181B; /* zinc-900 — cards, panels */
|
||||||
|
--pow-bg-raised: #27272A; /* zinc-800 — controls, chips, hover */
|
||||||
|
--pow-bg-inset: #09090B; /* zinc-950 — rare deep wells */
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--pow-border-subtle: #27272A; /* zinc-800 — default hairline */
|
||||||
|
--pow-border-default: #3F3F46; /* zinc-700 */
|
||||||
|
--pow-border-strong: #52525B; /* zinc-600 — emphasis */
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--pow-text-primary: #FFFFFF;
|
||||||
|
--pow-text-secondary: #A1A1AA; /* zinc-400 */
|
||||||
|
--pow-text-muted: #71717A; /* zinc-500 */
|
||||||
|
--pow-text-subtle: #52525B; /* zinc-600 — disabled */
|
||||||
|
--pow-text-inverted: #000000; /* on white surfaces */
|
||||||
|
|
||||||
|
/* Accent / error (one canonical red, told apart by treatment) */
|
||||||
|
--pow-accent-red: #DC2626; /* red-600 — accent fills/edges + destructive */
|
||||||
|
--pow-accent-red-strong: #B91C1C; /* red-700 — hover/pressed */
|
||||||
|
--pow-accent-red-text: #F87171; /* red-400 — red text on dark */
|
||||||
|
--pow-accent-red-border: #991B1B; /* red-800 — outlines */
|
||||||
|
--pow-accent-red-wash: rgba(127, 29, 29, 0.30); /* red-900/30 — banners/hover */
|
||||||
|
|
||||||
|
/* Other semantic state */
|
||||||
|
--pow-success: #34D399; /* emerald-400 */
|
||||||
|
--pow-success-fill: #059669; /* emerald-600 */
|
||||||
|
--pow-warning: #FBBF24; /* amber-400 */
|
||||||
|
--pow-warning-edge: #78350F; /* amber-900 */
|
||||||
|
--pow-info: #60A5FA; /* blue-400 */
|
||||||
|
--pow-info-wash: #172554; /* blue-950 */
|
||||||
|
|
||||||
|
/* Primary action = white, not a hue */
|
||||||
|
--pow-action-primary-bg: #FFFFFF;
|
||||||
|
--pow-action-primary-text: #000000;
|
||||||
|
--pow-action-primary-hover-bg: #E4E4E7; /* zinc-200 */
|
||||||
|
--pow-action-primary-disabled-bg: #3F3F46; /* zinc-700 */
|
||||||
|
--pow-action-primary-disabled-text: #71717A;/* zinc-500 */
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--pow-font-display: var(--font-display), 'Bebas Neue', sans-serif;
|
||||||
|
--pow-font-body: var(--font-sans), 'Space Grotesk', system-ui, sans-serif;
|
||||||
|
--pow-tracking-wider: 0.05em;
|
||||||
|
|
||||||
|
/* Radius (two-tier) */
|
||||||
|
--pow-radius-control: 4px; /* buttons, inputs, chips */
|
||||||
|
--pow-radius-container: 8px; /* cards, panels, modals */
|
||||||
|
--pow-radius-full: 9999px; /* pills, avatars */
|
||||||
|
|
||||||
|
/* Layout (mirrors globals.css; the app already defines these) */
|
||||||
|
--pow-sidebar-width: 240px;
|
||||||
|
--pow-nav-height: 64px;
|
||||||
|
--pow-bottom-nav-height: 64px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Inspiration & provenance
|
||||||
|
|
||||||
|
Proof of Work's design was **extracted document-as-is** (Case B) from the existing code on
|
||||||
|
2026-06-19 — there were no prior brand guidelines and no external reference set. So for this
|
||||||
|
repo the "inspiration" is the **as-built UI itself**, plus the brand mark. This folder records
|
||||||
|
the *why/where* behind the contract.
|
||||||
|
|
||||||
|
## De-facto references (the source the look was harvested from)
|
||||||
|
|
||||||
|
- **Brand mark** — [`../brand/logo-kettlebell.png`](../brand/logo-kettlebell.png): a pure-white
|
||||||
|
kettlebell silhouette on near-black. It establishes the core identity directly: **monochrome,
|
||||||
|
white-on-black, no color in the mark.** Everything else follows from this.
|
||||||
|
- **As-built styling surfaces** (where every value was censused by Tailwind-class frequency):
|
||||||
|
- `proof-of-work/app/globals.css` — base bg, heading treatment, scrollbar, layout vars.
|
||||||
|
- `proof-of-work/app/layout.tsx` — fonts (Bebas Neue display, Space Grotesk body), theme color.
|
||||||
|
- `proof-of-work/tailwind.config.ts` — font-family wiring, spacing/radius extensions.
|
||||||
|
- `proof-of-work/components/**` + `proof-of-work/app/main/**` — the inline utility classes that
|
||||||
|
were frequency-ranked to find the canonical neutral (zinc), surface ladder, type scale,
|
||||||
|
radii, and the white-primary / red-error patterns.
|
||||||
|
- `proof-of-work/public/manifest.json` — `theme_color`/`background_color` `#0A0A0A` (the
|
||||||
|
external anchor that confirmed the canonical canvas color).
|
||||||
|
|
||||||
|
## Owner decisions captured in the reconcile (2026-06-19)
|
||||||
|
|
||||||
|
The extract was literal except for two owner-driven calls:
|
||||||
|
|
||||||
|
1. **Red promoted to a brand accent.** As-built, red was error/destructive only. The owner chose
|
||||||
|
to elevate it to *the* accent (still keeping white as the primary button). Canonical red =
|
||||||
|
**`#DC2626`** (Tailwind red-600, "Blood Red"), which also re-tints the error states so the UI
|
||||||
|
carries a single coherent red.
|
||||||
|
- The candidates that were compared on the real `#0A0A0A` background:
|
||||||
|
[`red-accent-candidates.png`](./red-accent-candidates.png) (rendered from
|
||||||
|
[`red-accent-candidates.html`](./red-accent-candidates.html)). Options were Signal `#EF4444`,
|
||||||
|
Blood `#DC2626` *(chosen)*, Vermilion `#FF3B30`, Crimson `#E11D48`.
|
||||||
|
2. **Two-tier radius rule.** The code mixed 4px (`rounded`) and 8px (`rounded-lg`) with no rule;
|
||||||
|
the owner adopted **4px for controls, 8px for containers**.
|
||||||
|
|
||||||
|
Everything else (zinc as the one neutral, white primary button, Bebas-uppercase-tracked
|
||||||
|
headings, flat/border-based depth, dense small type scale) is the as-built look, documented
|
||||||
|
faithfully in [`../DESIGN.md`](../DESIGN.md).
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body {
|
||||||
|
background:#0A0A0A; color:#fff;
|
||||||
|
font-family:'Space Grotesk', system-ui, sans-serif;
|
||||||
|
padding:36px 40px 44px;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
.display { font-family:'Bebas Neue', sans-serif; letter-spacing:.05em; text-transform:uppercase; }
|
||||||
|
h1 { font-family:'Bebas Neue',sans-serif; letter-spacing:.06em; font-size:34px; }
|
||||||
|
.sub { color:#a1a1aa; font-size:13px; margin-top:4px; margin-bottom:26px; }
|
||||||
|
.row {
|
||||||
|
display:grid; grid-template-columns:200px 1fr; gap:28px;
|
||||||
|
align-items:center;
|
||||||
|
padding:22px 0; border-top:1px solid #27272a;
|
||||||
|
}
|
||||||
|
/* left: raw swatch + name */
|
||||||
|
.swatch { height:120px; border-radius:8px; }
|
||||||
|
.name { font-family:'Bebas Neue',sans-serif; letter-spacing:.06em; font-size:26px; margin-top:12px; }
|
||||||
|
.hex { color:#71717a; font-size:13px; font-variant-numeric:tabular-nums; }
|
||||||
|
|
||||||
|
/* right: vignette strip */
|
||||||
|
.vignette { display:flex; gap:20px; align-items:stretch; flex-wrap:nowrap; }
|
||||||
|
.card {
|
||||||
|
background:#18181b; border:1px solid #27272a; border-left:4px solid var(--a);
|
||||||
|
border-radius:8px; padding:14px 16px; width:230px;
|
||||||
|
}
|
||||||
|
.card .label { font-family:'Bebas Neue',sans-serif; letter-spacing:.12em; font-size:13px; color:var(--a); }
|
||||||
|
.card .h { font-family:'Bebas Neue',sans-serif; letter-spacing:.05em; font-size:22px; margin-top:6px; }
|
||||||
|
.card .stat { display:flex; align-items:baseline; gap:8px; margin-top:8px; }
|
||||||
|
.card .stat .n { font-size:30px; font-weight:700; font-variant-numeric:tabular-nums; }
|
||||||
|
.card .stat .d { color:var(--a); font-size:14px; font-weight:600; }
|
||||||
|
|
||||||
|
.col { display:flex; flex-direction:column; gap:12px; justify-content:center; }
|
||||||
|
|
||||||
|
/* nav active state */
|
||||||
|
.nav { display:flex; gap:18px; align-items:center; }
|
||||||
|
.nav a { font-family:'Bebas Neue',sans-serif; letter-spacing:.1em; font-size:14px; color:#71717a; padding-bottom:6px; }
|
||||||
|
.nav a.active { color:var(--a); border-bottom:2px solid var(--a); }
|
||||||
|
|
||||||
|
/* badge (accent wash) */
|
||||||
|
.badge {
|
||||||
|
display:inline-block; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.08em;
|
||||||
|
color:var(--a); background:color-mix(in srgb, var(--a) 15%, transparent);
|
||||||
|
border:1px solid color-mix(in srgb, var(--a) 45%, transparent);
|
||||||
|
padding:4px 10px; border-radius:4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* buttons */
|
||||||
|
.btns { display:flex; gap:10px; }
|
||||||
|
.btn-primary {
|
||||||
|
background:#fff; color:#000; font-weight:700; text-transform:uppercase; letter-spacing:.08em;
|
||||||
|
font-size:12px; padding:9px 16px; border-radius:4px; border:none;
|
||||||
|
}
|
||||||
|
.btn-accent {
|
||||||
|
background:transparent; color:var(--a); border:1px solid var(--a);
|
||||||
|
font-weight:700; text-transform:uppercase; letter-spacing:.08em;
|
||||||
|
font-size:12px; padding:9px 16px; border-radius:4px;
|
||||||
|
}
|
||||||
|
.reflabel { font-size:10px; color:#52525b; text-transform:uppercase; letter-spacing:.1em; margin-top:6px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Proof of Work — red accent candidates</h1>
|
||||||
|
<div class="sub">All on the app background #0A0A0A. White stays the primary button (reference, same in every row); red is used only as an accent: labels, active nav, card edge, stat delta, ghost button, badge.</div>
|
||||||
|
|
||||||
|
<!-- A -->
|
||||||
|
<div class="row" style="--a:#EF4444">
|
||||||
|
<div>
|
||||||
|
<div class="swatch" style="background:#EF4444"></div>
|
||||||
|
<div class="name">A · Signal Red</div>
|
||||||
|
<div class="hex">#EF4444 · Tailwind red-500 (in-family)</div>
|
||||||
|
</div>
|
||||||
|
<div class="vignette">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Personal Record</div>
|
||||||
|
<div class="h">Back Squat</div>
|
||||||
|
<div class="stat"><span class="n">142.5</span><span class="d">▲ +5kg</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="nav"><a class="active">Today</a><a>History</a><a>Programs</a></div>
|
||||||
|
<span class="badge">PR · Week 6</span>
|
||||||
|
<div class="btns"><button class="btn-primary">Save workout</button><button class="btn-accent">Refine</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- B -->
|
||||||
|
<div class="row" style="--a:#DC2626">
|
||||||
|
<div>
|
||||||
|
<div class="swatch" style="background:#DC2626"></div>
|
||||||
|
<div class="name">B · Blood Red</div>
|
||||||
|
<div class="hex">#DC2626 · Tailwind red-600 (deeper)</div>
|
||||||
|
</div>
|
||||||
|
<div class="vignette">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Personal Record</div>
|
||||||
|
<div class="h">Back Squat</div>
|
||||||
|
<div class="stat"><span class="n">142.5</span><span class="d">▲ +5kg</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="nav"><a class="active">Today</a><a>History</a><a>Programs</a></div>
|
||||||
|
<span class="badge">PR · Week 6</span>
|
||||||
|
<div class="btns"><button class="btn-primary">Save workout</button><button class="btn-accent">Refine</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- C -->
|
||||||
|
<div class="row" style="--a:#FF3B30">
|
||||||
|
<div>
|
||||||
|
<div class="swatch" style="background:#FF3B30"></div>
|
||||||
|
<div class="name">C · Vermilion</div>
|
||||||
|
<div class="hex">#FF3B30 · hot orange-red</div>
|
||||||
|
</div>
|
||||||
|
<div class="vignette">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Personal Record</div>
|
||||||
|
<div class="h">Back Squat</div>
|
||||||
|
<div class="stat"><span class="n">142.5</span><span class="d">▲ +5kg</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="nav"><a class="active">Today</a><a>History</a><a>Programs</a></div>
|
||||||
|
<span class="badge">PR · Week 6</span>
|
||||||
|
<div class="btns"><button class="btn-primary">Save workout</button><button class="btn-accent">Refine</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- D -->
|
||||||
|
<div class="row" style="--a:#E11D48">
|
||||||
|
<div>
|
||||||
|
<div class="swatch" style="background:#E11D48"></div>
|
||||||
|
<div class="name">D · Crimson</div>
|
||||||
|
<div class="hex">#E11D48 · cooler, pink-edge (rose-600)</div>
|
||||||
|
</div>
|
||||||
|
<div class="vignette">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Personal Record</div>
|
||||||
|
<div class="h">Back Squat</div>
|
||||||
|
<div class="stat"><span class="n">142.5</span><span class="d">▲ +5kg</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="nav"><a class="active">Today</a><a>History</a><a>Programs</a></div>
|
||||||
|
<span class="badge">PR · Week 6</span>
|
||||||
|
<div class="btns"><button class="btn-primary">Save workout</button><button class="btn-accent">Refine</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"$description": "Proof of Work design tokens (W3C DTCG). Extracted document-as-is from the as-built Tailwind UI on 2026-06-19; red accent (#DC2626) and the two-tier radius rule are owner-driven. Neutral ramp is Tailwind 'zinc'. Hexes are the literal Tailwind values the code already uses.",
|
||||||
|
"color": {
|
||||||
|
"bg": {
|
||||||
|
"canvas": { "$type": "color", "$value": "#0A0A0A", "$description": "App background; PWA theme_color and <body> bg. The anchor value." },
|
||||||
|
"surface": { "$type": "color", "$value": "#18181B", "$description": "zinc-900 — default raised surface (cards, panels)." },
|
||||||
|
"raised": { "$type": "color", "$value": "#27272A", "$description": "zinc-800 — controls, chips, next step up / hover." },
|
||||||
|
"inset": { "$type": "color", "$value": "#09090B", "$description": "zinc-950 — rare deep wells, slightly below canvas." }
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"subtle": { "$type": "color", "$value": "#27272A", "$description": "zinc-800 — default hairline." },
|
||||||
|
"default": { "$type": "color", "$value": "#3F3F46", "$description": "zinc-700." },
|
||||||
|
"strong": { "$type": "color", "$value": "#52525B", "$description": "zinc-600 — emphasis/hover." }
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": { "$type": "color", "$value": "#FFFFFF" },
|
||||||
|
"secondary": { "$type": "color", "$value": "#A1A1AA", "$description": "zinc-400." },
|
||||||
|
"muted": { "$type": "color", "$value": "#71717A", "$description": "zinc-500." },
|
||||||
|
"subtle": { "$type": "color", "$value": "#52525B", "$description": "zinc-600 — disabled/least emphasis." },
|
||||||
|
"inverted": { "$type": "color", "$value": "#000000", "$description": "Text on white/light surfaces (primary button)." }
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"red": { "$type": "color", "$value": "#DC2626", "$description": "Canonical red — brand accent AND error/destructive (red-600). Distinguished by treatment, not by a second red." },
|
||||||
|
"red-strong": { "$type": "color", "$value": "#B91C1C", "$description": "red-700 — hover/pressed for the accent." },
|
||||||
|
"red-text": { "$type": "color", "$value": "#F87171", "$description": "red-400 — red text/icon on the dark canvas." },
|
||||||
|
"red-border": { "$type": "color", "$value": "#991B1B", "$description": "red-800 — error/destructive outlines." },
|
||||||
|
"red-wash": { "$type": "color", "$value": "rgba(127, 29, 29, 0.30)", "$description": "red-900/30 — error banner / destructive-hover background wash." }
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"success": { "$type": "color", "$value": "#34D399", "$description": "emerald-400 — success text/icon on dark." },
|
||||||
|
"success-fill": { "$type": "color", "$value": "#059669", "$description": "emerald-600 — success fill." },
|
||||||
|
"warning": { "$type": "color", "$value": "#FBBF24", "$description": "amber-400 — warning text/icon." },
|
||||||
|
"warning-edge": { "$type": "color", "$value": "#78350F", "$description": "amber-900 — warning border/wash edge." },
|
||||||
|
"info": { "$type": "color", "$value": "#60A5FA", "$description": "blue-400 — info text (used sparingly)." },
|
||||||
|
"info-wash": { "$type": "color", "$value": "#172554", "$description": "blue-950 — info wash background." }
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary-bg": { "$type": "color", "$value": "{color.text.primary}", "$description": "Primary button background is WHITE — not a hue." },
|
||||||
|
"primary-text": { "$type": "color", "$value": "{color.text.inverted}" },
|
||||||
|
"primary-hover-bg": { "$type": "color", "$value": "#E4E4E7", "$description": "zinc-200." },
|
||||||
|
"primary-disabled-bg": { "$type": "color", "$value": "#3F3F46", "$description": "zinc-700." },
|
||||||
|
"primary-disabled-text": { "$type": "color", "$value": "#71717A", "$description": "zinc-500." }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"font": {
|
||||||
|
"family": {
|
||||||
|
"display": { "$type": "fontFamily", "$value": "Bebas Neue", "$description": "var(--font-display). Headings, buttons, labels — always UPPERCASE + tracked." },
|
||||||
|
"body": { "$type": "fontFamily", "$value": "Space Grotesk", "$description": "var(--font-sans). All running text, data, numbers." }
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"xs": { "$type": "dimension", "$value": "12px" },
|
||||||
|
"sm": { "$type": "dimension", "$value": "14px" },
|
||||||
|
"base": { "$type": "dimension", "$value": "16px" },
|
||||||
|
"lg": { "$type": "dimension", "$value": "18px" },
|
||||||
|
"xl": { "$type": "dimension", "$value": "20px" },
|
||||||
|
"2xl": { "$type": "dimension", "$value": "24px" },
|
||||||
|
"3xl": { "$type": "dimension", "$value": "30px" },
|
||||||
|
"4xl": { "$type": "dimension", "$value": "36px" }
|
||||||
|
},
|
||||||
|
"weight": {
|
||||||
|
"normal": { "$type": "fontWeight", "$value": 400 },
|
||||||
|
"medium": { "$type": "fontWeight", "$value": 500 },
|
||||||
|
"semibold": { "$type": "fontWeight", "$value": 600 },
|
||||||
|
"bold": { "$type": "fontWeight", "$value": 700 }
|
||||||
|
},
|
||||||
|
"letterSpacing": {
|
||||||
|
"wider": { "$type": "dimension", "$value": "0.05em", "$description": "Tailwind tracking-wider — the signature pairing with UPPERCASE Bebas." }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"space": {
|
||||||
|
"$description": "Tailwind 4px scale; the values the as-built UI leans on.",
|
||||||
|
"1": { "$type": "dimension", "$value": "4px" },
|
||||||
|
"2": { "$type": "dimension", "$value": "8px" },
|
||||||
|
"3": { "$type": "dimension", "$value": "12px" },
|
||||||
|
"4": { "$type": "dimension", "$value": "16px" },
|
||||||
|
"5": { "$type": "dimension", "$value": "20px" },
|
||||||
|
"6": { "$type": "dimension", "$value": "24px" },
|
||||||
|
"8": { "$type": "dimension", "$value": "32px" }
|
||||||
|
},
|
||||||
|
"radius": {
|
||||||
|
"control": { "$type": "dimension", "$value": "4px", "$description": "Tailwind `rounded` — buttons, inputs, chips." },
|
||||||
|
"container": { "$type": "dimension", "$value": "8px", "$description": "Tailwind `rounded-lg` — cards, panels, modals, banners." },
|
||||||
|
"full": { "$type": "dimension", "$value": "9999px", "$description": "Pills, avatars, dots." }
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"sidebar-width": { "$type": "dimension", "$value": "240px", "$description": "CSS var --sidebar-width; desktop sidebar (md+)." },
|
||||||
|
"nav-height": { "$type": "dimension", "$value": "64px", "$description": "CSS var --nav-height." },
|
||||||
|
"bottom-nav-height": { "$type": "dimension", "$value": "64px", "$description": "CSS var --bottom-nav-height; mobile bottom nav." }
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"overlay": { "$type": "shadow", "$value": "0 10px 15px -3px rgba(0,0,0,0.5)", "$description": "Reserved for floating overlays ONLY (modals, popovers). Static cards use bg layering + borders, never a shadow. Value approximates Tailwind shadow-lg; the codebase also uses shadow-xl/2xl for overlays." }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,15 +20,85 @@ 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` /
|
||||||
`error`); add new ones under `lib/ai/providers/` and register in `index.ts`.
|
`error`); add new ones under `lib/ai/providers/` and register in `index.ts`.
|
||||||
`openai.ts` exports both `openai` and `openai-compatible`, so the four provider files
|
`openai.ts` exports both `openai` and `openai-compatible`, so the five provider files
|
||||||
register **5** providers (`claude`, `openai`, `openai-compatible`, `gemini`, `ollama`).
|
register **6** providers (`claude`, `openai`, `openai-compatible`, `gemini`, `ollama`,
|
||||||
|
`sparkcontrol`).
|
||||||
|
- **SparkControl** (`sparkcontrol.ts`) — the operator's own self-hosted local-inference
|
||||||
|
gateway. OpenAI-compatible wire format, so it reuses `generateOpenAIStyle` with
|
||||||
|
`{ requireApiKey: false }` (keyless on the LAN — the streamer omits the `Authorization`
|
||||||
|
header when no key is set). Reached over the **internal same-box StartOS address**
|
||||||
|
(`http://spark-control.startos:9999/v1`, plain HTTP — no TLS, no cert-skip). Custom base
|
||||||
|
URL ⇒ SSRF-guarded + admin-only, same as Ollama. The Settings UI auto-detects the loaded
|
||||||
|
vLLM model via `app/api/ai/sparkcontrol/model` (probes SparkControl's `/api/endpoints`
|
||||||
|
→ `vllm.model`), mirroring the Ollama `/api/tags` auto-detect. Free in the cost UI.
|
||||||
|
- **Base-URL hygiene:** only custom-URL providers (`requiresBaseUrl`: ollama,
|
||||||
|
openai-compatible, sparkcontrol) store a base URL. Both config write paths
|
||||||
|
(`configs` POST + `[id]` PATCH) null it for fixed-URL providers, and the Settings form
|
||||||
|
clears it on provider change — otherwise a stale URL silently rides along to
|
||||||
|
claude/openai/gemini, which ignore it and hit their hardcoded endpoints.
|
||||||
- Streaming AI uses SSE; partial JSON is recovered with `lib/ai/lenientJson.ts`.
|
- Streaming AI uses SSE; partial JSON is recovered with `lib/ai/lenientJson.ts`.
|
||||||
- Pricing/model menus live in `lib/ai/pricing.ts` (`PRICES`, `MODEL_MENU`) — keep them
|
- Pricing/model menus live in `lib/ai/pricing.ts` (`PRICES`, `MODEL_MENU`) — keep them
|
||||||
paired so every menu model has a price entry (there's a test enforcing this).
|
paired so every menu model has a price entry (there's a test enforcing this).
|
||||||
|
- **Adding a provider** (precedent: `sparkcontrol`, 1.2.0:7) is a fan-out across ~8 spots —
|
||||||
|
miss one and it half-works: the provider file + `ProviderId` union (`types.ts`) + register
|
||||||
|
in `providers/index.ts` (`ALL` + `PROVIDER_ORDER`); the zod `provider` enum in **both**
|
||||||
|
`configs` POST and `[id]` PATCH (+ `defaultName` PRETTY map); the UI `PROVIDERS` list in
|
||||||
|
`AIIntegration.tsx` (`requiresKey`/`requiresUrl` must mirror the server `requiresApiKey`/
|
||||||
|
`requiresBaseUrl`); `MODEL_MENU` (`[]` if no curated menu) + an `estimateCost` branch
|
||||||
|
(free/null for self-hosted). A custom-URL provider is admin-only + SSRF-guarded everywhere
|
||||||
|
(configs POST/PATCH, `ai/test`, any probe route) and must appear in those routes' 403
|
||||||
|
enumeration strings. `ai/test` and `generate` work for free once it's in `getProvider`.
|
||||||
|
|
||||||
|
## Model-output robustness (esp. local models)
|
||||||
|
|
||||||
|
Local models (Qwen via SparkControl, Ollama) don't honor the JSON contract as tightly
|
||||||
|
as the cloud APIs, so the parse/apply path is deliberately tolerant. Two layers, both
|
||||||
|
added after the first SparkControl run surfaced the failures live:
|
||||||
|
|
||||||
|
- **Decimal integers** (1.2.0:8): models emit `"rpe": 7.5` / `"reps": 8.0` where the
|
||||||
|
schema expects ints. `looseInt(z.number().int()…)` (`programSchema.ts`, used by
|
||||||
|
`workoutSchema.ts`) rounds a number to the nearest int **before** the `.int()` check —
|
||||||
|
wrap every integer field in both schemas with it. Transform-before-validate, so inferred
|
||||||
|
types are unchanged. Without it, one stray decimal fails the ENTIRE parse.
|
||||||
|
- **Exercise→library name matching** (1.2.0:9): models return a good `exerciseName` with a
|
||||||
|
null or invented `exerciseId`. `lib/ai/exerciseMatch.ts` (`resolveExerciseIds`) normalizes
|
||||||
|
the name (lowercase, strip the `(barbell)`-style qualifier + punctuation) and auto-maps
|
||||||
|
only **unique confident** matches; ambiguous/unknown stay null so the UI flags them for
|
||||||
|
manual mapping. Wired into BOTH generate flows at the parse→display boundary
|
||||||
|
(`GenerateWorkoutClient`, `GenerateClient`) — re-resolve there if you add a third flow.
|
||||||
|
- **Latency characteristic (not a bug):** a thinking model (Qwen3.x) spends most of its
|
||||||
|
output tokens on internal reasoning, streamed as `reasoning_content` — which the OpenAI
|
||||||
|
streamer ignores (it reads only `delta.content`). So `tokensOut` can be ~10× the visible
|
||||||
|
JSON and a generation runs minutes (e.g. 7.4k out, 2.8k-char JSON, ~3 min on a DGX Spark
|
||||||
|
at ~41 tok/s). The lever is **disabling thinking on the vLLM/SparkControl side** (or via a
|
||||||
|
`chat_template_kwargs:{enable_thinking:false}` request param); left on by owner's choice.
|
||||||
|
|
||||||
## SSRF / provider-URL safety
|
## SSRF / provider-URL safety
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export async function PATCH(
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error:
|
error:
|
||||||
'Only an admin can configure providers with a custom base URL (Ollama / OpenAI-compatible).',
|
'Only an admin can configure providers with a custom base URL (Ollama, SparkControl, OpenAI-compatible).',
|
||||||
},
|
},
|
||||||
{ status: 403 },
|
{ status: 403 },
|
||||||
);
|
);
|
||||||
@@ -93,8 +93,13 @@ export async function PATCH(
|
|||||||
const data: Record<string, string | null> = {};
|
const data: Record<string, string | null> = {};
|
||||||
if (parsed.data.name !== undefined) data.name = parsed.data.name;
|
if (parsed.data.name !== undefined) data.name = parsed.data.name;
|
||||||
if (parsed.data.model !== undefined) data.model = parsed.data.model;
|
if (parsed.data.model !== undefined) data.model = parsed.data.model;
|
||||||
|
// Fixed-URL providers (claude/openai/gemini) ignore a base URL — never let an
|
||||||
|
// edit attach one (the footgun that produced a gemini config carrying a stale
|
||||||
|
// baseUrl). Provider can't change on PATCH, so `existing.provider` is authoritative.
|
||||||
if (parsed.data.baseUrl !== undefined)
|
if (parsed.data.baseUrl !== undefined)
|
||||||
data.baseUrl = parsed.data.baseUrl || null;
|
data.baseUrl = isCustomUrlProvider(existing.provider)
|
||||||
|
? parsed.data.baseUrl || null
|
||||||
|
: null;
|
||||||
if (parsed.data.apiKey !== undefined)
|
if (parsed.data.apiKey !== undefined)
|
||||||
data.apiKey = parsed.data.apiKey || null;
|
data.apiKey = parsed.data.apiKey || null;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ import { isCustomUrlProvider } from '@/lib/ai/providers';
|
|||||||
* [id]/activate/route.ts so the action is explicit + auditable.
|
* [id]/activate/route.ts so the action is explicit + auditable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PROVIDERS = ['claude', 'openai', 'openai-compatible', 'gemini', 'ollama'] as const;
|
const PROVIDERS = [
|
||||||
|
'claude',
|
||||||
|
'openai',
|
||||||
|
'openai-compatible',
|
||||||
|
'gemini',
|
||||||
|
'ollama',
|
||||||
|
'sparkcontrol',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
@@ -82,32 +89,44 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const { name, provider, model, baseUrl, apiKey, setActive } = parsed.data;
|
const { name, provider, model, baseUrl, apiKey, setActive } = parsed.data;
|
||||||
|
|
||||||
// Custom-URL providers (Ollama / OpenAI-compatible) are admin-only — a
|
// Custom-URL providers (Ollama, SparkControl, OpenAI-compatible) are
|
||||||
// non-admin pointing the server at an arbitrary URL is the SSRF actor
|
// admin-only — a non-admin pointing the server at an arbitrary URL is the
|
||||||
// vector. Fixed-URL cloud providers stay per-user.
|
// SSRF actor vector. Fixed-URL cloud providers stay per-user.
|
||||||
if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) {
|
if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error:
|
error:
|
||||||
'Only an admin can configure providers with a custom base URL (Ollama / OpenAI-compatible).',
|
'Only an admin can configure providers with a custom base URL (Ollama, SparkControl, OpenAI-compatible).',
|
||||||
},
|
},
|
||||||
{ status: 403 },
|
{ status: 403 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only custom-URL providers (Ollama / OpenAI-compatible / SparkControl) carry
|
||||||
|
// a base URL. Fixed-URL providers (claude/openai/gemini) hit their hardcoded
|
||||||
|
// endpoint and ignore it — so we drop any stale baseUrl here rather than
|
||||||
|
// storing an impossible config (the footgun behind the gemini-with-a-baseURL
|
||||||
|
// mismatch). The UI also clears the field on provider change.
|
||||||
|
const normalizedBaseUrl = isCustomUrlProvider(provider) ? baseUrl || null : null;
|
||||||
|
|
||||||
const profile = await prisma.aIConfigProfile.create({
|
const profile = await prisma.aIConfigProfile.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
name: name ?? defaultName(provider, model),
|
name: name ?? defaultName(provider, model),
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
baseUrl: baseUrl || null,
|
baseUrl: normalizedBaseUrl,
|
||||||
apiKey: apiKey || null,
|
apiKey: apiKey || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (setActive) {
|
if (setActive) {
|
||||||
await activate(user.id, profile.id, { provider, model, baseUrl, apiKey });
|
await activate(user.id, profile.id, {
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
baseUrl: normalizedBaseUrl,
|
||||||
|
apiKey,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -128,6 +147,7 @@ function defaultName(provider: string, model: string): string {
|
|||||||
'openai-compatible': 'Custom',
|
'openai-compatible': 'Custom',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
ollama: 'Ollama',
|
ollama: 'Ollama',
|
||||||
|
sparkcontrol: 'SparkControl',
|
||||||
};
|
};
|
||||||
const label = PRETTY[provider] ?? provider;
|
const label = PRETTY[provider] ?? provider;
|
||||||
return `${label} · ${model}`;
|
return `${label} · ${model}`;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { assertSafeProviderUrl } from '@/lib/ai/safeUrl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/sparkcontrol/model?baseUrl=...
|
||||||
|
*
|
||||||
|
* Probes SparkControl's service-discovery endpoint (`/api/endpoints`) and
|
||||||
|
* returns the model vLLM currently has loaded, so the Settings UI can
|
||||||
|
* auto-fill the model field (the same role the Ollama /api/tags probe plays).
|
||||||
|
* When no baseUrl is given it walks the canonical same-box StartOS addresses
|
||||||
|
* and, on a hit, hands back a `baseUrl` (with the `/v1` chat suffix) the UI
|
||||||
|
* can pre-fill.
|
||||||
|
*
|
||||||
|
* `/api/endpoints` lives at the host root, NOT under `/v1` — so we strip a
|
||||||
|
* trailing `/v1` off the configured chat base URL before probing.
|
||||||
|
*
|
||||||
|
* Authenticated + admin-only: pointing the server at an arbitrary URL is the
|
||||||
|
* SSRF surface (same gate as the Ollama probe), and a non-admin shouldn't be
|
||||||
|
* able to fingerprint the local network.
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* { ok: true, baseUrl, model: string | null, ready: boolean | null, ms }
|
||||||
|
* { ok: false, baseUrl, error, ms }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
// Canonical same-box addresses (StartOS 0.4 `.startos`, legacy 0.3 `.embassy`),
|
||||||
|
// including the `/v1` chat suffix the config field expects.
|
||||||
|
const DEFAULT_CANDIDATES = [
|
||||||
|
'http://spark-control.startos:9999/v1',
|
||||||
|
'http://spark-control.embassy:9999/v1',
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
|
||||||
|
// Custom-URL surface ⇒ admin-only (same as the Ollama probe).
|
||||||
|
if (!user.isAdmin)
|
||||||
|
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 });
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const explicit = url.searchParams.get('baseUrl');
|
||||||
|
const candidates = explicit ? [explicit] : DEFAULT_CANDIDATES;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const result = await probe(candidate);
|
||||||
|
if (result.ok) return NextResponse.json(result);
|
||||||
|
// For an explicit URL, surface the failure right away.
|
||||||
|
if (explicit) return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: false,
|
||||||
|
baseUrl: candidates[0],
|
||||||
|
error: 'No SparkControl instance responded at the default StartOS addresses.',
|
||||||
|
ms: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EndpointsResponse {
|
||||||
|
vllm?: { ready?: boolean; model?: string | null; disabled?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(baseUrl: string) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
let url: string;
|
||||||
|
try {
|
||||||
|
// `/api/endpoints` lives at the host root, independent of the `/v1` chat
|
||||||
|
// path — so probe the origin, not a string-derived prefix. (new URL also
|
||||||
|
// throws on a malformed baseUrl, which this catch turns into ok:false.)
|
||||||
|
url = new URL(baseUrl).origin + '/api/endpoints';
|
||||||
|
await assertSafeProviderUrl(url);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false as const, baseUrl, error: (e as Error).message, ms: Date.now() - t0 };
|
||||||
|
}
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { signal: ctrl.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!res.ok) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
baseUrl,
|
||||||
|
error: `SparkControl returned HTTP ${res.status}`,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as EndpointsResponse;
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
baseUrl,
|
||||||
|
model: body.vllm?.model ?? null,
|
||||||
|
ready: body.vllm?.ready ?? null,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
baseUrl,
|
||||||
|
error: ctrl.signal.aborted
|
||||||
|
? `Timed out after ${PROBE_TIMEOUT_MS / 1000}s`
|
||||||
|
: (e as Error).message,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,7 +120,7 @@ export async function POST(request: NextRequest) {
|
|||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error:
|
error:
|
||||||
'Only an admin can test providers with a custom base URL (Ollama / OpenAI-compatible).',
|
'Only an admin can test providers with a custom base URL (Ollama, SparkControl, OpenAI-compatible).',
|
||||||
},
|
},
|
||||||
{ status: 403 },
|
{ status: 403 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'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 },
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { Loader2, Sparkles } from 'lucide-react';
|
import { Loader2, Sparkles } from 'lucide-react';
|
||||||
import { lenientJsonParse } from '@/lib/ai/lenientJson';
|
import { lenientJsonParse } from '@/lib/ai/lenientJson';
|
||||||
import { estimateCost, formatCost } from '@/lib/ai/pricing';
|
import { estimateCost, formatCost } from '@/lib/ai/pricing';
|
||||||
|
import { resolveExerciseIds } from '@/lib/ai/exerciseMatch';
|
||||||
|
|
||||||
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
@@ -176,10 +177,23 @@ export default function GenerateClient({
|
|||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const gen = await r.json();
|
const gen = await r.json();
|
||||||
if (gen.parsedProgram) {
|
if (gen.parsedProgram) {
|
||||||
|
const parsed = JSON.parse(gen.parsedProgram) as AIProgram;
|
||||||
|
// Auto-resolve exercises the model named but didn't (or wrongly)
|
||||||
|
// id'd against the library, so the user isn't asked to hand-map an
|
||||||
|
// exercise they already own. Ambiguous ones stay unmapped.
|
||||||
setPhase({
|
setPhase({
|
||||||
kind: 'parsed',
|
kind: 'parsed',
|
||||||
raw,
|
raw,
|
||||||
program: JSON.parse(gen.parsedProgram) as AIProgram,
|
program: {
|
||||||
|
...parsed,
|
||||||
|
weeks: parsed.weeks.map((w) => ({
|
||||||
|
...w,
|
||||||
|
days: w.days.map((d) => ({
|
||||||
|
...d,
|
||||||
|
exercises: resolveExerciseIds(d.exercises, exercises),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,635 @@
|
|||||||
|
'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 { resolveExerciseIds } from '@/lib/ai/exerciseMatch';
|
||||||
|
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) {
|
||||||
|
const parsed = JSON.parse(gen.parsedProgram) as AIWorkout;
|
||||||
|
// Auto-resolve exercises the model named but didn't (or wrongly)
|
||||||
|
// id'd against the library, so the user isn't asked to hand-map an
|
||||||
|
// exercise they already own (e.g. "Overhead Press" -> "Overhead
|
||||||
|
// Press (barbell)"). Ambiguous ones stay unmapped.
|
||||||
|
setWorkout({
|
||||||
|
...parsed,
|
||||||
|
exercises: resolveExerciseIds(parsed.exercises, exercises),
|
||||||
|
});
|
||||||
|
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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,12 @@ const PROVIDERS = [
|
|||||||
},
|
},
|
||||||
{ id: 'gemini', label: 'Google Gemini', requiresKey: true, requiresUrl: false },
|
{ id: 'gemini', label: 'Google Gemini', requiresKey: true, requiresUrl: false },
|
||||||
{ id: 'ollama', label: 'Ollama (self-hosted)', requiresKey: false, requiresUrl: true },
|
{ id: 'ollama', label: 'Ollama (self-hosted)', requiresKey: false, requiresUrl: true },
|
||||||
|
{
|
||||||
|
id: 'sparkcontrol',
|
||||||
|
label: 'SparkControl (local)',
|
||||||
|
requiresKey: false,
|
||||||
|
requiresUrl: true,
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type ProviderId = (typeof PROVIDERS)[number]['id'];
|
type ProviderId = (typeof PROVIDERS)[number]['id'];
|
||||||
@@ -382,6 +388,11 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
|
|||||||
const [ollamaProbing, setOllamaProbing] = useState(false);
|
const [ollamaProbing, setOllamaProbing] = useState(false);
|
||||||
const [ollamaProbeError, setOllamaProbeError] = useState<string | null>(null);
|
const [ollamaProbeError, setOllamaProbeError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// SparkControl auto-detect (the currently-loaded vLLM model via /api/endpoints).
|
||||||
|
const [sparkModel, setSparkModel] = useState<string | null>(null);
|
||||||
|
const [sparkProbing, setSparkProbing] = useState(false);
|
||||||
|
const [sparkProbeError, setSparkProbeError] = useState<string | null>(null);
|
||||||
|
|
||||||
const meta = PROVIDERS.find((p) => p.id === provider);
|
const meta = PROVIDERS.find((p) => p.id === provider);
|
||||||
|
|
||||||
// Probe Ollama on provider switch (or baseUrl change while ollama).
|
// Probe Ollama on provider switch (or baseUrl change while ollama).
|
||||||
@@ -427,6 +438,48 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [provider, baseUrl]);
|
}, [provider, baseUrl]);
|
||||||
|
|
||||||
|
// Probe SparkControl on provider switch (or baseUrl change while selected):
|
||||||
|
// pre-fill the canonical same-box URL and the loaded model name.
|
||||||
|
useEffect(() => {
|
||||||
|
if (provider !== 'sparkcontrol') {
|
||||||
|
setSparkModel(null);
|
||||||
|
setSparkProbeError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setSparkProbing(true);
|
||||||
|
setSparkProbeError(null);
|
||||||
|
const url = baseUrl
|
||||||
|
? `/api/ai/sparkcontrol/model?baseUrl=${encodeURIComponent(baseUrl)}`
|
||||||
|
: '/api/ai/sparkcontrol/model';
|
||||||
|
fetch(url)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((b) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (b.ok) {
|
||||||
|
setSparkModel(b.model ?? null);
|
||||||
|
setSparkProbeError(null);
|
||||||
|
// Pre-fill the URL (with /v1) if the user hadn't typed one yet.
|
||||||
|
if (!baseUrl && b.baseUrl) setBaseUrl(b.baseUrl);
|
||||||
|
// Pre-pick the loaded model in create mode if the field is empty.
|
||||||
|
if (!isEdit && !model && b.model) setModel(b.model);
|
||||||
|
} else {
|
||||||
|
setSparkModel(null);
|
||||||
|
setSparkProbeError(b.error ?? 'Probe failed');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) setSparkProbeError((e as Error).message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setSparkProbing(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [provider, baseUrl]);
|
||||||
|
|
||||||
// Reset draft test result whenever the user changes any input — so the
|
// Reset draft test result whenever the user changes any input — so the
|
||||||
// green "✓ Connected" indicator never lingers from a previous attempt.
|
// green "✓ Connected" indicator never lingers from a previous attempt.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -511,6 +564,9 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
|
|||||||
setProvider(e.target.value as ProviderId);
|
setProvider(e.target.value as ProviderId);
|
||||||
setModel(''); // reset on provider change
|
setModel(''); // reset on provider change
|
||||||
setModelMode('menu');
|
setModelMode('menu');
|
||||||
|
// Clear any URL typed for a previous (custom-URL) provider so it
|
||||||
|
// can't ride along to a fixed-URL provider whose field is hidden.
|
||||||
|
setBaseUrl('');
|
||||||
}}
|
}}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
disabled={isEdit}
|
disabled={isEdit}
|
||||||
@@ -570,6 +626,30 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
) : provider === 'sparkcontrol' ? (
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
Model{' '}
|
||||||
|
{sparkProbing ? (
|
||||||
|
<span className="text-zinc-500 normal-case font-normal">· detecting…</span>
|
||||||
|
) : sparkModel ? (
|
||||||
|
<span className="text-emerald-400 normal-case font-normal">· detected</span>
|
||||||
|
) : sparkProbeError ? (
|
||||||
|
<span className="text-amber-400 normal-case font-normal">
|
||||||
|
· could not reach SparkControl (type a name)
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder={sparkModel ?? 'auto-detected from SparkControl'}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
) : showMenu ? (
|
) : showMenu ? (
|
||||||
<Field label="Model">
|
<Field label="Model">
|
||||||
<select
|
<select
|
||||||
@@ -628,7 +708,9 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
|
|||||||
placeholder={
|
placeholder={
|
||||||
meta.id === 'ollama'
|
meta.id === 'ollama'
|
||||||
? 'http://ollama.startos:11434'
|
? 'http://ollama.startos:11434'
|
||||||
: 'https://your-gateway.example.com/v1'
|
: meta.id === 'sparkcontrol'
|
||||||
|
? 'http://spark-control.startos:9999/v1'
|
||||||
|
: 'https://your-gateway.example.com/v1'
|
||||||
}
|
}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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) }]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Resolve an AI-suggested exercise to a library exercise id by NAME — a
|
||||||
|
* fallback for when the model's `exerciseId` is missing or isn't in the
|
||||||
|
* library. Models (local ones especially) often return a good display name
|
||||||
|
* with a null or invented id, e.g. "Overhead Press" when the library has
|
||||||
|
* "Overhead Press (barbell)".
|
||||||
|
*
|
||||||
|
* Rather than make the user hand-map an exercise they clearly already own, we
|
||||||
|
* match on a normalized name and auto-resolve only when the match is
|
||||||
|
* UNAMBIGUOUS. Ambiguous or no-match cases return null so the UI still flags
|
||||||
|
* them for manual mapping — a wrong auto-map is worse than asking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LibraryEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowercase, drop parenthetical qualifiers ("(barbell)", "(dumbbell)"), and
|
||||||
|
* collapse punctuation/whitespace to single spaces — so "Overhead Press
|
||||||
|
* (Barbell)" and "overhead-press" both normalize to "overhead press".
|
||||||
|
*/
|
||||||
|
export function normalizeExerciseName(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\([^)]*\)/g, ' ') // strip "(barbell)" etc.
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ') // punctuation/symbols → space
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best confident library id for a suggested name, or null when there's no
|
||||||
|
* match OR the match is ambiguous (multiple library exercises fit).
|
||||||
|
* Conservative by design.
|
||||||
|
*/
|
||||||
|
export function matchLibraryExerciseId(
|
||||||
|
name: string,
|
||||||
|
library: LibraryEntry[],
|
||||||
|
): string | null {
|
||||||
|
const q = normalizeExerciseName(name);
|
||||||
|
if (!q) return null;
|
||||||
|
|
||||||
|
const normed = library.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
norm: normalizeExerciseName(e.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 1. Exact normalized match. Unique → take it; a tie (e.g. barbell + dumbbell
|
||||||
|
// variants both normalize the same) → ambiguous, leave for manual mapping.
|
||||||
|
const exact = normed.filter((e) => e.norm === q);
|
||||||
|
if (exact.length === 1) return exact[0].id;
|
||||||
|
if (exact.length > 1) return null;
|
||||||
|
|
||||||
|
// 2. One-sided prefix on a word boundary: the suggested name is a prefix of
|
||||||
|
// exactly one library name, or vice-versa (catches non-parenthetical
|
||||||
|
// qualifiers like "Overhead Press Barbell"). Uniqueness keeps a generic
|
||||||
|
// word like "press" from mapping to anything.
|
||||||
|
const prefix = normed.filter(
|
||||||
|
(e) => e.norm.startsWith(q + ' ') || q.startsWith(e.norm + ' '),
|
||||||
|
);
|
||||||
|
if (prefix.length === 1) return prefix[0].id;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill in unresolved `exerciseId`s on a list of AI-suggested exercises by
|
||||||
|
* confident name match. Already-valid ids and unmatched names are left
|
||||||
|
* untouched (the latter stay null so the UI flags them for manual mapping).
|
||||||
|
*/
|
||||||
|
export function resolveExerciseIds<
|
||||||
|
T extends { exerciseId: string | null; exerciseName: string },
|
||||||
|
>(items: T[], library: LibraryEntry[]): T[] {
|
||||||
|
const libIds = new Set(library.map((e) => e.id));
|
||||||
|
return items.map((it) =>
|
||||||
|
it.exerciseId && libIds.has(it.exerciseId)
|
||||||
|
? it
|
||||||
|
: {
|
||||||
|
...it,
|
||||||
|
exerciseId: matchLibraryExerciseId(it.exerciseName, library) ?? it.exerciseId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,10 +123,12 @@ export const MODEL_MENU: Record<string, ModelOption[]> = {
|
|||||||
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||||
{ id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash (legacy)' },
|
{ id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash (legacy)' },
|
||||||
],
|
],
|
||||||
// openai-compatible + ollama: no curated menu — model names are
|
// openai-compatible + ollama + sparkcontrol: no curated menu — model names
|
||||||
// gateway- or host-specific. Ollama auto-detects via /api/tags.
|
// are gateway- or host-specific. Ollama auto-detects via /api/tags;
|
||||||
|
// SparkControl auto-detects the loaded model via /api/endpoints.
|
||||||
'openai-compatible': [],
|
'openai-compatible': [],
|
||||||
ollama: [],
|
ollama: [],
|
||||||
|
sparkcontrol: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Find the price entry whose key is a (case-insensitive) prefix of the model string. */
|
/** Find the price entry whose key is a (case-insensitive) prefix of the model string. */
|
||||||
@@ -153,6 +155,7 @@ export function estimateCost(opts: {
|
|||||||
tokensOut: number | null;
|
tokensOut: number | null;
|
||||||
}): number | null {
|
}): number | null {
|
||||||
if (opts.provider === 'ollama') return 0; // self-hosted, no per-token cost
|
if (opts.provider === 'ollama') return 0; // self-hosted, no per-token cost
|
||||||
|
if (opts.provider === 'sparkcontrol') return 0; // self-hosted local inference, free
|
||||||
if (opts.provider === 'openai-compatible') return null; // we don't know the gateway's pricing
|
if (opts.provider === 'openai-compatible') return null; // we don't know the gateway's pricing
|
||||||
if (opts.tokensIn == null || opts.tokensOut == null) return null;
|
if (opts.tokensIn == null || opts.tokensOut == null) return null;
|
||||||
const price = findPrice(opts.model);
|
const price = findPrice(opts.model);
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models — local ones especially (Qwen via SparkControl, Llama via Ollama) —
|
||||||
|
* sometimes emit a decimal where we expect an integer (`"rpe": 7.5`,
|
||||||
|
* `"reps": 8.0`). Round to the nearest int BEFORE the `.int()` check so one
|
||||||
|
* stray decimal doesn't fail the whole parse. Non-numbers pass through
|
||||||
|
* untouched, so the outer `.optional()`/`.nullable()` still apply. Shared with
|
||||||
|
* the single-workout schema (`workoutSchema.ts`).
|
||||||
|
*/
|
||||||
|
export const looseInt = (schema: z.ZodNumber) =>
|
||||||
|
z.preprocess((v) => (typeof v === 'number' ? Math.round(v) : v), schema);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shape we ask LLMs to produce, validated server-side via Zod
|
* The shape we ask LLMs to produce, validated server-side via Zod
|
||||||
* after parsing whatever JSON came back. Maps 1:1 onto the existing
|
* after parsing whatever JSON came back. Maps 1:1 onto the existing
|
||||||
@@ -16,12 +27,12 @@ import { z } from 'zod';
|
|||||||
export const aiExerciseSchema = z.object({
|
export const aiExerciseSchema = z.object({
|
||||||
exerciseId: z.string().nullable(),
|
exerciseId: z.string().nullable(),
|
||||||
exerciseName: z.string().min(1),
|
exerciseName: z.string().min(1),
|
||||||
order: z.number().int().nonnegative(),
|
order: looseInt(z.number().int().nonnegative()),
|
||||||
sets: z.number().int().positive().optional().nullable(),
|
sets: looseInt(z.number().int().positive()).optional().nullable(),
|
||||||
repsMin: z.number().int().positive().optional().nullable(),
|
repsMin: looseInt(z.number().int().positive()).optional().nullable(),
|
||||||
repsMax: z.number().int().positive().optional().nullable(),
|
repsMax: looseInt(z.number().int().positive()).optional().nullable(),
|
||||||
rpe: z.number().int().min(1).max(10).optional().nullable(),
|
rpe: looseInt(z.number().int().min(1).max(10)).optional().nullable(),
|
||||||
restSeconds: z.number().int().nonnegative().optional().nullable(),
|
restSeconds: looseInt(z.number().int().nonnegative()).optional().nullable(),
|
||||||
/// Suggested starting weight. Not required (cardio, bodyweight,
|
/// Suggested starting weight. Not required (cardio, bodyweight,
|
||||||
/// stretching all leave it null). When provided alongside an
|
/// stretching all leave it null). When provided alongside an
|
||||||
/// exerciseId that the user starts a workout from, this seeds the
|
/// exerciseId that the user starts a workout from, this seeds the
|
||||||
@@ -34,14 +45,14 @@ export const aiExerciseSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const aiDaySchema = z.object({
|
export const aiDaySchema = z.object({
|
||||||
dayOfWeek: z.number().int().min(0).max(6),
|
dayOfWeek: looseInt(z.number().int().min(0).max(6)),
|
||||||
name: z.string().optional().nullable(),
|
name: z.string().optional().nullable(),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
exercises: z.array(aiExerciseSchema),
|
exercises: z.array(aiExerciseSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const aiWeekSchema = z.object({
|
export const aiWeekSchema = z.object({
|
||||||
weekNumber: z.number().int().positive(),
|
weekNumber: looseInt(z.number().int().positive()),
|
||||||
phase: z.string().optional().nullable(),
|
phase: z.string().optional().nullable(),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
days: z.array(aiDaySchema),
|
days: z.array(aiDaySchema),
|
||||||
@@ -51,7 +62,7 @@ export const aiProgramSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
type: z.string().min(1),
|
type: z.string().min(1),
|
||||||
durationWeeks: z.number().int().positive(),
|
durationWeeks: looseInt(z.number().int().positive()),
|
||||||
weeks: z.array(aiWeekSchema),
|
weeks: z.array(aiWeekSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ollama } from './ollama';
|
|||||||
import { claude } from './claude';
|
import { claude } from './claude';
|
||||||
import { openai, openaiCompatible } from './openai';
|
import { openai, openaiCompatible } from './openai';
|
||||||
import { gemini } from './gemini';
|
import { gemini } from './gemini';
|
||||||
|
import { sparkcontrol } from './sparkcontrol';
|
||||||
|
|
||||||
const ALL: Record<ProviderId, LLMProvider> = {
|
const ALL: Record<ProviderId, LLMProvider> = {
|
||||||
claude,
|
claude,
|
||||||
@@ -10,6 +11,7 @@ const ALL: Record<ProviderId, LLMProvider> = {
|
|||||||
'openai-compatible': openaiCompatible,
|
'openai-compatible': openaiCompatible,
|
||||||
gemini,
|
gemini,
|
||||||
ollama,
|
ollama,
|
||||||
|
sparkcontrol,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getProvider(id: string): LLMProvider | null {
|
export function getProvider(id: string): LLMProvider | null {
|
||||||
@@ -17,10 +19,10 @@ export function getProvider(id: string): LLMProvider | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True for providers that take a user-supplied base URL (Ollama,
|
* True for providers that take a user-supplied base URL (Ollama, SparkControl,
|
||||||
* OpenAI-compatible). Configuring these is admin-only — a non-admin pointing
|
* OpenAI-compatible). Configuring these is admin-only — a non-admin pointing
|
||||||
* the server at an arbitrary URL is the SSRF actor vector (EVALUATION.md P1).
|
* the server at an arbitrary URL is the SSRF actor vector. The fixed-URL cloud
|
||||||
* The fixed-URL cloud providers (claude/openai/gemini) stay per-user.
|
* providers (claude/openai/gemini) stay per-user.
|
||||||
*/
|
*/
|
||||||
export function isCustomUrlProvider(id: string): boolean {
|
export function isCustomUrlProvider(id: string): boolean {
|
||||||
return !!getProvider(id)?.requiresBaseUrl;
|
return !!getProvider(id)?.requiresBaseUrl;
|
||||||
@@ -33,6 +35,7 @@ export const PROVIDER_ORDER: ProviderId[] = [
|
|||||||
'openai-compatible',
|
'openai-compatible',
|
||||||
'gemini',
|
'gemini',
|
||||||
'ollama',
|
'ollama',
|
||||||
|
'sparkcontrol',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PROVIDERS = ALL;
|
export const PROVIDERS = ALL;
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ export async function* generateOpenAIStyle(
|
|||||||
opts: GenerateOpts,
|
opts: GenerateOpts,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
providerLabel: string,
|
providerLabel: string,
|
||||||
|
{ requireApiKey = true }: { requireApiKey?: boolean } = {},
|
||||||
): AsyncGenerator<GenerateChunk, void, void> {
|
): AsyncGenerator<GenerateChunk, void, void> {
|
||||||
if (!opts.apiKey) {
|
if (requireApiKey && !opts.apiKey) {
|
||||||
yield { type: 'error', message: `${providerLabel} API key is required.` };
|
yield { type: 'error', message: `${providerLabel} API key is required.` };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -29,7 +30,10 @@ export async function* generateOpenAIStyle(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
authorization: `Bearer ${opts.apiKey}`,
|
// Only send Authorization when we actually have a key. SparkControl
|
||||||
|
// and other keyless LAN gateways take no auth; an empty Bearer would
|
||||||
|
// be wrong (and some servers reject it).
|
||||||
|
...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { LLMProvider } from '../types';
|
||||||
|
import { generateOpenAIStyle } from './openai';
|
||||||
|
import { assertSafeProviderUrl } from '../safeUrl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SparkControl — a self-hosted local-inference gateway (the operator's own
|
||||||
|
* StartOS package). Its LLM surface is OpenAI-compatible
|
||||||
|
* (`POST {baseUrl}/chat/completions`, SSE `data:` frames, `[DONE]`), so we
|
||||||
|
* reuse the OpenAI-style streamer wholesale.
|
||||||
|
*
|
||||||
|
* Two differences from the generic `openai-compatible` provider:
|
||||||
|
* 1. **No API key.** SparkControl takes no auth on the LAN, so the key is
|
||||||
|
* optional (`requireApiKey: false`) — the streamer omits the
|
||||||
|
* Authorization header when none is set.
|
||||||
|
* 2. **Reached over the internal same-box address** (e.g.
|
||||||
|
* `http://spark-control.startos:9999/v1`) — plain HTTP, no TLS to worry
|
||||||
|
* about. The public LAN interface is HTTPS with a self-signed Start9
|
||||||
|
* cert; we deliberately don't go there, so no cert-verification games.
|
||||||
|
*
|
||||||
|
* Custom base URL ⇒ SSRF-guarded + admin-only, same as Ollama. The model name
|
||||||
|
* is whatever vLLM currently has loaded; the Settings UI auto-detects it via
|
||||||
|
* SparkControl's `/api/endpoints` discovery (see app/api/ai/sparkcontrol/model).
|
||||||
|
*/
|
||||||
|
export const sparkcontrol: LLMProvider = {
|
||||||
|
id: 'sparkcontrol',
|
||||||
|
label: 'SparkControl (local)',
|
||||||
|
requiresApiKey: false,
|
||||||
|
requiresBaseUrl: true,
|
||||||
|
async *generate(opts) {
|
||||||
|
if (!opts.baseUrl) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
'Base URL is required (e.g. http://spark-control.startos:9999/v1).',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// User-supplied base URL → SSRF guard (private-LAN + loopback allowed on
|
||||||
|
// purpose; reaching spark-control.startos is the feature).
|
||||||
|
try {
|
||||||
|
await assertSafeProviderUrl(opts.baseUrl);
|
||||||
|
} catch (e) {
|
||||||
|
yield { type: 'error', message: `SparkControl: ${(e as Error).message}` };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield* generateOpenAIStyle(opts, opts.baseUrl, 'SparkControl', {
|
||||||
|
requireApiKey: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -20,7 +20,8 @@ export type ProviderId =
|
|||||||
| 'openai'
|
| 'openai'
|
||||||
| 'openai-compatible'
|
| 'openai-compatible'
|
||||||
| 'gemini'
|
| 'gemini'
|
||||||
| 'ollama';
|
| 'ollama'
|
||||||
|
| 'sparkcontrol';
|
||||||
|
|
||||||
export interface GenerateOpts {
|
export interface GenerateOpts {
|
||||||
/** API key. Null/undefined for ollama on a trusted LAN. */
|
/** API key. Null/undefined for ollama on a trusted LAN. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { extractJson, looseInt } 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: looseInt(z.number().int().nonnegative()),
|
||||||
|
/// Number of working sets to pre-fill. Defaults to 3 in the hand-off
|
||||||
|
/// if the model omits it.
|
||||||
|
sets: looseInt(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: looseInt(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: looseInt(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: looseInt(z.number().int().min(1).max(5)).optional().nullable(),
|
||||||
|
/// Target duration in seconds for time-based work (e.g. a hold).
|
||||||
|
durationSeconds: looseInt(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 };
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
normalizeExerciseName,
|
||||||
|
matchLibraryExerciseId,
|
||||||
|
resolveExerciseIds,
|
||||||
|
} from '@/lib/ai/exerciseMatch';
|
||||||
|
|
||||||
|
const library = [
|
||||||
|
{ id: 'ohp', name: 'Overhead Press (barbell)' },
|
||||||
|
{ id: 'bench', name: 'Bench Press (barbell)' },
|
||||||
|
{ id: 'curl', name: 'Barbell Curl (barbell)' },
|
||||||
|
{ id: 'bike', name: 'Assault Bike (assault bike)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('normalizeExerciseName', () => {
|
||||||
|
it('lowercases, strips parens, and collapses punctuation', () => {
|
||||||
|
expect(normalizeExerciseName('Overhead Press (Barbell)')).toBe('overhead press');
|
||||||
|
expect(normalizeExerciseName('overhead-press')).toBe('overhead press');
|
||||||
|
expect(normalizeExerciseName(' Bench Press ')).toBe('bench press');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchLibraryExerciseId', () => {
|
||||||
|
it('maps a bare name to the parenthetical library variant (the reported case)', () => {
|
||||||
|
expect(matchLibraryExerciseId('Overhead Press', library)).toBe('ohp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
expect(matchLibraryExerciseId('overhead press', library)).toBe('ohp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a non-parenthetical qualifier via unique prefix', () => {
|
||||||
|
const lib = [{ id: 'ohp', name: 'Overhead Press Barbell' }];
|
||||||
|
expect(matchLibraryExerciseId('Overhead Press', lib)).toBe('ohp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the match is ambiguous (multiple variants normalize the same)', () => {
|
||||||
|
const ambiguous = [
|
||||||
|
{ id: 'ohp-bb', name: 'Overhead Press (barbell)' },
|
||||||
|
{ id: 'ohp-db', name: 'Overhead Press (dumbbell)' },
|
||||||
|
];
|
||||||
|
expect(matchLibraryExerciseId('Overhead Press', ambiguous)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not map a generic word that prefixes many exercises', () => {
|
||||||
|
// "press" is a prefix of both Overhead Press and Bench Press → not unique.
|
||||||
|
expect(matchLibraryExerciseId('Press', library)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an exercise not in the library', () => {
|
||||||
|
expect(matchLibraryExerciseId('Zercher Squat', library)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an empty/punctuation-only name', () => {
|
||||||
|
expect(matchLibraryExerciseId(' ', library)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveExerciseIds', () => {
|
||||||
|
it('fills a null id by confident name match', () => {
|
||||||
|
const items = [{ exerciseId: null, exerciseName: 'Overhead Press' }];
|
||||||
|
expect(resolveExerciseIds(items, library)[0].exerciseId).toBe('ohp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs an invalid (non-library) id by name', () => {
|
||||||
|
const items = [{ exerciseId: 'made-up-id', exerciseName: 'Bench Press' }];
|
||||||
|
expect(resolveExerciseIds(items, library)[0].exerciseId).toBe('bench');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a valid id untouched', () => {
|
||||||
|
const items = [{ exerciseId: 'curl', exerciseName: 'whatever' }];
|
||||||
|
expect(resolveExerciseIds(items, library)[0].exerciseId).toBe('curl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unmatched name as null for manual mapping', () => {
|
||||||
|
const items = [{ exerciseId: null, exerciseName: 'Zercher Squat' }];
|
||||||
|
expect(resolveExerciseIds(items, library)[0].exerciseId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves other fields', () => {
|
||||||
|
const items = [
|
||||||
|
{ exerciseId: null, exerciseName: 'Overhead Press', sets: 4, reps: 6 },
|
||||||
|
];
|
||||||
|
const out = resolveExerciseIds(items, library)[0];
|
||||||
|
expect(out).toMatchObject({ exerciseId: 'ohp', sets: 4, reps: 6 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,6 +75,20 @@ describe('parseAIProgram', () => {
|
|||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rounds decimal ints from the model instead of failing (RPE 7.5 -> 8)', () => {
|
||||||
|
const variant = structuredClone(valid);
|
||||||
|
const ex = variant.weeks[0].days[0].exercises[0] as Record<string, unknown>;
|
||||||
|
ex.rpe = 7.5; // -> 8
|
||||||
|
ex.sets = 4.2; // -> 4
|
||||||
|
const r = parseAIProgram(JSON.stringify(variant));
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
const out = r.program.weeks[0].days[0].exercises[0];
|
||||||
|
expect(out.rpe).toBe(8);
|
||||||
|
expect(out.sets).toBe(4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects when no JSON found', () => {
|
it('rejects when no JSON found', () => {
|
||||||
const r = parseAIProgram('the model just said hello');
|
const r = parseAIProgram('the model just said hello');
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { sparkcontrol } from '@/lib/ai/providers/sparkcontrol';
|
||||||
|
import type { GenerateChunk } from '@/lib/ai/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SparkControl provider: OpenAI-style streaming over the internal same-box
|
||||||
|
* URL, with NO API key (and therefore no Authorization header).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function sse(frames: string[]): string {
|
||||||
|
return frames.map((f) => `data: ${f}\n\n`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect(
|
||||||
|
gen: AsyncIterable<GenerateChunk>,
|
||||||
|
): Promise<GenerateChunk[]> {
|
||||||
|
const out: GenerateChunk[] = [];
|
||||||
|
for await (const c of gen) out.push(c);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sparkcontrol provider', () => {
|
||||||
|
it('errors when no base URL is configured', async () => {
|
||||||
|
const chunks = await collect(
|
||||||
|
sparkcontrol.generate({
|
||||||
|
model: 'whatever',
|
||||||
|
systemPrompt: 'sys',
|
||||||
|
userPrompt: 'hi',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(chunks[0]).toEqual({
|
||||||
|
type: 'error',
|
||||||
|
message: expect.stringContaining('Base URL is required'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams deltas + usage and sends NO Authorization header (keyless)', async () => {
|
||||||
|
const fetchMock = vi.fn(
|
||||||
|
// Typed params so mock.calls[0] is a [url, init] tuple, not [].
|
||||||
|
async (_url: string, _init: RequestInit) =>
|
||||||
|
new Response(
|
||||||
|
sse([
|
||||||
|
'{"choices":[{"delta":{"content":"Hello"}}]}',
|
||||||
|
'{"choices":[{"delta":{"content":" there"}}]}',
|
||||||
|
'{"usage":{"prompt_tokens":5,"completion_tokens":2}}',
|
||||||
|
'[DONE]',
|
||||||
|
]),
|
||||||
|
{ status: 200, headers: { 'content-type': 'text/event-stream' } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const chunks = await collect(
|
||||||
|
sparkcontrol.generate({
|
||||||
|
// Loopback literal → passes the SSRF guard without DNS.
|
||||||
|
baseUrl: 'http://127.0.0.1:9999/v1',
|
||||||
|
model: 'RedHatAI/Qwen3.6-35B-A3B-NVFP4',
|
||||||
|
systemPrompt: 'sys',
|
||||||
|
userPrompt: 'hi',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// POSTs to {baseUrl}/chat/completions.
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe('http://127.0.0.1:9999/v1/chat/completions');
|
||||||
|
|
||||||
|
// Keyless: no Authorization header at all.
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
expect('authorization' in headers).toBe(false);
|
||||||
|
expect(headers.authorization).toBeUndefined();
|
||||||
|
|
||||||
|
// Streamed text + usage made it through.
|
||||||
|
const text = chunks
|
||||||
|
.filter((c): c is Extract<GenerateChunk, { type: 'text' }> => c.type === 'text')
|
||||||
|
.map((c) => c.delta)
|
||||||
|
.join('');
|
||||||
|
expect(text).toBe('Hello there');
|
||||||
|
expect(chunks).toContainEqual({ type: 'usage', tokensIn: 5, tokensOut: 2 });
|
||||||
|
expect(chunks.some((c) => c.type === 'done')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks an SSRF-unsafe base URL (cloud metadata)', async () => {
|
||||||
|
const chunks = await collect(
|
||||||
|
sparkcontrol.generate({
|
||||||
|
baseUrl: 'http://169.254.169.254/v1',
|
||||||
|
model: 'x',
|
||||||
|
systemPrompt: 'sys',
|
||||||
|
userPrompt: 'hi',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(chunks[0].type).toBe('error');
|
||||||
|
expect((chunks[0] as { message: string }).message).toContain('SparkControl');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,106 @@
|
|||||||
|
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('rounds decimal ints from the model instead of failing (RPE 7.5 -> 8)', () => {
|
||||||
|
// Local models (Qwen via SparkControl) sometimes emit half-step RPE or
|
||||||
|
// other decimals where we expect integers. These must round, not blow up
|
||||||
|
// the whole parse (the bug that surfaced on the first SparkControl run).
|
||||||
|
const variant = structuredClone(valid);
|
||||||
|
const ex = variant.exercises[0] as Record<string, unknown>;
|
||||||
|
ex.rpe = 7.5; // -> 8
|
||||||
|
ex.reps = 8.6; // -> 9
|
||||||
|
ex.sets = 3.4; // -> 3
|
||||||
|
const r = parseAIWorkout(JSON.stringify(variant));
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.workout.exercises[0].rpe).toBe(8);
|
||||||
|
expect(r.workout.exercises[0].reps).toBe(9);
|
||||||
|
expect(r.workout.exercises[0].sets).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
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 as createConfig } from '@/app/api/ai/configs/route';
|
||||||
|
import { PATCH as patchConfig } from '@/app/api/ai/configs/[id]/route';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base-URL hygiene on AI config writes:
|
||||||
|
* - Fixed-URL providers (claude/openai/gemini) must NEVER store a base URL,
|
||||||
|
* even if one rides along in the body (the footgun that produced a gemini
|
||||||
|
* config silently pointed at a custom URL the gemini provider ignores).
|
||||||
|
* - Custom-URL providers (incl. SparkControl) keep it. SparkControl also
|
||||||
|
* needs no API key.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function jsonReq(url: string, body: unknown, method = 'POST'): NextRequest {
|
||||||
|
return new NextRequest(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeAdmin(email: string) {
|
||||||
|
return prisma.user.create({
|
||||||
|
data: { email, passwordHash: 'fake', isAdmin: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.aIConfigProfile.deleteMany();
|
||||||
|
await prisma.userPreferences.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
getCurrentUserMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/ai/configs — base-URL hygiene', () => {
|
||||||
|
it('drops a base URL on a fixed-URL provider (gemini) and its active mirror', async () => {
|
||||||
|
const u = await makeAdmin('a@x.com');
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
|
||||||
|
const res = await createConfig(
|
||||||
|
jsonReq('http://x/api/ai/configs', {
|
||||||
|
provider: 'gemini',
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
baseUrl: 'http://192.168.1.72:62419/v1',
|
||||||
|
apiKey: 'sk-real',
|
||||||
|
setActive: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.baseUrl).toBeNull();
|
||||||
|
|
||||||
|
const profile = await prisma.aIConfigProfile.findFirstOrThrow({
|
||||||
|
where: { userId: u.id },
|
||||||
|
});
|
||||||
|
expect(profile.baseUrl).toBeNull();
|
||||||
|
|
||||||
|
// The legacy mirror in UserPreferences must also be clean.
|
||||||
|
const prefs = await prisma.userPreferences.findUnique({ where: { userId: u.id } });
|
||||||
|
expect(prefs?.aiBaseUrl).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the base URL for SparkControl and needs no API key', async () => {
|
||||||
|
const u = await makeAdmin('b@x.com');
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
|
||||||
|
const res = await createConfig(
|
||||||
|
jsonReq('http://x/api/ai/configs', {
|
||||||
|
provider: 'sparkcontrol',
|
||||||
|
model: 'RedHatAI/Qwen3.6-35B-A3B-NVFP4',
|
||||||
|
baseUrl: 'http://spark-control.startos:9999/v1',
|
||||||
|
setActive: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.baseUrl).toBe('http://spark-control.startos:9999/v1');
|
||||||
|
expect(body.keyConfigured).toBe(false);
|
||||||
|
|
||||||
|
const profile = await prisma.aIConfigProfile.findFirstOrThrow({
|
||||||
|
where: { userId: u.id },
|
||||||
|
});
|
||||||
|
expect(profile.baseUrl).toBe('http://spark-control.startos:9999/v1');
|
||||||
|
expect(profile.apiKey).toBeNull();
|
||||||
|
|
||||||
|
const prefs = await prisma.userPreferences.findUnique({ where: { userId: u.id } });
|
||||||
|
expect(prefs?.aiBaseUrl).toBe('http://spark-control.startos:9999/v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-admin creating a SparkControl config and names it in the error', async () => {
|
||||||
|
const u = await prisma.user.create({
|
||||||
|
data: { email: 'nonadmin@x.com', passwordHash: 'fake', isAdmin: false },
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
|
||||||
|
const res = await createConfig(
|
||||||
|
jsonReq('http://x/api/ai/configs', {
|
||||||
|
provider: 'sparkcontrol',
|
||||||
|
model: 'some-model',
|
||||||
|
baseUrl: 'http://spark-control.startos:9999/v1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toContain('SparkControl');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/ai/configs/[id] — base-URL hygiene', () => {
|
||||||
|
it('refuses to attach a base URL to a fixed-URL provider on edit', async () => {
|
||||||
|
const u = await makeAdmin('c@x.com');
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
|
||||||
|
const created = await prisma.aIConfigProfile.create({
|
||||||
|
data: { userId: u.id, name: 'g', provider: 'gemini', model: 'gemini-2.5-flash' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await patchConfig(
|
||||||
|
jsonReq(`http://x/api/ai/configs/${created.id}`, {
|
||||||
|
baseUrl: 'http://192.168.1.72:62419/v1',
|
||||||
|
}, 'PATCH'),
|
||||||
|
{ params: Promise.resolve({ id: created.id }) },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const after = await prisma.aIConfigProfile.findFirstOrThrow({ where: { id: created.id } });
|
||||||
|
expect(after.baseUrl).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans the active-config mirror when stripping a base URL on edit', async () => {
|
||||||
|
const u = await makeAdmin('d@x.com');
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
|
||||||
|
// Create + activate a gemini config (mirrors into UserPreferences).
|
||||||
|
const createRes = await createConfig(
|
||||||
|
jsonReq('http://x/api/ai/configs', {
|
||||||
|
provider: 'gemini',
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
apiKey: 'sk-real',
|
||||||
|
setActive: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const res = await patchConfig(
|
||||||
|
jsonReq(`http://x/api/ai/configs/${id}`, {
|
||||||
|
baseUrl: 'http://192.168.1.72:62419/v1',
|
||||||
|
}, 'PATCH'),
|
||||||
|
{ params: Promise.resolve({ id }) },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const after = await prisma.aIConfigProfile.findFirstOrThrow({ where: { id } });
|
||||||
|
expect(after.baseUrl).toBeNull();
|
||||||
|
// The active mirror in UserPreferences must be clean too.
|
||||||
|
const prefs = await prisma.userPreferences.findUnique({ where: { userId: u.id } });
|
||||||
|
expect(prefs?.aiBaseUrl).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ export type ParsedSet = {
|
|||||||
distance?: number | null;
|
distance?: number | null;
|
||||||
distanceUnit?: string | null;
|
distanceUnit?: string | null;
|
||||||
calories?: number | null;
|
calories?: number | null;
|
||||||
|
watts?: number | null;
|
||||||
rpe?: number | null;
|
rpe?: number | null;
|
||||||
|
gear?: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
|
|||||||
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN customMetrics TEXT;"
|
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN customMetrics TEXT;"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('SetLog');" 2>/dev/null | grep -q "|watts|"; then
|
||||||
|
log "adding missing column SetLog.watts"
|
||||||
|
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN watts INTEGER;"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('SetLog');" 2>/dev/null | grep -q "|gear|"; then
|
||||||
|
log "adding missing column SetLog.gear"
|
||||||
|
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN gear INTEGER;"
|
||||||
|
fi
|
||||||
|
|
||||||
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|deletedAt|"; then
|
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|deletedAt|"; then
|
||||||
log "adding missing column Workout.deletedAt"
|
log "adding missing column Workout.deletedAt"
|
||||||
sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN deletedAt DATETIME;"
|
sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN deletedAt DATETIME;"
|
||||||
@@ -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;"
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ 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_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'
|
||||||
|
import { v_1_2_0_7 } from './v1.2.0.7'
|
||||||
|
import { v_1_2_0_8 } from './v1.2.0.8'
|
||||||
|
import { v_1_2_0_9 } from './v1.2.0.9'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Version graph for the `proof-of-work` package.
|
* Version graph for the `proof-of-work` package.
|
||||||
@@ -66,9 +73,36 @@ import { v_1_2_0_2 } from './v1.2.0.2'
|
|||||||
* server-action POST on a stale keep-alive socket
|
* server-action POST on a stale keep-alive socket
|
||||||
* (NSURLErrorNetworkConnectionLost); retry once on transport
|
* (NSURLErrorNetworkConnectionLost); retry once on transport
|
||||||
* failure. Client-only, no schema/data change.
|
* 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.
|
||||||
|
* v1.2.0:7 — SparkControl AI provider (6th provider): keyless, same-box
|
||||||
|
* internal address, model auto-detected via /api/endpoints. Plus a
|
||||||
|
* base-URL footgun fix (a custom URL could attach to a fixed-URL
|
||||||
|
* provider and be silently ignored). No schema/data change.
|
||||||
|
* v1.2.0:8 — Tolerate decimal integers in AI output: a shared looseInt rounds
|
||||||
|
* float values (e.g. a half-step RPE 7.5 from a local model) before
|
||||||
|
* the .int() check, so one stray decimal no longer fails the whole
|
||||||
|
* generation. Parse-only; no schema/data change.
|
||||||
|
* v1.2.0:9 — Fuzzy-match AI exercises to the library by name (both generate
|
||||||
|
* flows): "Overhead Press" auto-maps to "Overhead Press (barbell)";
|
||||||
|
* ambiguous names stay manual. Client-only; no schema/data change.
|
||||||
*/
|
*/
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_1_2_0_2,
|
current: v_1_2_0_9,
|
||||||
other: [
|
other: [
|
||||||
v_1_0_0_1,
|
v_1_0_0_1,
|
||||||
v_1_0_0_2,
|
v_1_0_0_2,
|
||||||
@@ -87,5 +121,12 @@ export const versionGraph = VersionGraph.of({
|
|||||||
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_1,
|
||||||
|
v_1_2_0_2,
|
||||||
|
v_1_2_0_3,
|
||||||
|
v_1_2_0_4,
|
||||||
|
v_1_2_0_5,
|
||||||
|
v_1_2_0_6,
|
||||||
|
v_1_2_0_7,
|
||||||
|
v_1_2_0_8,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.2.0:4 — Avg. watts as a first-class set field (2026-06-16).
|
||||||
|
*
|
||||||
|
* Average watts (assault bike, rower, ski erg) used to be a free-text entry
|
||||||
|
* stuffed into the per-set customMetrics JSON blob. It's now a real nullable
|
||||||
|
* column, SetLog.watts, written through every set path (create / PATCH /
|
||||||
|
* add-sets / import-save / account-import) and shown everywhere as
|
||||||
|
* "Avg. watts" with a proper numeric input.
|
||||||
|
*
|
||||||
|
* Additive schema change: the SetLog.watts column is added by the boot-time
|
||||||
|
* guarded ALTER in docker_entrypoint.sh (so this migration stays empty, like
|
||||||
|
* every other column add). Existing data is untouched — legacy watts values
|
||||||
|
* remain readable from customMetrics and migrate to the column on next save.
|
||||||
|
*/
|
||||||
|
export const v_1_2_0_4 = VersionInfo.of({
|
||||||
|
version: '1.2.0:4',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Average watts is now a first-class field for cardio machines (assault bike, rower, ski erg) — a proper numeric "Avg. watts" input instead of a free-text custom metric. Existing data is preserved.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.2.0:5 — Gear (breathing, 1-5) replaces RPE as the cardio effort field (2026-06-16).
|
||||||
|
*
|
||||||
|
* Cardio exercises now log a breathing "Gear" (1-5, per Brian MacKenzie)
|
||||||
|
* instead of RPE (6-10) as their effort field; non-cardio keeps RPE. An
|
||||||
|
* exercise counts as cardio if its equipment type is "cardio" or it carries
|
||||||
|
* the "cardio" muscle group (so Assault Bike, type "assault bike", qualifies).
|
||||||
|
*
|
||||||
|
* Additive schema change: the new nullable SetLog.gear column is added by the
|
||||||
|
* boot-time guarded ALTER in docker_entrypoint.sh (migration stays empty, like
|
||||||
|
* every other column add). Existing rpe data is untouched and still displays.
|
||||||
|
*/
|
||||||
|
export const v_1_2_0_5 = VersionInfo.of({
|
||||||
|
version: '1.2.0:5',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Cardio exercises (assault bike, rower, ski erg, running, etc.) now log a breathing "Gear" (1-5) instead of RPE as their effort field. Strength exercises still use RPE. No data changes.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.2.0:7 — SparkControl AI provider + base-URL footgun fix (2026-06-19).
|
||||||
|
*
|
||||||
|
* Adds a 6th AI provider, "SparkControl (local)" — the operator's own
|
||||||
|
* self-hosted local-inference gateway. Its LLM surface is OpenAI-compatible,
|
||||||
|
* so it reuses the OpenAI-style streamer, with two twists: no API key (keyless
|
||||||
|
* on the LAN) and reached over the internal same-box StartOS address
|
||||||
|
* (http://spark-control.startos:9999/v1, plain HTTP — no TLS games). The
|
||||||
|
* Settings UI auto-detects the loaded vLLM model via SparkControl's
|
||||||
|
* /api/endpoints discovery, mirroring the Ollama auto-detect.
|
||||||
|
*
|
||||||
|
* Also fixes a config footgun: a base URL could ride along to a fixed-URL
|
||||||
|
* provider (Claude/OpenAI/Gemini) whose form field is hidden — stored but
|
||||||
|
* silently ignored by the provider, which always hits its hardcoded endpoint.
|
||||||
|
* Both config write paths now drop a base URL for non-custom-URL providers,
|
||||||
|
* and the form clears it on provider change.
|
||||||
|
*
|
||||||
|
* No schema or data change: `AIConfigProfile.provider` is a free-text column,
|
||||||
|
* so the new value needs no migration. Existing configs are untouched.
|
||||||
|
*/
|
||||||
|
export const v_1_2_0_7 = VersionInfo.of({
|
||||||
|
version: '1.2.0:7',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'New AI provider: SparkControl — your self-hosted local-inference gateway. Reached over the same-box address with no API key; the model is auto-detected. Also fixes a bug where a custom base URL could attach to a fixed-URL provider (Claude/OpenAI/Gemini) and be silently ignored. No data changes.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.2.0:8 — Tolerate decimal integers in AI output (2026-06-19).
|
||||||
|
*
|
||||||
|
* Local models (notably Qwen via SparkControl, surfaced on the first
|
||||||
|
* SparkControl smoke test) sometimes emit a decimal where the AI-output
|
||||||
|
* schema expects an integer — e.g. a half-step `"rpe": 7.5`, or `"reps": 8.0`.
|
||||||
|
* Zod's `.int()` rejected these and failed the ENTIRE parse ("JSON did not
|
||||||
|
* match the expected shape"), so a single stray decimal killed an otherwise
|
||||||
|
* good generation.
|
||||||
|
*
|
||||||
|
* Fix: a shared `looseInt` helper rounds a number to the nearest integer
|
||||||
|
* BEFORE the `.int()` check, applied to every integer field in both the
|
||||||
|
* program and single-workout schemas (rpe, reps, sets, gear, order,
|
||||||
|
* durationSeconds, rest/week/day numbers, …). RPE/reps/sets are stored as
|
||||||
|
* integers downstream, so rounding is the correct landing.
|
||||||
|
*
|
||||||
|
* Client-/parse-only — no schema or data change.
|
||||||
|
*/
|
||||||
|
export const v_1_2_0_8 = VersionInfo.of({
|
||||||
|
version: '1.2.0:8',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'AI generation is now tolerant of decimal values from local models (e.g. a half-step RPE like 7.5) — they round to the nearest whole number instead of failing the whole generation. No data changes.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.2.0:9 — Fuzzy-match AI exercises to the library by name (2026-06-19).
|
||||||
|
*
|
||||||
|
* Both AI flows (program + single-workout) resolved suggested exercises to the
|
||||||
|
* user's library by EXACT `exerciseId` only, with no name fallback — so a model
|
||||||
|
* that returned a good name with a null or invented id (e.g. "Overhead Press"
|
||||||
|
* when the library has "Overhead Press (barbell)") forced the user to hand-map
|
||||||
|
* an exercise they already owned. Common with local models (Qwen via
|
||||||
|
* SparkControl) that don't reliably echo library ids.
|
||||||
|
*
|
||||||
|
* Fix: a shared name matcher (`lib/ai/exerciseMatch.ts`) normalizes names
|
||||||
|
* (lowercase, strip the "(barbell)"-style qualifier + punctuation) and
|
||||||
|
* auto-resolves UNIQUE confident matches; ambiguous or unknown names stay
|
||||||
|
* flagged for manual mapping (a wrong auto-map is worse than asking). Wired
|
||||||
|
* into both generate flows at the parse→display boundary.
|
||||||
|
*
|
||||||
|
* Client-only — no schema or data change.
|
||||||
|
*/
|
||||||
|
export const v_1_2_0_9 = VersionInfo.of({
|
||||||
|
version: '1.2.0:9',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'AI-suggested exercises now fuzzy-match your library by name — e.g. "Overhead Press" maps to your "Overhead Press (barbell)" automatically instead of asking you to map it by hand. Ambiguous matches are still left for you to pick. No data changes.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user