diff --git a/AGENTS.md b/AGENTS.md
index f364a46..933d9f0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -112,13 +112,13 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
## Current state
-Latest version is **1.2.0:5** — **Gear replaces RPE as the cardio effort field**: cardio exercises now log a breathing "Gear" (1–5, Brian MacKenzie) select instead of RPE (6–10); strength keeps RPE. An exercise is cardio when its equipment `type` is "cardio" **or** its `muscleGroups` contains "cardio" (`isCardioExercise` in `lib/exerciseOptions.ts`) — so Assault Bike (type "assault bike") qualifies, as do Box jump & Soccer (both tagged cardio). New nullable `SetLog.gear` column via boot-time guarded `ALTER`; plumbed through all 5 set-write paths, summary/edit views, CSV/JSON import-export. Program/AI **target**-RPE is a separate concept and untouched. **Built + sideloaded** (`immense-voyage.local`, 2026-06-16, `master`) as `proof-of-work_x86_64.s9pk` (80M, git `4be489d`). Verified: tsc clean (app + packaging), lint clean (pre-existing warnings only), **231 tests pass** (incl. gear + `isCardioExercise`), `next build` succeeds. Registry empty, **publishing parked** (sideload-only via `make install`).
+Latest version is **1.2.0:6** — **AI "generate today's workout"**: a new AI flow alongside program generation. Describe one session in plain words → a streamed, ready-to-log workout (exercises + suggested weights/reps/set-counts grounded in 90-day history) → inline-edit + a **Refine** box that round-trips changes back to the LLM → **Use this workout** pre-fills the normal New Workout form (nothing persists until you save). Reuses the whole generation spine (detached runner / SSE / lenient-JSON / 5 providers / `historyContext`) via a new **`AIGeneration.kind`** discriminant (`"program" | "workout"`, default "program"); the runner picks the parser by kind and stores JSON in the reused `parsedProgram` column. Workout rows are **ephemeral** (the saved Workout is the durable record) so they're filtered out of the program-shaped AI History (`kind:'program'`). Refine = a fresh generation seeded with the prior suggestion JSON (validated via `aiWorkoutSchema` → REVISION mode in `workoutPrompt.ts`). Hand-off is sessionStorage → `/main/workouts/new?from=ai` → `AiWorkoutPrefill` (`workoutDraft.ts::buildPrefillExercises`: expands to N sets, cardio→Gear / strength→RPE via `isCardioExercise`, drops unmapped ids). `EditWorkoutData.id` is now optional so the prefill **creates** (not PATCHes). AI suggests each weight in that exercise's effective unit (library JSON carries per-exercise `unit` = `defaultWeightUnit || "lbs"`, matching what `WorkoutForm.buildPayload` stores). New `AIGeneration.kind` column via boot-time guarded `ALTER`. New files: `lib/ai/workoutSchema.ts`, `workoutPrompt.ts`, `workoutDraft.ts`, `api/ai/generate-workout/route.ts`, `components/ai/GenerateWorkoutClient.tsx`, `components/workouts/AiWorkoutPrefill.tsx`, `app/main/ai/generate-workout/page.tsx`. **Built + sideloaded** (`immense-voyage.local`, 2026-06-19, `master`) as `proof-of-work_x86_64.s9pk` (80M). Verified: tsc clean (app + packaging), lint clean (pre-existing warnings only), **251 tests pass** (incl. `parseAIWorkout`, `buildPrefillExercises` gear/RPE mapping, generate-workout route auth/validation), `next build` succeeds. Registry empty, **publishing parked** (sideload-only via `make install`). See `docs/guides/ai-subsystem.md` → "Two generation kinds".
-**Confirmed on-box (2026-06-16, via `start-cli`):** box runs `1.2.0:5`; entrypoint logged `adding missing column SetLog.gear` and (earlier boot) `SetLog.watts`, each once; app launches `as nextjs` with no permission errors (clears the 1.2.0:3 / long-standing 1.1.0:9 non-root check). App DB shows an Assault Bike set saved with `gear=1` and no `rpe` — Gear select renders + persists for cardio, RPE for strength. Recent prior ships (1.2.0 line): **1.2.0:3** P3 hardening (login timing oracle + `exerciseId` ownership); **1.2.0:2** iOS Safari login first-tap retry; **1.2.0:1** Next 15 / React 19 upgrade.
+**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).
-**No on-box checks pending.** Known bug (tracked in `ROADMAP.md` → Known bugs): the **1.2.0:2** Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced on 1.2.0:5, first tap shows "An unexpected error occurred", second tap works. Diagnosis captured; the fix is gated on one data point — the first failed request's error code from Safari Web Inspector (`-1005` → client delayed-retry; `502`/`503` → Node keep-alive tuning).
+**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):
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`.
diff --git a/docs/guides/ai-subsystem.md b/docs/guides/ai-subsystem.md
index 310ae8b..e9c1272 100644
--- a/docs/guides/ai-subsystem.md
+++ b/docs/guides/ai-subsystem.md
@@ -20,6 +20,29 @@ generate/generations route handlers). Whole-repo rules live in `AGENTS.md`.
- Multi-config: `AIConfigProfile` rows per user; `UserPreferences.activeAIConfigId`
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
- Each provider yields an async iterable of `GenerateChunk` (`text` / `usage` / `done` /
diff --git a/proof-of-work/app/api/ai/generate-workout/route.ts b/proof-of-work/app/api/ai/generate-workout/route.ts
new file mode 100644
index 0000000..d1184fd
--- /dev/null
+++ b/proof-of-work/app/api/ai/generate-workout/route.ts
@@ -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 });
+}
diff --git a/proof-of-work/app/api/ai/generate/route.ts b/proof-of-work/app/api/ai/generate/route.ts
index 19fea5f..ffd7e99 100644
--- a/proof-of-work/app/api/ai/generate/route.ts
+++ b/proof-of-work/app/api/ai/generate/route.ts
@@ -147,6 +147,7 @@ ${libraryJson}${historyBlock}`;
const id = await kickoffGeneration({
prisma,
userId: user.id,
+ kind: 'program',
templateId: template?.id ?? null,
templateName: template?.name ?? null,
userInput: parsed.data.userInput,
diff --git a/proof-of-work/app/api/ai/generations/route.ts b/proof-of-work/app/api/ai/generations/route.ts
index 1a61b35..ab74d15 100644
--- a/proof-of-work/app/api/ai/generations/route.ts
+++ b/proof-of-work/app/api/ai/generations/route.ts
@@ -16,7 +16,8 @@ export async function GET(request: NextRequest) {
const offset = Math.max(parseInt(sp.get('offset') || '0'), 0);
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' },
take: limit + 1,
skip: offset,
diff --git a/proof-of-work/app/main/ai/generate-workout/page.tsx b/proof-of-work/app/main/ai/generate-workout/page.tsx
new file mode 100644
index 0000000..342306d
--- /dev/null
+++ b/proof-of-work/app/main/ai/generate-workout/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+ AI · Today's workout
+
+
+
+
+ {!aiConfigured ? (
+
+
AI is not configured.
+
+ Pick a provider, model, and (if needed) API key in{' '}
+
+ Settings → AI integration
+ {' '}
+ before you can generate a workout.
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/proof-of-work/app/main/ai/history/[id]/page.tsx b/proof-of-work/app/main/ai/history/[id]/page.tsx
index d70aed1..3f80b8b 100644
--- a/proof-of-work/app/main/ai/history/[id]/page.tsx
+++ b/proof-of-work/app/main/ai/history/[id]/page.tsx
@@ -34,7 +34,8 @@ export default async function GenerationDetailPage(props: {
const [row, exercises] = await Promise.all([
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({
where: { userId: user.id },
diff --git a/proof-of-work/app/main/ai/history/page.tsx b/proof-of-work/app/main/ai/history/page.tsx
index a0cb204..4509b26 100644
--- a/proof-of-work/app/main/ai/history/page.tsx
+++ b/proof-of-work/app/main/ai/history/page.tsx
@@ -12,7 +12,10 @@ export default async function HistoryPage() {
if (!user) redirect('/auth/login');
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' },
take: 25,
select: {
diff --git a/proof-of-work/app/main/ai/page.tsx b/proof-of-work/app/main/ai/page.tsx
index e85ede6..e2fcaf3 100644
--- a/proof-of-work/app/main/ai/page.tsx
+++ b/proof-of-work/app/main/ai/page.tsx
@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation';
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 { prisma } from '@/lib/prisma';
@@ -17,6 +17,14 @@ export default async function AIIndexPage() {
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
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',
icon: Sparkles,
diff --git a/proof-of-work/app/main/navigation.tsx b/proof-of-work/app/main/navigation.tsx
index f806c8a..277f3ac 100644
--- a/proof-of-work/app/main/navigation.tsx
+++ b/proof-of-work/app/main/navigation.tsx
@@ -44,7 +44,8 @@ const navLinks: NavLink[] = [
label: 'AI',
icon: Sparkles,
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/templates', label: 'Templates' },
],
diff --git a/proof-of-work/app/main/workouts/new/page.tsx b/proof-of-work/app/main/workouts/new/page.tsx
index c69da17..fa79a13 100644
--- a/proof-of-work/app/main/workouts/new/page.tsx
+++ b/proof-of-work/app/main/workouts/new/page.tsx
@@ -5,6 +5,7 @@ import { getCurrentUser } from "@/lib/auth";
import { getExercises } from "@/lib/db/exercises";
import { getWorkoutById } from "@/lib/db/workouts";
import WorkoutForm, { EditWorkoutData } from "@/components/workouts/WorkoutForm";
+import AiWorkoutPrefill from "@/components/workouts/AiWorkoutPrefill";
export const metadata = {
title: "Log Workout",
@@ -12,7 +13,7 @@ export const metadata = {
};
export default async function NewWorkoutPage(props: {
- searchParams: Promise<{ edit?: string }>;
+ searchParams: Promise<{ edit?: string; from?: string }>;
}) {
const searchParams = await props.searchParams;
const user = await getCurrentUser();
@@ -22,6 +23,11 @@ export default async function NewWorkoutPage(props: {
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
let editWorkout: EditWorkoutData | undefined;
if (searchParams.edit) {
@@ -95,11 +101,15 @@ export default async function NewWorkoutPage(props: {
{/* Form */}
-
+ {fromAi ? (
+
+ ) : (
+
+ )}
);
diff --git a/proof-of-work/components/ai/GenerateWorkoutClient.tsx b/proof-of-work/components/ai/GenerateWorkoutClient.tsx
new file mode 100644
index 0000000..4ecfd61
--- /dev/null
+++ b/proof-of-work/components/ai/GenerateWorkoutClient.tsx
@@ -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 | 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({ 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(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 | null = null;
+
+ es.addEventListener('text', (ev) => {
+ const data = JSON.parse((ev as MessageEvent).data);
+ raw += data.delta;
+ const next = lenientJsonParse(raw) as Partial | 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 (
+
+
+ Provider: {providerLabel}
+ {' · '}Model: {modelLabel}
+
+
+
+
+ {showResult && (
+
+
+
+ {streaming ? 'Generating…' : 'Suggested workout'}
+
+
+ {tokens.in != null && (
+ <>
+ {tokens.in} in · {tokens.out ?? '?'} out
+ >
+ )}
+ {costStr && <> · {costStr}>}
+ {tokens.durationMs != null && (
+ <> · {(tokens.durationMs / 1000).toFixed(1)}s>
+ )}
+
+
+
+ {streaming && (
+ <>
+ {phase.lastPartial ? (
+
+ ) : (
+
+
+ Waiting for the first parseable JSON…
+
+ )}
+
+ Raw stream
+
+ {phase.raw || '(waiting for first token…)'}
+
+
+
+ >
+ )}
+
+ {phase.kind === 'failed' && (
+ <>
+
+ {phase.message}
+
+ {phase.raw && (
+
+ Raw response
+
+ {phase.raw}
+
+
+ )}
+ >
+ )}
+
+ {!streaming && workout && (
+ 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');
+ }}
+ />
+ )}
+
+ )}
+
+ );
+}
+
+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(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,
+ ) => {
+ 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 (
+
+
+
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 && (
+
{workout.notes}
+ )}
+
+ {workout.exercises.length} exercise
+ {workout.exercises.length === 1 ? '' : 's'}
+
+
+
+ {unresolvedCount > 0 && (
+
+ {unresolvedCount} exercise(s) the AI couldn't map to your library.
+ Pick a replacement or remove them before using this workout.
+
+ )}
+
+
+ {workout.exercises.map((ex, idx) => {
+ const isUnknown = !ex.exerciseId || !exerciseLookup.has(ex.exerciseId);
+ const lib = ex.exerciseId ? exerciseLookup.get(ex.exerciseId) : null;
+ return (
+
+
+
+ {lib?.name ?? ex.exerciseName}
+ {isUnknown && (
+
+ not in library
+
+ )}
+
+
removeExercise(idx)}
+ className="text-xs text-red-400 hover:text-red-300 px-1"
+ title="Remove from workout"
+ >
+ ✕
+
+
+
+ {isUnknown && (
+
+ 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"
+ >
+ Map to existing exercise…
+ {exercises.map((opt) => (
+
+ {opt.name} ({opt.type})
+
+ ))}
+
+ )}
+
+
+ updateExercise(idx, { sets: v })}
+ />
+ updateExercise(idx, { reps: v })}
+ />
+ updateExercise(idx, { suggestedWeight: v })}
+ />
+
+ {ex.notes && (
+ {ex.notes}
+ )}
+
+ );
+ })}
+
+
+
+
+
+ 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}
+ />
+ {
+ 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
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
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
+
+
+ Opens a pre-filled workout — nothing is saved until you save it there.
+
+
+
+ );
+
+ function NumField({
+ label,
+ value,
+ step,
+ onChange,
+ }: {
+ label: string;
+ value?: number | null;
+ step?: string;
+ onChange: (v: number | null) => void;
+ }) {
+ return (
+
+
+ {label}
+
+ 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"
+ />
+
+ );
+ }
+}
+
+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}
+
+ {children}
+
+ );
+}
+
+function PartialPreview({ partial }: { partial: Partial }) {
+ const exercises = (partial.exercises as AIWorkoutExercise[] | undefined) ?? [];
+ return (
+
+
+
+
+ Building workout…{' '}
+ {partial.name && (
+ {partial.name}
+ )}
+
+
+ {exercises.length > 0 && (
+
+ {exercises.map((ex, i) => (
+
+ {(ex?.order ?? i) + 1}. {' '}
+ {ex?.exerciseName ?? '…'}
+ {ex?.sets ? (
+
+ {' '}
+ · {ex.sets}×{ex.reps ?? '?'}
+ {ex.suggestedWeight != null
+ ? ` @ ${ex.suggestedWeight}${ex.suggestedWeightUnit ?? ''}`
+ : ''}
+
+ ) : null}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/proof-of-work/components/workouts/AiWorkoutPrefill.tsx b/proof-of-work/components/workouts/AiWorkoutPrefill.tsx
new file mode 100644
index 0000000..180b55d
--- /dev/null
+++ b/proof-of-work/components/workouts/AiWorkoutPrefill.tsx
@@ -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(() => {
+ 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 (
+
+ );
+}
diff --git a/proof-of-work/components/workouts/WorkoutForm.tsx b/proof-of-work/components/workouts/WorkoutForm.tsx
index 14de800..1b0fc04 100644
--- a/proof-of-work/components/workouts/WorkoutForm.tsx
+++ b/proof-of-work/components/workouts/WorkoutForm.tsx
@@ -245,7 +245,10 @@ interface ExerciseWithSets {
}
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;
date: string; // ISO string
durationMinutes?: number | null;
@@ -655,9 +658,11 @@ export default function WorkoutForm({
if (!response.ok) throw new Error("Failed to save workout");
}
- // Navigate back: to detail page if editing, otherwise to list
- if (editWorkout) {
- router.push(`/main/workouts/${savedWorkoutId || editWorkout.id}`);
+ // Navigate back: to detail page if we have a workout id (editing, or
+ // an AI-prefilled workout that has now been created), otherwise to list.
+ const detailId = savedWorkoutId || editWorkout?.id;
+ if (detailId) {
+ router.push(`/main/workouts/${detailId}`);
} else {
router.push("/main/workouts");
}
diff --git a/proof-of-work/lib/ai/generationRunner.ts b/proof-of-work/lib/ai/generationRunner.ts
index a3b0668..f3df759 100644
--- a/proof-of-work/lib/ai/generationRunner.ts
+++ b/proof-of-work/lib/ai/generationRunner.ts
@@ -34,6 +34,7 @@
import type { PrismaClient } from '@prisma/client';
import { getProvider } from './providers';
import { parseAIProgram } from './programSchema';
+import { parseAIWorkout } from './workoutSchema';
export interface GenerationDelta {
type: 'text' | 'usage' | 'complete' | 'error';
@@ -114,6 +115,9 @@ export function subscribe(
export interface KickoffOpts {
prisma: PrismaClient;
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;
templateName: string | null;
userInput: string;
@@ -139,6 +143,7 @@ export async function kickoffGeneration(opts: KickoffOpts): Promise {
const generation = await opts.prisma.aIGeneration.create({
data: {
userId: opts.userId,
+ kind: opts.kind,
templateId: opts.templateId,
templateName: opts.templateName,
userInput: opts.userInput,
@@ -248,10 +253,11 @@ async function runGeneration(generationId: string, opts: KickoffOpts) {
let parsedJson: string | null = null;
let parseErr: string | null = null;
if (!providerError && raw) {
- const r = parseAIProgram(raw);
+ const r =
+ opts.kind === 'workout' ? parseAIWorkout(raw) : parseAIProgram(raw);
if (r.ok) {
parsedOk = true;
- parsedJson = JSON.stringify(r.program);
+ parsedJson = JSON.stringify('workout' in r ? r.workout : r.program);
} else {
parseErr = r.reason;
}
diff --git a/proof-of-work/lib/ai/workoutDraft.ts b/proof-of-work/lib/ai/workoutDraft.ts
new file mode 100644
index 0000000..8d5f909
--- /dev/null
+++ b/proof-of-work/lib/ai/workoutDraft.ts
@@ -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 {
+ 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[] {
+ const byId = new Map(exercises.map((e) => [e.id, e]));
+ const out: PrefillExercise[] = [];
+ 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;
+}
diff --git a/proof-of-work/lib/ai/workoutPrompt.ts b/proof-of-work/lib/ai/workoutPrompt.ts
new file mode 100644
index 0000000..6ffb6e3
--- /dev/null
+++ b/proof-of-work/lib/ai/workoutPrompt.ts
@@ -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');
+}
diff --git a/proof-of-work/lib/ai/workoutSchema.ts b/proof-of-work/lib/ai/workoutSchema.ts
new file mode 100644
index 0000000..0401846
--- /dev/null
+++ b/proof-of-work/lib/ai/workoutSchema.ts
@@ -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;
+export type AIWorkoutExercise = z.infer;
+
+/**
+ * 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": "",
+ "notes": "",
+ "exercises": [
+ {
+ "exerciseId": "",
+ "exerciseName": "",
+ "order": = 0>,
+ "sets": = 1, number of working sets>,
+ "reps": ,
+ "suggestedWeight": ,
+ "suggestedWeightUnit": "<\\"lbs\\" | \\"kg\\", optional — match the exercise's \`unit\` from the LIBRARY>",
+ "rpe": ,
+ "gear": ,
+ "durationSeconds": ,
+ "notes": ""
+ }
+ ]
+}`;
+
+/**
+ * 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 };
+}
diff --git a/proof-of-work/prisma/schema.prisma b/proof-of-work/prisma/schema.prisma
index 6482f5b..c0ec384 100644
--- a/proof-of-work/prisma/schema.prisma
+++ b/proof-of-work/prisma/schema.prisma
@@ -422,13 +422,20 @@ model AIGeneration {
userInput String
systemPrompt 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
/// generator so navigating-away clients can resume display via
/// polling. Final value matches `rawResponse` once status flips
/// to 'completed' or 'failed'.
progressText 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
model String
tokensIn Int?
diff --git a/proof-of-work/tests/ai-workoutDraft.test.ts b/proof-of-work/tests/ai-workoutDraft.test.ts
new file mode 100644
index 0000000..a53cead
--- /dev/null
+++ b/proof-of-work/tests/ai-workoutDraft.test.ts
@@ -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();
+ });
+});
diff --git a/proof-of-work/tests/ai-workoutSchema.test.ts b/proof-of-work/tests/ai-workoutSchema.test.ts
new file mode 100644
index 0000000..5a24679
--- /dev/null
+++ b/proof-of-work/tests/ai-workoutSchema.test.ts
@@ -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);
+ });
+});
diff --git a/proof-of-work/tests/routes-ai-workout.test.ts b/proof-of-work/tests/routes-ai-workout.test.ts
new file mode 100644
index 0000000..6ddfbeb
--- /dev/null
+++ b/proof-of-work/tests/routes-ai-workout.test.ts
@@ -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;
+ 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[1]);
+}
+function rawReq(rawBody: string): NextRequest {
+ return new NextRequest(URL, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: rawBody,
+ } as ConstructorParameters[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);
+ });
+});
diff --git a/start9/0.4/docker_entrypoint.sh b/start9/0.4/docker_entrypoint.sh
index 000480a..c8d4863 100755
--- a/start9/0.4/docker_entrypoint.sh
+++ b/start9/0.4/docker_entrypoint.sh
@@ -221,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;"
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
log "adding ProgramExercise.suggestedWeight + suggestedWeightUnit"
sqlite3 "$DB_PATH" "ALTER TABLE ProgramExercise ADD COLUMN suggestedWeight REAL;"
diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts
index 1bfdfe5..5c1c513 100644
--- a/start9/0.4/startos/versions/index.ts
+++ b/start9/0.4/startos/versions/index.ts
@@ -20,6 +20,7 @@ import { v_1_2_0_2 } from './v1.2.0.2'
import { v_1_2_0_3 } from './v1.2.0.3'
import { v_1_2_0_4 } from './v1.2.0.4'
import { v_1_2_0_5 } from './v1.2.0.5'
+import { v_1_2_0_6 } from './v1.2.0.6'
/**
* Version graph for the `proof-of-work` package.
@@ -80,9 +81,14 @@ import { v_1_2_0_5 } from './v1.2.0.5'
* 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({
- current: v_1_2_0_5,
+ current: v_1_2_0_6,
other: [
v_1_0_0_1,
v_1_0_0_2,
@@ -104,5 +110,6 @@ export const versionGraph = VersionGraph.of({
v_1_2_0_2,
v_1_2_0_3,
v_1_2_0_4,
+ v_1_2_0_5,
],
})
diff --git a/start9/0.4/startos/versions/v1.2.0.6.ts b/start9/0.4/startos/versions/v1.2.0.6.ts
new file mode 100644
index 0000000..eb5c5e0
--- /dev/null
+++ b/start9/0.4/startos/versions/v1.2.0.6.ts
@@ -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,
+ },
+})