From e3cd908f4fb6ff97d3eaafadb4ece9398fec963d Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 3 Jul 2026 21:39:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(fp):=20WP-07=20=E2=80=94=20brief=20on=20th?= =?UTF-8?q?e=20shared=20idioms=20+=20RemoteData=20MDX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse brief.store's busy signal + nullable lastError into one Idle | Busy | Failed union (saveState gets matching tag-object style), and route brief.page's load through RemoteData + instead of a hand-rolled @switch, via a BriefStore.remoteData projection of the machine's existing loading/failed tags -- the machine keeps owning the letter's own status lifecycle untouched. New brief.store.spec.ts covers the Busy->Idle/Failed transitions; new Foundations/RemoteData & Async MDX page documents the pattern and the WP-06 typed-loaded-slot fallback. Deviation from the original plan recorded in the WP file. --- docs/backlog/README.md | 2 +- docs/backlog/WP-07-brief-idioms.md | 61 +- documentation.json | 3198 +++++++++-------- src/app/brief/application/brief.store.spec.ts | 88 + src/app/brief/application/brief.store.ts | 68 +- src/app/brief/ui/brief.page.ts | 93 +- src/docs/remote-data.mdx | 97 + 7 files changed, 1997 insertions(+), 1610 deletions(-) create mode 100644 src/app/brief/application/brief.store.spec.ts create mode 100644 src/docs/remote-data.mdx diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 26ad530..4af0fb8 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -46,7 +46,7 @@ for its existing violations, so every WP ends green. | [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | | [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done | | [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done | -| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo | +| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done | | [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo | | [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo | | [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo | diff --git a/docs/backlog/WP-07-brief-idioms.md b/docs/backlog/WP-07-brief-idioms.md index bfc3b1b..3ca59af 100644 --- a/docs/backlog/WP-07-brief-idioms.md +++ b/docs/backlog/WP-07-brief-idioms.md @@ -1,6 +1,6 @@ # WP-07 — Brief on the shared idioms + RemoteData MDX -Status: todo +Status: done Phase: 1 — FP/DDD core Depends on: WP-06 (typed ``) @@ -50,15 +50,53 @@ bypassing the shared molecule. ## Acceptance criteria -- [ ] No boolean-plus-error signal pairs in `brief/`. -- [ ] `/brief` renders all four async states (check with `?scenario=slow|empty|error`). -- [ ] Specs cover the transition union (Busy→Failed, Busy→Idle). -- [ ] MDX renders under Foundations. +- [x] No boolean-plus-error signal pairs in `brief/`. +- [x] `/brief` renders all four async states (checked with `?scenario=slow|error`; see + Deviation for why `empty` isn't meaningful here). +- [x] Specs cover the transition union (Busy→Failed, Busy→Idle) — `brief.store.spec.ts` + (new). +- [x] MDX renders under Foundations. ## Verification -GREEN + `npm run test-storybook:ci`. Manual: `npm start` → `/brief` with -`?scenario=slow`, `?scenario=error`; exercise autosave + submit + rejection flow. +GREEN + `npm run test-storybook:ci` (197 unit tests, 137 Storybook/a11y — both up from +WP-06's baseline by the new store spec). Manual smoke via a running `docker compose` +stack + Playwright: `/brief` normal load, `?scenario=slow` (spinner), `?scenario=error` +(failure alert + working retry), and `/brief?role=approver` — all with no console errors. + +## Deviation from the original plan + +**The machine's `loading`/`failed` tags were NOT moved out of `BriefState`.** The +Decisions block hedges this ("only if they purely mirror the fetch") — they do, but +removing them turns out to need more than a re-type: `createStore(initial, reduce)` +requires a concrete `initial: BriefState` value, and once `loading`/`failed` are gone +there is no state left to represent "not loaded yet" without inventing a second wrapping +layer (the store's top-level signal would need to become `RemoteData` +directly, with the machine's `reduce` only invoked inside the `Success` branch — a +different wiring shape from every other machine in the app, and a ~250-line ripple +through `brief.machine.spec.ts`). That redesign is a bigger, riskier change than this WP's +"re-type, don't restructure" framing calls for. + +Instead, `BriefStore.remoteData` **projects** the existing machine model onto +`RemoteData` (`loading`→`Loading`, `failed`→ +`Failure`, `loaded`→`Success`), and `brief.page.ts` renders that projection through +``. This satisfies the actual goal (the load lifecycle renders through the +shared molecule, not a hand-rolled `@switch`) without touching `brief.machine.ts` or its +spec at all — `BriefState` keeps its three tags exactly as they were. The seam holds: +`RemoteData` still owns "is the fetch done", the machine still owns "what is the letter +doing" (draft/submitted/approved/rejected/sent) once loaded. + +**`?scenario=empty` doesn't apply to `/brief`.** It rewrites the HTTP body to `[]`, which +fails `parseBriefView`'s `!dto.brief` check — the same as any malformed response, so it +surfaces as a `Failure`, not an `Empty`. A single-letter GET has no meaningful "empty" +state (unlike a list endpoint), so this isn't a gap — `AsyncComponent`'s `Empty` branch +simply never fires for this resource, by construction (no `isEmpty` input is passed). + +**Reused the WP-06 fallback for the loaded slot.** `` can't +type `let-s` to the loaded value for the same structural reason WP-06 documented +(a directive's generic can't inherit from a sibling `[data]` input) — `brief.page.ts` adds +a `loaded` computed and narrows with `@if (loaded(); as s)`, matching +`dashboard.page.ts`/`registration-detail.page.ts`. ## Out of scope @@ -67,4 +105,11 @@ Brief component stories (WP-15); machine renaming conventions (WP-08). ## Risks Autosave (debounced) interplay with the new transition union — flush ordering must stay -as-is; the machine spec pins it. +as-is; `brief.store.spec.ts`'s Busy→Idle/Failed tests exercise `transition()`, which +still calls `flushSave()` before the server action exactly as before. One subtle, +pre-existing edge case changed slightly: if a debounced autosave fails mid-transition +(setting the error) and the transition's own server action then succeeds, the original +code left the stale autosave error visible (it only cleared `lastError` at the very start +of `transition()`/`resetDemo()`); the re-typed version now clears it on that same +successful end, since `actionState` only holds one current value. Judged an acceptable, +arguably-corrective difference, not a behavior this WP needed to preserve. diff --git a/documentation.json b/documentation.json index 3012163..94cf9d2 100644 --- a/documentation.json +++ b/documentation.json @@ -2082,7 +2082,54 @@ }, { "name": "Draft", - "id": "interface-Draft-e2705c063a865160d8b4effa4448b7409aad1ea6065acd928ddce1cf628f34fd7984fe8d1078d372fb64c525f4288babd5bdf5ff700d1c7405eecb15ea85550c", + "id": "interface-Draft-3f75a0ff51d12ccab50bfa13789cace778d5acff9f4feaa1607cdf1acb4a5ab4fcc687512424cb2252ec07568f6aea07a0b4f04469cbeed733c28654443ffd9e", + "file": "src/app/herregistratie/domain/herregistratie.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "import { Result, assertNever } from '@shared/kernel/fp';\nimport { Uren, parseUren } from '@registratie/domain/value-objects/uren';\nimport {\n UploadState,\n UploadMsg,\n initialUpload,\n reduceUpload,\n requiredCategoriesSatisfied,\n deliveryRefs,\n} from '@shared/upload/upload.machine';\n\n/** What the user is typing (raw, possibly invalid). */\nexport interface Draft {\n uren: string;\n jaren: string;\n punten: string;\n}\n\nexport type StepErrors = Partial>;\n\n/** What we have AFTER parsing — branded/typed, guaranteed valid. */\nexport interface Valid {\n uren: Uren;\n jaren: number;\n punten: number;\n documents: Array<{ categoryId: string; channel: 'digital' | 'post'; documentId?: string }>;\n}\n\n/**\n * The whole wizard as one tagged union. `step` and `errors` exist ONLY while\n * Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.\n * So \"submitting while a field is invalid\" or \"showing the success screen with\n * errors set\" are unrepresentable — the bug class is gone by construction.\n */\nexport type WizardState =\n | { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors; upload: UploadState }\n | { tag: 'Submitting'; data: Valid }\n | { tag: 'Submitted'; data: Valid }\n | { tag: 'Failed'; data: Valid; error: string };\n\nexport const initial: WizardState = {\n tag: 'Editing',\n step: 1,\n draft: { uren: '', jaren: '', punten: '' },\n errors: {},\n upload: initialUpload,\n};\n\n/** Has the user meaningfully started, so it's worth persisting as a Concept? */\nexport function hasProgress(s: Extract): boolean {\n return (\n s.step > 1 ||\n !!s.draft.uren ||\n !!s.draft.jaren ||\n !!s.draft.punten ||\n deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)\n );\n}\n\n/** Parse every field; on success hand back a Valid, else the per-field errors. */\nfunction validate(draft: Draft, upload: UploadState): Result {\n const uren = parseUren(draft.uren);\n const jaren = parseUren(draft.jaren);\n const punten = parseUren(draft.punten);\n const errors: StepErrors = {};\n if (!uren.ok) errors.uren = uren.error;\n if (!jaren.ok) errors.jaren = jaren.error;\n if (!punten.ok) errors.punten = punten.error;\n if (!requiredCategoriesSatisfied(upload)) {\n errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies \"per post nasturen\").`;\n }\n if (uren.ok && jaren.ok && punten.ok && !errors.documenten) {\n return {\n ok: true,\n value: {\n uren: uren.value,\n jaren: jaren.value,\n punten: punten.value,\n documents: deliveryRefs(upload),\n },\n };\n }\n return { ok: false, error: errors };\n}\n\n/** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */\nexport function next(s: WizardState): WizardState {\n if (s.tag !== 'Editing') return s;\n const errors: StepErrors = {};\n if (s.step === 1) {\n const uren = parseUren(s.draft.uren);\n const jaren = parseUren(s.draft.jaren);\n if (!uren.ok) errors.uren = uren.error;\n if (!jaren.ok) errors.jaren = jaren.error;\n return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors };\n }\n if (s.step === 2) {\n const punten = parseUren(s.draft.punten);\n if (!punten.ok) errors.punten = punten.error;\n return punten.ok ? { ...s, step: 3, errors: {} } : { ...s, errors };\n }\n return s;\n}\n\nexport function back(s: WizardState): WizardState {\n if (s.tag !== 'Editing' || s.step === 1) return s;\n return { ...s, step: (s.step - 1) as 1 | 2, errors: {} };\n}\n\n/** Jump back to an earlier step to correct data (controle → step N). Forward\n jumps are not allowed (would skip validation). */\nexport function gaNaarStap(s: WizardState, step: 1 | 2 | 3): WizardState {\n if (s.tag !== 'Editing' || step >= s.step) return s;\n return { ...s, step, errors: {} };\n}\n\n/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */\nexport function submit(s: WizardState): WizardState {\n if (s.tag !== 'Editing' || s.step !== 3) return s;\n const result = validate(s.draft, s.upload);\n return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };\n}\n\n/** Route an upload sub-message through the pure upload reducer (Editing only). */\nexport function upload(s: WizardState, msg: UploadMsg): WizardState {\n if (s.tag !== 'Editing') return s;\n return { ...s, upload: reduceUpload(s.upload, msg) };\n}\n\n/** Resolve the async submit. Only meaningful while Submitting. */\nexport function resolve(s: WizardState, r: Result): WizardState {\n if (s.tag !== 'Submitting') return s;\n return r.ok\n ? { tag: 'Submitted', data: s.data }\n : { tag: 'Failed', data: s.data, error: r.error };\n}\n\n/** Update one draft field while editing; ignored in any other state. */\nexport function setField(s: WizardState, key: keyof Draft, value: string): WizardState {\n if (s.tag !== 'Editing') return s;\n return { ...s, draft: { ...s.draft, [key]: value } };\n}\n\n/**\n * Every event that can happen to the wizard, as one message type. The component\n * sends a WizardMsg; `reduce` decides the next state. This is the Elm\n * Model+Msg+update pattern: ONE pure function describes all state changes.\n */\nexport type WizardMsg =\n | { tag: 'SetField'; key: keyof Draft; value: string }\n | { tag: 'Next' }\n | { tag: 'Back' }\n | { tag: 'GaNaarStap'; step: 1 | 2 | 3 }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed' }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Upload'; msg: UploadMsg }\n | { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)\n\nexport function reduce(s: WizardState, m: WizardMsg): WizardState {\n switch (m.tag) {\n case 'SetField':\n return setField(s, m.key, m.value);\n case 'Next':\n return next(s);\n case 'Back':\n return back(s);\n case 'GaNaarStap':\n return gaNaarStap(s, m.step);\n case 'Submit':\n return submit(s);\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'Upload':\n return upload(s, m.msg);\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", + "properties": [ + { + "name": "jaren", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 15 + }, + { + "name": "punten", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 16 + }, + { + "name": "uren", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 14 + } + ], + "indexSignatures": [], + "kind": 172, + "description": "

What the user is typing (raw, possibly invalid).

\n", + "rawdescription": "\nWhat the user is typing (raw, possibly invalid).", + "methods": [], + "extends": [] + }, + { + "name": "Draft", + "id": "interface-Draft-e2705c063a865160d8b4effa4448b7409aad1ea6065acd928ddce1cf628f34fd7984fe8d1078d372fb64c525f4288babd5bdf5ff700d1c7405eecb15ea85550c-1", "file": "src/app/registratie/domain/change-request.machine.ts", "deprecated": false, "deprecationMessage": "", @@ -2125,11 +2172,14 @@ "description": "

What the user is typing (raw, possibly invalid).

\n", "rawdescription": "\nWhat the user is typing (raw, possibly invalid).", "methods": [], - "extends": [] + "extends": [], + "isDuplicate": true, + "duplicateId": 1, + "duplicateName": "Draft-1" }, { "name": "Draft", - "id": "interface-Draft-9669bb22f8d4cc591a3bd64d9fc83275b70ad9e7275d59b4d1d394db7252e8820908b20c593f0b3a16c415c40ad1232ed7d04ba684d0dee9d72bc1ae820d06b6-1", + "id": "interface-Draft-9669bb22f8d4cc591a3bd64d9fc83275b70ad9e7275d59b4d1d394db7252e8820908b20c593f0b3a16c415c40ad1232ed7d04ba684d0dee9d72bc1ae820d06b6-2", "file": "src/app/registratie/domain/registratie-wizard.machine.ts", "deprecated": false, "deprecationMessage": "", @@ -2254,56 +2304,6 @@ "methods": [], "extends": [], "isDuplicate": true, - "duplicateId": 1, - "duplicateName": "Draft-1" - }, - { - "name": "Draft", - "id": "interface-Draft-3f75a0ff51d12ccab50bfa13789cace778d5acff9f4feaa1607cdf1acb4a5ab4fcc687512424cb2252ec07568f6aea07a0b4f04469cbeed733c28654443ffd9e-2", - "file": "src/app/herregistratie/domain/herregistratie.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "type": "interface", - "sourceCode": "import { Result, assertNever } from '@shared/kernel/fp';\nimport { Uren, parseUren } from '@registratie/domain/value-objects/uren';\nimport {\n UploadState,\n UploadMsg,\n initialUpload,\n reduceUpload,\n requiredCategoriesSatisfied,\n deliveryRefs,\n} from '@shared/upload/upload.machine';\n\n/** What the user is typing (raw, possibly invalid). */\nexport interface Draft {\n uren: string;\n jaren: string;\n punten: string;\n}\n\nexport type StepErrors = Partial>;\n\n/** What we have AFTER parsing — branded/typed, guaranteed valid. */\nexport interface Valid {\n uren: Uren;\n jaren: number;\n punten: number;\n documents: Array<{ categoryId: string; channel: 'digital' | 'post'; documentId?: string }>;\n}\n\n/**\n * The whole wizard as one tagged union. `step` and `errors` exist ONLY while\n * Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.\n * So \"submitting while a field is invalid\" or \"showing the success screen with\n * errors set\" are unrepresentable — the bug class is gone by construction.\n */\nexport type WizardState =\n | { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors; upload: UploadState }\n | { tag: 'Submitting'; data: Valid }\n | { tag: 'Submitted'; data: Valid }\n | { tag: 'Failed'; data: Valid; error: string };\n\nexport const initial: WizardState = {\n tag: 'Editing',\n step: 1,\n draft: { uren: '', jaren: '', punten: '' },\n errors: {},\n upload: initialUpload,\n};\n\n/** Has the user meaningfully started, so it's worth persisting as a Concept? */\nexport function hasProgress(s: Extract): boolean {\n return (\n s.step > 1 ||\n !!s.draft.uren ||\n !!s.draft.jaren ||\n !!s.draft.punten ||\n deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)\n );\n}\n\n/** Parse every field; on success hand back a Valid, else the per-field errors. */\nfunction validate(draft: Draft, upload: UploadState): Result {\n const uren = parseUren(draft.uren);\n const jaren = parseUren(draft.jaren);\n const punten = parseUren(draft.punten);\n const errors: StepErrors = {};\n if (!uren.ok) errors.uren = uren.error;\n if (!jaren.ok) errors.jaren = jaren.error;\n if (!punten.ok) errors.punten = punten.error;\n if (!requiredCategoriesSatisfied(upload)) {\n errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies \"per post nasturen\").`;\n }\n if (uren.ok && jaren.ok && punten.ok && !errors.documenten) {\n return {\n ok: true,\n value: {\n uren: uren.value,\n jaren: jaren.value,\n punten: punten.value,\n documents: deliveryRefs(upload),\n },\n };\n }\n return { ok: false, error: errors };\n}\n\n/** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */\nexport function next(s: WizardState): WizardState {\n if (s.tag !== 'Editing') return s;\n const errors: StepErrors = {};\n if (s.step === 1) {\n const uren = parseUren(s.draft.uren);\n const jaren = parseUren(s.draft.jaren);\n if (!uren.ok) errors.uren = uren.error;\n if (!jaren.ok) errors.jaren = jaren.error;\n return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors };\n }\n if (s.step === 2) {\n const punten = parseUren(s.draft.punten);\n if (!punten.ok) errors.punten = punten.error;\n return punten.ok ? { ...s, step: 3, errors: {} } : { ...s, errors };\n }\n return s;\n}\n\nexport function back(s: WizardState): WizardState {\n if (s.tag !== 'Editing' || s.step === 1) return s;\n return { ...s, step: (s.step - 1) as 1 | 2, errors: {} };\n}\n\n/** Jump back to an earlier step to correct data (controle → step N). Forward\n jumps are not allowed (would skip validation). */\nexport function gaNaarStap(s: WizardState, step: 1 | 2 | 3): WizardState {\n if (s.tag !== 'Editing' || step >= s.step) return s;\n return { ...s, step, errors: {} };\n}\n\n/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */\nexport function submit(s: WizardState): WizardState {\n if (s.tag !== 'Editing' || s.step !== 3) return s;\n const result = validate(s.draft, s.upload);\n return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };\n}\n\n/** Route an upload sub-message through the pure upload reducer (Editing only). */\nexport function upload(s: WizardState, msg: UploadMsg): WizardState {\n if (s.tag !== 'Editing') return s;\n return { ...s, upload: reduceUpload(s.upload, msg) };\n}\n\n/** Resolve the async submit. Only meaningful while Submitting. */\nexport function resolve(s: WizardState, r: Result): WizardState {\n if (s.tag !== 'Submitting') return s;\n return r.ok\n ? { tag: 'Submitted', data: s.data }\n : { tag: 'Failed', data: s.data, error: r.error };\n}\n\n/** Update one draft field while editing; ignored in any other state. */\nexport function setField(s: WizardState, key: keyof Draft, value: string): WizardState {\n if (s.tag !== 'Editing') return s;\n return { ...s, draft: { ...s.draft, [key]: value } };\n}\n\n/**\n * Every event that can happen to the wizard, as one message type. The component\n * sends a WizardMsg; `reduce` decides the next state. This is the Elm\n * Model+Msg+update pattern: ONE pure function describes all state changes.\n */\nexport type WizardMsg =\n | { tag: 'SetField'; key: keyof Draft; value: string }\n | { tag: 'Next' }\n | { tag: 'Back' }\n | { tag: 'GaNaarStap'; step: 1 | 2 | 3 }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed' }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Upload'; msg: UploadMsg }\n | { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)\n\nexport function reduce(s: WizardState, m: WizardMsg): WizardState {\n switch (m.tag) {\n case 'SetField':\n return setField(s, m.key, m.value);\n case 'Next':\n return next(s);\n case 'Back':\n return back(s);\n case 'GaNaarStap':\n return gaNaarStap(s, m.step);\n case 'Submit':\n return submit(s);\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'Upload':\n return upload(s, m.msg);\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", - "properties": [ - { - "name": "jaren", - "deprecated": false, - "deprecationMessage": "", - "type": "string", - "indexKey": "", - "optional": false, - "description": "", - "line": 15 - }, - { - "name": "punten", - "deprecated": false, - "deprecationMessage": "", - "type": "string", - "indexKey": "", - "optional": false, - "description": "", - "line": 16 - }, - { - "name": "uren", - "deprecated": false, - "deprecationMessage": "", - "type": "string", - "indexKey": "", - "optional": false, - "description": "", - "line": 14 - } - ], - "indexSignatures": [], - "kind": 172, - "description": "

What the user is typing (raw, possibly invalid).

\n", - "rawdescription": "\nWhat the user is typing (raw, possibly invalid).", - "methods": [], - "extends": [], - "isDuplicate": true, "duplicateId": 2, "duplicateName": "Draft-2" }, @@ -5533,54 +5533,7 @@ }, { "name": "Valid", - "id": "interface-Valid-e2705c063a865160d8b4effa4448b7409aad1ea6065acd928ddce1cf628f34fd7984fe8d1078d372fb64c525f4288babd5bdf5ff700d1c7405eecb15ea85550c", - "file": "src/app/registratie/domain/change-request.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "type": "interface", - "sourceCode": "import { Result, assertNever } from '@shared/kernel/fp';\nimport { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';\n\n/** What the user is typing (raw, possibly invalid). */\nexport interface Draft {\n straat: string;\n postcode: string;\n woonplaats: string;\n}\n\n/** After parsing — postcode is the branded type, so downstream can't get a raw one. */\nexport interface Valid {\n straat: string;\n postcode: Postcode;\n woonplaats: string;\n}\n\nexport type Errors = Partial>;\n\n/**\n * The change-request (adreswijziging) form as one tagged union — the SAME idiom\n * as the wizards, just single-step. `draft`/`errors` exist only while Editing;\n * Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting\n * an invalid draft, a success screen with errors) are unrepresentable.\n */\nexport type State =\n | { tag: 'Editing'; draft: Draft; errors: Errors }\n | { tag: 'Submitting'; data: Valid }\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };\n\nexport const initial: State = {\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n};\n\n/** Parse via the value objects; on success hand back a Valid, else per-field errors. */\nfunction validate(draft: Draft): Result {\n const straat = draft.straat.trim();\n const postcode = parsePostcode(draft.postcode);\n const errors: Errors = {};\n if (!straat) errors.straat = $localize`:@@validation.straat:Vul straat en huisnummer in.`;\n if (!postcode.ok) errors.postcode = postcode.error;\n if (straat && postcode.ok) {\n return {\n ok: true,\n value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() },\n };\n }\n return { ok: false, error: errors };\n}\n\nexport type Msg =\n | { tag: 'SetField'; key: keyof Draft; value: string }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed'; referentie: string }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Reset' }\n | { tag: 'Seed'; state: State }; // mount a specific state (stories/tests)\n\nexport function reduce(s: State, m: Msg): State {\n switch (m.tag) {\n case 'SetField':\n return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;\n case 'Submit': {\n if (s.tag !== 'Editing') return s;\n const r = validate(s.draft);\n return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };\n }\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting'\n ? { tag: 'Submitted', data: s.data, referentie: m.referentie }\n : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'Reset':\n return initial;\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", - "properties": [ - { - "name": "postcode", - "deprecated": false, - "deprecationMessage": "", - "type": "Postcode", - "indexKey": "", - "optional": false, - "description": "", - "line": 14 - }, - { - "name": "straat", - "deprecated": false, - "deprecationMessage": "", - "type": "string", - "indexKey": "", - "optional": false, - "description": "", - "line": 13 - }, - { - "name": "woonplaats", - "deprecated": false, - "deprecationMessage": "", - "type": "string", - "indexKey": "", - "optional": false, - "description": "", - "line": 15 - } - ], - "indexSignatures": [], - "kind": 172, - "description": "

After parsing — postcode is the branded type, so downstream can't get a raw one.

\n", - "rawdescription": "\nAfter parsing — postcode is the branded type, so downstream can't get a raw one.", - "methods": [], - "extends": [] - }, - { - "name": "Valid", - "id": "interface-Valid-3f75a0ff51d12ccab50bfa13789cace778d5acff9f4feaa1607cdf1acb4a5ab4fcc687512424cb2252ec07568f6aea07a0b4f04469cbeed733c28654443ffd9e-1", + "id": "interface-Valid-3f75a0ff51d12ccab50bfa13789cace778d5acff9f4feaa1607cdf1acb4a5ab4fcc687512424cb2252ec07568f6aea07a0b4f04469cbeed733c28654443ffd9e", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", "deprecated": false, "deprecationMessage": "", @@ -5633,6 +5586,53 @@ "description": "

What we have AFTER parsing — branded/typed, guaranteed valid.

\n", "rawdescription": "\nWhat we have AFTER parsing — branded/typed, guaranteed valid.", "methods": [], + "extends": [] + }, + { + "name": "Valid", + "id": "interface-Valid-e2705c063a865160d8b4effa4448b7409aad1ea6065acd928ddce1cf628f34fd7984fe8d1078d372fb64c525f4288babd5bdf5ff700d1c7405eecb15ea85550c-1", + "file": "src/app/registratie/domain/change-request.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "import { Result, assertNever } from '@shared/kernel/fp';\nimport { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';\n\n/** What the user is typing (raw, possibly invalid). */\nexport interface Draft {\n straat: string;\n postcode: string;\n woonplaats: string;\n}\n\n/** After parsing — postcode is the branded type, so downstream can't get a raw one. */\nexport interface Valid {\n straat: string;\n postcode: Postcode;\n woonplaats: string;\n}\n\nexport type Errors = Partial>;\n\n/**\n * The change-request (adreswijziging) form as one tagged union — the SAME idiom\n * as the wizards, just single-step. `draft`/`errors` exist only while Editing;\n * Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting\n * an invalid draft, a success screen with errors) are unrepresentable.\n */\nexport type State =\n | { tag: 'Editing'; draft: Draft; errors: Errors }\n | { tag: 'Submitting'; data: Valid }\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };\n\nexport const initial: State = {\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n};\n\n/** Parse via the value objects; on success hand back a Valid, else per-field errors. */\nfunction validate(draft: Draft): Result {\n const straat = draft.straat.trim();\n const postcode = parsePostcode(draft.postcode);\n const errors: Errors = {};\n if (!straat) errors.straat = $localize`:@@validation.straat:Vul straat en huisnummer in.`;\n if (!postcode.ok) errors.postcode = postcode.error;\n if (straat && postcode.ok) {\n return {\n ok: true,\n value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() },\n };\n }\n return { ok: false, error: errors };\n}\n\nexport type Msg =\n | { tag: 'SetField'; key: keyof Draft; value: string }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed'; referentie: string }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Reset' }\n | { tag: 'Seed'; state: State }; // mount a specific state (stories/tests)\n\nexport function reduce(s: State, m: Msg): State {\n switch (m.tag) {\n case 'SetField':\n return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;\n case 'Submit': {\n if (s.tag !== 'Editing') return s;\n const r = validate(s.draft);\n return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };\n }\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting'\n ? { tag: 'Submitted', data: s.data, referentie: m.referentie }\n : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'Reset':\n return initial;\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", + "properties": [ + { + "name": "postcode", + "deprecated": false, + "deprecationMessage": "", + "type": "Postcode", + "indexKey": "", + "optional": false, + "description": "", + "line": 14 + }, + { + "name": "straat", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 13 + }, + { + "name": "woonplaats", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 15 + } + ], + "indexSignatures": [], + "kind": 172, + "description": "

After parsing — postcode is the branded type, so downstream can't get a raw one.

\n", + "rawdescription": "\nAfter parsing — postcode is the branded type, so downstream can't get a raw one.", + "methods": [], "extends": [], "isDuplicate": true, "duplicateId": 1, @@ -6825,9 +6825,23 @@ }, { "name": "BriefStore", - "id": "injectable-BriefStore-2e37c2db30504299ce43e5015f505eeecc1c7668d4a65f34e7dc99a363d13690169dccdca4bd71b33545c251ce1a9f33b504f82cf61c430ccf1a2538631ec408", + "id": "injectable-BriefStore-d49743e426155c1b80538a861ad83174e87eea9f939e2102a680afd86f7f05c08d12156d0db866bb34df7b492e3ed43fdb8f54cc449d74fc030ea6c3dc7ebacd", "file": "src/app/brief/application/brief.store.ts", "properties": [ + { + "name": "actionState", + "defaultValue": "signal({ tag: 'Idle' })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 41, + "modifierKind": [ + 123 + ] + }, { "name": "adapter", "defaultValue": "inject(BriefAdapter)", @@ -6837,7 +6851,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 24, + "line": 36, "modifierKind": [ 123 ] @@ -6851,7 +6865,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 100 + "line": 135 }, { "name": "brief", @@ -6862,21 +6876,21 @@ "indexKey": "", "optional": false, "description": "", - "line": 33, + "line": 66, "modifierKind": [ 123 ] }, { "name": "busy", - "defaultValue": "signal(false)", + "defaultValue": "computed(() => this.actionState().tag === 'Busy')", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 28, + "line": 42, "modifierKind": [ 148 ] @@ -6890,7 +6904,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 39, + "line": 72, "modifierKind": [ 148 ] @@ -6904,7 +6918,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 38, + "line": 71, "modifierKind": [ 148 ] @@ -6918,7 +6932,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 40, + "line": 73, "modifierKind": [ 148 ] @@ -6932,7 +6946,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 41, + "line": 74, "modifierKind": [ 148 ] @@ -6946,7 +6960,7 @@ "indexKey": "", "optional": false, "description": "

Submit is allowed only when required sections are filled AND no blocking errors.

\n", - "line": 50, + "line": 83, "rawdescription": "\nSubmit is allowed only when required sections are filled AND no blocking errors.", "modifierKind": [ 148 @@ -6961,7 +6975,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 43, + "line": 76, "modifierKind": [ 123 ] @@ -6975,21 +6989,21 @@ "indexKey": "", "optional": false, "description": "", - "line": 47, + "line": 80, "modifierKind": [ 148 ] }, { "name": "lastError", - "defaultValue": "signal(null)", + "defaultValue": "computed(() => {\n const s = this.actionState();\n return s.tag === 'Failed' ? s.error : null;\n })", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 29, + "line": 43, "modifierKind": [ 148 ] @@ -7003,7 +7017,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 27, + "line": 39, "modifierKind": [ 148 ] @@ -7017,18 +7031,33 @@ "indexKey": "", "optional": false, "description": "", - "line": 101 + "line": 136 + }, + { + "name": "remoteData", + "defaultValue": "computed>(() => {\n const s = this.model();\n switch (s.tag) {\n case 'loading':\n return { tag: 'Loading' };\n case 'failed':\n return { tag: 'Failure', error: new Error(s.reason) };\n case 'loaded':\n return { tag: 'Success', value: s };\n }\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

The load lifecycle as RemoteData, for <app-async> — the machine keeps\nowning the letter's own domain lifecycle (draft/submitted/approved/…); this is\npurely a projection of its loading/failed tags onto the shared async seam.

\n", + "line": 54, + "rawdescription": "\nThe load lifecycle as `RemoteData`, for `` — the machine keeps\nowning the letter's own domain lifecycle (draft/submitted/approved/…); this is\npurely a projection of its loading/failed tags onto the shared async seam.", + "modifierKind": [ + 148 + ] }, { "name": "saveState", - "defaultValue": "signal<'idle' | 'saving' | 'saved' | 'error'>('idle')", + "defaultValue": "signal({ tag: 'Idle' })", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "

Surfaced autosave state for the indicator + aria-live region.

\n", - "line": 31, + "line": 49, "rawdescription": "\nSurfaced autosave state for the indicator + aria-live region.", "modifierKind": [ 148 @@ -7042,7 +7071,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 67, + "line": 100, "modifierKind": [ 123 ] @@ -7056,7 +7085,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 102 + "line": 137 }, { "name": "store", @@ -7067,7 +7096,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 25, + "line": 37, "modifierKind": [ 123 ] @@ -7081,7 +7110,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 99 + "line": 134 }, { "name": "unresolved", @@ -7092,7 +7121,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 48, + "line": 81, "modifierKind": [ 148 ] @@ -7114,7 +7143,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 120, + "line": 154, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -7149,7 +7178,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 62, + "line": 95, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nAn edit: apply it optimistically in the pure reducer, then debounce-save.", @@ -7174,7 +7203,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 74, + "line": 107, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -7188,7 +7217,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 55, + "line": 88, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -7201,7 +7230,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 88, + "line": 121, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nDemo \"start over\": recreate the brief server-side and load the fresh view.", @@ -7216,7 +7245,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 68, + "line": 101, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -7239,7 +7268,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 106, + "line": 141, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -7266,7 +7295,7 @@ "deprecationMessage": "", "description": "

Root singleton for the letter: the Elm store (Model + dispatch), the derived\nread-model, and the commands (effects) that call the adapter and dispatch the\noutcome. Mirrors BigProfileStore. All of canEdit/canApprove/canReject/\ncanSend, diagnostics, unresolved, canSubmit are DERIVED here — never\nstored. The permission flags come from the server's decision DTO (PRD-0002 phase\nP1) via BriefState.loaded.decisions — this store never computes them itself.

\n", "rawdescription": "\n\nRoot singleton for the letter: the Elm store (Model + dispatch), the derived\nread-model, and the commands (effects) that call the adapter and dispatch the\noutcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/\n`canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never\nstored. The permission flags come from the server's decision DTO (PRD-0002 phase\nP1) via `BriefState.loaded.decisions` — this store never computes them itself.\n", - "sourceCode": "import { Injectable, computed, inject, signal } from '@angular/core';\nimport { Result } from '@shared/kernel/fp';\nimport { createStore } from '@shared/application/store';\nimport {\n Brief,\n allDiagnostics,\n canSubmit,\n hasBlockingErrors,\n unresolvedPlaceholders,\n} from '@brief/domain/brief';\nimport { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';\nimport { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';\n\n/**\n * Root singleton for the letter: the Elm store (Model + dispatch), the derived\n * read-model, and the commands (effects) that call the adapter and dispatch the\n * outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/\n * `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never\n * stored. The permission flags come from the server's decision DTO (PRD-0002 phase\n * P1) via `BriefState.loaded.decisions` — this store never computes them itself.\n */\n@Injectable({ providedIn: 'root' })\nexport class BriefStore {\n private adapter = inject(BriefAdapter);\n private store = createStore(initial, reduce);\n\n readonly model = this.store.model;\n readonly busy = signal(false);\n readonly lastError = signal(null);\n /** Surfaced autosave state for the indicator + aria-live region. */\n readonly saveState = signal<'idle' | 'saving' | 'saved' | 'error'>('idle');\n\n private brief = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.brief : null;\n });\n\n readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);\n readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);\n readonly canReject = computed(() => this.decisions()?.canReject ?? false);\n readonly canSend = computed(() => this.decisions()?.canSend ?? false);\n\n private decisions = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.decisions : null;\n });\n readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));\n readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));\n /** Submit is allowed only when required sections are filled AND no blocking errors. */\n readonly canSubmit = computed(() => {\n const b = this.brief();\n return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());\n });\n\n async load() {\n const r = await this.adapter.load();\n if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });\n else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });\n }\n\n /** An edit: apply it optimistically in the pure reducer, then debounce-save. */\n edit(msg: BriefMsg) {\n this.store.dispatch(msg);\n this.scheduleSave();\n }\n\n private saveTimer?: ReturnType;\n private scheduleSave() {\n if (!this.canEdit()) return;\n clearTimeout(this.saveTimer);\n // ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.\n this.saveTimer = setTimeout(() => void this.flushSave(), 600);\n }\n private async flushSave() {\n const b = this.brief();\n if (!b) return;\n this.saveState.set('saving');\n const r = await this.adapter.save(b.sections);\n if (r.ok) {\n this.saveState.set('saved');\n } else {\n this.lastError.set(r.error);\n this.saveState.set('error');\n }\n }\n\n /** Demo \"start over\": recreate the brief server-side and load the fresh view. */\n async resetDemo() {\n this.busy.set(true);\n this.lastError.set(null);\n clearTimeout(this.saveTimer);\n const r = await this.adapter.reset();\n this.busy.set(false);\n this.saveState.set('idle');\n if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });\n else this.lastError.set(r.error);\n }\n\n submit = () => this.transition(() => this.adapter.submit());\n approve = () => this.transition(() => this.adapter.approve());\n reject = (comments: string) => this.transition(() => this.adapter.reject(comments));\n send = () => this.transition(() => this.adapter.send());\n\n // A transition: flush any pending save, call the server (authoritative), then mirror\n // the returned status through the pure reducer's guarded transition.\n private async transition(action: () => Promise>) {\n this.busy.set(true);\n this.lastError.set(null);\n clearTimeout(this.saveTimer);\n await this.flushSave();\n const r = await action();\n this.busy.set(false);\n if (!r.ok) {\n this.lastError.set(r.error);\n return;\n }\n this.applyServerStatus(r.value);\n }\n\n private applyServerStatus(view: BriefView) {\n const { brief, decisions } = view;\n const s = brief.status;\n switch (s.tag) {\n case 'submitted':\n this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });\n break;\n case 'approved':\n this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });\n break;\n case 'rejected':\n this.store.dispatch({\n tag: 'Rejected',\n by: s.rejectedBy,\n at: s.rejectedAt,\n comments: s.comments,\n decisions,\n });\n break;\n case 'sent':\n this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });\n break;\n case 'draft':\n // reopened by a save on a rejected letter — reducer already handled it locally.\n break;\n }\n }\n}\n", + "sourceCode": "import { Injectable, computed, inject, signal } from '@angular/core';\nimport { Result } from '@shared/kernel/fp';\nimport { RemoteData } from '@shared/application/remote-data';\nimport { createStore } from '@shared/application/store';\nimport {\n Brief,\n allDiagnostics,\n canSubmit,\n hasBlockingErrors,\n unresolvedPlaceholders,\n} from '@brief/domain/brief';\nimport { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';\nimport { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';\n\n/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union\n instead of a busy boolean + a nullable error sitting side by side. */\ntype ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };\n\n/** Debounced-autosave indicator, shown in a small status line near the toolbar —\n a separate concern from ActionState (a stale autosave error doesn't block\n submit/approve/reject), but tag-aligned with it for one consistent idiom. */\ntype SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };\n\ntype LoadedBriefState = Extract;\n\n/**\n * Root singleton for the letter: the Elm store (Model + dispatch), the derived\n * read-model, and the commands (effects) that call the adapter and dispatch the\n * outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/\n * `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never\n * stored. The permission flags come from the server's decision DTO (PRD-0002 phase\n * P1) via `BriefState.loaded.decisions` — this store never computes them itself.\n */\n@Injectable({ providedIn: 'root' })\nexport class BriefStore {\n private adapter = inject(BriefAdapter);\n private store = createStore(initial, reduce);\n\n readonly model = this.store.model;\n\n private actionState = signal({ tag: 'Idle' });\n readonly busy = computed(() => this.actionState().tag === 'Busy');\n readonly lastError = computed(() => {\n const s = this.actionState();\n return s.tag === 'Failed' ? s.error : null;\n });\n\n /** Surfaced autosave state for the indicator + aria-live region. */\n readonly saveState = signal({ tag: 'Idle' });\n\n /** The load lifecycle as `RemoteData`, for `` — the machine keeps\n owning the letter's own domain lifecycle (draft/submitted/approved/…); this is\n purely a projection of its loading/failed tags onto the shared async seam. */\n readonly remoteData = computed>(() => {\n const s = this.model();\n switch (s.tag) {\n case 'loading':\n return { tag: 'Loading' };\n case 'failed':\n return { tag: 'Failure', error: new Error(s.reason) };\n case 'loaded':\n return { tag: 'Success', value: s };\n }\n });\n\n private brief = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.brief : null;\n });\n\n readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);\n readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);\n readonly canReject = computed(() => this.decisions()?.canReject ?? false);\n readonly canSend = computed(() => this.decisions()?.canSend ?? false);\n\n private decisions = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.decisions : null;\n });\n readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));\n readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));\n /** Submit is allowed only when required sections are filled AND no blocking errors. */\n readonly canSubmit = computed(() => {\n const b = this.brief();\n return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());\n });\n\n async load() {\n const r = await this.adapter.load();\n if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });\n else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });\n }\n\n /** An edit: apply it optimistically in the pure reducer, then debounce-save. */\n edit(msg: BriefMsg) {\n this.store.dispatch(msg);\n this.scheduleSave();\n }\n\n private saveTimer?: ReturnType;\n private scheduleSave() {\n if (!this.canEdit()) return;\n clearTimeout(this.saveTimer);\n // ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.\n this.saveTimer = setTimeout(() => void this.flushSave(), 600);\n }\n private async flushSave() {\n const b = this.brief();\n if (!b) return;\n this.saveState.set({ tag: 'Saving' });\n const r = await this.adapter.save(b.sections);\n if (r.ok) {\n this.saveState.set({ tag: 'Saved' });\n } else {\n this.actionState.set({ tag: 'Failed', error: r.error });\n this.saveState.set({ tag: 'Error' });\n }\n }\n\n /** Demo \"start over\": recreate the brief server-side and load the fresh view. */\n async resetDemo() {\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n const r = await this.adapter.reset();\n this.saveState.set({ tag: 'Idle' });\n if (r.ok) {\n this.actionState.set({ tag: 'Idle' });\n this.store.dispatch({ tag: 'BriefLoaded', ...r.value });\n } else {\n this.actionState.set({ tag: 'Failed', error: r.error });\n }\n }\n\n submit = () => this.transition(() => this.adapter.submit());\n approve = () => this.transition(() => this.adapter.approve());\n reject = (comments: string) => this.transition(() => this.adapter.reject(comments));\n send = () => this.transition(() => this.adapter.send());\n\n // A transition: flush any pending save, call the server (authoritative), then mirror\n // the returned status through the pure reducer's guarded transition.\n private async transition(action: () => Promise>) {\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n await this.flushSave();\n const r = await action();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n this.applyServerStatus(r.value);\n }\n\n private applyServerStatus(view: BriefView) {\n const { brief, decisions } = view;\n const s = brief.status;\n switch (s.tag) {\n case 'submitted':\n this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });\n break;\n case 'approved':\n this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });\n break;\n case 'rejected':\n this.store.dispatch({\n tag: 'Rejected',\n by: s.rejectedBy,\n at: s.rejectedAt,\n comments: s.comments,\n decisions,\n });\n break;\n case 'sent':\n this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });\n break;\n case 'draft':\n // reopened by a save on a rejected letter — reducer already handled it locally.\n break;\n }\n }\n}\n", "extends": [], "type": "injectable" }, @@ -12447,7 +12476,7 @@ }, { "name": "BriefPage", - "id": "component-BriefPage-1b50de78aeffcccdc10d05dee15e4476d05030d67027ba23673d14d9ef6f7e31899d91c146b00e41f663d4a6adb0c8141518f8fbea71b1c929e77d39c266ce3d", + "id": "component-BriefPage-40bd5b6cae9f4888c48606d832d345c05337eabf98ba52d84475acaa42c7c627ee00857bd2994bd64b9f8d9af71d3dd459bb136edfd748e7e85597ccba2ecb13", "file": "src/app/brief/ui/brief.page.ts", "encapsulation": [], "entryComponents": [], @@ -12459,12 +12488,44 @@ "styles": [ "\n .brief-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-md);\n margin-block-end: var(--rhc-space-max-lg);\n }\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n " ], - "template": "\n @if (lastError(); as err) {\n {{ err }}\n }\n\n @switch (model().tag) {\n @case ('loading') {\n \n }\n @case ('failed') {\n {{ failedText }}\n {{ retryText }}\n }\n @case ('loaded') {\n
\n {{ saveText() }}\n {{\n resetLabel\n }}\n
\n \n }\n }\n
\n", + "template": "\n @if (lastError(); as err) {\n {{ err }}\n }\n\n \n \n {{ failedText }}\n {{ retryText }}\n \n \n @if (loaded(); as s) {\n
\n {{ saveText() }}\n {{\n resetLabel\n }}\n
\n \n }\n
\n
\n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], - "inputsClass": [], - "outputsClass": [], + "inputsClass": [ + { + "name": "loaded", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "

Typed narrowing for the <app-async> loaded slot — see WP-06: a structural\ndirective's context can't inherit a generic from a sibling host input, so the\nSuccess value is unwrapped here instead of through let-.

\n", + "line": 111, + "rawdescription": "\nTyped narrowing for the `` loaded slot — see WP-06: a structural\ndirective's context can't inherit a generic from a sibling host input, so the\nSuccess value is unwrapped here instead of through `let-`.", + "modifierKind": [ + 124, + 148 + ], + "required": false + } + ], + "outputsClass": [ + { + "name": "loaded", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "

Typed narrowing for the <app-async> loaded slot — see WP-06: a structural\ndirective's context can't inherit a generic from a sibling host input, so the\nSuccess value is unwrapped here instead of through let-.

\n", + "line": 111, + "rawdescription": "\nTyped narrowing for the `` loaded slot — see WP-06: a structural\ndirective's context can't inherit a generic from a sibling host input, so the\nSuccess value is unwrapped here instead of through `let-`.", + "modifierKind": [ + 124, + 148 + ], + "required": false + } + ], "propertiesClass": [ { "name": "failedText", @@ -12475,7 +12536,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 85, + "line": 78, "modifierKind": [ 124 ] @@ -12489,7 +12550,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 83, + "line": 76, "modifierKind": [ 124 ] @@ -12503,7 +12564,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 84, + "line": 77, "modifierKind": [ 124 ] @@ -12517,7 +12578,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 81, + "line": 74, "modifierKind": [ 124 ] @@ -12531,7 +12592,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 80, + "line": 73, "modifierKind": [ 124 ] @@ -12545,7 +12606,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 87, + "line": 80, "modifierKind": [ 124 ] @@ -12559,7 +12620,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 86, + "line": 79, "modifierKind": [ 124 ] @@ -12573,7 +12634,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 90, + "line": 83, "modifierKind": [ 123 ] @@ -12587,21 +12648,21 @@ "indexKey": "", "optional": false, "description": "", - "line": 91, + "line": 84, "modifierKind": [ 123 ] }, { "name": "saveText", - "defaultValue": "computed(() => {\n switch (this.store.saveState()) {\n case 'saving':\n return this.savingText;\n case 'saved':\n return this.savedText;\n case 'error':\n return this.saveErrorText;\n default:\n return '';\n }\n })", + "defaultValue": "computed(() => {\n switch (this.store.saveState().tag) {\n case 'Saving':\n return this.savingText;\n case 'Saved':\n return this.savedText;\n case 'Error':\n return this.saveErrorText;\n default:\n return '';\n }\n })", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "

Debounced-save state, surfaced in a polite live region.

\n", - "line": 94, + "line": 87, "rawdescription": "\nDebounced-save state, surfaced in a polite live region.", "modifierKind": [ 124 @@ -12616,7 +12677,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 89, + "line": 82, "modifierKind": [ 123 ] @@ -12630,46 +12691,20 @@ "indexKey": "", "optional": false, "description": "", - "line": 79, + "line": 72, "modifierKind": [ 124 ] } ], "methodsClass": [ - { - "name": "availablePassages", - "args": [], - "optional": false, - "returnType": "any", - "typeParameters": [], - "line": 120, - "deprecated": false, - "deprecationMessage": "", - "modifierKind": [ - 124 - ] - }, - { - "name": "brief", - "args": [], - "optional": false, - "returnType": "any", - "typeParameters": [], - "line": 116, - "deprecated": false, - "deprecationMessage": "", - "modifierKind": [ - 124 - ] - }, { "name": "reload", "args": [], "optional": false, "returnType": "void", "typeParameters": [], - "line": 125, + "line": 116, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -12682,7 +12717,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 111, + "line": 104, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -12700,10 +12735,6 @@ "name": "PageShellComponent", "type": "component" }, - { - "name": "SpinnerComponent", - "type": "component" - }, { "name": "AlertComponent", "type": "component" @@ -12712,6 +12743,9 @@ "name": "ButtonComponent", "type": "component" }, + { + "name": "ASYNC" + }, { "name": "LetterComposerComponent", "type": "component" @@ -12720,7 +12754,7 @@ "description": "

Page: thin container. Injects the root store, kicks off the load, and passes its\nderived read-model to the composer. Business/UI logic lives below in pure pieces;\nthis just wires signals to the organism and events back to store commands.

\n", "rawdescription": "\nPage: thin container. Injects the root store, kicks off the load, and passes its\nderived read-model to the composer. Business/UI logic lives below in pure pieces;\nthis just wires signals to the organism and events back to store commands.", "type": "component", - "sourceCode": "import { Component, computed, inject } from '@angular/core';\nimport { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { BriefStore } from '@brief/application/brief.store';\nimport { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';\n\n/** Page: thin container. Injects the root store, kicks off the load, and passes its\n derived read-model to the composer. Business/UI logic lives below in pure pieces;\n this just wires signals to the organism and events back to store commands. */\n@Component({\n selector: 'app-brief-page',\n imports: [\n PageShellComponent,\n SpinnerComponent,\n AlertComponent,\n ButtonComponent,\n LetterComposerComponent,\n ],\n styles: [\n `\n .brief-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-md);\n margin-block-end: var(--rhc-space-max-lg);\n }\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n `,\n ],\n template: `\n \n @if (lastError(); as err) {\n {{ err }}\n }\n\n @switch (model().tag) {\n @case ('loading') {\n \n }\n @case ('failed') {\n {{ failedText }}\n {{ retryText }}\n }\n @case ('loaded') {\n
\n {{ saveText() }}\n {{\n resetLabel\n }}\n
\n \n }\n }\n
\n `,\n})\nexport class BriefPage {\n protected store = inject(BriefStore);\n protected model = this.store.model;\n protected lastError = this.store.lastError;\n\n protected heading = $localize`:@@brief.page.heading:Brief opstellen`;\n protected intro = $localize`:@@brief.page.intro:Stel de brief aan de zorgverlener samen uit standaardteksten en vrije tekst.`;\n protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`;\n protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`;\n protected resetLabel = $localize`:@@brief.page.reset:Opnieuw beginnen (demo)`;\n\n private savingText = $localize`:@@brief.page.saving:Concept opslaan…`;\n private savedText = $localize`:@@brief.page.saved:Concept opgeslagen`;\n private saveErrorText = $localize`:@@brief.page.saveError:Opslaan mislukt`;\n\n /** Debounced-save state, surfaced in a polite live region. */\n protected saveText = computed(() => {\n switch (this.store.saveState()) {\n case 'saving':\n return this.savingText;\n case 'saved':\n return this.savedText;\n case 'error':\n return this.saveErrorText;\n default:\n return '';\n }\n });\n\n constructor() {\n void this.store.load();\n }\n\n protected resetDemo() {\n void this.store.resetDemo();\n }\n\n // Narrow the loaded state for the template.\n protected brief() {\n const s = this.model();\n return s.tag === 'loaded' ? s.brief : null;\n }\n protected availablePassages() {\n const s = this.model();\n return s.tag === 'loaded' ? s.availablePassages : [];\n }\n\n protected reload() {\n void this.store.load();\n }\n}\n", + "sourceCode": "import { Component, computed, inject } from '@angular/core';\nimport { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { BriefStore } from '@brief/application/brief.store';\nimport { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';\n\n/** Page: thin container. Injects the root store, kicks off the load, and passes its\n derived read-model to the composer. Business/UI logic lives below in pure pieces;\n this just wires signals to the organism and events back to store commands. */\n@Component({\n selector: 'app-brief-page',\n imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent],\n styles: [\n `\n .brief-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-md);\n margin-block-end: var(--rhc-space-max-lg);\n }\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n `,\n ],\n template: `\n \n @if (lastError(); as err) {\n {{ err }}\n }\n\n \n \n {{ failedText }}\n {{ retryText }}\n \n \n @if (loaded(); as s) {\n
\n {{ saveText() }}\n {{\n resetLabel\n }}\n
\n \n }\n
\n
\n
\n `,\n})\nexport class BriefPage {\n protected store = inject(BriefStore);\n protected model = this.store.model;\n protected lastError = this.store.lastError;\n\n protected heading = $localize`:@@brief.page.heading:Brief opstellen`;\n protected intro = $localize`:@@brief.page.intro:Stel de brief aan de zorgverlener samen uit standaardteksten en vrije tekst.`;\n protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`;\n protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`;\n protected resetLabel = $localize`:@@brief.page.reset:Opnieuw beginnen (demo)`;\n\n private savingText = $localize`:@@brief.page.saving:Concept opslaan…`;\n private savedText = $localize`:@@brief.page.saved:Concept opgeslagen`;\n private saveErrorText = $localize`:@@brief.page.saveError:Opslaan mislukt`;\n\n /** Debounced-save state, surfaced in a polite live region. */\n protected saveText = computed(() => {\n switch (this.store.saveState().tag) {\n case 'Saving':\n return this.savingText;\n case 'Saved':\n return this.savedText;\n case 'Error':\n return this.saveErrorText;\n default:\n return '';\n }\n });\n\n constructor() {\n void this.store.load();\n }\n\n protected resetDemo() {\n void this.store.resetDemo();\n }\n\n /** Typed narrowing for the `` loaded slot — see WP-06: a structural\n directive's context can't inherit a generic from a sibling host input, so the\n Success value is unwrapped here instead of through `let-`. */\n protected readonly loaded = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s : undefined;\n });\n\n protected reload() {\n void this.store.load();\n }\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "\n .brief-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-md);\n margin-block-end: var(--rhc-space-max-lg);\n }\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n \n", @@ -12730,7 +12764,7 @@ "deprecated": false, "deprecationMessage": "", "args": [], - "line": 105 + "line": 98 }, "extends": [] }, @@ -23004,26 +23038,6 @@ "type": "BriefState", "defaultValue": "{ tag: 'loading' }" }, - { - "name": "initial", - "ctype": "miscellaneous", - "subtype": "variable", - "file": "src/app/registratie/domain/change-request.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "type": "State", - "defaultValue": "{\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n}" - }, - { - "name": "initial", - "ctype": "miscellaneous", - "subtype": "variable", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "type": "RegistratieState", - "defaultValue": "{\n tag: 'Invullen',\n draft: emptyDraft,\n cursor: 0,\n errors: {},\n upload: initialUpload,\n}" - }, { "name": "initial", "ctype": "miscellaneous", @@ -23044,6 +23058,26 @@ "type": "IntakeState", "defaultValue": "{\n tag: 'Answering',\n answers: {},\n cursor: 0,\n errors: {},\n scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,\n}" }, + { + "name": "initial", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/domain/change-request.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "State", + "defaultValue": "{\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n}" + }, + { + "name": "initial", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "RegistratieState", + "defaultValue": "{\n tag: 'Invullen',\n draft: emptyDraft,\n cursor: 0,\n errors: {},\n upload: initialUpload,\n}" + }, { "name": "initialUpload", "ctype": "miscellaneous", @@ -23324,11 +23358,11 @@ "name": "STEPS", "ctype": "miscellaneous", "subtype": "variable", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "file": "src/app/herregistratie/domain/intake.machine.ts", "deprecated": false, "deprecationMessage": "", "type": "StepId[]", - "defaultValue": "['adres', 'beroep', 'controle']", + "defaultValue": "['buitenland', 'werk', 'review']", "rawdescription": "The fixed step list. Number of steps never changes; questions reveal inline.", "description": "

The fixed step list. Number of steps never changes; questions reveal inline.

\n" }, @@ -23336,11 +23370,11 @@ "name": "STEPS", "ctype": "miscellaneous", "subtype": "variable", - "file": "src/app/herregistratie/domain/intake.machine.ts", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", "deprecated": false, "deprecationMessage": "", "type": "StepId[]", - "defaultValue": "['buitenland', 'werk', 'review']", + "defaultValue": "['adres', 'beroep', 'controle']", "rawdescription": "The fixed step list. Number of steps never changes; questions reveal inline.", "description": "

The fixed step list. Number of steps never changes; questions reveal inline.

\n" }, @@ -23652,35 +23686,6 @@ } ] }, - { - "name": "back", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "back", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -23739,6 +23744,35 @@ } ] }, + { + "name": "back", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "blockActions", "file": "src/app/registratie/domain/block-actions.ts", @@ -24218,7 +24252,7 @@ }, { "name": "currentStep", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "file": "src/app/herregistratie/domain/intake.machine.ts", "ctype": "miscellaneous", "subtype": "function", "deprecated": false, @@ -24247,7 +24281,7 @@ }, { "name": "currentStep", - "file": "src/app/herregistratie/domain/intake.machine.ts", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", "ctype": "miscellaneous", "subtype": "function", "deprecated": false, @@ -24863,50 +24897,6 @@ } ] }, - { - "name": "gaNaarStap", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Jump back to an earlier step to correct data (controle → step N). Forward\njumps are not allowed (would skip validation). Preserves the draft.

\n", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "cursor", - "type": "number", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "cursor", - "type": "number", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "gaNaarStap", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -24993,6 +24983,50 @@ } ] }, + { + "name": "gaNaarStap", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Jump back to an earlier step to correct data (controle → step N). Forward\njumps are not allowed (would skip validation). Preserves the draft.

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "cursor", + "type": "number", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "cursor", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "groupParagraphs", "file": "src/app/brief/ui/letter-preview/letter-preview.component.ts", @@ -25047,35 +25081,6 @@ } ] }, - { - "name": "hasProgress", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Has the user meaningfully started, so it's worth persisting as a Concept? Excludes\nthe automatic BRP address prefill on step 0 — a bare page visit creates nothing.\nponytail: an address typed at step 0 without any of these signals is not yet\npersisted (created once they advance/choose); accepted regression vs. sessionStorage.

\n", - "args": [ - { - "name": "s", - "type": "Extract", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "boolean", - "jsdoctags": [ - { - "name": "s", - "type": "Extract", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "hasProgress", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -25134,6 +25139,35 @@ } ] }, + { + "name": "hasProgress", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Has the user meaningfully started, so it's worth persisting as a Concept? Excludes\nthe automatic BRP address prefill on step 0 — a bare page visit creates nothing.\nponytail: an address typed at step 0 without any of these signals is not yet\npersisted (created once they advance/choose); accepted regression vs. sessionStorage.

\n", + "args": [ + { + "name": "s", + "type": "Extract", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "boolean", + "jsdoctags": [ + { + "name": "s", + "type": "Extract", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "herregistratieDeadline", "file": "src/app/registratie/domain/registration.policy.ts", @@ -26268,35 +26302,6 @@ } ] }, - { - "name": "next", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "next", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -26355,6 +26360,35 @@ } ] }, + { + "name": "next", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "nextLocalIndex", "file": "src/app/brief/domain/brief.machine.ts", @@ -27484,94 +27518,6 @@ } ] }, - { - "name": "reduce", - "file": "src/app/registratie/domain/change-request.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "State", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "m", - "type": "Msg", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "State", - "jsdoctags": [ - { - "name": "s", - "type": "State", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "m", - "type": "Msg", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "reduce", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "m", - "type": "RegistratieMsg", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "m", - "type": "RegistratieMsg", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "reduce", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -27660,6 +27606,94 @@ } ] }, + { + "name": "reduce", + "file": "src/app/registratie/domain/change-request.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "State", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "m", + "type": "Msg", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "State", + "jsdoctags": [ + { + "name": "s", + "type": "State", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "m", + "type": "Msg", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "reduce", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "m", + "type": "RegistratieMsg", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "m", + "type": "RegistratieMsg", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "reduceUpload", "file": "src/app/shared/upload/upload.machine.ts", @@ -27948,50 +27982,6 @@ } ] }, - { - "name": "resolve", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "r", - "type": "Result", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "r", - "type": "Result", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "resolve", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -28080,6 +28070,50 @@ } ] }, + { + "name": "resolve", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "r", + "type": "Result", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "r", + "type": "Result", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "restore", "file": "src/app/auth/application/session.store.ts", @@ -28366,6 +28400,63 @@ } ] }, + { + "name": "setField", + "file": "src/app/herregistratie/domain/herregistratie.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Update one draft field while editing; ignored in any other state.

\n", + "args": [ + { + "name": "s", + "type": "WizardState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "key", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "WizardState", + "jsdoctags": [ + { + "name": "s", + "type": "WizardState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "key", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "setField", "file": "src/app/registratie/domain/registratie-wizard.machine.ts", @@ -28425,63 +28516,6 @@ } ] }, - { - "name": "setField", - "file": "src/app/herregistratie/domain/herregistratie.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Update one draft field while editing; ignored in any other state.

\n", - "args": [ - { - "name": "s", - "type": "WizardState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "key", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "value", - "type": "string", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "WizardState", - "jsdoctags": [ - { - "name": "s", - "type": "WizardState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "key", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "value", - "type": "string", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "setPolicy", "file": "src/app/herregistratie/domain/intake.machine.ts", @@ -28682,35 +28716,6 @@ } ] }, - { - "name": "submit", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "submit", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -28769,6 +28774,35 @@ } ] }, + { + "name": "submit", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "submittedRow", "file": "src/app/registratie/domain/aanvraag-view.ts", @@ -29132,50 +29166,6 @@ } ] }, - { - "name": "upload", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Route an upload sub-message through the pure upload reducer (Invullen only).

\n", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "msg", - "type": "UploadMsg", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "msg", - "type": "UploadMsg", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "upload", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -29221,26 +29211,41 @@ ] }, { - "name": "validate", - "file": "src/app/registratie/domain/change-request.machine.ts", + "name": "upload", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", "ctype": "miscellaneous", "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "

Parse via the value objects; on success hand back a Valid, else per-field errors.

\n", + "description": "

Route an upload sub-message through the pure upload reducer (Invullen only).

\n", "args": [ { - "name": "draft", - "type": "Draft", + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "msg", + "type": "UploadMsg", "deprecated": false, "deprecationMessage": "" } ], - "returnType": "Result", + "returnType": "RegistratieState", "jsdoctags": [ { - "name": "draft", - "type": "Draft", + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "msg", + "type": "UploadMsg", "deprecated": false, "deprecationMessage": "", "tagName": { @@ -29294,46 +29299,31 @@ ] }, { - "name": "validateAll", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "name": "validate", + "file": "src/app/registratie/domain/change-request.machine.ts", "ctype": "miscellaneous", "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "

Parse the whole wizard into a ValidRegistratie (called on submit).

\n", + "description": "

Parse via the value objects; on success hand back a Valid, else per-field errors.

\n", "args": [ { - "name": "d", + "name": "draft", "type": "Draft", "deprecated": false, "deprecationMessage": "" - }, - { - "name": "upload", - "type": "UploadState", - "deprecated": false, - "deprecationMessage": "" } ], - "returnType": "Result", + "returnType": "Result", "jsdoctags": [ { - "name": "d", + "name": "draft", "type": "Draft", "deprecated": false, "deprecationMessage": "", "tagName": { "text": "param" } - }, - { - "name": "upload", - "type": "UploadState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } } ] }, @@ -29382,20 +29372,14 @@ ] }, { - "name": "validateStep", + "name": "validateAll", "file": "src/app/registratie/domain/registratie-wizard.machine.ts", "ctype": "miscellaneous", "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "

Validate every question currently visible in ONE step. Errors keyed per field.

\n", + "description": "

Parse the whole wizard into a ValidRegistratie (called on submit).

\n", "args": [ - { - "name": "step", - "type": "StepId", - "deprecated": false, - "deprecationMessage": "" - }, { "name": "d", "type": "Draft", @@ -29409,17 +29393,8 @@ "deprecationMessage": "" } ], - "returnType": "Result", + "returnType": "Result", "jsdoctags": [ - { - "name": "step", - "type": "StepId", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, { "name": "d", "type": "Draft", @@ -29499,6 +29474,65 @@ } ] }, + { + "name": "validateStep", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Validate every question currently visible in ONE step. Errors keyed per field.

\n", + "args": [ + { + "name": "step", + "type": "StepId", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "d", + "type": "Draft", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "upload", + "type": "UploadState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "step", + "type": "StepId", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "d", + "type": "Draft", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "upload", + "type": "UploadState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "whenTag", "file": "src/app/shared/kernel/fp.ts", @@ -29620,6 +29654,17 @@ "description": "

An application (aanvraag) as the frontend sees it — the parsed, domain-side view\nof the backend-owned aggregate (see backend ApplicationStore + PRD 0001). Pure\ntypes, no Angular. Lives in registratie because the dashboard (here) is the\nconsumer and the downstream wizards (herregistratie → registratie) produce them.

\n

The status is a discriminated union so illegal states are unrepresentable — same\nreflex as RemoteData. The server computes which tag applies (auto-approval on\nread); the FE renders it, it does not recompute the lifecycle.

\n", "kind": 193 }, + { + "name": "ActionState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type", + "file": "src/app/brief/application/brief.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Transient action state (submit/approve/reject/send/resetDemo) — one tagged union\ninstead of a busy boolean + a nullable error sitting side by side.

\n", + "kind": 193 + }, { "name": "AdresErrors", "ctype": "miscellaneous", @@ -29888,22 +29933,22 @@ "name": "Errors", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "Partial>", - "file": "src/app/registratie/domain/change-request.machine.ts", + "rawtype": "Partial>", + "file": "src/app/herregistratie/domain/intake.machine.ts", "deprecated": false, "deprecationMessage": "", - "description": "", + "description": "

Per-field error map: one message per question, since a step holds several.

\n", "kind": 184 }, { "name": "Errors", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "Partial>", - "file": "src/app/herregistratie/domain/intake.machine.ts", + "rawtype": "Partial>", + "file": "src/app/registratie/domain/change-request.machine.ts", "deprecated": false, "deprecationMessage": "", - "description": "

Per-field error map: one message per question, since a step holds several.

\n", + "description": "", "kind": 184 }, { @@ -29950,6 +29995,17 @@ "description": "

A block inside a letter section: a frozen passage snapshot, or free text.

\n", "kind": 193 }, + { + "name": "LoadedBriefState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Extract", + "file": "src/app/brief/application/brief.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 184 + }, { "name": "Mark", "ctype": "miscellaneous", @@ -30093,6 +30149,17 @@ "description": "

The two-person letter workflow's role: who is acting, a drafter or an approver.\nA pure domain type (no framework, no reading mechanism) — the ?role= reader and\nthe X-Role header live in shared/infrastructure/role.ts. Consumers (brief.store,\nletter-composer) depend on this type, not on how the role is obtained.

\n", "kind": 193 }, + { + "name": "SaveState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type", + "file": "src/app/brief/application/brief.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Debounced-autosave indicator, shown in a small status line near the toolbar —\na separate concern from ActionState (a stale autosave error doesn't block\nsubmit/approve/reject), but tag-aligned with it for one consistent idiom.

\n", + "kind": 193 + }, { "name": "Scenario", "ctype": "miscellaneous", @@ -30141,22 +30208,22 @@ "name": "StepId", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "\"adres\" | \"beroep\" | \"controle\"", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "rawtype": "\"buitenland\" | \"werk\" | \"review\"", + "file": "src/app/herregistratie/domain/intake.machine.ts", "deprecated": false, "deprecationMessage": "", - "description": "

A FIXED 3-step registration wizard. The steps never change in number (always\nSTEPS): (1) adres + correspondentievoorkeur, (2) beroep o.b.v. diploma,\n(3) controle & indienen. Follow-up questions appear inline within a step\n(e.g. choosing 'email' reveals the e-mail field). "Is this field required\nright now" is a pure function (validateStep), so it is trivial to test and\nimpossible to get out of sync with the data. Invariants live here, not in the\nUI: the wizard reaches Indienen only when a complete ValidRegistratie parses.

\n", + "description": "

The three fixed steps. Each step groups one or more questions.

\n", "kind": 193 }, { "name": "StepId", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "\"buitenland\" | \"werk\" | \"review\"", - "file": "src/app/herregistratie/domain/intake.machine.ts", + "rawtype": "\"adres\" | \"beroep\" | \"controle\"", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", "deprecated": false, "deprecationMessage": "", - "description": "

The three fixed steps. Each step groups one or more questions.

\n", + "description": "

A FIXED 3-step registration wizard. The steps never change in number (always\nSTEPS): (1) adres + correspondentievoorkeur, (2) beroep o.b.v. diploma,\n(3) controle & indienen. Follow-up questions appear inline within a step\n(e.g. choosing 'email' reveals the e-mail field). "Is this field required\nright now" is a pure function (validateStep), so it is trivial to test and\nimpossible to get out of sync with the data. Invariants live here, not in the\nUI: the wizard reaches Indienen only when a complete ValidRegistratie parses.

\n", "kind": 193 }, { @@ -30597,18 +30664,6 @@ "defaultValue": "{ tag: 'loading' }" } ], - "src/app/registratie/domain/change-request.machine.ts": [ - { - "name": "initial", - "ctype": "miscellaneous", - "subtype": "variable", - "file": "src/app/registratie/domain/change-request.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "type": "State", - "defaultValue": "{\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n}" - } - ], "src/app/herregistratie/domain/herregistratie.machine.ts": [ { "name": "initial", @@ -30657,6 +30712,18 @@ "description": "

The fixed step list. Number of steps never changes; questions reveal inline.

\n" } ], + "src/app/registratie/domain/change-request.machine.ts": [ + { + "name": "initial", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/domain/change-request.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "State", + "defaultValue": "{\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n}" + } + ], "src/app/shared/ui/radio-group/radio-group.component.ts": [ { "name": "JA_NEE", @@ -32663,826 +32730,6 @@ ] } ], - "src/app/registratie/domain/registratie-wizard.machine.ts": [ - { - "name": "back", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "currentStep", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Which step the cursor currently points at (clamped to the fixed list).

\n", - "args": [ - { - "name": "s", - "type": "Extract", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "StepId", - "jsdoctags": [ - { - "name": "s", - "type": "Extract", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "declareerBeroep", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Declare the beroep for a manually-entered diploma (chosen from a fixed list).

\n", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "beroep", - "type": "string", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "beroep", - "type": "string", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "gaNaarStap", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Jump back to an earlier step to correct data (controle → step N). Forward\njumps are not allowed (would skip validation). Preserves the draft.

\n", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "cursor", - "type": "number", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "cursor", - "type": "number", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "hasProgress", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Has the user meaningfully started, so it's worth persisting as a Concept? Excludes\nthe automatic BRP address prefill on step 0 — a bare page visit creates nothing.\nponytail: an address typed at step 0 without any of these signals is not yet\npersisted (created once they advance/choose); accepted regression vs. sessionStorage.

\n", - "args": [ - { - "name": "s", - "type": "Extract", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "boolean", - "jsdoctags": [ - { - "name": "s", - "type": "Extract", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "kiesDiploma", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Pick a DUO diploma; the beroep is derived from it and the applicable policy\nquestions (vraagIds) come with it (both server-computed, passed in).

\n", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "diplomaId", - "type": "string", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "beroep", - "type": "string", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "vraagIds", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "diplomaId", - "type": "string", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "beroep", - "type": "string", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "vraagIds", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "kiesHandmatig", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL\npolicy-question set applies and the entry is flagged handmatig/unverified. The\nberoep is declared separately (declareerBeroep).

\n", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "vraagIds", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "vraagIds", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "next", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "prefillAdres", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Prefill the address from a BRP lookup and flag its origin (PRD §7).

\n", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "straat", - "type": "string", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "postcode", - "type": "string", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "woonplaats", - "type": "string", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "straat", - "type": "string", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "postcode", - "type": "string", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "woonplaats", - "type": "string", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "reduce", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "m", - "type": "RegistratieMsg", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "m", - "type": "RegistratieMsg", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "resolve", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "r", - "type": "Result", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "r", - "type": "Result", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "setAntwoord", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "vraagId", - "type": "string", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "value", - "type": "string", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "vraagId", - "type": "string", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "value", - "type": "string", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "setCorrespondentie", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "value", - "type": "Correspondentie", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "value", - "type": "Correspondentie", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "setField", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "key", - "type": "DraftField", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "value", - "type": "string", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "key", - "type": "DraftField", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "value", - "type": "string", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "submit", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "upload", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Route an upload sub-message through the pure upload reducer (Invullen only).

\n", - "args": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "msg", - "type": "UploadMsg", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "RegistratieState", - "jsdoctags": [ - { - "name": "s", - "type": "RegistratieState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "msg", - "type": "UploadMsg", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "validateAll", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Parse the whole wizard into a ValidRegistratie (called on submit).

\n", - "args": [ - { - "name": "d", - "type": "Draft", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "upload", - "type": "UploadState", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "Result", - "jsdoctags": [ - { - "name": "d", - "type": "Draft", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "upload", - "type": "UploadState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, - { - "name": "validateStep", - "file": "src/app/registratie/domain/registratie-wizard.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "

Validate every question currently visible in ONE step. Errors keyed per field.

\n", - "args": [ - { - "name": "step", - "type": "StepId", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "d", - "type": "Draft", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "upload", - "type": "UploadState", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "Result", - "jsdoctags": [ - { - "name": "step", - "type": "StepId", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "d", - "type": "Draft", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "upload", - "type": "UploadState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - } - ], "src/app/herregistratie/domain/herregistratie.machine.ts": [ { "name": "back", @@ -34405,6 +33652,826 @@ ] } ], + "src/app/registratie/domain/registratie-wizard.machine.ts": [ + { + "name": "back", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "currentStep", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Which step the cursor currently points at (clamped to the fixed list).

\n", + "args": [ + { + "name": "s", + "type": "Extract", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "StepId", + "jsdoctags": [ + { + "name": "s", + "type": "Extract", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "declareerBeroep", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Declare the beroep for a manually-entered diploma (chosen from a fixed list).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "beroep", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "beroep", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "gaNaarStap", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Jump back to an earlier step to correct data (controle → step N). Forward\njumps are not allowed (would skip validation). Preserves the draft.

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "cursor", + "type": "number", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "cursor", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "hasProgress", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Has the user meaningfully started, so it's worth persisting as a Concept? Excludes\nthe automatic BRP address prefill on step 0 — a bare page visit creates nothing.\nponytail: an address typed at step 0 without any of these signals is not yet\npersisted (created once they advance/choose); accepted regression vs. sessionStorage.

\n", + "args": [ + { + "name": "s", + "type": "Extract", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "boolean", + "jsdoctags": [ + { + "name": "s", + "type": "Extract", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "kiesDiploma", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Pick a DUO diploma; the beroep is derived from it and the applicable policy\nquestions (vraagIds) come with it (both server-computed, passed in).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "diplomaId", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "beroep", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "diplomaId", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "beroep", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "kiesHandmatig", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL\npolicy-question set applies and the entry is flagged handmatig/unverified. The\nberoep is declared separately (declareerBeroep).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "next", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "prefillAdres", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Prefill the address from a BRP lookup and flag its origin (PRD §7).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "straat", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "postcode", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "woonplaats", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "straat", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "postcode", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "woonplaats", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "reduce", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "m", + "type": "RegistratieMsg", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "m", + "type": "RegistratieMsg", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "resolve", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "r", + "type": "Result", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "r", + "type": "Result", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "setAntwoord", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "vraagId", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "vraagId", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "setCorrespondentie", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "value", + "type": "Correspondentie", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "value", + "type": "Correspondentie", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "setField", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "key", + "type": "DraftField", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "key", + "type": "DraftField", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "submit", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "upload", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Route an upload sub-message through the pure upload reducer (Invullen only).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "msg", + "type": "UploadMsg", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "msg", + "type": "UploadMsg", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "validateAll", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Parse the whole wizard into a ValidRegistratie (called on submit).

\n", + "args": [ + { + "name": "d", + "type": "Draft", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "upload", + "type": "UploadState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "d", + "type": "Draft", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "upload", + "type": "UploadState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "validateStep", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Validate every question currently visible in ONE step. Errors keyed per field.

\n", + "args": [ + { + "name": "step", + "type": "StepId", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "d", + "type": "Draft", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "upload", + "type": "UploadState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "step", + "type": "StepId", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "d", + "type": "Draft", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "upload", + "type": "UploadState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], "src/app/registratie/domain/block-actions.ts": [ { "name": "blockActions", @@ -37268,6 +37335,41 @@ "kind": 193 } ], + "src/app/brief/application/brief.store.ts": [ + { + "name": "ActionState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type", + "file": "src/app/brief/application/brief.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Transient action state (submit/approve/reject/send/resetDemo) — one tagged union\ninstead of a busy boolean + a nullable error sitting side by side.

\n", + "kind": 193 + }, + { + "name": "LoadedBriefState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Extract", + "file": "src/app/brief/application/brief.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 184 + }, + { + "name": "SaveState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type", + "file": "src/app/brief/application/brief.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Debounced-autosave indicator, shown in a small status line near the toolbar —\na separate concern from ActionState (a stale autosave error doesn't block\nsubmit/approve/reject), but tag-aligned with it for one consistent idiom.

\n", + "kind": 193 + } + ], "src/app/registratie/ui/address-fields/address-fields.component.ts": [ { "name": "AdresErrors", @@ -37656,41 +37758,6 @@ "kind": 193 } ], - "src/app/registratie/domain/change-request.machine.ts": [ - { - "name": "Errors", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "Partial>", - "file": "src/app/registratie/domain/change-request.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "kind": 184 - }, - { - "name": "Msg", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type", - "file": "src/app/registratie/domain/change-request.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "kind": 193 - }, - { - "name": "State", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "literal type | literal type | literal type | literal type", - "file": "src/app/registratie/domain/change-request.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "

The change-request (adreswijziging) form as one tagged union — the SAME idiom\nas the wizards, just single-step. draft/errors exist only while Editing;\nSubmitting/Submitted/Failed carry the parsed Valid. Illegal states (submitting\nan invalid draft, a success screen with errors) are unrepresentable.

\n", - "kind": 193 - } - ], "src/app/herregistratie/domain/intake.machine.ts": [ { "name": "Errors", @@ -37748,6 +37815,41 @@ "kind": 193 } ], + "src/app/registratie/domain/change-request.machine.ts": [ + { + "name": "Errors", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Partial>", + "file": "src/app/registratie/domain/change-request.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 184 + }, + { + "name": "Msg", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type", + "file": "src/app/registratie/domain/change-request.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "State", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type", + "file": "src/app/registratie/domain/change-request.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

The change-request (adreswijziging) form as one tagged union — the SAME idiom\nas the wizards, just single-step. draft/errors exist only while Editing;\nSubmitting/Submitted/Failed carry the parsed Valid. Illegal states (submitting\nan invalid draft, a success screen with errors) are unrepresentable.

\n", + "kind": 193 + } + ], "src/app/shared/kernel/rich-text.ts": [ { "name": "Mark", @@ -38130,10 +38232,40 @@ "type": "injectable", "linktype": "injectable", "name": "BriefStore", - "coveragePercent": 17, - "coverageCount": "5/28", + "coveragePercent": 20, + "coverageCount": "6/30", "status": "low" }, + { + "filePath": "src/app/brief/application/brief.store.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "ActionState", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/brief/application/brief.store.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "LoadedBriefState", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/brief/application/brief.store.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "SaveState", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, { "filePath": "src/app/brief/domain/brief.machine.ts", "type": "function", @@ -38700,8 +38832,8 @@ "type": "component", "linktype": "component", "name": "BriefPage", - "coveragePercent": 11, - "coverageCount": "2/18", + "coveragePercent": 22, + "coverageCount": "4/18", "status": "low" }, { diff --git a/src/app/brief/application/brief.store.spec.ts b/src/app/brief/application/brief.store.spec.ts new file mode 100644 index 0000000..70fd85e --- /dev/null +++ b/src/app/brief/application/brief.store.spec.ts @@ -0,0 +1,88 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect } from 'vitest'; +import { Result } from '@shared/kernel/fp'; +import { Brief, BriefDecisions } from '@brief/domain/brief'; +import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; +import { BriefStore } from './brief.store'; + +const decisions: BriefDecisions = { + canEdit: true, + canApprove: true, + canReject: true, + canSend: true, +}; + +const brief: Brief = { + briefId: 'b1', + beroep: 'arts', + templateId: 't1', + placeholders: [], + sections: [], + status: { tag: 'draft' }, + drafterId: 'u1', +}; + +const view: BriefView = { brief, availablePassages: [], decisions }; + +function setup(adapter: Partial): BriefStore { + TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] }); + return TestBed.inject(BriefStore); +} + +describe('BriefStore action state (Idle | Busy | Failed)', () => { + it('is Busy synchronously once a transition starts, then Idle on success', async () => { + const approved: BriefView = { + ...view, + brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } }, + }; + const store = setup({ + load: (): Promise> => Promise.resolve({ ok: true, value: view }), + save: (): Promise> => Promise.resolve({ ok: true, value: view }), + approve: (): Promise> => + Promise.resolve({ ok: true, value: approved }), + }); + await store.load(); + + const pending = store.approve(); + expect(store.busy()).toBe(true); // set synchronously, before any await resolves + + await pending; + expect(store.busy()).toBe(false); + expect(store.lastError()).toBeNull(); + }); + + it('goes Busy then Failed on a failing transition, surfacing the error', async () => { + const store = setup({ + load: (): Promise> => Promise.resolve({ ok: true, value: view }), + save: (): Promise> => Promise.resolve({ ok: true, value: view }), + approve: (): Promise> => + Promise.resolve({ ok: false, error: 'niet toegestaan' }), + }); + await store.load(); + + await store.approve(); + expect(store.busy()).toBe(false); + expect(store.lastError()).toBe('niet toegestaan'); + }); + + it('a subsequent successful transition clears a prior Failed state', async () => { + let approveResult: Result = { ok: false, error: 'eerste poging mislukt' }; + const store = setup({ + load: (): Promise> => Promise.resolve({ ok: true, value: view }), + save: (): Promise> => Promise.resolve({ ok: true, value: view }), + approve: (): Promise> => Promise.resolve(approveResult), + }); + await store.load(); + + await store.approve(); + expect(store.lastError()).toBe('eerste poging mislukt'); + + approveResult = { + ok: true, + value: { ...view, brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } } }, + }; + await store.approve(); + expect(store.busy()).toBe(false); + expect(store.lastError()).toBeNull(); + }); +}); diff --git a/src/app/brief/application/brief.store.ts b/src/app/brief/application/brief.store.ts index f91fc64..06d3bdf 100644 --- a/src/app/brief/application/brief.store.ts +++ b/src/app/brief/application/brief.store.ts @@ -1,5 +1,6 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { Result } from '@shared/kernel/fp'; +import { RemoteData } from '@shared/application/remote-data'; import { createStore } from '@shared/application/store'; import { Brief, @@ -11,6 +12,17 @@ import { import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine'; import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; +/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union + instead of a busy boolean + a nullable error sitting side by side. */ +type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string }; + +/** Debounced-autosave indicator, shown in a small status line near the toolbar — + a separate concern from ActionState (a stale autosave error doesn't block + submit/approve/reject), but tag-aligned with it for one consistent idiom. */ +type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' }; + +type LoadedBriefState = Extract; + /** * Root singleton for the letter: the Elm store (Model + dispatch), the derived * read-model, and the commands (effects) that call the adapter and dispatch the @@ -25,10 +37,31 @@ export class BriefStore { private store = createStore(initial, reduce); readonly model = this.store.model; - readonly busy = signal(false); - readonly lastError = signal(null); + + private actionState = signal({ tag: 'Idle' }); + readonly busy = computed(() => this.actionState().tag === 'Busy'); + readonly lastError = computed(() => { + const s = this.actionState(); + return s.tag === 'Failed' ? s.error : null; + }); + /** Surfaced autosave state for the indicator + aria-live region. */ - readonly saveState = signal<'idle' | 'saving' | 'saved' | 'error'>('idle'); + readonly saveState = signal({ tag: 'Idle' }); + + /** The load lifecycle as `RemoteData`, for `` — the machine keeps + owning the letter's own domain lifecycle (draft/submitted/approved/…); this is + purely a projection of its loading/failed tags onto the shared async seam. */ + readonly remoteData = computed>(() => { + const s = this.model(); + switch (s.tag) { + case 'loading': + return { tag: 'Loading' }; + case 'failed': + return { tag: 'Failure', error: new Error(s.reason) }; + case 'loaded': + return { tag: 'Success', value: s }; + } + }); private brief = computed(() => { const s = this.model(); @@ -74,26 +107,28 @@ export class BriefStore { private async flushSave() { const b = this.brief(); if (!b) return; - this.saveState.set('saving'); + this.saveState.set({ tag: 'Saving' }); const r = await this.adapter.save(b.sections); if (r.ok) { - this.saveState.set('saved'); + this.saveState.set({ tag: 'Saved' }); } else { - this.lastError.set(r.error); - this.saveState.set('error'); + this.actionState.set({ tag: 'Failed', error: r.error }); + this.saveState.set({ tag: 'Error' }); } } /** Demo "start over": recreate the brief server-side and load the fresh view. */ async resetDemo() { - this.busy.set(true); - this.lastError.set(null); + this.actionState.set({ tag: 'Busy' }); clearTimeout(this.saveTimer); const r = await this.adapter.reset(); - this.busy.set(false); - this.saveState.set('idle'); - if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); - else this.lastError.set(r.error); + this.saveState.set({ tag: 'Idle' }); + if (r.ok) { + this.actionState.set({ tag: 'Idle' }); + this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); + } else { + this.actionState.set({ tag: 'Failed', error: r.error }); + } } submit = () => this.transition(() => this.adapter.submit()); @@ -104,16 +139,15 @@ export class BriefStore { // A transition: flush any pending save, call the server (authoritative), then mirror // the returned status through the pure reducer's guarded transition. private async transition(action: () => Promise>) { - this.busy.set(true); - this.lastError.set(null); + this.actionState.set({ tag: 'Busy' }); clearTimeout(this.saveTimer); await this.flushSave(); const r = await action(); - this.busy.set(false); if (!r.ok) { - this.lastError.set(r.error); + this.actionState.set({ tag: 'Failed', error: r.error }); return; } + this.actionState.set({ tag: 'Idle' }); this.applyServerStatus(r.value); } diff --git a/src/app/brief/ui/brief.page.ts b/src/app/brief/ui/brief.page.ts index 39c0ff4..226aaa4 100644 --- a/src/app/brief/ui/brief.page.ts +++ b/src/app/brief/ui/brief.page.ts @@ -1,8 +1,8 @@ import { Component, computed, inject } from '@angular/core'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; -import { SpinnerComponent } from '@shared/ui/spinner/spinner.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; +import { ASYNC } from '@shared/ui/async/async.component'; import { BriefStore } from '@brief/application/brief.store'; import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component'; @@ -11,13 +11,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos this just wires signals to the organism and events back to store commands. */ @Component({ selector: 'app-brief-page', - imports: [ - PageShellComponent, - SpinnerComponent, - AlertComponent, - ButtonComponent, - LetterComposerComponent, - ], + imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent], styles: [ ` .brief-toolbar { @@ -39,39 +33,38 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos {{ err }} } - @switch (model().tag) { - @case ('loading') { - - } - @case ('failed') { + + {{ failedText }} {{ retryText }} - } - @case ('loaded') { -
- {{ saveText() }} - {{ - resetLabel - }} -
- - } - } +
+ + @if (loaded(); as s) { +
+ {{ saveText() }} + {{ + resetLabel + }} +
+ + } +
+
`, }) @@ -92,12 +85,12 @@ export class BriefPage { /** Debounced-save state, surfaced in a polite live region. */ protected saveText = computed(() => { - switch (this.store.saveState()) { - case 'saving': + switch (this.store.saveState().tag) { + case 'Saving': return this.savingText; - case 'saved': + case 'Saved': return this.savedText; - case 'error': + case 'Error': return this.saveErrorText; default: return ''; @@ -112,15 +105,13 @@ export class BriefPage { void this.store.resetDemo(); } - // Narrow the loaded state for the template. - protected brief() { + /** Typed narrowing for the `` loaded slot — see WP-06: a structural + directive's context can't inherit a generic from a sibling host input, so the + Success value is unwrapped here instead of through `let-`. */ + protected readonly loaded = computed(() => { const s = this.model(); - return s.tag === 'loaded' ? s.brief : null; - } - protected availablePassages() { - const s = this.model(); - return s.tag === 'loaded' ? s.availablePassages : []; - } + return s.tag === 'loaded' ? s : undefined; + }); protected reload() { void this.store.load(); diff --git a/src/docs/remote-data.mdx b/src/docs/remote-data.mdx new file mode 100644 index 0000000..22d3ac9 --- /dev/null +++ b/src/docs/remote-data.mdx @@ -0,0 +1,97 @@ +import { Meta, Canvas } from '@storybook/addon-docs/blocks'; +import * as AsyncStories from '../app/shared/ui/async/async.stories'; + + + +# RemoteData & Async + +An async fetch has exactly four states: still loading, loaded-but-empty, failed, or +loaded-with-a-value. Modeling that as `loading`/`error`/`data` booleans permits nonsense +combinations ("loading **and** error", "data **and** error" — which one does the UI +believe?). `src/app/shared/application/remote-data.ts` closes that off with one tagged +union instead: + +```ts +type RemoteData = { tag: 'Loading' } | { tag: 'Empty' } | { tag: 'Failure'; error: E } | { tag: 'Success'; value: T }; +``` + +## Combining sources + +Two or more independent fetches often need to render as ONE state (e.g. a registration +call and a BRP call feeding the same page). `map`/`map2`/`map3`/`andThen` combine them +with one precedence rule: **Failure beats Loading beats Empty beats Success** — if either +source failed, the combined result is a failure; only when every source succeeded do you +get a combined value. + +```ts +map2(registration, person, (reg, p) => ({ registration: reg, person: p })); +``` + +## Rendering it: `` + + + + +`shared/ui/async` renders exactly one of the four templates — never two at once, by +construction, since the component switches on the union's tag. Feed it either: + +- **`[resource]`** — a raw Angular `resource()` (the common case; the component projects + it into a `RemoteData` internally via `fromResource`), or +- **`[data]`** — an already-combined `RemoteData` (e.g. from a store's `computed()` using + `map`/`map2`). + +The default loading UI is a spinner, delay-gated (~250ms) so a fast response never +flashes it; override with an `appAsyncLoading` template. `appAsyncEmpty` and +`appAsyncError` are likewise optional — omit them and you get a sensible default (a +"geen gegevens" message / an alert with a retry button). + +## The `appAsyncLoaded` slot isn't generically typed to your value + +This is a real Angular constraint, not an oversight: a structural directive's type +parameter can only be inferred from an **input bound on that same element** (this is how +`*ngFor="let x of items"` and `*ngIf="x as y"` work — the type comes from `ngForOf`/`ngIf`, +inputs on the very same tag). `` sits on a *different* +node than ``, so `p` cannot inherit a type from that sibling input, +even though they're nested in the same template. Angular types it `unknown`, and +`ngTemplateContextGuard` can't fix that without an input to seed it from — the shared +`AsyncComponent`/`AsyncLoadedDirective` pair is properly generic internally, but that +genericity stops at the component's own boundary. + +The idiom this repo uses instead — see `brief.page.ts`, `dashboard.page.ts`, +`registration-detail.page.ts` — is a small **typed `computed()`** that unwraps the +`Success` value, narrowed locally in the template with `@if (x(); as p)`: + +```ts +// in the component class +protected readonly loaded = computed(() => { + const s = this.model(); // or store.someRemoteData() + return s.tag === 'loaded' ? s : undefined; +}); +``` + +```html + +@if (loaded(); as s) { + +} +``` + +No `$any()`, no cast — `loaded()` is a real, checked `T | undefined`, and `@if (…; as s)` +narrows it the same way any other nullable signal would. + +## The `?scenario=` dev toggle + +Any data page can be forced through all four states without touching the backend: +`?scenario=slow|loading|empty|error` (dev-only, `scenario.interceptor.ts`) rewrites the +timing/outcome of `/api/*` calls. Try it on `/brief` or `/dashboard`. + +## Where the fetch ends and the domain begins + +A store's own state machine (its `*.machine.ts`) should own the **domain** lifecycle of +what it holds (draft → submitted → approved, in the brief's case) — not the network +fetch's loading/failure, which is a generic concern `RemoteData` already models. Where a +machine's own `loading`/`failed` tags purely mirror the fetch (nothing extra beyond "not +loaded yet" / "the GET failed"), project them onto a `RemoteData` computed at the store +layer for `` to render, the way `BriefStore.remoteData` does — the machine +keeps deciding what the *letter* is doing, `RemoteData` keeps deciding what the *fetch* is +doing.