dba478aa23
Four incremental upgrades to the AI program generator. No schema change, no /data migration.
1. History as context (the killer feature)
- lib/ai/historyContext.ts builds a 90-day per-exercise rollup:
frequency, recent weights, estimated 1RM (Epley), avg RPE,
days-since-last, plus a STAGNANT flag when the heaviest weight in
the new half doesn't beat the old half.
- Generate page surfaces an "Include my workout history as context"
checkbox (default on at >=10 logged workouts). When checked, the
~1-3 KB summary is appended to the system prompt so the model can
recommend things like "you've stalled bench at 245 — try paused reps."
- We deliberately don't ship raw set logs (privacy + token cost).
2. Test connection
- POST /api/ai/test sends a tiny "say hi in 3 words" prompt and
reports latency + first sample, or the error inline.
- "Test connection" button next to "Save AI config" in
Settings -> AI integration. Verifies provider/model/key/baseUrl
without going through full program generation.
3. Cost estimator
- lib/ai/pricing.ts ships a price table for major models
(Claude 3.5/3.7/4/4.5, GPT-4o/5/o1/o3/o4-mini, Gemini 1.5/2.0/2.5).
Ollama always returns 0; openai-compatible returns null.
- Generation history shows per-row cost + a 30-day rolling total
at the top of the page.
4. Streaming preview render
- lib/ai/lenientJson.ts: stack-aware partial-JSON parser that
auto-closes open strings/brackets/braces in reverse-of-opening
order, drops dangling key:value pairs and partial keywords.
Returns a best-effort snapshot of the program-so-far on each chunk.
- Generate UI now renders a live "Building program..." panel that
updates as weeks/days/exercises arrive instead of just showing
raw text and waiting for stream end.
Tests: 26 new (ai-historyContext.test.ts, ai-lenientJson.test.ts,
ai-pricing.test.ts). 161 total pass.
225 lines
6.9 KiB
TypeScript
225 lines
6.9 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { prisma } from '@/lib/prisma';
|
|
import {
|
|
buildHistorySummary,
|
|
formatHistoryContext,
|
|
} from '@/lib/ai/historyContext';
|
|
|
|
beforeEach(async () => {
|
|
await prisma.session.deleteMany();
|
|
await prisma.setLog.deleteMany();
|
|
await prisma.workout.deleteMany();
|
|
await prisma.programExercise.deleteMany();
|
|
await prisma.programDay.deleteMany();
|
|
await prisma.programWeek.deleteMany();
|
|
await prisma.program.deleteMany();
|
|
await prisma.exercise.deleteMany();
|
|
await prisma.aIGeneration.deleteMany();
|
|
await prisma.aIPromptTemplate.deleteMany();
|
|
await prisma.user.deleteMany();
|
|
await prisma.instanceSettings.deleteMany();
|
|
});
|
|
|
|
async function setup() {
|
|
const user = await prisma.user.create({
|
|
data: { email: 'a@x', passwordHash: 'fake' },
|
|
});
|
|
const bench = await prisma.exercise.create({
|
|
data: {
|
|
userId: user.id,
|
|
name: 'Bench Press',
|
|
type: 'barbell',
|
|
muscleGroups: '[]',
|
|
},
|
|
});
|
|
const squat = await prisma.exercise.create({
|
|
data: {
|
|
userId: user.id,
|
|
name: 'Squat',
|
|
type: 'barbell',
|
|
muscleGroups: '[]',
|
|
},
|
|
});
|
|
return { user, bench, squat };
|
|
}
|
|
|
|
async function logWorkout(
|
|
userId: string,
|
|
daysAgo: number,
|
|
sets: Array<{ exerciseId: string; reps: number; weight: number; rpe?: number }>,
|
|
) {
|
|
const date = new Date(Date.now() - daysAgo * 86_400_000);
|
|
return prisma.workout.create({
|
|
data: {
|
|
userId,
|
|
date,
|
|
setLogs: {
|
|
create: sets.map((s, i) => ({
|
|
exerciseId: s.exerciseId,
|
|
setNumber: i + 1,
|
|
reps: s.reps,
|
|
weight: s.weight,
|
|
rpe: s.rpe,
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
describe('buildHistorySummary', () => {
|
|
it('returns empty summary for a user with no workouts', async () => {
|
|
const user = await prisma.user.create({
|
|
data: { email: 'a@x', passwordHash: 'fake' },
|
|
});
|
|
const s = await buildHistorySummary(prisma, user.id);
|
|
expect(s.totalWorkouts).toBe(0);
|
|
expect(s.exercises).toEqual([]);
|
|
});
|
|
|
|
it('summarizes a single user\'s recent activity', async () => {
|
|
const { user, bench, squat } = await setup();
|
|
// 3 bench sessions, 2 squat sessions in last 30 days
|
|
await logWorkout(user.id, 1, [
|
|
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
|
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
|
]);
|
|
await logWorkout(user.id, 4, [
|
|
{ exerciseId: bench.id, reps: 5, weight: 235 },
|
|
{ exerciseId: bench.id, reps: 5, weight: 235 },
|
|
]);
|
|
await logWorkout(user.id, 7, [
|
|
{ exerciseId: bench.id, reps: 5, weight: 215 },
|
|
{ exerciseId: squat.id, reps: 5, weight: 315 },
|
|
]);
|
|
await logWorkout(user.id, 14, [
|
|
{ exerciseId: squat.id, reps: 5, weight: 305 },
|
|
]);
|
|
|
|
const s = await buildHistorySummary(prisma, user.id);
|
|
expect(s.totalWorkouts).toBe(4);
|
|
expect(s.workoutsPerWeek).toBeGreaterThan(0);
|
|
expect(s.exercises).toHaveLength(2);
|
|
|
|
const benchSummary = s.exercises.find((e) => e.name === 'Bench Press');
|
|
expect(benchSummary).toBeTruthy();
|
|
expect(benchSummary!.totalSets).toBe(5);
|
|
expect(benchSummary!.distinctWorkouts).toBe(3);
|
|
expect(benchSummary!.bestWeight).toBe(235);
|
|
expect(benchSummary!.daysSinceLast).toBeLessThanOrEqual(2); // logged 1 day ago
|
|
|
|
// Epley(235, 5) = 235 * (1 + 5/30) = 274.17 → 274
|
|
expect(benchSummary!.estimated1RM).toBe(274);
|
|
});
|
|
|
|
it('flags stagnation on a stuck exercise', async () => {
|
|
const { user, bench } = await setup();
|
|
// 6 sessions all at the same weight
|
|
for (let d = 0; d < 6; d++) {
|
|
await logWorkout(user.id, d * 5, [
|
|
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
|
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
|
]);
|
|
}
|
|
const s = await buildHistorySummary(prisma, user.id);
|
|
const bs = s.exercises.find((e) => e.name === 'Bench Press');
|
|
expect(bs?.stagnant).toBe(true);
|
|
});
|
|
|
|
it('does NOT flag stagnation on a progressing exercise', async () => {
|
|
const { user, bench } = await setup();
|
|
// 6 sessions with progressive weight
|
|
for (let d = 0; d < 6; d++) {
|
|
await logWorkout(user.id, (5 - d) * 7, [
|
|
{ exerciseId: bench.id, reps: 5, weight: 200 + d * 10 },
|
|
]);
|
|
}
|
|
const s = await buildHistorySummary(prisma, user.id);
|
|
const bs = s.exercises.find((e) => e.name === 'Bench Press');
|
|
expect(bs?.stagnant).toBe(false);
|
|
});
|
|
|
|
it('excludes workouts outside the window', async () => {
|
|
const { user, bench } = await setup();
|
|
await logWorkout(user.id, 5, [{ exerciseId: bench.id, reps: 5, weight: 225 }]);
|
|
await logWorkout(user.id, 200, [{ exerciseId: bench.id, reps: 5, weight: 200 }]);
|
|
const s = await buildHistorySummary(prisma, user.id, 90);
|
|
expect(s.totalWorkouts).toBe(1);
|
|
expect(s.exercises[0].totalSets).toBe(1);
|
|
});
|
|
|
|
it('excludes soft-deleted workouts', async () => {
|
|
const { user, bench } = await setup();
|
|
const w = await logWorkout(user.id, 3, [
|
|
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
|
]);
|
|
await prisma.workout.update({
|
|
where: { id: w.id },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
const s = await buildHistorySummary(prisma, user.id);
|
|
expect(s.totalWorkouts).toBe(0);
|
|
});
|
|
|
|
it('isolates per-user data (does not bleed across users)', async () => {
|
|
const { user, bench } = await setup();
|
|
const otherUser = await prisma.user.create({
|
|
data: { email: 'b@x', passwordHash: 'fake' },
|
|
});
|
|
const otherBench = await prisma.exercise.create({
|
|
data: {
|
|
userId: otherUser.id,
|
|
name: 'Bench Press',
|
|
type: 'barbell',
|
|
muscleGroups: '[]',
|
|
},
|
|
});
|
|
await logWorkout(user.id, 1, [{ exerciseId: bench.id, reps: 5, weight: 225 }]);
|
|
await logWorkout(otherUser.id, 1, [
|
|
{ exerciseId: otherBench.id, reps: 100, weight: 999 },
|
|
]);
|
|
const s = await buildHistorySummary(prisma, user.id);
|
|
expect(s.totalWorkouts).toBe(1);
|
|
expect(s.exercises[0].bestWeight).toBe(225); // not 999
|
|
});
|
|
});
|
|
|
|
describe('formatHistoryContext', () => {
|
|
it('emits a friendly message on empty history', () => {
|
|
const out = formatHistoryContext({
|
|
windowDays: 90,
|
|
totalWorkouts: 0,
|
|
workoutsPerWeek: 0,
|
|
primaryTypes: [],
|
|
exercises: [],
|
|
});
|
|
expect(out).toMatch(/no workouts/);
|
|
});
|
|
|
|
it('formats a populated summary into a compact block', () => {
|
|
const out = formatHistoryContext({
|
|
windowDays: 90,
|
|
totalWorkouts: 30,
|
|
workoutsPerWeek: 3.3,
|
|
primaryTypes: ['barbell', 'dumbbell', 'cable'],
|
|
exercises: [
|
|
{
|
|
name: 'Bench Press',
|
|
type: 'barbell',
|
|
totalSets: 36,
|
|
distinctWorkouts: 12,
|
|
daysSinceLast: 2,
|
|
lastWeight: 235,
|
|
lastReps: 5,
|
|
bestWeight: 245,
|
|
estimated1RM: 286,
|
|
avgRpe: 8.5,
|
|
stagnant: false,
|
|
},
|
|
],
|
|
});
|
|
expect(out).toMatch(/30 workouts/);
|
|
expect(out).toMatch(/Bench Press/);
|
|
expect(out).toMatch(/STAGNANT|RPE/);
|
|
});
|
|
});
|