2b0abad68e
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.
104 lines
3.2 KiB
TypeScript
104 lines
3.2 KiB
TypeScript
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();
|
|
});
|
|
});
|