Identify is a clinician movement-assessment flow. A participant sees movements, marks how spacious each one feels, and the engine walks them toward a hypothesis or an unsuccessful end. For a long time that walk lived in a spreadsheet of IF/THEN paths. Clinicians could read it. Engineers could not prove a run ever finished.
I almost built the Next.js admin screen first. That would have been a trap. Every dead-end branch would have looked like a styling bug. A missing step would have looked like a click that did nothing. You cannot debug a state machine by arguing with a button.
Why the brain cannot live in React
The catalogue (the movements you can present) and the flow definition (which step comes next) belong in Convex so operators can version them. The brain does not. If next talks to auth, the database, or a React store, you cannot replay a session in a test without standing up the whole app.
I pulled the sheet into seeded catalogue and flow documents, then wrote a pure next(runState, event, catalogue, now) reducer. No Convex context. No UI imports. Events are a small union: answer, pick, completeExercise, replay. The reducer throws if you touch an ended run. The default arm is exhaustive, so a new event kind cannot slip in as a silent no-op.
That split is the whole architecture. Convex holds the data. TypeScript holds the transitions. Vitest holds the authors honest.
Replay the sheet before anyone clicks
Vitest drives eleven scenario tests through runEvents, replaying the same event lists the sheet authors described. Paths that should reach a hypothesis. Starting Low A and B forks. Restart mid-run. Replay after a compare stall. Each test seeds catalogue plus flow once, folds events, and asserts terminal status plus the presentations that matter.
When a rule sends you to a step that no longer exists, CI fails. Nobody has to build a screen to click through the hole. That is cheaper than a demo day where a clinician hits a wall and you shrug.
export function next(run: RunState, event: FlowEvent, catalogue: Catalogue, now: number): RunState {
if (run.status === 'ended') throw new Error('Run already ended');
switch (event.kind) {
case 'answer':
return answer(run, event.presentableRef, event.value, catalogue, now);
case 'pick':
return pick(run, event.presentableRef, now);
case 'completeExercise':
return completeExercise(run, catalogue);
case 'replay':
return replay(run, event.presentableRef);
default: {
const exhaustive: never = event;
return exhaustive;
}
}
}
export function runEvents(run, events, catalogue, now) {
let state = run;
for (const event of events) state = next(state, event, catalogue, now);
return state;
}I did not put this behind a mutation that also checks the operator role. Auth belongs at the Convex function boundary. The reducer should be boring enough to run in a Node test file with a fake clock.
Spreadsheet in a mutation drifts
I copied the sheet into a giant if tree inside a mutation once, on paper. It would have compiled. It would have drifted the first week someone edited a cell. Authors still think in the spreadsheet during discovery. Engineers translate into seed data. The UI can stay thin because the tests already name the path.
The admin screen can arrive later. It will call the same reducer through mutations. If the sheet changes, you update seed data and add a scenario before you merge. Spreadsheet logic stops being tribal knowledge the moment a test names the path.
If you are porting a clinician-facing flow out of a spreadsheet, write the reducer and the scenarios first. Pretty forms can wait.
Happy coding! Sander