Compare commits

..

8 Commits

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

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

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

Built + sideloaded to immense-voyage.local as 1.2.0:6; on-box ALTER and
non-root launch confirmed via start-cli. tsc clean (app + packaging),
251 tests pass, next build + s9pk build succeed.
2026-06-19 10:59:12 -05:00
Keysat 0401a831b7 Document read-only on-box verification via start-cli
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Record the installed-version / package logs / attach+sqlite3 commands used to
verify ALTERs and persisted data on the box, so future sessions verify
directly instead of deferring to the StartOS web UI.
2026-06-16 16:37:54 -05:00
Keysat d1bc895e5e Log Safari first-tap login bug as a known bug with diagnosis
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
1.2.0:2's retryOnTransportError does not fix the mobile-Safari first-login
failure (reproduced on 1.2.0:5: first tap errors, second works). Record the
diagnosis and the gating next step (capture the first request's error code:
-1005 -> client delayed retry; 502/503 -> Node keep-alive tuning) so a future
session resumes from here. Correct the now-stale Current state line.
2026-06-16 16:04:12 -05:00
Keysat 184382f75c Record Gear/RPE effort convention; mark 1.2.0:5 confirmed on-box
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Add the cardio-effort (Gear vs RPE) convention, and tighten Current state:
1.2.0:5 + 1.2.0:4 ALTERs and non-root boot verified on the box via start-cli
(installed-version, package logs, and an app-DB read showing a saved gear=1
Assault Bike set). Only the 1.2.0:2 Safari first-tap check remains open.
2026-06-16 15:44:47 -05:00
Keysat 38503436e1 Update Current state: 1.2.0:5 built + sideloaded (Gear replaces RPE for cardio)
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
2026-06-16 14:49:43 -05:00
Keysat 4be489d6d3 v1.2.0:5 — Gear (breathing, 1-5) replaces RPE as the effort field for cardio
Cardio exercises now log a breathing "Gear" (1-5, per Brian MacKenzie)
instead of RPE (6-10) as their effort field; strength keeps RPE. An exercise
counts as cardio when its equipment type is "cardio" or it carries the
"cardio" muscle group (isCardioExercise in lib/exerciseOptions), so the
Assault Bike (type "assault bike") qualifies.

