From be1fcb4b40802f58fba1825e97b4cd12e9364f69 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 17:06:02 +0200 Subject: [PATCH] feat: add Primary message to the 3 wizard machines (RD-07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each wizard component re-derives the step-boundary decision the reducer already owns: advance on a middle step, submit on the last step. This ticket moves that decision into the machine, so RD-08 can replace the component's guard with one dispatch. Add a `Primary` message to each Msg union, and export a `primary(s)` function next to the existing `next`/`submit` pair. `primary` is a three-line branch that delegates to `next`/`submit` and writes no new validation. Each machine tests "last step" in its own vocabulary, per the ticket's Decisions block: `herregistratie` checks `step === 3`, `intake` checks `currentStep(s) === 'review'`, `registratie` checks `currentStep(s) === 'controle'`. `Next` and `Submit` stay in every union and every reducer — `Primary` is purely additive. Add 3 spec cases per machine (9 total): Primary advances from a non-final step, Primary submits from the final step, and Primary is a no-op outside the editing state. Each case also asserts the equivalence the ticket requires for RD-08's migration: `reduce(s, Primary)` equals `reduce(s, Next)` at a non-final step, and equals `reduce(s, Submit)` at the final step. Regenerate `behaviour-spec.mdx` for the 9 new `it()` titles. Co-Authored-By: Claude Sonnet 5 --- .../domain/herregistratie.machine.spec.ts | 20 +++ .../domain/herregistratie.machine.ts | 10 ++ .../domain/intake.machine.spec.ts | 28 ++++ .../herregistratie/domain/intake.machine.ts | 10 ++ .../domain/registratie-wizard.machine.spec.ts | 21 +++ .../domain/registratie-wizard.machine.ts | 10 ++ .../RD-07-primary-message.md | 157 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- libs/shared/docs/behaviour-spec.mdx | 17 +- 9 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 docs/project/readable-codebase/RD-07-primary-message.md diff --git a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts index d90f0ad..2bc2a7f 100644 --- a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts @@ -8,6 +8,7 @@ import { back, gaNaarStap, submit, + primary, resolve, reduce, WizardState, @@ -104,6 +105,25 @@ describe('wizard.machine', () => { }); }); +describe('primary', () => { + it('Primary advances to the next step from a non-final step', () => { + const s = toStep2('4160', '200'); // Editing, step 2 — not the final step + expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' })); + expect(expectTag(primary(s), 'Editing').step).toBe(3); + }); + + it('Primary submits from the final step', () => { + const s = toStep3('4160', '200'); // Editing, step 3 — the final step + expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' })); + expect(primary(s).tag).toBe('Submitting'); + }); + + it('Primary is a no-op from a non-editing state', () => { + const submitting = submit(toStep3('4160', '200')); + expect(primary(submitting)).toBe(submitting); + }); +}); + describe('reduce (message-driven)', () => { it('drives the full happy path via messages', () => { let s: WizardState = initial; diff --git a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts index ffed921..f513e33 100644 --- a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts +++ b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts @@ -121,6 +121,13 @@ export function submit(s: WizardState): WizardState { return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error }; } +/** The primary button's action: advance, or submit from the last step. No-op + outside Editing — this is the one decision `onPrimary()` used to make. */ +export function primary(s: WizardState): WizardState { + if (s.tag !== 'Editing') return s; + return s.step === 3 ? submit(s) : next(s); +} + /** Route an upload sub-message through the pure upload reducer (Editing only). */ export function upload(s: WizardState, msg: UploadMsg): WizardState { if (s.tag !== 'Editing') return s; @@ -152,6 +159,7 @@ export type WizardMsg = | { tag: 'Back' } | { tag: 'GaNaarStap'; step: 1 | 2 | 3 } | { tag: 'Submit' } + | { tag: 'Primary' } | { tag: 'Retry' } | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } @@ -170,6 +178,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState { return gaNaarStap(s, m.step); case 'Submit': return submit(s); + case 'Primary': + return primary(s); case 'Retry': return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; case 'SubmitConfirmed': diff --git a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts index 9987b85..a4a48d8 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts @@ -10,6 +10,7 @@ import { back, gaNaarStap, submit, + primary, resolve, reduce, IntakeState, @@ -207,6 +208,33 @@ describe('submit', () => { }); }); +describe('primary', () => { + // Same fixture as the 'submit' describe block above: buitenland answered 'nee', + // uren high enough to skip the scholing question. + const highUren = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '4160' }, + ); + + it('Primary advances to the next step from a non-final step', () => { + expect(currentStep(expectTag(highUren, 'Answering'))).toBe('buitenland'); // not the final step + expect(reduce(highUren, { tag: 'Primary' })).toEqual(reduce(highUren, { tag: 'Next' })); + expect(expectTag(primary(highUren), 'Answering').cursor).toBe(1); + }); + + it('Primary submits from the final step', () => { + const atReview = reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' }); + expect(currentStep(expectTag(atReview, 'Answering'))).toBe('review'); // the final step + expect(reduce(atReview, { tag: 'Primary' })).toEqual(reduce(atReview, { tag: 'Submit' })); + expect(primary(atReview).tag).toBe('Submitting'); + }); + + it('Primary is a no-op from a non-editing state', () => { + const submitting = submit(reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' })); + expect(primary(submitting)).toBe(submitting); + }); +}); + describe('reduce (message-driven happy path)', () => { it('drives abroad branch end to end', () => { let s: IntakeState = initial; diff --git a/apps/ssp/src/app/herregistratie/domain/intake.machine.ts b/apps/ssp/src/app/herregistratie/domain/intake.machine.ts index e970425..354e744 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.machine.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.machine.ts @@ -200,6 +200,13 @@ export function submit(s: IntakeState): IntakeState { return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error }; } +/** The primary button's action: advance, or submit from the review step. No-op + outside Answering — this is the one decision `onPrimary()` used to make. */ +export function primary(s: IntakeState): IntakeState { + if (s.tag !== 'Answering') return s; + return currentStep(s) === 'review' ? submit(s) : next(s); +} + export function resolve(s: IntakeState, r: Result): IntakeState { if (s.tag !== 'Submitting') return s; return r.ok @@ -213,6 +220,7 @@ export type IntakeMsg = | { tag: 'Back' } | { tag: 'GaNaarStap'; cursor: number } | { tag: 'Submit' } + | { tag: 'Primary' } | { tag: 'Retry' } | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } @@ -231,6 +239,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState { return gaNaarStap(s, m.cursor); case 'Submit': return submit(s); + case 'Primary': + return primary(s); case 'Retry': return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; case 'SubmitConfirmed': diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts index f137d23..59cfa73 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts @@ -17,6 +17,7 @@ import { setField, prefillAdres, submit, + primary, resolve, reduce, } from './registratie-wizard.machine'; @@ -248,6 +249,26 @@ describe('submit', () => { }); }); +describe('primary', () => { + it('Primary advances to the next step from a non-final step', () => { + const s = toBeroepStepWithDiploma(); // Invullen, beroep step — not the final step + expect(currentStep(expectTag(s, 'Invullen'))).toBe('beroep'); + expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' })); + expect(currentStep(expectTag(primary(s), 'Invullen'))).toBe('controle'); + }); + + it('Primary submits from the final step', () => { + const s = toControleStep(); // Invullen, controle step — the final step + expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' })); + expect(primary(s).tag).toBe('Indienen'); + }); + + it('Primary is a no-op from a non-editing state', () => { + const indienen = toIndienen(); + expect(primary(indienen)).toBe(indienen); + }); +}); + describe('reduce (message-driven happy path)', () => { it('adres and correspondentie set, Next advances from adres to beroep', () => { // Given the initial wizard. diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts index 069178a..1158420 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts @@ -287,6 +287,13 @@ export function submit(s: RegistratieState): RegistratieState { return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error }; } +/** The primary button's action: advance, or submit from the controle step. + No-op outside Invullen — this is the one decision `onPrimary()` used to make. */ +export function primary(s: RegistratieState): RegistratieState { + if (s.tag !== 'Invullen') return s; + return currentStep(s) === 'controle' ? submit(s) : next(s); +} + /** Route an upload sub-message through the pure upload reducer (Invullen only). */ export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState { if (s.tag !== 'Invullen') return s; @@ -312,6 +319,7 @@ export type RegistratieMsg = | { tag: 'Back' } | { tag: 'GaNaarStap'; cursor: number } | { tag: 'Submit' } + | { tag: 'Primary' } | { tag: 'Retry' } | { tag: 'SubmitConfirmed'; referentie: string } | { tag: 'SubmitFailed'; error: string } @@ -342,6 +350,8 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState return gaNaarStap(s, m.cursor); case 'Submit': return submit(s); + case 'Primary': + return primary(s); case 'Retry': return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s; case 'SubmitConfirmed': diff --git a/docs/project/readable-codebase/RD-07-primary-message.md b/docs/project/readable-codebase/RD-07-primary-message.md new file mode 100644 index 0000000..a50aa5f --- /dev/null +++ b/docs/project/readable-codebase/RD-07-primary-message.md @@ -0,0 +1,157 @@ +# RD-07 — Move the step-boundary decision into the 3 wizard machines + +Status: done +Source: PLAN.md 1a + +## Why + +Each wizard component decides in the UI whether the primary button means "next step" or +"submit", duplicating a rule the reducer already owns: + +| Component | Line | Body | +| ------------------------------------ | ------- | -------------------------------------------------------------------------------- | +| `herregistratie-wizard.component.ts` | 256-259 | `if (s.tag !== 'Editing') return;` then `s.step < 3 ? Next : Submit` | +| `intake-wizard.component.ts` | 368-371 | `if (s.tag !== 'Answering') return;` then `step() === 'review' ? Submit : Next` | +| `registratie-wizard.component.ts` | 613-616 | `if (s.tag !== 'Invullen') return;` then `step() === 'controle' ? Submit : Next` | + +Each already-exported `next`/`submit` pair holds the real transition, so the component is +re-deriving a decision the machine can make. After this ticket, `onPrimary()` in RD-08 becomes +a single `dispatch({ tag: 'Primary' })`, and the wizard shell's `primary`/`back`/`retry` +outputs map 1:1 onto messages — which is what `.claude/skills/form-machine/SKILL.md:74-78` +already claims they do. + +This ticket is **domain-only**: machines and their specs. No component changes. + +## Read first + +- `apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts` — `next` at 87, + `submit` at 118, `reduce`'s `Next`/`Submit` cases at 165/171 +- `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` — `currentStep` at 86, `next` at + 173, `submit` at 197, cases at 226/232 +- `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts` — `currentStep` at 96, + `next` at 265, `submit` at 284, cases at 337/343 +- The three matching `*.machine.spec.ts` files +- CLAUDE.md decision 3 (state, and the naming rule for machines) + +## Decisions (pre-made, don't relitigate) + +1. **Add `{ tag: 'Primary' }` to each of the three `Msg` unions**, plus an exported + `primary(s)` function, plus a `case 'Primary'` in each `reduce` that delegates to it. Same + shape as the existing `Next`/`Submit` cases. + +2. **Express "last step" in each machine's own vocabulary. Do NOT invent a shared + `isLastStep` helper.** The three state shapes genuinely differ: + - `herregistratie`: `Editing` carries `step: 1 | 2 | 3` and there is **no `STEPS` array** → + the test is `s.step === 3`. + - `intake`: `Answering` carries `cursor: number` against + `STEPS = ['buitenland','werk','review']` → the test is `currentStep(s) === 'review'`. + - `registratie`: `Invullen` carries `cursor: number` against + `STEPS = ['adres','beroep','controle']` → the test is `currentStep(s) === 'controle'`. + + Two of the three could share a `cursor === STEPS.length - 1` form, but the third cannot. + A helper covering two of three, plus a special case, is more to read than three plain + expressions. + +3. **`primary` delegates to the existing exported `next` and `submit`.** Write no new + validation and duplicate no transition logic. The whole function is a branch: + + ```ts + export function primary(s: WizardState): WizardState { + if (s.tag !== 'Editing') return s; + return s.step === 3 ? submit(s) : next(s); + } + ``` + + (…and the equivalent, in its own vocabulary, for the other two.) + +4. **The guard moves into the machine.** `primary` returns `s` unchanged when the state is not + the editing state, so RD-08 can delete the component preamble. Note the editing tag differs + per machine: `Editing`, `Answering`, and `Invullen`. **`Invullen`/`Indienen`/`Ingediend`/ + `Mislukt` are correct Dutch domain tags per CLAUDE.md — do not "fix" them to English.** + +5. **KEEP `Next` and `Submit` in all three `Msg` unions.** Verified: they are dispatched + across **9 spec files**, including `intake.acceptance.spec.ts`, which uses them as a + readable behaviour narrative, and the three `*-has-progress.spec.ts` files. Removing them + would rewrite dozens of spec lines for no gain. `Primary` is purely **additive**. + +6. **Do not touch any component.** RD-08 migrates the three wizards. If you edit a + `*.component.ts` in this ticket, it is out of scope. + +7. **Keep `SCHOLING_THRESHOLD_DEFAULT` greppable.** `npm run check:seam` greps for it as a + top-level `export const` in `intake.machine.ts:43`. Do not move or inline it. + +## Files + +- `apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts` + `.spec.ts` +- `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` + `.spec.ts` +- `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts` + `.spec.ts` + +## Steps + +1. In each machine: add `{ tag: 'Primary' }` to the `Msg` union, export `primary(s)` next to + `next`/`submit`, and add the `case 'Primary'` to `reduce`. +2. In each machine spec: add the three cases from Acceptance below. +3. Run `npm run gen:behaviour-spec` — new `it()` titles otherwise fail the drift check. +4. Update this ticket's `Status:` to `done` and the README's RD-07 row to `done`. +5. Commit all of it together. + +## Acceptance criteria + +Three cases per machine spec (9 total), pure `reduce` calls, no TestBed: + +``` +- Primary advances to the next step from a non-final step +- Primary submits from the final step +- Primary is a no-op from a non-editing state +``` + +The third case is what lets RD-08 delete the component guard, so do not skip it. + +```bash +npm test # exits 0 +npm run ci # exits 0 +``` + +Prove `Primary` produces exactly what the components produce today, so the migration in RD-08 +is behaviour-preserving. For each machine, these must be equal: + +``` +reduce(s, { tag: 'Primary' }) === reduce(s, { tag: 'Next' }) // at a non-final step +reduce(s, { tag: 'Primary' }) === reduce(s, { tag: 'Submit' }) // at the final step +``` + +Prove nothing was removed: + +```bash +grep -c "tag: 'Next'\|tag: 'Submit'" apps/ssp/src/app/herregistratie/domain/intake.machine.ts +# Next and Submit must still be in the union and still handled in reduce +``` + +## Verification + +`npm run ci`. This ticket touches no story, no `.mdx` and no component, so `--full` is not +required. + +## Out of scope + +- The three components. RD-08. +- `besluit.machine.ts` and `change-request.machine.ts` — single-step forms with no step + boundary, so `Primary` would mean nothing there. RD-06 handles those two. +- Removing `Next`/`Submit` (decision 5). +- Renaming the Dutch tags in `registratie-wizard.machine.ts` (decision 4). + +## Risks + +- **`behaviour-spec.mdx` drift.** 9 new `it()` titles across 3 spec files. + `scripts/ci-local.sh` regenerates `libs/shared/docs/behaviour-spec.mdx` from a path-sorted + walk of spec titles and fails on any drift. Run `npm run gen:behaviour-spec` in the same + commit. +- **`check:seam`** greps `SCHOLING_THRESHOLD_DEFAULT` (decision 7) and + `besluit.machine.ts`'s `BESLUIT_TAGS`. Neither should move, but if `check:seam` fails, that + is why. +- **`snippets.generated.ts` drift.** `intake.machine.ts:59` carries a + `// #region showcase:steps` marker around `STEPS`. If your edit moves or splits that region, + run `npm run gen:snippets` in the same commit. +- **Do not make `primary` clever.** It is a three-line branch delegating to two existing + functions. If it grows validation, error mapping, or a cursor calculation, the transition + logic has been duplicated instead of reused. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 5355557..f2ea454 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -101,7 +101,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-04 | Story titles to `Domein//`; add the missing stories | 03 | yes | todo | | RD-05 | `createStore` gains the effect map + specs | 02 | | done | | RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | done | -| RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | todo | +| RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | done | | RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | todo | | RD-09 | **Docs + generator:** `form-machine.hbs`, ARCHITECTURE, fp-tea, skill | 08 | | todo | | RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | todo | diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 8301797..2afe646 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 510 frontend behaviours across +**is** the suite, reshaped for a business reader. 519 frontend behaviours across 9 contexts; 261 backend behaviours across 42 test classes. @@ -383,6 +383,15 @@ classes. - gaNaarStap jumps back to an earlier step, clearing errors - gaNaarStap ignores a same/forward jump and jumps outside Answering +#### primary + +- Primary advances to the next step from a non-final step +- Primary submits from the final step +- Primary is a no-op from a non-editing state +- Primary advances to the next step from a non-final step +- Primary submits from the final step +- Primary is a no-op from a non-editing state + #### reduce (message-driven happy path) - drives abroad branch end to end @@ -605,6 +614,12 @@ classes. - a diploma with questions blocks Next until they are answered - validateAll keeps only the answers to the questions that applied +#### primary + +- Primary advances to the next step from a non-final step +- Primary submits from the final step +- Primary is a no-op from a non-editing state + #### reduce (message-driven happy path) - adres and correspondentie set, Next advances from adres to beroep