New nullable SetLog.gear column added by the boot-time guarded ALTER in
docker_entrypoint.sh (additive, idempotent); plumbed through all 5 set-write
paths, the summary/edit views, and CSV/JSON import-export. Existing rpe data
is untouched and still displays. Program/AI target-RPE is unaffected.
2026-06-16 14:49:15 -05:00
Keysat ef3d079ca2 Record first-class-set-metric convention + CSV round-trip backlog
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Capture the ~17-touchpoint recipe for promoting a set metric to a column
(the watts precedent) so the next one doesn't need a repo-wide grep, and log
the pre-existing CSV export/import header-name asymmetry as backlog.
2026-06-16 14:07:03 -05:00
41 changed files with 1888 additions and 57 deletions
+11 -4
View File
@@ -63,6 +63,11 @@ Build/sideload the s9pk (from `start9/0.4/`): `make x86` then `make install`. Ta
Both `install` and `publish` read host/registry config from `~/.startos/config.yaml`, which is **not in the repo** — verify against the live setup, not from a checkout. Both `install` and `publish` read host/registry config from `~/.startos/config.yaml`, which is **not in the repo** — verify against the live setup, not from a checkout.
**Verify on-box state read-only via `start-cli`** (the same host config) instead of punting to the StartOS web UI — used this way to confirm the 1.2.0:4/:5 ALTERs and a persisted set:
- `start-cli package installed-version proof-of-work` — what version the box actually runs.
- `start-cli package logs proof-of-work --limit N | grep -iE "adding missing column|as nextjs|error"` — confirms boot ALTERs ran (each logs once) and the non-root launch.
- `start-cli package attach proof-of-work -- sqlite3 /data/app.db "<SELECT …>"` — read-only query of the live app DB (e.g. confirm a new column exists / a value persisted). **SELECT only**; never mutate prod data this way.
Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds, uploads to FileBrowser, registers) — separate from the generic `make publish`. Unpublish: `~/.proof-of-work/unpublish.sh`. Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds, uploads to FileBrowser, registers) — separate from the generic `make publish`. Unpublish: `~/.proof-of-work/unpublish.sh`.
`npm run db:seed` (= `tsx prisma/seed.ts`) seeds **only the `InstanceSettings` singleton** — deliberately NO users and NO curated library (the library attaches at admin-creation and via the boot-time ensure). It is **live, not dead** — invoked at Docker image-build time (`start9/0.4/Dockerfile`) and the local-dev first-run path. `npm run create-admin` (= `tsx scripts/create-admin.ts`) is the local-dev equivalent of the StartOS "Set admin credentials" action: creates the first admin + seeds their library; `--force` to reset/promote an existing account. Runtime first-boot/upgrade seeding is handled separately by `docker_entrypoint.sh`. `npm run db:seed` (= `tsx prisma/seed.ts`) seeds **only the `InstanceSettings` singleton** — deliberately NO users and NO curated library (the library attaches at admin-creation and via the boot-time ensure). It is **live, not dead** — invoked at Docker image-build time (`start9/0.4/Dockerfile`) and the local-dev first-run path. `npm run create-admin` (= `tsx scripts/create-admin.ts`) is the local-dev equivalent of the StartOS "Set admin credentials" action: creates the first admin + seeds their library; `--force` to reset/promote an existing account. Runtime first-boot/upgrade seeding is handled separately by `docker_entrypoint.sh`.
@@ -72,6 +77,8 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
- **Versioning is ExVer**: `1.1.0:4` (note the colon). Every release = a new `start9/0.4/startos/versions/vMAJOR.MINOR.PATCH.N.ts` file, imported into `versions/index.ts` and promoted to `current` (previous `current` moves into `other[]`). - **Versioning is ExVer**: `1.1.0:4` (note the colon). Every release = a new `start9/0.4/startos/versions/vMAJOR.MINOR.PATCH.N.ts` file, imported into `versions/index.ts` and promoted to `current` (previous `current` moves into `other[]`).
- **Bump the version BEFORE building the s9pk** — Start9 0.4 won't recognize a rebuild as an update otherwise. - **Bump the version BEFORE building the s9pk** — Start9 0.4 won't recognize a rebuild as an update otherwise.
- **Schema changes are additive ALTERs in `docker_entrypoint.sh`**, guarded by `PRAGMA table_info` checks. Keep `schema.prisma` in sync as the mirror, but the entrypoint is what migrates live `/data`. Never write a destructive migration. - **Schema changes are additive ALTERs in `docker_entrypoint.sh`**, guarded by `PRAGMA table_info` checks. Keep `schema.prisma` in sync as the mirror, but the entrypoint is what migrates live `/data`. Never write a destructive migration.
- **Adding a first-class numeric set metric** (precedent: `watts`, 1.2.0:4): mirror `calories` end-to-end — `schema.prisma` column + `prisma generate`; guarded additive `ALTER` in `docker_entrypoint.sh`; zod field + insert in all **5 set-write paths** (`workouts` POST, `workouts/[id]` PATCH, `workouts/[id]/sets`, `workouts/import/save`, `me/import`); `SetRow.tsx` (`show*`/state/`emitUpdate`/`buildSummary`/`firstField`/input) + `WorkoutForm.tsx` (set interfaces, `buildPayload`, `handleUpdateSet`, `initial*` prop, has-data checks); read summary in `app/main/workouts/[id]/page.tsx` + edit mapping in `app/main/workouts/new/page.tsx`; CSV export/parse + `page-csv` payload; field-option label lists (`lib/exerciseOptions.ts`, `app/main/exercises/[id]/page.tsx`, `ExercisePicker.tsx`). The `inputFields` token == the column name; the human label lives in those option lists (token `watts` → "Avg. watts"). `me/export` rides the 1:1 Prisma dump automatically. Add a round-trip test in `tests/routes-crud.test.ts`.
- **Logged-set effort is Gear or RPE, by cardio-ness** (1.2.0:5): the effort select is always shown (not an `inputFields` token). Cardio exercises log breathing **Gear** (`SetLog.gear`, 15); everything else logs **RPE** (`SetLog.rpe`, 610). The switch is `isCardioExercise(exercise)` (`lib/exerciseOptions.ts`): `type === "cardio"` OR `muscleGroups` contains "cardio". `SetRow` takes an `isCardio` prop (from `WorkoutForm`) and renders one; both are always emitted (the hidden one stays empty). Distinct from program/AI **target**-RPE (`ProgramExercise.rpe`), which is unrelated and unaffected.
- **Commit subject** = `vX.Y.Z:N — short summary`, imperative, body explains the *why*. - **Commit subject** = `vX.Y.Z:N — short summary`, imperative, body explains the *why*.
- **Git remote is self-hosted** (a private Start9 registry + a FileBrowser artifact host), NOT GitHub. The actual registry/file-host URLs are constants in `~/.proof-of-work/{publish,unpublish}.sh`; FileBrowser creds live in `~/.keysat/filebrowser.env` (outside the repo, gitignored). Default branch is `master`. - **Git remote is self-hosted** (a private Start9 registry + a FileBrowser artifact host), NOT GitHub. The actual registry/file-host URLs are constants in `~/.proof-of-work/{publish,unpublish}.sh`; FileBrowser creds live in `~/.keysat/filebrowser.env` (outside the repo, gitignored). Default branch is `master`.
- **Authorization tiers**: whole-instance routes (`settings/{export,import}-db`) are **admin-only** (`!user.isAdmin → 403`); per-user data routes scope by `user.id`. Custom-URL AI providers (Ollama, OpenAI-compatible — anything with `requiresBaseUrl`) are **admin-only** (SSRF surface); fixed-URL cloud providers (claude/openai/gemini) stay per-user. Gate the server route AND hide the control in the UI. - **Authorization tiers**: whole-instance routes (`settings/{export,import}-db`) are **admin-only** (`!user.isAdmin → 403`); per-user data routes scope by `user.id`. Custom-URL AI providers (Ollama, OpenAI-compatible — anything with `requiresBaseUrl`) are **admin-only** (SSRF surface); fixed-URL cloud providers (claude/openai/gemini) stay per-user. Gate the server route AND hide the control in the UI.
@@ -105,13 +112,13 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
## Current state ## Current state
Latest version is **1.2.0:4****avg. watts is now a first-class set field**: promotes average watts (assault bike / rower / ski erg) from a free-text `customMetrics` JSON entry to a real nullable `SetLog.watts` column, written through every set path (create / PATCH / add-sets / import-save / account-import) and shown everywhere as "Avg. watts" with a numeric input. This is the **first schema change in the 1.2.0 line**: the column is added by the boot-time additive `ALTER` in `docker_entrypoint.sh` (guarded, idempotent); legacy watts in `customMetrics` stays readable and migrates to the column on next save (no data migration). Internal token/column is `watts` (matches the existing assault-bike `inputFields` token + legacy `customMetrics` key → zero exercise migration); only ever *displayed* as "Avg. watts". **Built + sideloaded** to the StartOS box (`immense-voyage.local`, 2026-06-16, on `master`) as `proof-of-work_x86_64.s9pk` (80M, git `390aaf5`). Verified before build: tsc clean (app + packaging), lint clean (only pre-existing warnings), **223 tests pass**, `next build` succeeds. Registry empty, **publishing parked** (sideload-only via `make install`). Latest version is **1.2.0:6****AI "generate today's workout"**: describe one session in plain words → a streamed, ready-to-log workout (suggested weights/reps/set-counts grounded in 90-day history) → inline-edit or **Refine** (round-trips changes back to the LLM) → **Use this workout** pre-fills the New Workout form (nothing persists until you save). Reuses the program-generation spine via a new `AIGeneration.kind` discriminant (`"program" | "workout"`); workout rows are ephemeral (the saved Workout is the durable record) so they're filtered out of the program-shaped AI History. New `AIGeneration.kind` column (default "program") via boot-time guarded `ALTER`. **Architecture + hand-off (sessionStorage `?from=ai` → `AiWorkoutPrefill`, `EditWorkoutData.id?`→CREATE, per-exercise weight-unit grounding) are documented in `docs/guides/ai-subsystem.md` → "Two generation kinds".** **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), **251 tests pass**, `next build` + s9pk build succeed. Registry empty, **publishing parked** (sideload-only via `make install`).
Recent prior ships (1.2.0 line): **1.2.0:3** — P3 hardening (login timing oracle + `exerciseId` ownership). **1.2.0:2** — iOS Safari login/signup first-tap retry (`lib/retryAction.ts`). **1.2.0:1** — Next 15 / React 19 upgrade. (1.2.0:13 had no schema/data change.) **Confirmed on-box (2026-06-19, via `start-cli`):** box runs `1.2.0:6`; entrypoint logged `adding AIGeneration.kind (default 'program')` once, then launched `as nextjs` with no errors (clears the long-standing non-root check); read-only `SELECT` confirms the `AIGeneration.kind` column exists and the existing generation row backfilled to `program`. Recent prior ships (1.2.0 line): **1.2.0:5** Gear replaces RPE for cardio; **1.2.0:4** watts as first-class set field; **1.2.0:3** P3 hardening (login timing oracle + `exerciseId` ownership).
**Pending on-box check:** confirm 1.2.0:4 boots clean in StartOS → Logs — on the existing-data upgrade path the entrypoint logs `adding missing column SetLog.watts` (one-shot; no-op thereafter), no permission errors — and that logging an Assault Bike set with Avg. watts saves and displays as `157 W`. Still also pending from earlier ships: the 1.2.0:3 non-root boot (entrypoint logs `launching … as nextjs`, app writes `/data` as uid 1001 — also clears the long-standing 1.1.0:9 non-root check), **and** the 1.2.0:2 Safari first-tap proof (log in from Safari on iPhone/iPad; first Sign In tap works — `-1005` in Web Inspector confirms the retry path, anything else points at a proxy↔container keep-alive mismatch). **No on-box checks pending.** Known bug (tracked in `ROADMAP.md` → Known bugs): the **1.2.0:2** Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced 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 (5 providers, multi-config, background generation, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history). Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, **single-workout generation + refine**, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).
Next steps (priority order): Next steps (priority order):
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`. 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`.
+9
View File
@@ -2,6 +2,14 @@
Longer-term backlog. Near-term state + next steps live in `AGENTS.md` → Current state. Longer-term backlog. Near-term state + next steps live in `AGENTS.md` → Current state.
## Known bugs
- **Mobile-Safari first-login-tap fails ("An unexpected error occurred"); second tap works.** Reproduced on iPhone/iPad Safari against 1.2.0:5 (desktop Safari untested — user declined). The first Sign In tap fails, a second manual tap succeeds. **1.2.0:2's `retryOnTransportError` does NOT fix it.** Diagnosis so far: `LoginForm` only surfaces that error when *both* the initial action call and its in-tap retry throw, so the immediate retry isn't escaping the bad connection — only a fresh user-initiated tap does. Box app logs show no server-side error/500/reset around the attempt, so it's a transport-layer failure, not an app bug.
- **Gating data (do this first):** capture the first failed request's error in Safari Web Inspector (iOS→Mac, Network/Console tab). The code picks the fix:
- `-1005` "The network connection was lost" → client-side stale keep-alive socket. Fix = a *delayed* retry (let Safari tear down the dead socket before retrying), not the current instant one.
- `502`/`503` → StartOS-proxy↔Node keep-alive mismatch (Node closing idle conns the proxy reuses). Fix = raise Node `keepAliveTimeout`/`headersTimeout` server-side; a client retry only masks it.
- Files: `lib/retryAction.ts`, `app/auth/login/LoginForm.tsx`, `app/auth/signup/SignupForm.tsx`.
## AI quality ## AI quality
- Tiered prompt formatting (also the immediate next step): JSON-Schema output enforcement via Ollama `format` and OpenAI `response_format`; pipe-separated library rows; XML-tagged prompt sections; Ollama-only few-shot example; stable prefix first for prompt-cache hits. - Tiered prompt formatting (also the immediate next step): JSON-Schema output enforcement via Ollama `format` and OpenAI `response_format`; pipe-separated library rows; XML-tagged prompt sections; Ollama-only few-shot example; stable prefix first for prompt-cache hits.
@@ -25,6 +33,7 @@ Done in 1.1.0:9 (P2 batch): input-validation 500s → 400 (`lib/http.ts readJson
- Adherence tracking: compare logged workouts against the planned `ProgramDay` (the `programDayId` link already exists). - Adherence tracking: compare logged workouts against the planned `ProgramDay` (the `programDayId` link already exists).
- Per-user export/import polish and scheduled backups. - Per-user export/import polish and scheduled backups.
- CSV export↔import round-trip: export writes `setX`-prefixed headers (`setCalories`/`setWatts`/`setNotes`) the importer doesn't read (it expects `calories`/`watts`/`notes`), so the app's own CSV export silently drops those on re-import (calories long-standing; watts since 1.2.0:4). Fix by aligning export header names with the parser, or adding the prefixed names as `knownColumns` aliases. (JSON account export/import round-trips fine.)
- Charts/progress views over history (the data and 1RM estimates already exist). - Charts/progress views over history (the data and 1RM estimates already exist).
## Hygiene ## Hygiene
+23
View File
@@ -20,6 +20,29 @@ generate/generations route handlers). Whole-repo rules live in `AGENTS.md`.
- Multi-config: `AIConfigProfile` rows per user; `UserPreferences.activeAIConfigId` - Multi-config: `AIConfigProfile` rows per user; `UserPreferences.activeAIConfigId`
points at the active one and is mirrored into the legacy `ai*` columns for back-compat. points at the active one and is mirrored into the legacy `ai*` columns for back-compat.
## Two generation kinds (`AIGeneration.kind`)
The runner spine is shared by two output shapes, discriminated by `AIGeneration.kind`
("program" | "workout", default "program"). The runner picks the parser by kind and
stores the JSON in the (reused) `parsedProgram` column.
- **program** (`kind: 'program'`) — `generate/route.ts``programSchema.ts`
(`PROGRAM_OUTPUT_SHAPE` / `parseAIProgram`). Applied to DB rows via `apply.ts`.
Shown in AI · History (which filters `kind: 'program'`).
- **workout** (`kind: 'workout'`) — `generate-workout/route.ts` (uses
`workoutPrompt.ts` + `workoutSchema.ts`: `WORKOUT_OUTPUT_SHAPE` / `parseAIWorkout`).
A single day's session. **No server-side apply**: the client (`GenerateWorkoutClient.tsx`)
stashes the reviewed suggestion in `sessionStorage` and routes to
`/main/workouts/new?from=ai`, where `AiWorkoutPrefill.tsx` expands it (via
`workoutDraft.ts::buildPrefillExercises`) and pre-fills the normal `WorkoutForm`
nothing persists until the user saves through the regular workout path.
**Refine = a new workout generation** seeded with the prior suggestion JSON
(`priorWorkout` in the route body → REVISION mode in `workoutPrompt.ts`). These rows
are ephemeral, so they're excluded from the program-shaped AI · History.
- Adding a new kind: extend the union in `KickoffOpts`, add a parser + output-shape,
branch the parser selection in `generationRunner.ts`, and decide whether it belongs in
History (filtered by kind).
## Provider abstraction ## Provider abstraction
- Each provider yields an async iterable of `GenerateChunk` (`text` / `usage` / `done` / - Each provider yields an async iterable of `GenerateChunk` (`text` / `usage` / `done` /
@@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { WORKOUT_OUTPUT_SHAPE, aiWorkoutSchema } from '@/lib/ai/workoutSchema';
import {
buildHistorySummary,
formatHistoryContext,
} from '@/lib/ai/historyContext';
import { buildWorkoutSystemPrompt } from '@/lib/ai/workoutPrompt';
import { kickoffGeneration } from '@/lib/ai/generationRunner';
/**
* POST /api/ai/generate-workout
*
* Kicks off a background runner (kind="workout") that streams a single
* day's workout suggestion, and returns the generation id. The caller
* subscribes via GET /api/ai/generations/[id]/stream (SSE) — the same
* spine as program generation.
*
* Body:
* { userInput: string, includeHistory?: boolean, priorWorkout?: AIWorkout }
*
* `priorWorkout` switches the prompt into REVISION mode: userInput is the
* change instruction and the model re-emits the full revised workout.
*
* Response:
* 201 { id: "...generationId..." }
* 400 { error: "..." }
*/
const bodySchema = z.object({
userInput: z.string().min(1),
includeHistory: z.boolean().optional().default(false),
// The current suggestion, when refining. Validated against the same
// shape the model emits so we only ever feed it well-formed JSON.
priorWorkout: aiWorkoutSchema.optional().nullable(),
});
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid body', details: parsed.error.errors },
{ status: 400 },
);
}
const prefs = await prisma.userPreferences.findUnique({
where: { userId: user.id },
});
if (!prefs?.aiProvider || !prefs?.aiModel) {
return NextResponse.json(
{
error:
'AI is not configured. Open Settings → AI integration and pick a provider + model.',
},
{ status: 400 },
);
}
const weightUnit = (prefs.defaultWeightUnit as 'lbs' | 'kg') || 'lbs';
// Library for the prompt. We include each exercise's effective logging
// unit (`defaultWeightUnit || "lbs"` — the exact unit the saved workout
// will store, see WorkoutForm.buildPayload) so the model suggests the
// weight NUMBER in that unit. Without this the model would assume the
// user's global preferred unit, which diverges for per-exercise unit
// overrides (e.g. kettlebells in kg) and silently mislabels the weight.
const exercises = await prisma.exercise.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
type: true,
muscleGroups: true,
defaultWeightUnit: true,
},
});
const libraryJson = JSON.stringify(
exercises.map((e) => ({
id: e.id,
name: e.name,
type: e.type,
unit: e.defaultWeightUnit || 'lbs',
muscleGroups: (() => {
try {
return JSON.parse(e.muscleGroups);
} catch {
return [];
}
})(),
})),
);
// History context if requested.
let historyBlock = '';
if (parsed.data.includeHistory) {
const summary = await buildHistorySummary(prisma, user.id);
historyBlock = formatHistoryContext(summary);
}
const isLocalModel = prefs.aiProvider === 'ollama';
const priorWorkoutJson = parsed.data.priorWorkout
? JSON.stringify(parsed.data.priorWorkout)
: undefined;
const basePrompt = buildWorkoutSystemPrompt({
weightUnit,
hasHistoryContext: parsed.data.includeHistory,
isLocalModel,
priorWorkoutJson,
});
const systemPrompt = `${basePrompt}
# OUTPUT SHAPE
${WORKOUT_OUTPUT_SHAPE}
# LIBRARY (use these exerciseIds; do not invent ids)
${libraryJson}${historyBlock}`;
const id = await kickoffGeneration({
prisma,
userId: user.id,
kind: 'workout',
templateId: null,
templateName: priorWorkoutJson ? 'Workout (refine)' : 'Workout',
userInput: parsed.data.userInput,
systemPrompt,
userPrompt: parsed.data.userInput,
provider: prefs.aiProvider,
model: prefs.aiModel,
apiKey: prefs.aiApiKey,
baseUrl: prefs.aiBaseUrl,
});
return NextResponse.json({ id }, { status: 201 });
}
@@ -147,6 +147,7 @@ ${libraryJson}${historyBlock}`;
const id = await kickoffGeneration({ const id = await kickoffGeneration({
prisma, prisma,
userId: user.id, userId: user.id,
kind: 'program',
templateId: template?.id ?? null, templateId: template?.id ?? null,
templateName: template?.name ?? null, templateName: template?.name ?? null,
userInput: parsed.data.userInput, userInput: parsed.data.userInput,
@@ -16,7 +16,8 @@ export async function GET(request: NextRequest) {
const offset = Math.max(parseInt(sp.get('offset') || '0'), 0); const offset = Math.max(parseInt(sp.get('offset') || '0'), 0);
const rows = await prisma.aIGeneration.findMany({ const rows = await prisma.aIGeneration.findMany({
where: { userId: user.id }, // Program history only; workout-kind rows are ephemeral (see history page).
where: { userId: user.id, kind: 'program' },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: limit + 1, take: limit + 1,
skip: offset, skip: offset,
@@ -21,6 +21,7 @@ interface ParsedSet {
calories?: number; calories?: number;
watts?: number; watts?: number;
rpe?: number; rpe?: number;
gear?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
notes?: string; notes?: string;
} }
@@ -139,6 +140,7 @@ export async function POST(request: NextRequest) {
"calories", "calories",
"watts", "watts",
"rpe", "rpe",
"gear",
"notes", "notes",
"custom_metrics_json", "custom_metrics_json",
"custommetricsjson", "custommetricsjson",
@@ -203,6 +205,7 @@ export async function POST(request: NextRequest) {
const calories = parseIntMaybe(row.calories); const calories = parseIntMaybe(row.calories);
const watts = parseIntMaybe(row.watts); 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;
@@ -258,6 +261,7 @@ export async function POST(request: NextRequest) {
calories, calories,
watts, 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,
}); });
+2
View File
@@ -51,6 +51,7 @@ 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(),
@@ -202,6 +203,7 @@ 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,
@@ -72,6 +72,7 @@ export async function GET() {
"setCalories", "setCalories",
"setWatts", "setWatts",
"rpe", "rpe",
"setGear",
"setNotes", "setNotes",
"customMetricsJson", "customMetricsJson",
]; ];
@@ -104,6 +105,7 @@ export async function GET() {
set.calories ?? "", set.calories ?? "",
set.watts ?? "", set.watts ?? "",
set.rpe ?? "", set.rpe ?? "",
set.gear ?? "",
set.notes ?? "", set.notes ?? "",
set.customMetrics ?? "", set.customMetrics ?? "",
]; ];
@@ -57,6 +57,7 @@ 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(),
@@ -153,6 +154,7 @@ 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,
@@ -14,6 +14,7 @@ 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(),
@@ -80,6 +81,7 @@ 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,
@@ -15,6 +15,7 @@ const setSchema = z.object({
calories: z.number().int().positive().optional(), calories: z.number().int().positive().optional(),
watts: 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(),
}); });
@@ -124,6 +125,7 @@ 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,
+2
View File
@@ -26,6 +26,7 @@ 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(),
@@ -175,6 +176,7 @@ 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,
@@ -0,0 +1,70 @@
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import GenerateWorkoutClient from '@/components/ai/GenerateWorkoutClient';
export const dynamic = 'force-dynamic';
export default async function GenerateWorkoutPage() {
const user = await getCurrentUser();
if (!user) redirect('/auth/login');
const [exercises, prefs, workoutCount] = await Promise.all([
prisma.exercise.findMany({
where: { userId: user.id },
select: { id: true, name: true, type: true },
orderBy: [{ type: 'asc' }, { name: 'asc' }],
}),
prisma.userPreferences.findUnique({
where: { userId: user.id },
select: { aiProvider: true, aiModel: true },
}),
prisma.workout.count({
where: { userId: user.id, deletedAt: null },
}),
]);
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="border-b border-zinc-800">
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center gap-3">
<Link
href="/main/ai"
className="text-zinc-400 hover:text-white"
aria-label="Back to AI"
>
<ChevronLeft className="w-5 h-5" />
</Link>
<h1 className="text-2xl sm:text-3xl font-bold text-white">
AI · Today&apos;s workout
</h1>
</div>
</div>
<div className="max-w-3xl mx-auto px-4 py-6">
{!aiConfigured ? (
<div className="bg-amber-950/30 border border-amber-900 rounded p-5 text-sm text-amber-200">
<p className="font-bold text-amber-100 mb-2">AI is not configured.</p>
<p>
Pick a provider, model, and (if needed) API key in{' '}
<Link href="/main/settings" className="underline hover:text-amber-100">
Settings AI integration
</Link>{' '}
before you can generate a workout.
</p>
</div>
) : (
<GenerateWorkoutClient
exercises={exercises}
providerLabel={prefs!.aiProvider!}
modelLabel={prefs!.aiModel!}
workoutCount={workoutCount}
/>
)}
</div>
</div>
);
}
@@ -34,7 +34,8 @@ export default async function GenerationDetailPage(props: {
const [row, exercises] = await Promise.all([ const [row, exercises] = await Promise.all([
prisma.aIGeneration.findFirst({ prisma.aIGeneration.findFirst({
where: { id: params.id, userId: user.id }, // Program history only — workout-kind rows aren't shown here.
where: { id: params.id, userId: user.id, kind: 'program' },
}), }),
prisma.exercise.findMany({ prisma.exercise.findMany({
where: { userId: user.id }, where: { userId: user.id },
+4 -1
View File
@@ -12,7 +12,10 @@ export default async function HistoryPage() {
if (!user) redirect('/auth/login'); if (!user) redirect('/auth/login');
const rows = await prisma.aIGeneration.findMany({ const rows = await prisma.aIGeneration.findMany({
where: { userId: user.id }, // Program history only. Single-workout generations (kind="workout")
// are ephemeral — the durable record is the saved Workout — so they
// don't belong in this program-shaped list/detail.
where: { userId: user.id, kind: 'program' },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 25, take: 25,
select: { select: {
+9 -1
View File
@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Sparkles, ListChecks, History } from 'lucide-react'; import { Sparkles, ListChecks, History, Dumbbell } from 'lucide-react';
import { getCurrentUser } from '@/lib/auth'; import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
@@ -17,6 +17,14 @@ export default async function AIIndexPage() {
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel; const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
const cards = [ const cards = [
{
href: '/main/ai/generate-workout',
icon: Dumbbell,
title: "Today's workout",
blurb:
'Describe today\'s session in plain words and get a ready-to-log workout — exercises with suggested weights and reps from your history. Refine it, then pre-fill the log.',
disabled: !aiConfigured,
},
{ {
href: '/main/ai/generate', href: '/main/ai/generate',
icon: Sparkles, icon: Sparkles,
+11 -7
View File
@@ -24,6 +24,7 @@ interface ParsedSet {
calories?: number; calories?: number;
watts?: number; watts?: number;
rpe?: number; rpe?: number;
gear?: number;
customMetrics?: Record<string, string>; customMetrics?: Record<string, string>;
notes?: string; notes?: string;
} }
@@ -399,6 +400,9 @@ export default function ImportCSVPage() {
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" &&
@@ -767,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, watts, rpe, notes, custom_* distance, distance_unit, calories, watts, rpe, gear, notes, custom_*
</p> </p>
</div> </div>
{loading && ( {loading && (
@@ -782,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,watts,rpe,notes,custom_temperature,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,157,7,,,"{\"resistance\":\"8\"}" 2025-02-17,Assault Bike,1,,, ,900,5,mi,120,157,,4,,,"{\"resistance\":\"8\"}"
2025-02-18,Cold Plunge,1,,, ,180,,,,,,felt great,50,`} 2025-02-18,Cold Plunge,1,,, ,180,,,,,,,felt great,50,`}
</pre> </pre>
</div> </div>
</div> </div>
+2 -1
View File
@@ -44,7 +44,8 @@ const navLinks: NavLink[] = [
label: 'AI', label: 'AI',
icon: Sparkles, icon: Sparkles,
subItems: [ subItems: [
{ href: '/main/ai/generate', label: 'Generate' }, { href: '/main/ai/generate-workout', label: "Today's workout" },
{ href: '/main/ai/generate', label: 'Generate program' },
{ href: '/main/ai/history', label: 'History' }, { href: '/main/ai/history', label: 'History' },
{ href: '/main/ai/templates', label: 'Templates' }, { href: '/main/ai/templates', label: 'Templates' },
], ],
@@ -19,6 +19,7 @@ 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;
@@ -53,7 +54,8 @@ function buildSetSummary(set: {
} }
} 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";
} }
+17 -6
View File
@@ -5,6 +5,7 @@ import { getCurrentUser } from "@/lib/auth";
import { getExercises } from "@/lib/db/exercises"; import { getExercises } from "@/lib/db/exercises";
import { getWorkoutById } from "@/lib/db/workouts"; import { getWorkoutById } from "@/lib/db/workouts";
import WorkoutForm, { EditWorkoutData } from "@/components/workouts/WorkoutForm"; import WorkoutForm, { EditWorkoutData } from "@/components/workouts/WorkoutForm";
import AiWorkoutPrefill from "@/components/workouts/AiWorkoutPrefill";
export const metadata = { export const metadata = {
title: "Log Workout", title: "Log Workout",
@@ -12,7 +13,7 @@ export const metadata = {
}; };
export default async function NewWorkoutPage(props: { export default async function NewWorkoutPage(props: {
searchParams: Promise<{ edit?: string }>; searchParams: Promise<{ edit?: string; from?: string }>;
}) { }) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const user = await getCurrentUser(); const user = await getCurrentUser();
@@ -22,6 +23,11 @@ export default async function NewWorkoutPage(props: {
const exercises = await getExercises(user.id); const exercises = await getExercises(user.id);
// Coming from the AI "today's workout" flow: the suggestion is in
// sessionStorage (client-only), so a client wrapper reads it and
// pre-fills the form. No ?edit fetch here.
const fromAi = searchParams.from === "ai";
// If ?edit=WORKOUT_ID, fetch existing workout for editing // If ?edit=WORKOUT_ID, fetch existing workout for editing
let editWorkout: EditWorkoutData | undefined; let editWorkout: EditWorkoutData | undefined;
if (searchParams.edit) { if (searchParams.edit) {
@@ -50,6 +56,7 @@ 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,
@@ -94,11 +101,15 @@ export default async function NewWorkoutPage(props: {
{/* Form */} {/* Form */}
<div className="max-w-2xl mx-auto px-4 py-6 pb-12"> <div className="max-w-2xl mx-auto px-4 py-6 pb-12">
<WorkoutForm {fromAi ? (
exercises={exercises} <AiWorkoutPrefill exercises={exercises} />
recentlyUsedExercises={[]} ) : (
editWorkout={editWorkout} <WorkoutForm
/> exercises={exercises}
recentlyUsedExercises={[]}
editWorkout={editWorkout}
/>
)}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,626 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2, Sparkles } from 'lucide-react';
import { lenientJsonParse } from '@/lib/ai/lenientJson';
import { estimateCost, formatCost } from '@/lib/ai/pricing';
import type { AiWorkoutDraft } from '@/lib/ai/workoutDraft';
interface LibraryExercise {
id: string;
name: string;
type: string;
}
// AI output shape — mirrors lib/ai/workoutSchema.ts (AIWorkout).
interface AIWorkoutExercise {
exerciseId: string | null;
exerciseName: string;
order: number;
sets?: number | null;
reps?: number | null;
suggestedWeight?: number | null;
suggestedWeightUnit?: 'lbs' | 'kg' | null;
rpe?: number | null;
gear?: number | null;
durationSeconds?: number | null;
notes?: string | null;
}
interface AIWorkout {
name: string;
notes?: string | null;
exercises: AIWorkoutExercise[];
}
// The ephemeral draft we hand to the New Workout form via sessionStorage.
export const AI_WORKOUT_DRAFT_KEY = 'ai-workout-draft';
type Phase =
| { kind: 'idle' }
| { kind: 'streaming'; raw: string; lastPartial: Partial<AIWorkout> | null }
| { kind: 'failed'; raw: string; message: string };
export default function GenerateWorkoutClient({
exercises,
providerLabel,
modelLabel,
workoutCount,
}: {
exercises: LibraryExercise[];
providerLabel: string;
modelLabel: string;
workoutCount: number;
}) {
const router = useRouter();
const [userInput, setUserInput] = useState('');
const [includeHistory, setIncludeHistory] = useState(workoutCount >= 1);
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
// The editable suggestion once parsed. Lifted to the parent so the
// Refine action can send the user's current edits back as the prior
// workout. null until the first successful parse.
const [workout, setWorkout] = useState<AIWorkout | null>(null);
// Refine instruction lives here (not in WorkoutPreview) because the
// preview unmounts while streaming — keeping it in the parent means a
// failed refine doesn't lose what the user typed; we clear it only on
// a successful regeneration.
const [refineInput, setRefineInput] = useState('');
const [tokens, setTokens] = useState<{ in?: number; out?: number; durationMs?: number }>({});
const closeStreamRef = useRef<(() => void) | null>(null);
const streaming = phase.kind === 'streaming';
/**
* Run a generation. `priorWorkout` present → REVISION mode: `input`
* is the change instruction and the model re-emits the full workout.
*/
const runGeneration = async (input: string, priorWorkout?: AIWorkout) => {
if (!input.trim()) return;
setPhase({ kind: 'streaming', raw: '', lastPartial: null });
setTokens({});
let id: string;
try {
const res = await fetch('/api/ai/generate-workout', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
userInput: input,
includeHistory,
priorWorkout: priorWorkout ?? null,
}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
setPhase({ kind: 'failed', raw: '', message: body.error ?? `HTTP ${res.status}` });
return;
}
id = body.id;
} catch (e) {
setPhase({ kind: 'failed', raw: '', message: (e as Error).message });
return;
}
attachStream(id);
};
const attachStream = (id: string) => {
const es = new EventSource(`/api/ai/generations/${id}/stream`);
closeStreamRef.current = () => es.close();
let raw = '';
let lastPartial: Partial<AIWorkout> | null = null;
es.addEventListener('text', (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
raw += data.delta;
const next = lenientJsonParse(raw) as Partial<AIWorkout> | null;
if (next) lastPartial = next; // sticky — kills flicker between parses
setPhase({ kind: 'streaming', raw, lastPartial });
});
es.addEventListener('usage', (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
setTokens((t) => ({ ...t, in: data.tokensIn, out: data.tokensOut }));
});
es.addEventListener('complete', async (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
es.close();
closeStreamRef.current = null;
setTokens((t) => ({
...t,
in: data.tokensIn ?? t.in,
out: data.tokensOut ?? t.out,
durationMs: data.durationMs,
}));
if (data.parsedOk) {
const r = await fetch(`/api/ai/generations/${id}`);
if (r.ok) {
const gen = await r.json();
if (gen.parsedProgram) {
setWorkout(JSON.parse(gen.parsedProgram) as AIWorkout);
setRefineInput(''); // consumed — clear only on success
setPhase({ kind: 'idle' });
return;
}
}
}
setPhase({
kind: 'failed',
raw,
message: data.errorMessage ?? 'Failed to parse model output.',
});
});
es.onerror = () => {
if (es.readyState === EventSource.CLOSED) {
closeStreamRef.current = null;
setPhase((p) =>
p.kind === 'streaming'
? {
kind: 'failed',
raw: p.raw,
message:
'Stream disconnected. The generation may still be running — check AI · History.',
}
: p,
);
}
};
};
// Warn before unload while streaming (the runner keeps going server-side).
useEffect(() => {
if (!streaming) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [streaming]);
// Detach on unmount; the server keeps generating regardless.
useEffect(() => () => closeStreamRef.current?.(), []);
const costStr = useMemo(() => {
if (tokens.in == null || tokens.out == null) return null;
return formatCost(
estimateCost({
provider: providerLabel,
model: modelLabel,
tokensIn: tokens.in,
tokensOut: tokens.out,
}),
);
}, [providerLabel, modelLabel, tokens.in, tokens.out]);
const showResult = streaming || phase.kind === 'failed' || workout != null;
return (
<div className="space-y-6">
<div className="text-xs text-zinc-500 uppercase tracking-wider">
Provider: <span className="text-zinc-300">{providerLabel}</span>
{' · '}Model: <span className="text-zinc-300">{modelLabel}</span>
</div>
<section className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
<Field label="Describe today's workout">
<textarea
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
placeholder="e.g. Upper body, focus on shoulders. Overhead press, 4 working sets. Abs: landmine rotation, oblique cable, landmine hollow-body hold. Pull-ups, biceps and triceps."
rows={6}
className={inputClass}
disabled={streaming}
/>
</Field>
<label className="flex items-start gap-2 text-xs text-zinc-300">
<input
type="checkbox"
checked={includeHistory}
onChange={(e) => setIncludeHistory(e.target.checked)}
disabled={streaming || workoutCount === 0}
className="mt-0.5"
/>
<span>
Use my history to suggest weights{' '}
<span className="text-zinc-500">
({workoutCount === 0
? 'no workouts logged yet — disabled'
: 'last 90 days · recent working weights per exercise'}
)
</span>
</span>
</label>
<button
type="button"
onClick={() => runGeneration(userInput)}
disabled={!userInput.trim() || streaming}
className="inline-flex items-center gap-2 px-5 py-2 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500"
>
<Sparkles className="w-4 h-4" />
{workout ? 'Regenerate' : 'Generate workout'}
</button>
</section>
{showResult && (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
{streaming ? 'Generating…' : 'Suggested workout'}
</h2>
<span className="text-[11px] text-zinc-500 uppercase tracking-wider">
{tokens.in != null && (
<>
{tokens.in} in · {tokens.out ?? '?'} out
</>
)}
{costStr && <> · {costStr}</>}
{tokens.durationMs != null && (
<> · {(tokens.durationMs / 1000).toFixed(1)}s</>
)}
</span>
</div>
{streaming && (
<>
{phase.lastPartial ? (
<PartialPreview partial={phase.lastPartial} />
) : (
<div className="text-xs text-zinc-500 italic flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Waiting for the first parseable JSON
</div>
)}
<details className="text-xs text-zinc-500">
<summary className="cursor-pointer">Raw stream</summary>
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 font-mono text-[11px] text-zinc-400 max-h-80 overflow-auto whitespace-pre-wrap mt-2">
{phase.raw || '(waiting for first token…)'}
<Loader2 className="inline w-3 h-3 animate-spin ml-2" />
</div>
</details>
</>
)}
{phase.kind === 'failed' && (
<>
<div className="bg-red-950/40 border border-red-900 rounded p-3 text-sm text-red-300">
{phase.message}
</div>
{phase.raw && (
<details className="text-xs text-zinc-500">
<summary className="cursor-pointer">Raw response</summary>
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 mt-2 whitespace-pre-wrap">
{phase.raw}
</pre>
</details>
)}
</>
)}
{!streaming && workout && (
<WorkoutPreview
workout={workout}
setWorkout={(updater) => setWorkout((w) => (w ? updater(w) : w))}
exercises={exercises}
refineInput={refineInput}
setRefineInput={setRefineInput}
onRefine={() => runGeneration(refineInput, workout)}
onUse={(draft) => {
sessionStorage.setItem(AI_WORKOUT_DRAFT_KEY, JSON.stringify(draft));
router.push('/main/workouts/new?from=ai');
}}
/>
)}
</section>
)}
</div>
);
}
function WorkoutPreview({
workout,
setWorkout,
exercises,
refineInput,
setRefineInput,
onRefine,
onUse,
}: {
workout: AIWorkout;
setWorkout: (updater: (w: AIWorkout) => AIWorkout) => void;
exercises: LibraryExercise[];
refineInput: string;
setRefineInput: (v: string) => void;
onRefine: () => void;
onUse: (draft: AiWorkoutDraft) => void;
}) {
const [error, setError] = useState<string | null>(null);
const exerciseLookup = useMemo(
() => new Map(exercises.map((e) => [e.id, e])),
[exercises],
);
const unresolvedCount = useMemo(
() =>
workout.exercises.filter(
(ex) => !ex.exerciseId || !exerciseLookup.has(ex.exerciseId),
).length,
[workout, exerciseLookup],
);
const updateExercise = (
idx: number,
patch: Partial<AIWorkoutExercise>,
) => {
setWorkout((w) => {
const next = structuredClone(w);
next.exercises[idx] = { ...next.exercises[idx], ...patch };
return next;
});
};
const removeExercise = (idx: number) => {
setWorkout((w) => {
const next = structuredClone(w);
next.exercises.splice(idx, 1);
next.exercises.forEach((ex, i) => (ex.order = i));
return next;
});
};
const numOrNull = (v: string) => {
if (v.trim() === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
const handleUse = () => {
if (unresolvedCount > 0) {
setError(`Map or remove the ${unresolvedCount} unknown exercise(s) first.`);
return;
}
setError(null);
onUse({
name: workout.name,
notes: workout.notes ?? undefined,
exercises: workout.exercises.map((ex) => ({
exerciseId: ex.exerciseId!,
sets: ex.sets && ex.sets > 0 ? ex.sets : 3,
reps: ex.reps ?? undefined,
suggestedWeight: ex.suggestedWeight ?? undefined,
suggestedWeightUnit: ex.suggestedWeightUnit ?? undefined,
rpe: ex.rpe ?? undefined,
gear: ex.gear ?? undefined,
durationSeconds: ex.durationSeconds ?? undefined,
notes: ex.notes ?? undefined,
})),
});
};
return (
<div className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
<div>
<input
value={workout.name}
onChange={(e) => setWorkout((w) => ({ ...w, name: e.target.value }))}
className="text-lg font-bold text-white bg-transparent border-b border-transparent hover:border-zinc-700 focus:border-zinc-500 focus:outline-none w-full"
/>
{workout.notes && (
<p className="text-sm text-zinc-400 mt-1 italic">{workout.notes}</p>
)}
<p className="text-xs text-zinc-500 mt-1">
{workout.exercises.length} exercise
{workout.exercises.length === 1 ? '' : 's'}
</p>
</div>
{unresolvedCount > 0 && (
<div className="rounded bg-amber-950/30 border border-amber-900 px-3 py-2 text-xs text-amber-200">
{unresolvedCount} exercise(s) the AI couldn&apos;t map to your library.
Pick a replacement or remove them before using this workout.
</div>
)}
<ul className="space-y-2">
{workout.exercises.map((ex, idx) => {
const isUnknown = !ex.exerciseId || !exerciseLookup.has(ex.exerciseId);
const lib = ex.exerciseId ? exerciseLookup.get(ex.exerciseId) : null;
return (
<li
key={idx}
className={`rounded p-3 ${
isUnknown
? 'bg-amber-950/30 border border-amber-900'
: 'bg-zinc-950 border border-zinc-800'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="text-white text-sm">
{lib?.name ?? ex.exerciseName}
{isUnknown && (
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-400">
not in library
</span>
)}
</div>
<button
type="button"
onClick={() => removeExercise(idx)}
className="text-xs text-red-400 hover:text-red-300 px-1"
title="Remove from workout"
>
</button>
</div>
{isUnknown && (
<select
value={ex.exerciseId ?? ''}
onChange={(e) =>
updateExercise(idx, { exerciseId: e.target.value || null })
}
className="mt-2 w-full text-xs px-2 py-1 rounded border border-amber-900 bg-zinc-900 text-white"
>
<option value="">Map to existing exercise</option>
{exercises.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.name} ({opt.type})
</option>
))}
</select>
)}
<div className="mt-2 grid grid-cols-3 gap-2">
<NumField
label="Sets"
value={ex.sets}
onChange={(v) => updateExercise(idx, { sets: v })}
/>
<NumField
label="Reps"
value={ex.reps}
onChange={(v) => updateExercise(idx, { reps: v })}
/>
<NumField
label={`Weight${ex.suggestedWeightUnit ? ` (${ex.suggestedWeightUnit})` : ''}`}
value={ex.suggestedWeight}
step="any"
onChange={(v) => updateExercise(idx, { suggestedWeight: v })}
/>
</div>
{ex.notes && (
<div className="text-xs text-zinc-400 mt-2 italic">{ex.notes}</div>
)}
</li>
);
})}
</ul>
<div className="border-t border-zinc-800 pt-4 space-y-3">
<Field label="Refine (send a change back to the AI)">
<div className="flex gap-2">
<input
value={refineInput}
onChange={(e) => setRefineInput(e.target.value)}
onKeyDown={(e) => {
// Cleared by the parent only on a successful regeneration, so
// a failed refine keeps what the user typed.
if (e.key === 'Enter' && refineInput.trim()) onRefine();
}}
placeholder="e.g. make overhead press 5 sets; swap the oblique exercise"
className={inputClass}
/>
<button
type="button"
onClick={() => {
if (refineInput.trim()) onRefine();
}}
disabled={!refineInput.trim()}
className="shrink-0 px-4 py-2 rounded bg-zinc-700 text-white font-bold text-xs uppercase tracking-wider hover:bg-zinc-600 disabled:bg-zinc-800 disabled:text-zinc-600"
>
Refine
</button>
</div>
</Field>
{error && (
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
{error}
</div>
)}
<button
type="button"
onClick={handleUse}
disabled={unresolvedCount > 0 || workout.exercises.length === 0}
className="px-5 py-2 rounded bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider hover:bg-emerald-600 disabled:bg-zinc-700 disabled:text-zinc-500"
>
Use this workout
</button>
<p className="text-[11px] text-zinc-500">
Opens a pre-filled workout nothing is saved until you save it there.
</p>
</div>
</div>
);
function NumField({
label,
value,
step,
onChange,
}: {
label: string;
value?: number | null;
step?: string;
onChange: (v: number | null) => void;
}) {
return (
<label className="block">
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider block mb-0.5">
{label}
</span>
<input
type="number"
inputMode="decimal"
step={step}
value={value ?? ''}
onChange={(e) => onChange(numOrNull(e.target.value))}
className="w-full px-2 py-1 text-sm rounded border border-zinc-700 bg-zinc-800 text-white focus:outline-none focus:ring-1 focus:ring-white/30"
/>
</label>
);
}
}
const inputClass =
'w-full px-3 py-2 text-sm rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/30';
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
{label}
</span>
{children}
</label>
);
}
function PartialPreview({ partial }: { partial: Partial<AIWorkout> }) {
const exercises = (partial.exercises as AIWorkoutExercise[] | undefined) ?? [];
return (
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Loader2 className="w-3 h-3 animate-spin text-zinc-500" />
<span className="text-zinc-400">
Building workout{' '}
{partial.name && (
<span className="text-white font-semibold">{partial.name}</span>
)}
</span>
</div>
{exercises.length > 0 && (
<ul className="text-xs text-zinc-300 space-y-1">
{exercises.map((ex, i) => (
<li key={i}>
<span className="text-zinc-500">{(ex?.order ?? i) + 1}.</span>{' '}
{ex?.exerciseName ?? '…'}
{ex?.sets ? (
<span className="text-zinc-500">
{' '}
· {ex.sets}×{ex.reps ?? '?'}
{ex.suggestedWeight != null
? ` @ ${ex.suggestedWeight}${ex.suggestedWeightUnit ?? ''}`
: ''}
</span>
) : null}
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,70 @@
'use client';
import { useEffect, useState } from 'react';
import { Exercise } from '@prisma/client';
import WorkoutForm, { EditWorkoutData } from './WorkoutForm';
import { AI_WORKOUT_DRAFT_KEY } from '@/components/ai/GenerateWorkoutClient';
import { buildPrefillExercises, type AiWorkoutDraft } from '@/lib/ai/workoutDraft';
/**
* Reads the ephemeral AI workout draft from sessionStorage (stashed by
* GenerateWorkoutClient before navigating to /main/workouts/new?from=ai),
* expands each suggested exercise into N pre-filled SetLogs, and renders
* the normal WorkoutForm. Nothing is persisted until the user saves
* through the regular workout path.
*
* The draft has no workout id, so WorkoutForm's first save CREATEs.
* Effort follows the app convention: cardio → gear (1-5), else → rpe.
*
* If the draft is missing (e.g. a refresh cleared it), we fall back to a
* blank form so the page is never broken.
*/
export default function AiWorkoutPrefill({
exercises,
}: {
exercises: Exercise[];
}) {
// Read + build once on mount via a lazy initializer. This stays PURE
// (no sessionStorage mutation) so React's StrictMode double-invoke is
// safe — both passes read the same draft. The one-shot removal happens
// in the effect below, after the value is captured.
const [editWorkout] = useState<EditWorkoutData | undefined>(() => {
if (typeof window === 'undefined') return undefined;
const raw = sessionStorage.getItem(AI_WORKOUT_DRAFT_KEY);
if (!raw) return undefined;
let draft: AiWorkoutDraft;
try {
draft = JSON.parse(raw);
} catch {
return undefined;
}
const builtExercises: EditWorkoutData['exercises'] =
buildPrefillExercises(draft, exercises);
if (builtExercises.length === 0) return undefined;
return {
// No id → first save CREATEs a new workout.
name: draft.name || '',
date: new Date().toISOString(),
notes: draft.notes,
exercises: builtExercises,
};
});
// Clear the one-shot draft after mount so a manual reload starts blank.
// removeItem is idempotent, so StrictMode's double-run is harmless.
useEffect(() => {
sessionStorage.removeItem(AI_WORKOUT_DRAFT_KEY);
}, []);
return (
<WorkoutForm
exercises={exercises}
recentlyUsedExercises={[]}
editWorkout={editWorkout}
/>
);
}
+64 -25
View File
@@ -18,9 +18,12 @@ 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;
@@ -33,6 +36,7 @@ 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;
@@ -45,6 +49,7 @@ 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;
@@ -58,9 +63,11 @@ 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,
@@ -91,6 +98,7 @@ 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() || "");
@@ -134,6 +142,7 @@ 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;
@@ -144,6 +153,7 @@ export default function SetRow({
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;
@@ -158,6 +168,7 @@ 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,
@@ -169,7 +180,7 @@ export default function SetRow({
: undefined, : undefined,
}); });
}, },
[reps, weight, rpe, notes, duration, distance, calories, watts, customValues, onUpdate] [reps, weight, rpe, gear, notes, duration, distance, calories, watts, customValues, onUpdate]
); );
const handleConfirm = () => { const handleConfirm = () => {
@@ -192,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, watts }); 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
@@ -208,7 +219,11 @@ export default function SetRow({
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";
}; };
@@ -396,28 +411,52 @@ export default function SetRow({
</div> </div>
)} )}
{/* RPE select — always shown */} {/* Effort select — Gear (1-5, breathing gear) for cardio, else RPE (6-10) */}
<div className="flex-1 min-w-[50px] max-w-[60px]"> {isCardio ? (
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5"> <div className="flex-1 min-w-[50px] max-w-[60px]">
RPE <label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
</label> Gear
<select </label>
value={rpe} <select
onChange={(e) => { value={gear}
const val = e.target.value; onChange={(e) => {
setRpe(val); const val = e.target.value;
emitUpdate({ rpe: val }); 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" }}
> 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="">-</option>
<option value="7">7</option> <option value="1">1</option>
<option value="8">8</option> <option value="2">2</option>
<option value="9">9</option> <option value="3">3</option>
<option value="10">10</option> <option value="4">4</option>
</select> <option value="5">5</option>
</div> </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,6 +233,7 @@ 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;
@@ -243,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;
@@ -257,6 +262,7 @@ 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;
@@ -344,6 +350,7 @@ 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,
@@ -507,6 +514,7 @@ 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;
@@ -559,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;
@@ -580,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
}, },
@@ -648,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");
} }
@@ -856,9 +868,11 @@ 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}
+8 -2
View File
@@ -34,6 +34,7 @@
import type { PrismaClient } from '@prisma/client'; import type { PrismaClient } from '@prisma/client';
import { getProvider } from './providers'; import { getProvider } from './providers';
import { parseAIProgram } from './programSchema'; import { parseAIProgram } from './programSchema';
import { parseAIWorkout } from './workoutSchema';
export interface GenerationDelta { export interface GenerationDelta {
type: 'text' | 'usage' | 'complete' | 'error'; type: 'text' | 'usage' | 'complete' | 'error';
@@ -114,6 +115,9 @@ export function subscribe(
export interface KickoffOpts { export interface KickoffOpts {
prisma: PrismaClient; prisma: PrismaClient;
userId: string; userId: string;
/** "program" (multi-week) or "workout" (single day). Selects the
* output parser and is persisted on the row. */
kind: 'program' | 'workout';
templateId: string | null; templateId: string | null;
templateName: string | null; templateName: string | null;
userInput: string; userInput: string;
@@ -139,6 +143,7 @@ export async function kickoffGeneration(opts: KickoffOpts): Promise<string> {
const generation = await opts.prisma.aIGeneration.create({ const generation = await opts.prisma.aIGeneration.create({
data: { data: {
userId: opts.userId, userId: opts.userId,
kind: opts.kind,
templateId: opts.templateId, templateId: opts.templateId,
templateName: opts.templateName, templateName: opts.templateName,
userInput: opts.userInput, userInput: opts.userInput,
@@ -248,10 +253,11 @@ async function runGeneration(generationId: string, opts: KickoffOpts) {
let parsedJson: string | null = null; let parsedJson: string | null = null;
let parseErr: string | null = null; let parseErr: string | null = null;
if (!providerError && raw) { if (!providerError && raw) {
const r = parseAIProgram(raw); const r =
opts.kind === 'workout' ? parseAIWorkout(raw) : parseAIProgram(raw);
if (r.ok) { if (r.ok) {
parsedOk = true; parsedOk = true;
parsedJson = JSON.stringify(r.program); parsedJson = JSON.stringify('workout' in r ? r.workout : r.program);
} else { } else {
parseErr = r.reason; parseErr = r.reason;
} }
+78
View File
@@ -0,0 +1,78 @@
import { isCardioExercise } from '@/lib/exerciseOptions';
/**
* The ephemeral draft the "today's workout" flow hands to the New Workout
* form (via sessionStorage). One entry per exercise, with a working set
* count plus a single target weight/reps that we expand into N identical
* pre-filled sets. Shared by the producer (GenerateWorkoutClient) and the
* consumer (AiWorkoutPrefill) so the shape stays in sync.
*/
export interface AiWorkoutDraftExercise {
exerciseId: string;
sets: number;
reps?: number;
suggestedWeight?: number;
suggestedWeightUnit?: 'lbs' | 'kg';
rpe?: number;
gear?: number;
durationSeconds?: number;
notes?: string;
}
export interface AiWorkoutDraft {
name: string;
notes?: string;
exercises: AiWorkoutDraftExercise[];
}
export interface PrefillSet {
setNumber: number;
reps?: number;
weight?: number;
rpe?: number;
gear?: number;
durationSeconds?: number;
notes?: string;
}
export interface PrefillExercise<E> {
exercise: E;
sets: PrefillSet[];
}
/** Default working sets when the model omits a positive count. */
const DEFAULT_SET_COUNT = 3;
/**
* Expand a draft into pre-filled exercises against the user's library.
*
* - Exercises whose `exerciseId` isn't in the library are dropped (the
* preview forces the user to map them first, so this is just defensive).
* - Each exercise becomes `sets` identical SetLogs seeded with the
* suggested weight/reps.
* - Effort follows the app convention: cardio logs `gear` (1-5), every
* other exercise logs `rpe`. We keep only the matching one so a stray
* value on the wrong kind never reaches the form.
* - The coaching note rides only on the first set (avoids N copies).
*/
export function buildPrefillExercises<
E extends { id: string; type?: string | null; muscleGroups?: string | null },
>(draft: AiWorkoutDraft, exercises: E[]): PrefillExercise<E>[] {
const byId = new Map(exercises.map((e) => [e.id, e]));
const out: PrefillExercise<E>[] = [];
for (const d of draft.exercises) {
const exercise = byId.get(d.exerciseId);
if (!exercise) continue;
const cardio = isCardioExercise(exercise);
const setCount = d.sets && d.sets > 0 ? d.sets : DEFAULT_SET_COUNT;
const sets: PrefillSet[] = Array.from({ length: setCount }, (_, i) => ({
setNumber: i + 1,
reps: d.reps,
weight: d.suggestedWeight,
rpe: cardio ? undefined : d.rpe,
gear: cardio ? d.gear : undefined,
durationSeconds: d.durationSeconds,
notes: i === 0 ? d.notes : undefined,
}));
out.push({ exercise, sets });
}
return out;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* System-prompt builder for the "generate today's workout" flow. The
* sibling of systemPromptBase.ts (which targets multi-week programs).
*
* Job: force the single-workout JSON contract, ground suggested weights
* in the user's history, and respect the app's Gear-vs-RPE effort
* convention (cardio logs breathing Gear 1-5; everything else logs
* RPE 6-10).
*/
export interface WorkoutPromptOpts {
/** "lbs" | "kg" — default suggestedWeightUnit when the model omits one. */
weightUnit: 'lbs' | 'kg';
/** Whether the user's workout history is included in the prompt. */
hasHistoryContext: boolean;
/** True when the model is local (Ollama) — needs blunter, shorter rules. */
isLocalModel: boolean;
/** When refining, the prior suggestion's JSON. Present → revision mode. */
priorWorkoutJson?: string;
}
export function buildWorkoutSystemPrompt(opts: WorkoutPromptOpts): string {
const lines: string[] = [];
lines.push(
'# ROLE',
'',
"You are a strength & conditioning coach building ONE training session for today from the user's brain-dump. Turn their loose description into a concrete, ready-to-log workout.",
'',
'# OUTPUT CONTRACT (mandatory)',
'',
'1. Reply with EXACTLY ONE JSON object matching the OUTPUT SHAPE. No prose before or after. No ```json fences.',
'2. Every exercise must use an `exerciseId` from the LIBRARY block. NEVER invent ids. If nothing fits, pick the closest match and explain the substitution in `notes`.',
'3. Honor what the user asked for: include the exercises they named, with the set counts / emphasis they specified. Add sensible accessory work only if they asked you to fill out a body part (e.g. "let\'s do biceps and triceps").',
`4. Every resistance exercise MUST have a \`suggestedWeight\` (a number) and a target \`reps\`. Cardio, stretching, and bodyweight exercises set \`suggestedWeight\` to null.`,
`5. Express \`suggestedWeight\` in THAT exercise's \`unit\` from the LIBRARY block, and set \`suggestedWeightUnit\` to match it (default "${opts.weightUnit}" if none is shown). Don't convert — give the number in the exercise's own unit.`,
'6. `sets` is the number of working sets to pre-fill (e.g. the user\'s "4 working sets" → sets: 4).',
'7. EFFORT: for CARDIO exercises set `gear` (1-5 breathing gear) and leave `rpe` null. For everything else set `rpe` (1-10) and leave `gear` null.',
'8. Use `durationSeconds` instead of `reps` for timed work (holds, carries, intervals).',
'9. `notes` is for a short coaching cue — one sentence, optional.',
'10. Keep it to a single realistic session (typically 3-8 exercises). Do NOT invent multiple days or weeks — this is ONE workout.',
);
if (opts.hasHistoryContext) {
lines.push(
'',
'# USING THE HISTORY BLOCK',
'',
"The HISTORY block below summarizes the user's last 90 days. Use it to:",
'- Set `suggestedWeight` near their recent working weights for that exercise, NOT round numbers from nowhere.',
'- Nudge progressive overload where appropriate (small jump if a lift is moving; hold or deload if STAGNANT).',
'- Match the rep ranges and effort they tend to train at.',
"- If an exercise they named has no history, estimate conservatively and say so in `notes`.",
);
} else {
lines.push(
'',
'# WEIGHT GUIDANCE WITHOUT HISTORY',
'',
`Without prior data, set conservative \`suggestedWeight\` values (round gym increments; 5${opts.weightUnit} jumps, 2.5${opts.weightUnit} for small accessories) and add a coaching note like "adjust to leave 2-3 reps in reserve" so the user knows it's a starting estimate.`,
);
}
if (opts.priorWorkoutJson) {
lines.push(
'',
'# REVISION MODE',
'',
'The user already has the workout below and wants you to change it. Apply their requested change and re-emit the COMPLETE revised workout as one JSON object (not a diff). Keep everything they did not ask to change.',
'',
'CURRENT WORKOUT:',
opts.priorWorkoutJson,
);
}
if (opts.isLocalModel) {
lines.push(
'',
'# LOCAL MODEL REMINDER',
'',
'You are running locally with limited reasoning. Build the simplest valid single-session workout that matches the request. Do not overthink. JSON only.',
);
}
return lines.join('\n');
}
+124
View File
@@ -0,0 +1,124 @@
import { z } from 'zod';
import { extractJson } from './programSchema';
/**
* The shape we ask LLMs to produce for a SINGLE day's workout (the
* "generate today's workout" flow). Distinct from the multi-week
* AIProgram in programSchema.ts.
*
* This does NOT map onto a DB table directly: the user reviews/edits the
* suggestion, then it pre-populates the normal New Workout form (nothing
* is persisted until they save through the regular workout path). So the
* shape is optimized for "pre-fill a logger" not "INSERT a Program".
*
* Per exercise we ask for a working `sets` count plus a single target
* `reps` / `suggestedWeight` — the hand-off expands that into N identical
* pre-filled SetLogs. (No warmup/ramping distinction in v1.)
*
* `exerciseId` is nullable: the model picks from the user's library when
* it can, but may suggest something not in the library (the preview
* prompts the user to map it). `exerciseName` is REQUIRED as the display
* label + fuzzy-match fallback.
*/
export const aiWorkoutExerciseSchema = z.object({
exerciseId: z.string().nullable(),
exerciseName: z.string().min(1),
order: z.number().int().nonnegative(),
/// Number of working sets to pre-fill. Defaults to 3 in the hand-off
/// if the model omits it.
sets: z.number().int().positive().optional().nullable(),
/// Target reps per set (the user overwrites with what they actually
/// did). Omit for time/distance-based work.
reps: z.number().int().positive().optional().nullable(),
/// Suggested working weight. Null for cardio / bodyweight / stretching.
suggestedWeight: z.number().nonnegative().optional().nullable(),
/// "lbs" | "kg". Optional; hand-off falls back to the user's
/// defaultWeightUnit when null.
suggestedWeightUnit: z.enum(['lbs', 'kg']).optional().nullable(),
/// Strength effort (1-10). The hand-off keeps this only for non-cardio
/// exercises (cardio uses `gear`).
rpe: z.number().int().min(1).max(10).optional().nullable(),
/// Cardio breathing gear (1-5). The hand-off keeps this only for
/// cardio exercises (strength uses `rpe`).
gear: z.number().int().min(1).max(5).optional().nullable(),
/// Target duration in seconds for time-based work (e.g. a hold).
durationSeconds: z.number().int().positive().optional().nullable(),
notes: z.string().optional().nullable(),
});
export const aiWorkoutSchema = z.object({
name: z.string().min(1),
notes: z.string().optional().nullable(),
exercises: z.array(aiWorkoutExerciseSchema),
});
export type AIWorkout = z.infer<typeof aiWorkoutSchema>;
export type AIWorkoutExercise = z.infer<typeof aiWorkoutExerciseSchema>;
/**
* JSON-schema-ish doc pasted into the system prompt so the model knows
* the exact shape to emit (same approach as PROGRAM_OUTPUT_SHAPE — not a
* provider "structured output" mode, since Ollama support is uneven).
*/
export const WORKOUT_OUTPUT_SHAPE = `{
"name": "<string, e.g. Upper Body — Shoulder Focus>",
"notes": "<string, optional, one-line session summary>",
"exercises": [
{
"exerciseId": "<string — REQUIRED — must be an id from the LIBRARY block. If no library exercise fits, pick the closest match and explain in notes; do NOT invent ids.>",
"exerciseName": "<string, the canonical name from the library>",
"order": <int >= 0>,
"sets": <int >= 1, number of working sets>,
"reps": <int, target reps per set; omit for time/distance work>,
"suggestedWeight": <number, working weight in the exercise's LIBRARY \`unit\`; omit/null for cardio, bodyweight, stretching>,
"suggestedWeightUnit": "<\\"lbs\\" | \\"kg\\", optional — match the exercise's \`unit\` from the LIBRARY>",
"rpe": <int 1-10, strength effort; use for NON-cardio exercises>,
"gear": <int 1-5, cardio breathing gear; use for CARDIO exercises instead of rpe>,
"durationSeconds": <int, optional, for timed holds/intervals>,
"notes": "<string, optional, short coaching cue>"
}
]
}`;
/**
* Parse + validate a model's raw response into an AIWorkout. Returns a
* clean workout or a structured error. Mirrors parseAIProgram.
*/
export function parseAIWorkout(
raw: string,
):
| { ok: true; workout: AIWorkout }
| { ok: false; reason: string; json?: string } {
const json = extractJson(raw);
if (!json) {
return {
ok: false,
reason: 'Could not find a JSON object in the response.',
};
}
let obj: unknown;
try {
obj = JSON.parse(json);
} catch (e) {
return {
ok: false,
reason: `JSON parse error: ${(e as Error).message}`,
json,
};
}
const result = aiWorkoutSchema.safeParse(obj);
if (!result.success) {
return {
ok: false,
reason:
'JSON did not match the expected shape: ' +
result.error.errors
.slice(0, 5)
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join('; '),
json,
};
}
return { ok: true, workout: result.data };
}
+16
View File
@@ -131,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"
);
}
+10 -2
View File
@@ -116,7 +116,8 @@ 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"
@@ -421,13 +422,20 @@ model AIGeneration {
userInput String userInput String
systemPrompt String systemPrompt String
userPrompt String userPrompt String
/// What this generation produces: "program" (multi-week Program) or
/// "workout" (a single day's workout the user pre-fills the log from).
/// Drives which parser the runner uses and which UI consumes the row.
/// Defaults to "program" so legacy rows read correctly post-migration.
kind String @default("program")
/// Streamed-so-far text. Updated periodically by the background /// Streamed-so-far text. Updated periodically by the background
/// generator so navigating-away clients can resume display via /// generator so navigating-away clients can resume display via
/// polling. Final value matches `rawResponse` once status flips /// polling. Final value matches `rawResponse` once status flips
/// to 'completed' or 'failed'. /// to 'completed' or 'failed'.
progressText String? progressText String?
rawResponse String? rawResponse String?
parsedProgram String? // JSON.stringify of the parsed structure /// JSON.stringify of the parsed structure. An AIProgram when
/// kind="program", an AIWorkout when kind="workout".
parsedProgram String?
provider String provider String
model String model String
tokensIn Int? tokensIn Int?
+103
View File
@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { buildPrefillExercises, type AiWorkoutDraft } from '@/lib/ai/workoutDraft';
// Minimal library shape buildPrefillExercises needs (id + cardio inputs).
const lib = [
{ id: 'press', type: 'barbell', muscleGroups: '["shoulders"]' },
{ id: 'bike', type: 'cardio', muscleGroups: '[]' },
// Tagged cardio via muscleGroups even though the equipment type isn't.
{ id: 'boxjump', type: 'bodyweight', muscleGroups: '["legs","cardio"]' },
];
describe('buildPrefillExercises', () => {
it('expands a strength exercise into N sets with weight+reps and RPE only', () => {
const draft: AiWorkoutDraft = {
name: 'Push',
exercises: [
{
exerciseId: 'press',
sets: 4,
reps: 6,
suggestedWeight: 95,
rpe: 8,
gear: 3, // wrong-kind value — must be dropped for non-cardio
},
],
};
const [ex] = buildPrefillExercises(draft, lib);
expect(ex.sets).toHaveLength(4);
expect(ex.sets.map((s) => s.setNumber)).toEqual([1, 2, 3, 4]);
for (const s of ex.sets) {
expect(s.weight).toBe(95);
expect(s.reps).toBe(6);
expect(s.rpe).toBe(8);
expect(s.gear).toBeUndefined();
}
});
it('uses gear (not rpe) for a cardio exercise', () => {
const draft: AiWorkoutDraft = {
name: 'Conditioning',
exercises: [
{ exerciseId: 'bike', sets: 1, durationSeconds: 600, gear: 3, rpe: 8 },
],
};
const [ex] = buildPrefillExercises(draft, lib);
expect(ex.sets[0].gear).toBe(3);
expect(ex.sets[0].rpe).toBeUndefined();
expect(ex.sets[0].durationSeconds).toBe(600);
});
it('treats an exercise tagged "cardio" in muscleGroups as cardio', () => {
const draft: AiWorkoutDraft = {
name: 'Plyo',
exercises: [{ exerciseId: 'boxjump', sets: 3, reps: 5, rpe: 7, gear: 2 }],
};
const [ex] = buildPrefillExercises(draft, lib);
expect(ex.sets[0].gear).toBe(2);
expect(ex.sets[0].rpe).toBeUndefined();
});
it('defaults to 3 sets when the count is missing or non-positive', () => {
const draft: AiWorkoutDraft = {
name: 'X',
exercises: [
{ exerciseId: 'press', sets: 0, reps: 5, suggestedWeight: 100 },
],
};
const [ex] = buildPrefillExercises(draft, lib);
expect(ex.sets).toHaveLength(3);
});
it('drops exercises whose id is not in the library', () => {
const draft: AiWorkoutDraft = {
name: 'X',
exercises: [
{ exerciseId: 'ghost', sets: 3, reps: 5 },
{ exerciseId: 'press', sets: 2, reps: 5, suggestedWeight: 100 },
],
};
const out = buildPrefillExercises(draft, lib);
expect(out).toHaveLength(1);
expect(out[0].exercise.id).toBe('press');
});
it('puts the coaching note on the first set only', () => {
const draft: AiWorkoutDraft = {
name: 'X',
exercises: [
{
exerciseId: 'press',
sets: 3,
reps: 5,
suggestedWeight: 100,
notes: 'brace hard',
},
],
};
const [ex] = buildPrefillExercises(draft, lib);
expect(ex.sets[0].notes).toBe('brace hard');
expect(ex.sets[1].notes).toBeUndefined();
expect(ex.sets[2].notes).toBeUndefined();
});
});
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { parseAIWorkout } from '@/lib/ai/workoutSchema';
describe('parseAIWorkout', () => {
const valid = {
name: 'Upper — Shoulders',
notes: 'Overhead press focus',
exercises: [
{
exerciseId: 'cabc',
exerciseName: 'Overhead Press',
order: 0,
sets: 4,
reps: 6,
suggestedWeight: 95,
suggestedWeightUnit: 'lbs',
rpe: 8,
},
{
exerciseId: 'cdef',
exerciseName: 'Assault Bike',
order: 1,
sets: 1,
durationSeconds: 600,
gear: 3,
},
],
};
it('accepts a valid single workout', () => {
const r = parseAIWorkout(JSON.stringify(valid));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.workout.name).toBe('Upper — Shoulders');
expect(r.workout.exercises).toHaveLength(2);
expect(r.workout.exercises[0].suggestedWeight).toBe(95);
expect(r.workout.exercises[1].gear).toBe(3);
}
});
it('accepts null exerciseId for unresolved exercises', () => {
const variant = structuredClone(valid);
variant.exercises[0].exerciseId = null as unknown as string;
const r = parseAIWorkout(JSON.stringify(variant));
expect(r.ok).toBe(true);
});
it('strips markdown fences and commentary', () => {
const wrapped =
"Here's today's session:\n\n```json\n" +
JSON.stringify(valid) +
'\n```\n\nEnjoy!';
const r = parseAIWorkout(wrapped);
expect(r.ok).toBe(true);
});
it('rejects when no JSON is present', () => {
const r = parseAIWorkout('the model just said hi');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/Could not find/);
});
it('rejects a parse-level syntax error inside balanced braces', () => {
const r = parseAIWorkout('{ "name": "x", }');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/parse error/i);
});
it('rejects when the shape is wrong (missing exercises)', () => {
const r = parseAIWorkout(JSON.stringify({ name: 'X' }));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/shape/);
});
it('rejects an out-of-range gear', () => {
const variant = structuredClone(valid);
variant.exercises[1].gear = 9; // gear is 1-5
const r = parseAIWorkout(JSON.stringify(variant));
expect(r.ok).toBe(false);
});
it('rejects an empty exercise name', () => {
const variant = structuredClone(valid);
variant.exercises[0].exerciseName = '';
const r = parseAIWorkout(JSON.stringify(variant));
expect(r.ok).toBe(false);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { isCardioExercise } from '@/lib/exerciseOptions';
describe('isCardioExercise', () => {
it('treats type "cardio" as cardio', () => {
expect(isCardioExercise({ type: 'cardio', muscleGroups: '["cardio"]' })).toBe(true);
});
it('treats the cardio muscle group as cardio even when type differs (Assault Bike)', () => {
expect(
isCardioExercise({ type: 'assault bike', muscleGroups: '["cardio","legs","back","shoulders"]' })
).toBe(true);
});
it('is case/whitespace-insensitive on the muscle group', () => {
expect(isCardioExercise({ type: 'other', muscleGroups: '[" Cardio "]' })).toBe(true);
});
it('treats strength work (no cardio signal) as non-cardio', () => {
expect(isCardioExercise({ type: 'barbell', muscleGroups: '["back","biceps"]' })).toBe(false);
});
it('handles missing/empty fields without throwing', () => {
expect(isCardioExercise({})).toBe(false);
expect(isCardioExercise({ type: null, muscleGroups: null })).toBe(false);
expect(isCardioExercise({ type: '', muscleGroups: 'not json' })).toBe(false);
});
});
@@ -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);
});
});
+61
View File
@@ -339,6 +339,45 @@ describe('POST /api/workouts', () => {
expect(stored?.watts).toBe(157); 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({
@@ -466,6 +505,28 @@ describe('PATCH /api/workouts/[id]', () => {
const stored = await prisma.setLog.findFirst({ where: { workoutId: workout.id } }); const stored = await prisma.setLog.findFirst({ where: { workoutId: workout.id } });
expect(stored?.watts).toBe(180); expect(stored?.watts).toBe(180);
}); });
it('persists gear when replacing sets via PATCH', async () => {
const alice = await makeUser({ email: 'a@x' });
const bike = await prisma.exercise.create({
data: { userId: alice.id, name: 'Assault Bike', type: 'assault bike', muscleGroups: '["cardio"]' },
});
const workout = await prisma.workout.create({
data: { userId: alice.id, date: new Date(), name: 'Cond' },
});
getCurrentUserMock.mockResolvedValue(alice);
const res = await patchWorkout(
jsonReq(
'http://x/api/workouts/' + workout.id,
{ sets: [{ exerciseId: bike.id, setNumber: 1, durationSeconds: 600, gear: 4 }] },
{ method: 'PATCH' },
),
{ params: Promise.resolve({ id: workout.id }) },
);
expect(res.status).toBe(200);
const stored = await prisma.setLog.findFirst({ where: { workoutId: workout.id } });
expect(stored?.gear).toBe(4);
});
}); });
describe('POST /api/workouts/import/save', () => { describe('POST /api/workouts/import/save', () => {
+1
View File
@@ -91,6 +91,7 @@ export type ParsedSet = {
calories?: number | null; calories?: number | null;
watts?: number | null; watts?: number | null;
rpe?: number | null; rpe?: number | null;
gear?: number | null;
notes?: string | null; notes?: string | null;
}; };
+12
View File
@@ -77,6 +77,11 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN watts INTEGER;" sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN watts INTEGER;"
fi 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;"
@@ -216,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;"
+13 -1
View File
@@ -19,6 +19,8 @@ import { v_1_2_0_1 } from './v1.2.0.1'
import { v_1_2_0_2 } from './v1.2.0.2' import { v_1_2_0_2 } from './v1.2.0.2'
import { v_1_2_0_3 } from './v1.2.0.3' import { v_1_2_0_3 } from './v1.2.0.3'
import { v_1_2_0_4 } from './v1.2.0.4' import { v_1_2_0_4 } from './v1.2.0.4'
import { v_1_2_0_5 } from './v1.2.0.5'
import { v_1_2_0_6 } from './v1.2.0.6'
/** /**
* Version graph for the `proof-of-work` package. * Version graph for the `proof-of-work` package.
@@ -76,9 +78,17 @@ import { v_1_2_0_4 } from './v1.2.0.4'
* column, added by the boot-time additive ALTER). Written through * column, added by the boot-time additive ALTER). Written through
* every set path; legacy watts in customMetrics stays readable and * every set path; legacy watts in customMetrics stays readable and
* migrates on next save. * migrates on next save.
* v1.2.0:5 — Gear (breathing, 1-5, Brian MacKenzie) replaces RPE as the effort
* field for cardio exercises (type "cardio" or "cardio" muscle
* group); strength keeps RPE. New SetLog.gear column via boot ALTER.
* v1.2.0:6 — AI "generate today's workout": describe a single session and get
* a ready-to-log workout (suggested weights/reps from history),
* inline-edit + refine-with-AI, then pre-fill the workout log.
* Reuses the generation spine via a new AIGeneration.kind
* discriminant (boot ALTER, default "program"). No data changes.
*/ */
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_1_2_0_4, current: v_1_2_0_6,
other: [ other: [
v_1_0_0_1, v_1_0_0_1,
v_1_0_0_2, v_1_0_0_2,
@@ -99,5 +109,7 @@ export const versionGraph = VersionGraph.of({
v_1_2_0_1, v_1_2_0_1,
v_1_2_0_2, v_1_2_0_2,
v_1_2_0_3, v_1_2_0_3,
v_1_2_0_4,
v_1_2_0_5,
], ],
}) })
+25
View File
@@ -0,0 +1,25 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:5 — Gear (breathing, 1-5) replaces RPE as the cardio effort field (2026-06-16).
*
* Cardio exercises now log a breathing "Gear" (1-5, per Brian MacKenzie)
* instead of RPE (6-10) as their effort field; non-cardio keeps RPE. An
* exercise counts as cardio if its equipment type is "cardio" or it carries
* the "cardio" muscle group (so Assault Bike, type "assault bike", qualifies).
*
* Additive schema change: the new nullable SetLog.gear column is added by the
* boot-time guarded ALTER in docker_entrypoint.sh (migration stays empty, like
* every other column add). Existing rpe data is untouched and still displays.
*/
export const v_1_2_0_5 = VersionInfo.of({
version: '1.2.0:5',
releaseNotes: {
en_US:
'Cardio exercises (assault bike, rower, ski erg, running, etc.) now log a breathing "Gear" (1-5) instead of RPE as their effort field. Strength exercises still use RPE. No data changes.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})
+32
View File
@@ -0,0 +1,32 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:6 — AI "generate today's workout" (2026-06-19).
*
* A new AI flow alongside program generation: describe a single session in
* plain words and get a ready-to-log workout back — exercises with suggested
* weights + target reps + set counts grounded in recent history. Inline-edit
* it, send a follow-up to refine it via the model, then "Use this workout" to
* pre-fill the normal New Workout form (nothing persists until you save).
*
* Reuses the existing generation spine (detached runner / SSE / lenient JSON /
* providers / history context) via a new AIGeneration.kind discriminant
* ("program" | "workout"). Single-workout rows are ephemeral and excluded from
* the program-shaped AI history.
*
* Additive schema change: the new AIGeneration.kind column (default "program")
* is added by the boot-time guarded ALTER in docker_entrypoint.sh, so this
* migration stays empty like every other column add. Existing rows read as
* "program"; no data changes.
*/
export const v_1_2_0_6 = VersionInfo.of({
version: '1.2.0:6',
releaseNotes: {
en_US:
'New: AI "Today\'s workout". Describe a session in plain words and get a ready-to-log workout with suggested weights and reps from your history. Edit it, refine it with the AI, then pre-fill the workout log. No data changes.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})