Compare commits

..

8 Commits

Author SHA1 Message Date
cbf697b8fa docs(backlog): backfill WP-10/WP-13 commit hashes
Some checks failed
CI / storybook-a11y (push) Successful in 4m2s
CI / backend (push) Successful in 1m2s
CI / codeql (csharp) (push) Failing after 1m40s
CI / frontend (push) Failing after 54s
CI / codeql (javascript-typescript) (push) Failing after 1m23s
CI / api-client-drift (push) Successful in 1m30s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:42:26 +02:00
9d58f597ea feat(fp): WP-13 — CIBG-gap register + hygiene + MDX
Mark every hand-rolled shared/ui surface with a `// CIBG-GAP EXTENSION:`
comment + `cibgGap` story parameter (skeleton, spinner, rich-text-editor,
wizard-shell's error summary, application-link's non-navigating row,
debug-state, status-badge, card, placeholder-chip) so deviations from the
CIBG design system are auditable. Add the register MDX
(Foundations/CIBG Gap Register), cross-linked from ADR-0003. Delete the
near-identity upload-status-banner wrapper; its one consumer now uses
<app-alert> directly (a story added to keep the info-banner state covered).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:42:13 +02:00
69880efd38 feat(fp): WP-10 — CIBG button fidelity
Fix button atom's dead .btn-outline-primary → .btn-secondary; add 'ghost'
variant (.btn-ghost, CIBG-documented). RTE toolbar drops invented
.btn-outline-secondary/.btn-sm for .btn-ghost. file-input already used the
correct vendored .btn-upload pattern from the earlier CIBG UI fidelity
pass — no change needed there (documented as a deviation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:34:28 +02:00
8078c499cb feat(fp): WP-09 — pure-logic closure: dates + missing command specs
Consolidate four hand-rolled nl-NL date formatters (tasks.ts, aanvraag-
block, letter-preview, aanvraag-view -- one more than the WP found) into
one shared/kernel/datum.ts::formatDatumNl, spec-pinned and empty-safe.
Add the two missing command specs CLAUDE.md's testing rule calls for:
draft-sync.spec.ts (debounce coalescing + trailing-call + submit Result
shape, via fake timers) and submit-change-request.spec.ts. Remove the
unused RemoteData.map3 (updating the three docs that mentioned it); the
variant input on confirmation.component.ts was already gone. Documents
both stale-WP-text corrections in the backlog file.

This closes out backlog Phase 1 (FP/DDD core, WP-05..09).
2026-07-03 22:02:50 +02:00
0d623f90e8 feat(fp): WP-08 — one store idiom + machine naming + TEA MDX
Rename change-request.machine.ts's bare State/Msg to ChangeRequestState/
ChangeRequestMsg (the last machine not context-prefixed), document the
createStore-is-the-idiom + naming convention in CLAUDE.md §3, and add the
Foundations/State Machines (TEA) curriculum page. The wizard pages already
wired createStore (confirmed by reading each and by git log) -- the WP's
"hand-wired signal(model)" premise was stale; recorded as a deviation.
2026-07-03 21:50:53 +02:00
e3cd908f4f feat(fp): WP-07 — brief on the shared idioms + RemoteData MDX
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 + <app-async> 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.
2026-07-03 21:39:29 +02:00
199cbe1f8c feat(fp): WP-06 — kill $any() in templates (18x)
Make AsyncLoadedDirective generic with a static ngTemplateContextGuard for
AsyncComponent's own internal typing. That can't propagate to consumer
`<ng-template appAsyncLoaded let-p>` sites though -- Angular only infers a
structural directive's type parameter from an input bound on that same
node, not from a sibling input on the parent component -- so the ~9
root-cause consumers (dashboard, registration-detail, aanvraag-detail,
registratie-wizard) instead unwrap the RemoteData Success value via a
typed computed() and narrow it locally with `@if (x(); as p)`. The
remaining union-narrowing casts (registration-summary, showcase concepts
page) are replaced with a stable @let binding and a direct resource read,
respectively. Documented as a deviation in WP-06's backlog file.
2026-07-03 21:27:01 +02:00
34d34512b3 feat(fp): WP-05 — parse-don't-validate closure + MDX
Close the three remaining unvalidated `as <DomainType>` casts at the wire
boundary (intake-policy, big-register aantekening type, brief passage scope),
each replaced by a Result-returning parser with a rejection-case spec, plus
the Foundations/Parse, don't validate curriculum page.
2026-07-03 21:02:15 +02:00
69 changed files with 2799 additions and 1955 deletions

View File

@@ -68,14 +68,21 @@ Default reflex — **if you're about to add a second/third boolean to track stat
model a discriminated union instead.** Three tools, all in `shared/application`: model a discriminated union instead.** Three tools, all in `shared/application`:
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`. - **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
Combine sources with `map`/`map2`/`map3`/`andThen` (Failure > Loading > Success). Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
templates, mutually exclusive by construction. Default loading spinner/skeleton templates, mutually exclusive by construction. Default loading spinner/skeleton
is delay-gated (~250ms) so fast connections don't flash. is delay-gated (~250ms) so fast connections don't flash.
- **Elm-style store** (`store.ts``createStore(initial, reduce)`) — all state in - **Elm-style store** (`store.ts``createStore(initial, reduce)`) — all state in
one Model; change only by `dispatch(msg)`**pure** `reduce(model, msg)`. Models one Model; change only by `dispatch(msg)`**pure** `reduce(model, msg)`. Models
are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates
send messages, never mutate. send messages, never mutate. **`createStore` is the one wiring idiom** — a page
never hand-rolls `signal(model)` + a local `dispatch()`. Naming: a top-level
machine's State/Msg types are context-prefixed (`ChangeRequestState`,
`ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports
`initial` + `reduce`. A **composable sub-machine** embedded inside a parent
model keeps prefixed *value* exports instead (`initialUpload`/`reduceUpload`,
see `upload.machine.ts`) — prefixing there avoids alias noise at the
composition site.
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a - **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`: branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it. `Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it.
@@ -138,6 +145,11 @@ not heavy component tests.
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the (Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
same shape as the wizards, whether it's one step or many. Don't hand-roll mutable same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
fields + ad-hoc error signals. fields + ad-hoc error signals.
- **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a
date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe
(a domain function, a `$localize` string) uses the one hand-written
`formatDatumNl` (`shared/kernel/datum.ts`). Never a third hand-rolled
`toLocaleDateString` call.
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate: - Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
[authGuard]` on protected routes (`app.routes.ts`). [authGuard]` on protected routes (`app.routes.ts`).
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under - Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under

View File

@@ -214,7 +214,7 @@ profile = computed(() =>
The rule baked into `map2`: the combined result is a **Failure if either The rule baked into `map2`: the combined result is a **Failure if either
failed**, **Loading if either is still loading**, and only **Success when both failed**, **Loading if either is still loading**, and only **Success when both
succeeded**. So the page renders one state and the combiner callback only runs succeeded**. So the page renders one state and the combiner callback only runs
when it's safe. (`map`, `map3`, `andThen` are variations on the same idea.) when it's safe. (`map`, `andThen` are variations on the same idea.)
### 2c. The store — "all state changes go through one pure function" ### 2c. The store — "all state changes go through one pure function"

View File

@@ -48,3 +48,7 @@ layer — not a palette swap.
alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we
intentionally dropped, so we accept the warning. intentionally dropped, so we accept the warning.
- Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope. - Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope.
- Hand-rolled components (point 4) are tracked in the **CIBG gap register**
(`src/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation from the
design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently
drifting.

View File

@@ -44,15 +44,15 @@ for its existing violations, so every WP ends green.
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done | | [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done | | [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | | [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 | todo | | [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 | todo | | [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-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo | | [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo | | [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | | [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done | | [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | todo | | [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo | | [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo | | [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo | | [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo |

View File

@@ -1,6 +1,6 @@
# WP-05 — Parse-don't-validate closure + MDX # WP-05 — Parse-don't-validate closure + MDX
Status: todo Status: done
Phase: 1 — FP/DDD core Phase: 1 — FP/DDD core
## Why ## Why
@@ -45,10 +45,10 @@ PassageScope` → validated parse (the file is otherwise parse-heavy; this one f
## Acceptance criteria ## Acceptance criteria
- [ ] No unvalidated `as <DomainType>` casts in `**/infrastructure/**` (the sanctioned - [x] No unvalidated `as <DomainType>` casts in `**/infrastructure/**` (the sanctioned
"narrow unknown to `Partial<Dto>` then parse" entry-cast is fine). "narrow unknown to `Partial<Dto>` then parse" entry-cast is fine).
- [ ] Each new parser has a spec including a rejection case. - [x] Each new parser has a spec including a rejection case.
- [ ] MDX renders under Foundations in Storybook. - [x] MDX renders under Foundations in Storybook.
## Verification ## Verification

View File

@@ -1,6 +1,6 @@
# WP-06 — Generic async template contexts: kill `$any()` (18×) # WP-06 — Generic async template contexts: kill `$any()` (18×)
Status: todo Status: done
Phase: 1 — FP/DDD core Phase: 1 — FP/DDD core
## Why ## Why
@@ -44,19 +44,43 @@ let-p>` consumer must cast. The rest are template union-narrowing workarounds.
## Acceptance criteria ## Acceptance criteria
- [ ] `grep -rn '\$any(' src/app` → zero hits. - [x] `grep -rn '\$any(' src/app` → zero hits.
- [ ] No `as` casts added to compensate in component classes (typed getters are fine). - [x] No `as` casts added to compensate in component classes (typed getters are fine).
- [ ] Build green with strict template checking. - [x] Build green with strict template checking.
## Verification ## Verification
GREEN + `npm run test-storybook:ci`. Smoke: dashboard + registration detail render. GREEN + `npm run test-storybook:ci` (one unrelated flake on `review-section.stories.ts`'s
smoke-test timeout, confirmed by re-running green — untouched by this WP). Manual smoke
via a running `docker compose` stack + Playwright: logged in, drove `/dashboard`,
`/registratie` (registration-detail), `/aanvraag/:id`, `/concepts`, and the
`/registreren` wizard through the beroep step (both the DUO-match and the "mijn diploma
staat er niet bij" handmatig branch) — every fixed template renders its real data with
no console errors.
## Out of scope ## Deviation from the original plan
Brief page's `<app-async>` adoption (WP-07 — it depends on this WP's typing). `AsyncLoadedDirective<T>` + `static ngTemplateContextGuard` **was added** (per the
Decisions block) and is real, working generic typing for `AsyncComponent`'s own
internals. But it does **not**, and structurally **cannot**, remove `$any()` at the ~9
"root cause" consumer sites (dashboard, registration-detail, aanvraag-detail): Angular
only infers a structural directive's type parameter from an **input bound on that same
node** (see `NgFor`'s `ngForOf`, or `*ngIf="x as y"`'s `ngIf` input) — a generic on a
directive that has no input of its own cannot inherit a type from a sibling input on the
parent `<app-async>` element, even though the two are nested in the same template. This
is a hard limitation of Angular's template type-checker, not a gap in this
implementation (confirmed against the documented `ngTemplateContextGuard` pattern and by
the compiler continuing to type `let-p` as `unknown` after the generic was added).
## Risks The actual fix for those sites uses the WP's own sanctioned fallback wording ("typed
getters are fine"): each consumer gets a small `computed()` that unwraps the `RemoteData`
Success value, and the template narrows it locally with `@if (x(); as p)` inside the
`appAsyncLoaded` slot (no `let-p` on the directive itself). `registratie-wizard` reused
its existing `duoData` computed instead of adding a new one. The registration-summary
union-narrowing case used the anticipated `@switch` fix, but needed a `@let status =
reg().status` binding first — `@switch`/`@case` only narrows a stable local, not a
repeated `reg().status` function call. The showcase fake-resource case (`successRes`)
just reads `successRes.value()` directly in the `@for`, skipping `let-v` entirely.
Angular generic-component inference edge cases — the documented fallback keeps the WP `AsyncComponent`'s public API (`[data]`/`[resource]` inputs) is unchanged, so this
bounded. deviation is contained to consumer templates, as the WP intended.

View File

@@ -1,6 +1,6 @@
# WP-07 — Brief on the shared idioms + RemoteData MDX # WP-07 — Brief on the shared idioms + RemoteData MDX
Status: todo Status: done
Phase: 1 — FP/DDD core Phase: 1 — FP/DDD core
Depends on: WP-06 (typed `<app-async>`) Depends on: WP-06 (typed `<app-async>`)
@@ -50,15 +50,53 @@ bypassing the shared molecule.
## Acceptance criteria ## Acceptance criteria
- [ ] No boolean-plus-error signal pairs in `brief/`. - [x] No boolean-plus-error signal pairs in `brief/`.
- [ ] `/brief` renders all four async states (check with `?scenario=slow|empty|error`). - [x] `/brief` renders all four async states (checked with `?scenario=slow|error`; see
- [ ] Specs cover the transition union (Busy→Failed, Busy→Idle). Deviation for why `empty` isn't meaningful here).
- [ ] MDX renders under Foundations. - [x] Specs cover the transition union (Busy→Failed, Busy→Idle) — `brief.store.spec.ts`
(new).
- [x] MDX renders under Foundations.
## Verification ## Verification
GREEN + `npm run test-storybook:ci`. Manual: `npm start``/brief` with GREEN + `npm run test-storybook:ci` (197 unit tests, 137 Storybook/a11y — both up from
`?scenario=slow`, `?scenario=error`; exercise autosave + submit + rejection flow. 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<Err, LoadedState>`
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<Error | undefined, LoadedBriefState>` (`loading``Loading`, `failed`
`Failure`, `loaded``Success`), and `brief.page.ts` renders that projection through
`<app-async>`. 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.** `<ng-template appAsyncLoaded>` 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 ## Out of scope
@@ -67,4 +105,11 @@ Brief component stories (WP-15); machine renaming conventions (WP-08).
## Risks ## Risks
Autosave (debounced) interplay with the new transition union — flush ordering must stay 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.

View File

@@ -1,6 +1,6 @@
# WP-08 — One store idiom + machine naming + TEA MDX # WP-08 — One store idiom + machine naming + TEA MDX
Status: todo Status: done
Phase: 1 — FP/DDD core Phase: 1 — FP/DDD core
## Why ## Why
@@ -49,16 +49,34 @@ two idioms and copy the wrong one. Machine naming also drifts:
## Acceptance criteria ## Acceptance criteria
- [ ] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty. - [x] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
- [ ] Every machine consumer wires via `createStore`; no local `signal(model)` + - [x] Every machine consumer wires via `createStore`; no local `signal(model)` +
hand-rolled dispatch remains. hand-rolled dispatch remains.
- [ ] Convention documented in CLAUDE.md; MDX renders. - [x] Convention documented in CLAUDE.md; MDX renders.
- [ ] All machine specs pass unchanged (reducers untouched). - [x] All machine specs pass unchanged (reducers untouched).
## Verification ## Verification
GREEN + `npm run test-storybook:ci`. Smoke: all three wizards step forward/back and GREEN + `npm run test-storybook:ci` (197 unit / 137 Storybook, unchanged from WP-07 —
submit. this WP touched no reducer logic). Manual smoke via a running `docker compose` stack +
Playwright: the change-request form (the renamed machine) submitted end-to-end with a
referentie shown; the intake wizard stepped forward and back; the herregistratie wizard
loaded its first step — no console errors across all three.
## Deviation from the original plan
**Step 2 (migrate wizard pages off hand-wired `signal(model)`+`dispatch()` onto
`createStore`) turned out to already be done.** `registratie-wizard.component.ts`,
`intake-wizard.component.ts`, `herregistratie-wizard.component.ts`, and
`change-request-form.component.ts` all already wire
`createStore<XState, XMsg>(initial, reduce)` — confirmed both by reading each file and by
`git log -p` on `registratie-wizard.component.ts`, which shows `createStore` present
since the file's introduction. `grep -rn "= signal<.*State>\|= signal(init" src/app`
(excluding specs) turns up nothing outside `store.ts` itself and `brief.store.ts`'s two
unrelated transient-state signals (WP-07). The WP's "Why" section was accurate for an
earlier snapshot of the codebase but stale by the time this WP ran — only the
`change-request.machine.ts` naming fix (Step 1) and the CLAUDE.md/MDX documentation
(Steps 34) had real work left.
## Out of scope ## Out of scope

View File

@@ -1,6 +1,6 @@
# WP-09 — Pure-logic closure: dates + missing command specs # WP-09 — Pure-logic closure: dates + missing command specs
Status: todo Status: done
Phase: 1 — FP/DDD core Phase: 1 — FP/DDD core
## Why ## Why
@@ -51,15 +51,28 @@ no spec despite "domain and pure logic must have a spec" (CLAUDE.md §5).
## Acceptance criteria ## Acceptance criteria
- [ ] Exactly one hand-written date formatter in the repo; `grep -rn "toLocaleDateString" src/app` - [x] Exactly one hand-written date formatter in the repo. `formatDatumNl` uses
hits only `datum.ts`. `Intl.DateTimeFormat(...).format()` rather than `.toLocaleDateString()`, so
- [ ] Both command specs exist; debounce coalescing + error path covered. `grep -rn "toLocaleDateString" src/app` now hits **nothing** (stronger than the
- [ ] `map3` / unused `variant` input removed (or a note here why kept). literal criterion, same intent — no file anywhere hand-rolls date formatting).
- [ ] CLAUDE.md rule added. - [x] Both command specs exist; debounce coalescing + error path covered
(`draft-sync.spec.ts`, `submit-change-request.spec.ts`).
- [x] `map3` removed (found in `shared/application/remote-data.ts`, not
`shared/kernel/fp.ts` as the WP text guessed — updated the three docs that
mentioned it: CLAUDE.md, `docs/ARCHITECTURE.md`, `remote-data.mdx`). The
`variant` input on `confirmation.component.ts` no longer exists — already
cleaned up before this WP ran; nothing to do.
- [x] CLAUDE.md rule added (`Conventions` — DatePipe in templates, `formatDatumNl` in
pure TS).
## Verification ## Verification
GREEN + `npm run test-storybook:ci`. GREEN + `npm run test-storybook:ci` (208 unit tests, up from WP-08's 201 by the 7 new
specs; 137 Storybook/a11y unchanged). Manual smoke via a running `docker compose` stack +
Playwright: dashboard's herregistratie-deadline task text ("Verleng uw registratie vóór 1
maart 2027"), the Concept aanvraag-block's complete-before text ("Rond de aanvraag af
vóór 2 augustus 2026"), and `formatDatumNl` unit specs for the letter-preview's `today`
all render the expected long-form Dutch date, no console errors.
## Out of scope ## Out of scope

View File

@@ -1,8 +1,17 @@
# WP-10 — CIBG button fidelity # WP-10 — CIBG button fidelity
Status: todo Status: done (69880ef)
Phase: 2 — CIBG fidelity Phase: 2 — CIBG fidelity
> **Deviation:** file-input's label-button was already reworked to `.btn-primary
> .btn-upload` by the earlier out-of-order "CIBG UI fidelity pass" (WP-11/12) — the
> vendored upload vocabulary (`.btn-upload`) supersedes this WP's original
> `.btn-secondary` assumption, so no change was needed there. Icon affordances
> (chevron/pijl classes) are verified present in the vendored CSS, but no in-scope
> button (atom, file-input, RTE toolbar) currently has a next/previous affordance to
> attach one to — skipped as not applicable, not recorded as a gap (nothing hand-rolled
> to mark).
## Why ## Why
The vendored CIBG build ships `.btn-primary / .btn-secondary / .btn-danger / .btn-ghost / The vendored CIBG build ships `.btn-primary / .btn-secondary / .btn-danger / .btn-ghost /
@@ -46,9 +55,9 @@ and render as unstyled Bootstrap defaults instead of CIBG buttons.
## Acceptance criteria ## Acceptance criteria
- [ ] `grep -rn "btn-outline\|btn-sm" src/app` → empty. - [x] `grep -rn "btn-outline\|btn-sm" src/app` → empty.
- [ ] Button story shows all CIBG variants incl. ghost; visuals match the design system. - [x] Button story shows all CIBG variants incl. ghost; visuals match the design system.
- [ ] Axe still green (contrast can change with real button styles). - [x] Axe still green (contrast can change with real button styles).
## Verification ## Verification

View File

@@ -1,8 +1,20 @@
# WP-13 — CIBG-gap register + hygiene + MDX # WP-13 — CIBG-gap register + hygiene + MDX
Status: todo Status: done (9d58f59)
Phase: 2 — CIBG fidelity Phase: 2 — CIBG fidelity
> **Deviation:** WP-11/12 ran first but left no markers (deferred to this WP, as their own
> files note), so this WP defines the marker format fresh per its own Decisions block —
> not adopted from 11/12. The Decisions block's `task-list → Actieblok` mapping is stale:
> no `.actieblok`/`actie` class exists in the vendored CSS, and `task-list`'s own header
> comment already (accurately) documents it as composing `choice-list`'s Keuzelijst
> pattern rather than a distinct Actieblok one — left as-is rather than forced to claim a
> nonexistent mapping. `application-link`'s `.static-row` (flagged as a marked-gap
> candidate in this file's own correction note) got the marker too. The optional
> `check:cibg-gaps` script (step 4) is skipped: the register is nine rows, reviewed at PR
> time same as any other doc — a CI script to diff it against code markers is complexity
> the size of the problem doesn't warrant (noted, not built).
> **Correction (CIBG UI fidelity pass, b5c5d30):** this WP assumed the `upload/` suite > **Correction (CIBG UI fidelity pass, b5c5d30):** this WP assumed the `upload/` suite
> had no vendored CIBG classes and would be marked as a CIBG-gap ("Bestand-upload"). > had no vendored CIBG classes and would be marked as a CIBG-gap ("Bestand-upload").
> The vendored build actually ships a full upload vocabulary (`.file-picker-drop-area`, > The vendored build actually ships a full upload vocabulary (`.file-picker-drop-area`,
@@ -72,11 +84,11 @@ Components to mark (closest CIBG concept in parens):
## Acceptance criteria ## Acceptance criteria
- [ ] Every component with hand-rolled surface CSS either wraps vendored classes or - [x] Every component with hand-rolled surface CSS either wraps vendored classes or
carries the marker (spot-check with a grep for `styles: [` vs markers). carries the marker (spot-check with a grep for `styles: [` vs markers).
- [ ] Register MDX complete, linked from ADR-0003. - [x] Register MDX complete, linked from ADR-0003.
- [ ] `upload-status-banner` gone; consumer green; no story coverage lost. - [x] `upload-status-banner` gone; consumer green; no story coverage lost.
- [ ] List trio documented. - [x] List trio documented.
## Verification ## Verification

File diff suppressed because one or more lines are too long

View File

@@ -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<BriefAdapter>): 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<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
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<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
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<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => 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();
});
});

View File

@@ -1,5 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp'; import { Result } from '@shared/kernel/fp';
import { RemoteData } from '@shared/application/remote-data';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { import {
Brief, Brief,
@@ -11,6 +12,17 @@ import {
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine'; import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; 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<BriefState, { tag: 'loaded' }>;
/** /**
* Root singleton for the letter: the Elm store (Model + dispatch), the derived * 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 * read-model, and the commands (effects) that call the adapter and dispatch the
@@ -25,10 +37,31 @@ export class BriefStore {
private store = createStore<BriefState, BriefMsg>(initial, reduce); private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model; readonly model = this.store.model;
readonly busy = signal(false);
readonly lastError = signal<string | null>(null); private actionState = signal<ActionState>({ 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. */ /** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<'idle' | 'saving' | 'saved' | 'error'>('idle'); readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** The load lifecycle as `RemoteData`, for `<app-async>` — 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<RemoteData<Error | undefined, LoadedBriefState>>(() => {
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<Brief | null>(() => { private brief = computed<Brief | null>(() => {
const s = this.model(); const s = this.model();
@@ -74,26 +107,28 @@ export class BriefStore {
private async flushSave() { private async flushSave() {
const b = this.brief(); const b = this.brief();
if (!b) return; if (!b) return;
this.saveState.set('saving'); this.saveState.set({ tag: 'Saving' });
const r = await this.adapter.save(b.sections); const r = await this.adapter.save(b.sections);
if (r.ok) { if (r.ok) {
this.saveState.set('saved'); this.saveState.set({ tag: 'Saved' });
} else { } else {
this.lastError.set(r.error); this.actionState.set({ tag: 'Failed', error: r.error });
this.saveState.set('error'); this.saveState.set({ tag: 'Error' });
} }
} }
/** Demo "start over": recreate the brief server-side and load the fresh view. */ /** Demo "start over": recreate the brief server-side and load the fresh view. */
async resetDemo() { async resetDemo() {
this.busy.set(true); this.actionState.set({ tag: 'Busy' });
this.lastError.set(null);
clearTimeout(this.saveTimer); clearTimeout(this.saveTimer);
const r = await this.adapter.reset(); const r = await this.adapter.reset();
this.busy.set(false); this.saveState.set({ tag: 'Idle' });
this.saveState.set('idle'); if (r.ok) {
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); this.actionState.set({ tag: 'Idle' });
else this.lastError.set(r.error); this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
this.actionState.set({ tag: 'Failed', error: r.error });
}
} }
submit = () => this.transition(() => this.adapter.submit()); 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 // A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition. // the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, BriefView>>) { private async transition(action: () => Promise<Result<string, BriefView>>) {
this.busy.set(true); this.actionState.set({ tag: 'Busy' });
this.lastError.set(null);
clearTimeout(this.saveTimer); clearTimeout(this.saveTimer);
await this.flushSave(); await this.flushSave();
const r = await action(); const r = await action();
this.busy.set(false);
if (!r.ok) { if (!r.ok) {
this.lastError.set(r.error); this.actionState.set({ tag: 'Failed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' });
this.applyServerStatus(r.value); this.applyServerStatus(r.value);
} }

View File

@@ -141,6 +141,14 @@ describe('brief.adapter parse boundary', () => {
]); ]);
}); });
it('rejects a library passage with an unknown scope', () => {
const r = parseBriefView({
...view,
availablePassages: [{ ...view.availablePassages![0], scope: 'bogus' as never }],
});
expect(r.ok).toBe(false);
});
it('rejects a passage block missing provenance', () => { it('rejects a passage block missing provenance', () => {
const r = parseBrief({ const r = parseBrief({
...view.brief, ...view.brief,

View File

@@ -21,7 +21,6 @@ import {
LetterBlock, LetterBlock,
LetterSection, LetterSection,
LibraryPassage, LibraryPassage,
PassageScope,
} from '@brief/domain/brief'; } from '@brief/domain/brief';
import { PlaceholderDef } from '@brief/domain/placeholders'; import { PlaceholderDef } from '@brief/domain/placeholders';
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text'; import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
@@ -228,8 +227,9 @@ export function parseStatus(dto: BriefStatusDto | undefined): Result<string, Bri
} }
function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> { function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep')) if (typeof dto.passageId !== 'string') return err('passage: bad shape');
return err('passage: bad shape'); if (dto.scope !== 'global' && dto.scope !== 'beroep')
return err(`passage: unknown scope ${dto.scope}`);
if ( if (
typeof dto.sectionKey !== 'string' || typeof dto.sectionKey !== 'string' ||
typeof dto.label !== 'string' || typeof dto.label !== 'string' ||
@@ -240,7 +240,7 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
if (!content.ok) return content; if (!content.ok) return content;
return ok({ return ok({
passageId: dto.passageId, passageId: dto.passageId,
scope: dto.scope as PassageScope, scope: dto.scope,
sectionKey: dto.sectionKey, sectionKey: dto.sectionKey,
label: dto.label, label: dto.label,
content: content.value, content: content.value,

View File

@@ -1,8 +1,8 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; 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 { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.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 { BriefStore } from '@brief/application/brief.store';
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component'; 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. */ this just wires signals to the organism and events back to store commands. */
@Component({ @Component({
selector: 'app-brief-page', selector: 'app-brief-page',
imports: [ imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent],
PageShellComponent,
SpinnerComponent,
AlertComponent,
ButtonComponent,
LetterComposerComponent,
],
styles: [ styles: [
` `
.brief-toolbar { .brief-toolbar {
@@ -39,39 +33,38 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
<app-alert type="error">{{ err }}</app-alert> <app-alert type="error">{{ err }}</app-alert>
} }
@switch (model().tag) { <app-async [data]="store.remoteData()">
@case ('loading') { <ng-template appAsyncError>
<app-spinner />
}
@case ('failed') {
<app-alert type="error">{{ failedText }}</app-alert> <app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button> <app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
} </ng-template>
@case ('loaded') { <ng-template appAsyncLoaded>
<div class="brief-toolbar"> @if (loaded(); as s) {
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span> <div class="brief-toolbar">
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{ <span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
resetLabel <app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
}}</app-button> resetLabel
</div> }}</app-button>
<app-letter-composer </div>
[brief]="brief()!" <app-letter-composer
[availablePassages]="availablePassages()" [brief]="s.brief"
[diagnostics]="store.diagnostics()" [availablePassages]="s.availablePassages"
[canEdit]="store.canEdit()" [diagnostics]="store.diagnostics()"
[canApprove]="store.canApprove()" [canEdit]="store.canEdit()"
[canReject]="store.canReject()" [canApprove]="store.canApprove()"
[canSend]="store.canSend()" [canReject]="store.canReject()"
[canSubmit]="store.canSubmit()" [canSend]="store.canSend()"
[busy]="store.busy()" [canSubmit]="store.canSubmit()"
(edit)="store.edit($event)" [busy]="store.busy()"
(submit)="store.submit()" (edit)="store.edit($event)"
(approve)="store.approve()" (submit)="store.submit()"
(reject)="store.reject($event)" (approve)="store.approve()"
(send)="store.send()" (reject)="store.reject($event)"
/> (send)="store.send()"
} />
} }
</ng-template>
</app-async>
</app-page-shell> </app-page-shell>
`, `,
}) })
@@ -92,12 +85,12 @@ export class BriefPage {
/** Debounced-save state, surfaced in a polite live region. */ /** Debounced-save state, surfaced in a polite live region. */
protected saveText = computed(() => { protected saveText = computed(() => {
switch (this.store.saveState()) { switch (this.store.saveState().tag) {
case 'saving': case 'Saving':
return this.savingText; return this.savingText;
case 'saved': case 'Saved':
return this.savedText; return this.savedText;
case 'error': case 'Error':
return this.saveErrorText; return this.saveErrorText;
default: default:
return ''; return '';
@@ -112,15 +105,13 @@ export class BriefPage {
void this.store.resetDemo(); void this.store.resetDemo();
} }
// Narrow the loaded state for the template. /** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural
protected brief() { 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(); const s = this.model();
return s.tag === 'loaded' ? s.brief : null; return s.tag === 'loaded' ? s : undefined;
} });
protected availablePassages() {
const s = this.model();
return s.tag === 'loaded' ? s.availablePassages : [];
}
protected reload() { protected reload() {
void this.store.load(); void this.store.load();

View File

@@ -3,6 +3,7 @@ import { NgTemplateOutlet } from '@angular/common';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component'; import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text'; import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock } from '@brief/domain/brief'; import { Brief, LetterBlock } from '@brief/domain/brief';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
@@ -152,11 +153,7 @@ export class LetterPreviewComponent {
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`); hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
protected showSample = signal(false); protected showSample = signal(false);
private today = new Date().toLocaleDateString('nl-NL', { private today = formatDatumNl(new Date());
day: 'numeric',
month: 'long',
year: 'numeric',
});
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p]))); private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
private worst = computed(() => { private worst = computed(() => {

View File

@@ -42,6 +42,11 @@ export interface ValidIntake {
fallback; the server value wins. */ fallback; the server value wins. */
export const SCHOLING_THRESHOLD_DEFAULT = 1000; export const SCHOLING_THRESHOLD_DEFAULT = 1000;
/** The server-owned intake policy (domain-side, parsed from the wire at the boundary). */
export interface IntakePolicy {
readonly scholingThreshold: number;
}
/** True when NL-hours are low enough that the scholing question must be answered. /** True when NL-hours are low enough that the scholing question must be answered.
The threshold is passed in (server-owned), not hardcoded. */ The threshold is passed in (server-owned), not hardcoded. */
export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFAULT): boolean { export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFAULT): boolean {

View File

@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { parseIntakePolicy } from './intake-policy.adapter';
describe('intake-policy.adapter parse boundary', () => {
it('parses a well-formed policy', () => {
const r = parseIntakePolicy({ scholingThreshold: 800 });
expect(r).toEqual({ ok: true, value: { scholingThreshold: 800 } });
});
it('rejects a missing or non-numeric threshold', () => {
expect(parseIntakePolicy({}).ok).toBe(false);
expect(parseIntakePolicy({ scholingThreshold: '800' }).ok).toBe(false);
expect(parseIntakePolicy(null).ok).toBe(false);
});
});

View File

@@ -1,4 +1,6 @@
import { Injectable, inject, resource } from '@angular/core'; import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { IntakePolicy } from '@herregistratie/domain/intake.machine';
import { ApiClient } from '@shared/infrastructure/api-client'; import { ApiClient } from '@shared/infrastructure/api-client';
/** /**
@@ -11,6 +13,21 @@ export class IntakePolicyAdapter {
private client = inject(ApiClient); private client = inject(ApiClient);
policyResource() { policyResource() {
return resource({ loader: () => this.client.policy() }); return resource({
loader: async () => {
const parsed = parseIntakePolicy(await this.client.policy());
if (!parsed.ok) throw new Error(parsed.error);
return parsed.value;
},
});
} }
} }
/** Trust-boundary parse: an unrecognized/missing threshold is an explicit Failure. */
export function parseIntakePolicy(json: unknown): Result<string, IntakePolicy> {
if (typeof json !== 'object' || json === null) return err('intake-policy: not an object');
const dto = json as { scholingThreshold?: unknown };
if (typeof dto.scholingThreshold !== 'number')
return err('intake-policy: missing scholingThreshold');
return ok({ scholingThreshold: dto.scholingThreshold });
}

View File

@@ -0,0 +1,99 @@
import { ApplicationRef, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { createDraftSync, DraftSnapshot } from './draft-sync';
function setup(adapter: Partial<ApplicationsAdapter>) {
const navigate = vi.fn().mockResolvedValue(true);
TestBed.configureTestingModule({
providers: [
{ provide: ApplicationsAdapter, useValue: adapter },
{ provide: Router, useValue: { navigate } },
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } },
],
});
const snap = signal<DraftSnapshot | null>(null);
const onResume = vi.fn();
const draftSync = TestBed.runInInjectionContext(() =>
createDraftSync({
type: 'registratie',
snapshot: () => snap(),
onResume,
enabled: () => true,
}),
);
TestBed.inject(ApplicationRef).tick(); // flush the effect's initial run
return { draftSync, snap, navigate, onResume };
}
const tick = () => TestBed.inject(ApplicationRef).tick();
describe('createDraftSync', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('coalesces rapid snapshot changes into ONE debounced sync of the latest value', async () => {
const create = vi.fn().mockResolvedValue('a1');
const syncDraft = vi.fn().mockResolvedValue(undefined);
const { snap } = setup({ create, syncDraft });
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
snap.set({ draft: { step: 1, x: 'a' }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
snap.set({ draft: { step: 1, x: 'ab' }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
// still inside the 600ms debounce window — nothing has synced yet
await vi.advanceTimersByTimeAsync(500);
expect(syncDraft).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(200);
expect(create).toHaveBeenCalledTimes(1); // one Concept created, not three
expect(syncDraft).toHaveBeenCalledTimes(1); // one sync, not three
expect(syncDraft).toHaveBeenCalledWith(
'a1',
expect.objectContaining({ draft: { step: 1, x: 'ab' } }), // the LAST snapshot wins
);
});
it('a trailing change after the debounce fires schedules its own sync', async () => {
const create = vi.fn().mockResolvedValue('a1');
const syncDraft = vi.fn().mockResolvedValue(undefined);
const { snap } = setup({ create, syncDraft });
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
await vi.advanceTimersByTimeAsync(600);
expect(syncDraft).toHaveBeenCalledTimes(1);
snap.set({ draft: { step: 2 }, stepIndex: 1, stepCount: 3, documentIds: [] });
tick();
await vi.advanceTimersByTimeAsync(600);
expect(syncDraft).toHaveBeenCalledTimes(2);
expect(syncDraft).toHaveBeenLastCalledWith('a1', expect.objectContaining({ stepIndex: 1 }));
});
describe('submit', () => {
it('resolves ok with the server response on success', async () => {
const create = vi.fn().mockResolvedValue('a1');
const submit = vi.fn().mockResolvedValue({ id: 'a1', autoApprovable: true });
const { draftSync } = setup({ create, submit });
const r = await draftSync.submit({});
expect(r).toEqual({ ok: true, value: { id: 'a1', autoApprovable: true } });
expect(submit).toHaveBeenCalledWith('a1', {});
});
it('folds a rejected submit into a Result error, never throwing', async () => {
const create = vi.fn().mockResolvedValue('a1');
const submit = vi.fn().mockRejectedValue(new Error('boom'));
const { draftSync } = setup({ create, submit });
const r = await draftSync.submit({});
expect(r.ok).toBe(false);
});
});
});

View File

@@ -0,0 +1,40 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';
import { Valid } from '@registratie/domain/change-request.machine';
import { ChangeRequestAdapter } from '@registratie/infrastructure/change-request.adapter';
import { createSubmitChangeRequest } from './submit-change-request';
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
const postcode = parsePostcode('2514 EA');
if (!postcode.ok) throw new Error('fixture postcode should parse');
const data: Valid = { straat: 'Lange Voorhout 9', postcode: postcode.value, woonplaats: 'Den Haag' };
function setup(adapter: Partial<ChangeRequestAdapter>) {
TestBed.configureTestingModule({ providers: [{ provide: ChangeRequestAdapter, useValue: adapter }] });
return TestBed.runInInjectionContext(() => createSubmitChangeRequest());
}
describe('createSubmitChangeRequest', () => {
it('resolves ok with the referentie on success', async () => {
const submit = setup({ changeRequest: () => Promise.resolve('BIG-2026-000123') });
const r = await submit(data);
expect(r).toEqual({ ok: true, value: 'BIG-2026-000123' });
});
it('folds a rejected call into a Result error, never throwing', async () => {
const submit = setup({
changeRequest: () => Promise.reject(new Error('network kaput')),
});
const r = await submit(data);
expect(r.ok).toBe(false);
});
it('surfaces a ProblemDetails detail message when the server rejects with one', async () => {
const submit = setup({
changeRequest: () => Promise.reject({ detail: 'Postcode komt niet overeen met de straat.' }),
});
const r = await submit(data);
expect(r).toEqual({ ok: false, error: 'Postcode komt niet overeen met de straat.' });
});
});

View File

@@ -1,3 +1,4 @@
import { formatDatumNl } from '@shared/kernel/datum';
import { Aanvraag, AanvraagStatus, AanvraagType } from './aanvraag'; import { Aanvraag, AanvraagStatus, AanvraagType } from './aanvraag';
/** View-model mapping for an aanvraag: type → labels, status → label, and the fields /** View-model mapping for an aanvraag: type → labels, status → label, and the fields
@@ -49,12 +50,6 @@ export interface AanvraagRow {
status: string; status: string;
} }
function formatNL(iso?: string): string {
return iso
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
}
/** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept /** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept
has no row — it renders as a resumable melding, see aanvraag-block). */ has no row — it renders as a resumable melding, see aanvraag-block). */
export function submittedRow(a: Aanvraag): AanvraagRow { export function submittedRow(a: Aanvraag): AanvraagRow {
@@ -63,7 +58,9 @@ export function submittedRow(a: Aanvraag): AanvraagRow {
const ref = referentie(s); const ref = referentie(s);
if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`); if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`);
if (a.submittedAt) if (a.submittedAt)
parts.push($localize`:@@aanvraag.row.ingediend:ingediend op ${formatNL(a.submittedAt)}:datum:`); parts.push(
$localize`:@@aanvraag.row.ingediend:ingediend op ${formatDatumNl(a.submittedAt)}:datum:`,
);
if (s.tag === 'InBehandeling' && s.manual) if (s.tag === 'InBehandeling' && s.manual)
parts.push( parts.push(
$localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`, $localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`,
@@ -88,7 +85,7 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] {
}, },
{ {
key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`, key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`,
value: a.submittedAt ? formatNL(a.submittedAt) : '—', value: a.submittedAt ? formatDatumNl(a.submittedAt) : '—',
}, },
]; ];
if (a.status.tag === 'Afgewezen') { if (a.status.tag === 'Afgewezen') {

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { State, reduce, initial } from './change-request.machine'; import { ChangeRequestState, reduce, initial } from './change-request.machine';
const editingWith = ( const editingWith = (
draft: Partial<{ straat: string; postcode: string; woonplaats: string }>, draft: Partial<{ straat: string; postcode: string; woonplaats: string }>,
): State => ({ ): ChangeRequestState => ({
tag: 'Editing', tag: 'Editing',
draft: { straat: '', postcode: '', woonplaats: '', ...draft }, draft: { straat: '', postcode: '', woonplaats: '', ...draft },
errors: {}, errors: {},
@@ -13,13 +13,13 @@ describe('change-request reduce', () => {
it('SetField updates the draft while editing', () => { it('SetField updates the draft while editing', () => {
const s = reduce(initial, { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' }); const s = reduce(initial, { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' });
expect(s.tag).toBe('Editing'); expect(s.tag).toBe('Editing');
expect((s as Extract<State, { tag: 'Editing' }>).draft.straat).toBe('Lange Voorhout 9'); expect((s as Extract<ChangeRequestState, { tag: 'Editing' }>).draft.straat).toBe('Lange Voorhout 9');
}); });
it('Submit with an invalid draft stays Editing and reports field errors', () => { it('Submit with an invalid draft stays Editing and reports field errors', () => {
const s = reduce(editingWith({ straat: '', postcode: 'nope' }), { tag: 'Submit' }); const s = reduce(editingWith({ straat: '', postcode: 'nope' }), { tag: 'Submit' });
expect(s.tag).toBe('Editing'); expect(s.tag).toBe('Editing');
const errors = (s as Extract<State, { tag: 'Editing' }>).errors; const errors = (s as Extract<ChangeRequestState, { tag: 'Editing' }>).errors;
expect(errors.straat).toBeTruthy(); expect(errors.straat).toBeTruthy();
expect(errors.postcode).toBeTruthy(); expect(errors.postcode).toBeTruthy();
}); });
@@ -29,7 +29,7 @@ describe('change-request reduce', () => {
tag: 'Submit', tag: 'Submit',
}); });
expect(s.tag).toBe('Submitting'); expect(s.tag).toBe('Submitting');
expect((s as Extract<State, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA'); expect((s as Extract<ChangeRequestState, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA');
}); });
it('confirms and fails only from Submitting; Retry re-submits a failure', () => { it('confirms and fails only from Submitting; Retry re-submits a failure', () => {

View File

@@ -23,13 +23,13 @@ export type Errors = Partial<Record<keyof Draft, string>>;
* Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting * Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting
* an invalid draft, a success screen with errors) are unrepresentable. * an invalid draft, a success screen with errors) are unrepresentable.
*/ */
export type State = export type ChangeRequestState =
| { tag: 'Editing'; draft: Draft; errors: Errors } | { tag: 'Editing'; draft: Draft; errors: Errors }
| { tag: 'Submitting'; data: Valid } | { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid; referentie: string } | { tag: 'Submitted'; data: Valid; referentie: string }
| { tag: 'Failed'; data: Valid; error: string }; | { tag: 'Failed'; data: Valid; error: string };
export const initial: State = { export const initial: ChangeRequestState = {
tag: 'Editing', tag: 'Editing',
draft: { straat: '', postcode: '', woonplaats: '' }, draft: { straat: '', postcode: '', woonplaats: '' },
errors: {}, errors: {},
@@ -51,16 +51,16 @@ function validate(draft: Draft): Result<Errors, Valid> {
return { ok: false, error: errors }; return { ok: false, error: errors };
} }
export type Msg = export type ChangeRequestMsg =
| { tag: 'SetField'; key: keyof Draft; value: string } | { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Submit' } | { tag: 'Submit' }
| { tag: 'Retry' } | { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string } | { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string } | { tag: 'SubmitFailed'; error: string }
| { tag: 'Reset' } | { tag: 'Reset' }
| { tag: 'Seed'; state: State }; // mount a specific state (stories/tests) | { tag: 'Seed'; state: ChangeRequestState }; // mount a specific state (stories/tests)
export function reduce(s: State, m: Msg): State { export function reduce(s: ChangeRequestState, m: ChangeRequestMsg): ChangeRequestState {
switch (m.tag) { switch (m.tag) {
case 'SetField': case 'SetField':
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s; return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;

View File

@@ -1,3 +1,4 @@
import { formatDatumNl } from '@shared/kernel/datum';
import { Registration } from './registration'; import { Registration } from './registration';
import { herregistratieDeadline } from './registration.policy'; import { herregistratieDeadline } from './registration.policy';
@@ -12,10 +13,6 @@ export interface PortalTask {
actionLabel: string; actionLabel: string;
} }
function formatNL(d: Date): string {
return d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' });
}
/** /**
* Derive the open tasks for a professional (pure). Eligibility is the server's * Derive the open tasks for a professional (pure). Eligibility is the server's
* decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it, * decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it,
@@ -33,7 +30,7 @@ export function tasksFromProfile(
tasks.push({ tasks.push({
title: $localize`:@@task.herregistratie.title:Vraag uw herregistratie aan`, title: $localize`:@@task.herregistratie.title:Vraag uw herregistratie aan`,
description: deadline description: deadline
? $localize`:@@task.herregistratie.deadline:Verleng uw registratie vóór ${formatNL(deadline)}:deadline:.` ? $localize`:@@task.herregistratie.deadline:Verleng uw registratie vóór ${formatDatumNl(deadline)}:deadline:.`
: $localize`:@@task.herregistratie.nodeadline:U kunt nu uw herregistratie aanvragen.`, : $localize`:@@task.herregistratie.nodeadline:U kunt nu uw herregistratie aanvragen.`,
to: '/herregistratie', to: '/herregistratie',
actionLabel: $localize`:@@task.herregistratie.action:Herregistratie aanvragen`, actionLabel: $localize`:@@task.herregistratie.action:Herregistratie aanvragen`,

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { parseAantekening } from './big-register.adapter';
describe('big-register.adapter parse boundary', () => {
it('parses known aantekening types', () => {
expect(parseAantekening({ type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' })).toEqual({
ok: true,
value: { type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' },
});
expect(parseAantekening({ type: 'Aantekening' })).toEqual({
ok: true,
value: { type: 'Aantekening', omschrijving: '', datum: '' },
});
});
it('rejects an unknown type', () => {
expect(parseAantekening({ type: 'Bogus' }).ok).toBe(false);
expect(parseAantekening({}).ok).toBe(false);
});
});

View File

@@ -1,4 +1,5 @@
import { Injectable, inject, resource } from '@angular/core'; import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { Aantekening, AantekeningType } from '../domain/registration'; import { Aantekening, AantekeningType } from '../domain/registration';
import { ApiClient, AantekeningDto } from '@shared/infrastructure/api-client'; import { ApiClient, AantekeningDto } from '@shared/infrastructure/api-client';
@@ -16,15 +17,30 @@ export class BigRegisterAdapter {
private client = inject(ApiClient); private client = inject(ApiClient);
aantekeningenResource() { aantekeningenResource() {
return resource({ loader: () => this.client.notes().then((ns) => ns.map(toAantekening)) }); return resource({
loader: () =>
this.client.notes().then((ns) => {
const out: Aantekening[] = [];
for (const n of ns) {
const parsed = parseAantekening(n);
if (!parsed.ok) throw new Error(parsed.error);
out.push(parsed.value);
}
return out;
}),
});
} }
} }
/** Map the wire DTO (all fields optional) onto our domain type. */ const AANTEKENING_TYPES: readonly AantekeningType[] = ['Specialisme', 'Aantekening'];
function toAantekening(n: AantekeningDto): Aantekening {
return { /** Trust-boundary parse: an unrecognized type is an explicit Failure, never a silent cast. */
export function parseAantekening(n: AantekeningDto): Result<string, Aantekening> {
if (!n.type || !AANTEKENING_TYPES.includes(n.type as AantekeningType))
return err(`aantekening: unknown type ${n.type}`);
return ok({
type: n.type as AantekeningType, type: n.type as AantekeningType,
omschrijving: n.omschrijving ?? '', omschrijving: n.omschrijving ?? '',
datum: n.datum ?? '', datum: n.datum ?? '',
}; });
} }

View File

@@ -1,4 +1,5 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { formatDatumNl } from '@shared/kernel/datum';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
@@ -65,12 +66,6 @@ export class AanvraagBlockComponent {
protected conceptText = computed(() => { protected conceptText = computed(() => {
const s = this.aanvraag().status; const s = this.aanvraag().status;
if (s.tag !== 'Concept') return ''; if (s.tag !== 'Concept') return '';
return $localize`:@@aanvraagBlock.conceptMelding:Deze aanvraag is nog niet volledig afgerond — u bent gebleven bij stap ${s.stepIndex + 1}:stap: van ${s.stepCount}:totaal:. Rond de aanvraag af vóór ${formatNL(this.deadline())}:datum:.`; return $localize`:@@aanvraagBlock.conceptMelding:Deze aanvraag is nog niet volledig afgerond — u bent gebleven bij stap ${s.stepIndex + 1}:stap: van ${s.stepCount}:totaal:. Rond de aanvraag af vóór ${formatDatumNl(this.deadline())}:datum:.`;
}); });
} }
function formatNL(iso?: string): string {
return iso
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
}

View File

@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
@@ -30,24 +30,26 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
backLink="/dashboard" backLink="/dashboard"
> >
<app-async [data]="store.applications()"> <app-async [data]="store.applications()">
<ng-template appAsyncLoaded let-list> <ng-template appAsyncLoaded>
@let a = find($any(list)); @if (applications(); as list) {
@if (a) { @let a = find(list);
<app-data-block @if (a) {
i18n-ariaLabel="@@aanvraagDetail.ariaLabel" <app-data-block
ariaLabel="Aanvraaggegevens" i18n-ariaLabel="@@aanvraagDetail.ariaLabel"
> ariaLabel="Aanvraaggegevens"
@for (row of rows(a); track row.key) { >
<div app-data-row [key]="row.key" [value]="row.value"></div> @for (row of rows(a); track row.key) {
} <div app-data-row [key]="row.key" [value]="row.value"></div>
</app-data-block> }
<app-alert class="app-section" type="info" i18n="@@aanvraagDetail.stub"> </app-data-block>
De volledige afhandeling van deze aanvraag is nog niet beschikbaar in deze POC. <app-alert class="app-section" type="info" i18n="@@aanvraagDetail.stub">
</app-alert> De volledige afhandeling van deze aanvraag is nog niet beschikbaar in deze POC.
} @else { </app-alert>
<app-alert type="warning" i18n="@@aanvraagDetail.nietGevonden" } @else {
>Deze aanvraag is niet gevonden.</app-alert <app-alert type="warning" i18n="@@aanvraagDetail.nietGevonden"
> >Deze aanvraag is niet gevonden.</app-alert
>
}
} }
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>
@@ -63,4 +65,10 @@ export class AanvraagDetailPage {
protected find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id); protected find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id);
protected rows = detailRows; protected rows = detailRows;
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */
protected readonly applications = computed(() => {
const rd = this.store.applications();
return rd.tag === 'Success' ? rd.value : undefined;
});
} }

View File

@@ -10,7 +10,7 @@ import {
} from '@registratie/ui/address-fields/address-fields.component'; } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine'; import { ChangeRequestState, ChangeRequestMsg, initial, reduce } from '@registratie/domain/change-request.machine';
import { createSubmitChangeRequest } from '@registratie/application/submit-change-request'; import { createSubmitChangeRequest } from '@registratie/application/submit-change-request';
/** /**
@@ -70,10 +70,10 @@ export class ChangeRequestFormComponent {
// adapter); the UI holds only this bound command. Field initializer = injection // adapter); the UI holds only this bound command. Field initializer = injection
// context, like createStore below. // context, like createStore below.
private submit = createSubmitChangeRequest(); private submit = createSubmitChangeRequest();
private store = createStore<State, Msg>(initial, reduce); private store = createStore<ChangeRequestState, ChangeRequestMsg>(initial, reduce);
/** Optional seed so Storybook / tests can mount any state directly. */ /** Optional seed so Storybook / tests can mount any state directly. */
seed = input<State>(initial); seed = input<ChangeRequestState>(initial);
readonly state = this.store.model; readonly state = this.store.model;
protected dispatch = this.store.dispatch; protected dispatch = this.store.dispatch;

View File

@@ -87,59 +87,61 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
} }
<app-async [data]="store.profile()"> <app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p> <ng-template appAsyncLoaded>
@let tasks = tasksFor($any(p).registration); @if (profile(); as p) {
@let tasks = tasksFor(p.registration);
<section> <section>
@if (tasks.length) { @if (tasks.length) {
<app-task-list <app-task-list
class="app-section" class="app-section"
i18n-listHeading="@@dashboard.watMoetIkRegelen" i18n-listHeading="@@dashboard.watMoetIkRegelen"
listHeading="Wat moet ik regelen" listHeading="Wat moet ik regelen"
[tasks]="tasks" [tasks]="tasks"
/> />
} @else { } @else {
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen" <app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen"
>Wat moet ik regelen</app-heading >Wat moet ik regelen</app-heading
>
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">
U heeft op dit moment niets openstaan.
</p>
}
</section>
<section>
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie"
>Mijn registratie</app-heading
> >
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan"> <div class="app-section">
U heeft op dit moment niets openstaan. <app-registration-summary [reg]="p.registration" />
</p> </div>
} <app-data-block
</section> class="app-section"
i18n-heading="@@dashboard.persoonsgegevens"
<section> heading="Persoonsgegevens (BRP)"
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie" >
>Mijn registratie</app-heading <div
> app-data-row
<div class="app-section"> i18n-key="@@dashboard.straat"
<app-registration-summary [reg]="$any(p).registration" /> key="Straat"
</div> [value]="p.person.adres.straat"
<app-data-block ></div>
class="app-section" <div
i18n-heading="@@dashboard.persoonsgegevens" app-data-row
heading="Persoonsgegevens (BRP)" i18n-key="@@dashboard.postcode"
> key="Postcode"
<div [value]="p.person.adres.postcode"
app-data-row ></div>
i18n-key="@@dashboard.straat" <div
key="Straat" app-data-row
[value]="$any(p).person.adres.straat" i18n-key="@@dashboard.woonplaats"
></div> key="Woonplaats"
<div [value]="p.person.adres.woonplaats"
app-data-row ></div>
i18n-key="@@dashboard.postcode" </app-data-block>
key="Postcode" </section>
[value]="$any(p).person.adres.postcode" }
></div>
<div
app-data-row
i18n-key="@@dashboard.woonplaats"
key="Woonplaats"
[value]="$any(p).person.adres.woonplaats"
></div>
</app-data-block>
</section>
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" /> <app-skeleton height="2.5rem" [count]="6" />
@@ -152,8 +154,10 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
> >
<div class="app-section"> <div class="app-section">
<app-async [data]="store.aantekeningen()"> <app-async [data]="store.aantekeningen()">
<ng-template appAsyncLoaded let-r> <ng-template appAsyncLoaded>
<app-registration-table [rows]="$any(r)" /> @if (aantekeningen(); as r) {
<app-registration-table [rows]="r" />
}
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" /> <app-skeleton height="2.5rem" [count]="3" />
@@ -239,6 +243,19 @@ export class DashboardPage {
return tasksFromProfile(reg, this.eligible()); return tasksFromProfile(reg, this.eligible());
} }
/** Typed narrowing for the `<app-async>` loaded slot — `<ng-template>`'s own
context can't inherit a generic from a sibling host input (Angular only infers
a structural directive's type parameter from an input on that same node), so
the Success value is unwrapped here instead of through `let-`. */
protected readonly profile = computed(() => {
const rd = this.store.profile();
return rd.tag === 'Success' ? rd.value : undefined;
});
protected readonly aantekeningen = computed(() => {
const rd = this.store.aantekeningen();
return rd.tag === 'Success' ? rd.value : undefined;
});
/** Primary transactional actions, as an "aanvragen" list (see CIBG's /** Primary transactional actions, as an "aanvragen" list (see CIBG's
componenten/aanvragen). The core portal sections live in the header nav now; componenten/aanvragen). The core portal sections live in the header nav now;
the teaching pages (concepts/brief) are only reachable from here. */ the teaching pages (concepts/brief) are only reachable from here. */

View File

@@ -169,83 +169,85 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
} }
@case ('beroep') { @case ('beroep') {
<app-async [data]="lookupRd()"> <app-async [data]="lookupRd()">
<ng-template appAsyncLoaded let-data> <ng-template appAsyncLoaded>
<app-form-field @if (duoData(); as data) {
i18n-label="@@regWizard.diplomaLabel"
label="Kies het diploma waarmee u zich wilt registreren"
fieldId="diploma"
required
[error]="err('diploma')"
>
<app-radio-group
name="diploma"
[options]="diplomaOptions($any(data))"
[invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()"
(ngModelChange)="onDiplomaKeuze($any(data), $event)"
/>
</app-form-field>
@if (handmatigActief()) {
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
>Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw
beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig
beoordeeld.</app-alert
>
<app-form-field <app-form-field
i18n-label="@@regWizard.beroepLabel" i18n-label="@@regWizard.diplomaLabel"
label="Voor welk beroep wilt u zich registreren?" label="Kies het diploma waarmee u zich wilt registreren"
fieldId="hm-beroep" fieldId="diploma"
required
[error]="err('diploma')" [error]="err('diploma')"
> >
<app-radio-group <app-radio-group
name="hm-beroep" name="diploma"
[options]="beroepOptions($any(data))" [options]="diplomaOptions(data)"
[invalid]="!!err('diploma')" [invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''" [ngModel]="diplomaKeuze()"
(ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" (ngModelChange)="onDiplomaKeuze(data, $event)"
/> />
</app-form-field> </app-form-field>
} @else if (draft().beroep) {
<dl class="mb-0 app-section">
<div
app-data-row
i18n-key="@@regWizard.beroepAfgeleid"
key="Beroep (afgeleid uit diploma)"
[value]="draft().beroep ?? ''"
></div>
</dl>
}
@for (q of actieveVragen($any(data)); track q.id) { @if (handmatigActief()) {
<app-form-field <app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
[label]="q.vraag" >Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies
[fieldId]="'vraag-' + q.id" uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna
[error]="vraagErr(q.id)" handmatig beoordeeld.</app-alert
> >
@if (q.type === 'ja-nee') { <app-form-field
i18n-label="@@regWizard.beroepLabel"
label="Voor welk beroep wilt u zich registreren?"
fieldId="hm-beroep"
[error]="err('diploma')"
>
<app-radio-group <app-radio-group
[name]="'vraag-' + q.id" name="hm-beroep"
[options]="jaNee" [options]="beroepOptions(data)"
[invalid]="!!vraagErr(q.id)" [invalid]="!!err('diploma')"
[ngModel]="antwoord(q.id)" [ngModel]="draft().beroep ?? ''"
(ngModelChange)=" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/> />
} @else { </app-form-field>
<app-text-input } @else if (draft().beroep) {
[inputId]="'vraag-' + q.id" <dl class="mb-0 app-section">
[invalid]="!!vraagErr(q.id)" <div
[ngModel]="antwoord(q.id)" app-data-row
(ngModelChange)=" i18n-key="@@regWizard.beroepAfgeleid"
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event }) key="Beroep (afgeleid uit diploma)"
" [value]="draft().beroep ?? ''"
[ngModelOptions]="{ standalone: true }" ></div>
/> </dl>
} }
</app-form-field>
@for (q of actieveVragen(data); track q.id) {
<app-form-field
[label]="q.vraag"
[fieldId]="'vraag-' + q.id"
[error]="vraagErr(q.id)"
>
@if (q.type === 'ja-nee') {
<app-radio-group
[name]="'vraag-' + q.id"
[options]="jaNee"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/>
} @else {
<app-text-input
[inputId]="'vraag-' + q.id"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/>
}
</app-form-field>
}
} }
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>
@@ -485,8 +487,11 @@ export class RegistratieWizardComponent {
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup; protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the /** Parsed lookup as a plain value (or null) — used outside the beroep step (the
controle summary) where the <app-async> template variable isn't in scope. */ controle summary) where the <app-async> template variable isn't in scope, and
private duoData = computed<DuoLookupDto | null>(() => { inside it too: `<ng-template appAsyncLoaded>`'s own context can't inherit a
generic from the sibling [data] input (Angular only infers a structural
directive's type parameter from an input on that same node). */
protected duoData = computed<DuoLookupDto | null>(() => {
const rd = this.lookupRd(); const rd = this.lookupRd();
return rd.tag === 'Success' ? rd.value : null; return rd.tag === 'Success' ? rd.value : null;
}); });

View File

@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/async/async.component';
@@ -22,8 +22,10 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
backLink="/dashboard" backLink="/dashboard"
> >
<app-async [data]="store.profile()"> <app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p> <ng-template appAsyncLoaded>
<app-registration-summary [reg]="$any(p).registration" /> @if (profile(); as p) {
<app-registration-summary [reg]="p.registration" />
}
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" /> <app-skeleton height="2.5rem" [count]="6" />
@@ -38,4 +40,10 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
}) })
export class RegistrationDetailPage { export class RegistrationDetailPage {
protected store = inject(BigProfileStore); protected store = inject(BigProfileStore);
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */
protected readonly profile = computed(() => {
const rd = this.store.profile();
return rd.tag === 'Success' ? rd.value : undefined;
});
} }

View File

@@ -30,14 +30,17 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
key="Registratiedatum" key="Registratiedatum"
[value]="reg().registratiedatum | date: 'longDate'" [value]="reg().registratiedatum | date: 'longDate'"
></div> ></div>
<!-- Each status variant renders only the row its own data supports. --> <!-- Each status variant renders only the row its own data supports. A single
@switch (reg().status.tag) { @let binds status once so the @switch narrows its union by tag -- calling
reg().status again per case would give the checker a fresh, unnarrowed call. -->
@let status = reg().status;
@switch (status.tag) {
@case ('Geregistreerd') { @case ('Geregistreerd') {
<div <div
app-data-row app-data-row
i18n-key="@@summary.uiterste" i18n-key="@@summary.uiterste"
key="Uiterste herregistratie" key="Uiterste herregistratie"
[value]="$any(reg().status).herregistratieDatum | date: 'longDate'" [value]="status.herregistratieDatum | date: 'longDate'"
></div> ></div>
} }
@case ('Geschorst') { @case ('Geschorst') {
@@ -45,28 +48,18 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
app-data-row app-data-row
i18n-key="@@summary.geschorstTot" i18n-key="@@summary.geschorstTot"
key="Geschorst tot" key="Geschorst tot"
[value]="$any(reg().status).geschorstTot | date: 'longDate'" [value]="status.geschorstTot | date: 'longDate'"
></div>
<div
app-data-row
i18n-key="@@summary.reden"
key="Reden"
[value]="$any(reg().status).reden"
></div> ></div>
<div app-data-row i18n-key="@@summary.reden" key="Reden" [value]="status.reden"></div>
} }
@case ('Doorgehaald') { @case ('Doorgehaald') {
<div <div
app-data-row app-data-row
i18n-key="@@summary.doorgehaaldOp" i18n-key="@@summary.doorgehaaldOp"
key="Doorgehaald op" key="Doorgehaald op"
[value]="$any(reg().status).doorgehaaldOp | date: 'longDate'" [value]="status.doorgehaaldOp | date: 'longDate'"
></div>
<div
app-data-row
i18n-key="@@summary.reden"
key="Reden"
[value]="$any(reg().status).reden"
></div> ></div>
<div app-data-row i18n-key="@@summary.reden" key="Reden" [value]="status.reden"></div>
} }
} }
</app-data-block> </app-data-block>

View File

@@ -71,20 +71,6 @@ export function map2<E, A, B, R>(
return { tag: 'Success', value: f(a.value, b.value) }; return { tag: 'Success', value: f(a.value, b.value) };
} }
/** Combine three sources (built on map2). */
export function map3<E, A, B, C, R>(
a: RemoteData<E, A>,
b: RemoteData<E, B>,
c: RemoteData<E, C>,
f: (a: A, b: B, c: C) => R,
): RemoteData<E, R> {
return map2(
map2(a, b, (x, y) => [x, y] as const),
c,
([x, y], z) => f(x, y, z),
);
}
/** Chain a second source that depends on the first one's value. */ /** Chain a second source that depends on the first one's value. */
export function andThen<E, A, B>( export function andThen<E, A, B>(
rd: RemoteData<E, A>, rd: RemoteData<E, A>,

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { formatDatumNl } from './datum';
describe('formatDatumNl', () => {
it('formats a Date in long Dutch form', () => {
expect(formatDatumNl(new Date(2026, 6, 2))).toBe('2 juli 2026');
});
it('formats an ISO string the same way', () => {
expect(formatDatumNl('2026-07-02')).toBe('2 juli 2026');
});
it('is empty-safe: undefined, null, and empty string all yield the empty string', () => {
expect(formatDatumNl(undefined)).toBe('');
expect(formatDatumNl(null)).toBe('');
expect(formatDatumNl('')).toBe('');
});
it('returns empty for an unparseable string rather than "Invalid Date"', () => {
expect(formatDatumNl('not-a-date')).toBe('');
});
});

View File

@@ -0,0 +1,16 @@
/**
* The one hand-written date formatter for pure TS (non-template) code — a domain
* rule or a `$localize` string can't reach for Angular's `DatePipe`, so this covers
* that gap. Templates use `DatePipe` (`| date: 'longDate'`) instead; don't add a
* second hand-rolled formatter for either case.
*/
export function formatDatumNl(d: Date | string | undefined | null): string {
if (!d) return '';
const date = typeof d === 'string' ? new Date(d) : d;
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('nl-NL', {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(date);
}

View File

@@ -32,6 +32,10 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
@Component({ @Component({
selector: 'app-wizard-shell', selector: 'app-wizard-shell',
imports: [FormsModule, ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent], imports: [FormsModule, ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
// CIBG-GAP EXTENSION: Foutmelding — the vendored build has no error-summary/
// Veldvalidatie list pattern (verified absent from huisstijl.min.css); the
// .es-title/.es-list rules below are the hand-rolled surface, see cibg-gaps.mdx.
// They render inside a vendored `.feedback-error` alert (app-alert).
styles: [ styles: [
` `
.es-title { .es-title {

View File

@@ -15,6 +15,15 @@ const meta: Meta<WizardShellComponent> = {
<div wizardSuccess><p class="rhc-paragraph">Uw aanvraag is ontvangen.</p></div> <div wizardSuccess><p class="rhc-paragraph">Uw aanvraag is ontvangen.</p></div>
</app-wizard-shell>`, </app-wizard-shell>`,
}), }),
parameters: {
cibgGap: true,
docs: {
description: {
component:
'CIBG-gap extension (error summary only) — see Foundations/CIBG Gap Register.',
},
},
},
}; };
export default meta; export default meta;
type Story = StoryObj<WizardShellComponent>; type Story = StoryObj<WizardShellComponent>;

View File

@@ -2,6 +2,9 @@ import { Component, input, output } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
// CIBG-GAP EXTENSION: Aanvragen (non-navigating row) — the vendored
// `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row`
// mirrors it from tokens for the non-navigating case, see cibg-gaps.mdx.
/** Molecule: one row in a CIBG Huisstijl "aanvragen" list /** Molecule: one row in a CIBG Huisstijl "aanvragen" list
(designsystem.cibg.nl/componenten/aanvragen) — a white card-link styled by the (designsystem.cibg.nl/componenten/aanvragen) — a white card-link styled by the
vendored `.dashboard-block.applications li a` chain (bg, chevron, link-blue `h3`), vendored `.dashboard-block.applications li a` chain (bg, chevron, link-blue `h3`),

View File

@@ -12,6 +12,15 @@ const meta: Meta<ApplicationLinkComponent> = {
// Rows are <li>s in the "aanvragen" list — a real <ul> gives them their layout. // Rows are <li>s in the "aanvragen" list — a real <ul> gives them their layout.
template: `<div class="dashboard-block applications"><ul class="list-unstyled"><li app-application-link [heading]="heading" [subtitle]="subtitle" [status]="status" [cta]="cta" [to]="to" [clickable]="clickable"></li></ul></div>`, template: `<div class="dashboard-block applications"><ul class="list-unstyled"><li app-application-link [heading]="heading" [subtitle]="subtitle" [status]="status" [cta]="cta" [to]="to" [clickable]="clickable"></li></ul></div>`,
}), }),
parameters: {
cibgGap: true,
docs: {
description: {
component:
'CIBG-gap extension (non-navigating row only, see NietInteractief) — see Foundations/CIBG Gap Register.',
},
},
},
}; };
export default meta; export default meta;
type Story = StoryObj<ApplicationLinkComponent>; type Story = StoryObj<ApplicationLinkComponent>;

View File

@@ -6,10 +6,20 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data'; import { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';
/* Slot markers. Put on <ng-template> children of <app-async>. */ /* Slot markers. Put on <ng-template> children of <app-async>. Generic so the
$implicit context is typed as the resource's T instead of unknown — see
AsyncComponent's contentChild<AsyncLoadedDirective<T>> below, which threads the
host's own T through the query result type. */
@Directive({ selector: '[appAsyncLoaded]' }) @Directive({ selector: '[appAsyncLoaded]' })
export class AsyncLoadedDirective { export class AsyncLoadedDirective<T = unknown> {
constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {} constructor(public tpl: TemplateRef<{ $implicit: T }>) {}
static ngTemplateContextGuard<T>(
_dir: AsyncLoadedDirective<T>,
_ctx: unknown,
): _ctx is { $implicit: T } {
return true;
}
} }
@Directive({ selector: '[appAsyncLoading]' }) @Directive({ selector: '[appAsyncLoading]' })
export class AsyncLoadingDirective { export class AsyncLoadingDirective {
@@ -88,7 +98,7 @@ export class AsyncComponent<T> {
retryText = input($localize`:@@async.retry:Opnieuw proberen`); retryText = input($localize`:@@async.retry:Opnieuw proberen`);
emptyText = input($localize`:@@async.empty:Geen gegevens gevonden.`); emptyText = input($localize`:@@async.empty:Geen gegevens gevonden.`);
loadedTpl = contentChild.required(AsyncLoadedDirective); loadedTpl = contentChild.required<AsyncLoadedDirective<T>>(AsyncLoadedDirective);
loadingTpl = contentChild(AsyncLoadingDirective); loadingTpl = contentChild(AsyncLoadingDirective);
emptyTpl = contentChild(AsyncEmptyDirective); emptyTpl = contentChild(AsyncEmptyDirective);
errorTpl = contentChild(AsyncErrorDirective); errorTpl = contentChild(AsyncErrorDirective);

View File

@@ -1,6 +1,6 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
type Variant = 'primary' | 'secondary' | 'subtle' | 'danger'; type Variant = 'primary' | 'secondary' | 'subtle' | 'danger' | 'ghost';
/** Atom: button. Thin wrapper over the CIBG/Bootstrap button CSS. */ /** Atom: button. Thin wrapper over the CIBG/Bootstrap button CSS. */
@Component({ @Component({
@@ -11,9 +11,10 @@ type Variant = 'primary' | 'secondary' | 'subtle' | 'danger';
[disabled]="disabled()" [disabled]="disabled()"
class="btn" class="btn"
[class.btn-primary]="variant() === 'primary'" [class.btn-primary]="variant() === 'primary'"
[class.btn-outline-primary]="variant() === 'secondary'" [class.btn-secondary]="variant() === 'secondary'"
[class.btn-link]="variant() === 'subtle'" [class.btn-link]="variant() === 'subtle'"
[class.btn-danger]="variant() === 'danger'" [class.btn-danger]="variant() === 'danger'"
[class.btn-ghost]="variant() === 'ghost'"
> >
<ng-content /> <ng-content />
</button> </button>

View File

@@ -16,4 +16,5 @@ export const Primary: Story = { args: { variant: 'primary' } };
export const Secondary: Story = { args: { variant: 'secondary' } }; export const Secondary: Story = { args: { variant: 'secondary' } };
export const Subtle: Story = { args: { variant: 'subtle' } }; export const Subtle: Story = { args: { variant: 'subtle' } };
export const Danger: Story = { args: { variant: 'danger' } }; export const Danger: Story = { args: { variant: 'danger' } };
export const Ghost: Story = { args: { variant: 'ghost' } };
export const Disabled: Story = { args: { variant: 'primary', disabled: true } }; export const Disabled: Story = { args: { variant: 'primary', disabled: true } };

View File

@@ -1,6 +1,9 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
// CIBG-GAP EXTENSION: n/a — no vendored generic-card class; `.app-card` is a
// hand-rolled surface (NOT Bootstrap's `.card`), see cibg-gaps.mdx. Prefer the
// vendored Datablock (WP-12, `app-data-block`) for application/user data.
/** Molecule: a content card. Standardises the repeated card surface (white, /** Molecule: a content card. Standardises the repeated card surface (white,
subtle border, rounded, padded) so pages compose cards instead of hand-rolling subtle border, rounded, padded) so pages compose cards instead of hand-rolling
a hand-rolled card surface. Optional heading; the rest is projected. a hand-rolled card surface. Optional heading; the rest is projected.

View File

@@ -12,6 +12,10 @@ const meta: Meta<CardComponent> = {
</app-card>`, </app-card>`,
}), }),
args: { heading: 'Persoonsgegevens (BRP)', level: 3 }, args: { heading: 'Persoonsgegevens (BRP)', level: 3 },
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
}; };
export default meta; export default meta;
type Story = StoryObj<CardComponent>; type Story = StoryObj<CardComponent>;

View File

@@ -6,6 +6,8 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
import { map } from '@shared/application/remote-data'; import { map } from '@shared/application/remote-data';
import { maskBsn, redactProfile } from './mask'; import { maskBsn, redactProfile } from './mask';
// CIBG-GAP EXTENSION: n/a — devtool, no corresponding CIBG concept; deliberately
// off-theme by design (see the ponytail note below), see cibg-gaps.mdx.
/** /**
* Dev-only "show the current Model" panel (Elm-debugger style, read-only). * Dev-only "show the current Model" panel (Elm-debugger style, read-only).
* Observes the root singletons and renders them via the json pipe. Never a * Observes the root singletons and renders them via the json pipe. Never a

View File

@@ -5,6 +5,10 @@ import { DebugStateComponent } from './debug-state.component';
const meta: Meta<DebugStateComponent> = { const meta: Meta<DebugStateComponent> = {
title: 'Devtools/State debug', title: 'Devtools/State debug',
component: DebugStateComponent, component: DebugStateComponent,
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
}; };
export default meta; export default meta;
type Story = StoryObj<DebugStateComponent>; type Story = StoryObj<DebugStateComponent>;

View File

@@ -1,5 +1,7 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, input } from '@angular/core';
// CIBG-GAP EXTENSION: n/a — no vendored inline-chip/tag class; hand-rolled
// brace-wrapped chip, see cibg-gaps.mdx.
/** Atom: a highlighted, non-editable placeholder chip for READ-ONLY rendering /** Atom: a highlighted, non-editable placeholder chip for READ-ONLY rendering
(preview, diagnostics). Distinct styling for auto-resolvable vs manual fields and (preview, diagnostics). Distinct styling for auto-resolvable vs manual fields and
for linter error/warning states. Domain-free and presentational — the caller for linter error/warning states. Domain-free and presentational — the caller

View File

@@ -8,6 +8,10 @@ const meta: Meta<PlaceholderChipComponent> = {
props: args, props: args,
template: `<app-placeholder-chip [label]="label" [autoResolvable]="autoResolvable" [state]="state"></app-placeholder-chip>`, template: `<app-placeholder-chip [label]="label" [autoResolvable]="autoResolvable" [state]="state"></app-placeholder-chip>`,
}), }),
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
}; };
export default meta; export default meta;
type Story = StoryObj<PlaceholderChipComponent>; type Story = StoryObj<PlaceholderChipComponent>;

View File

@@ -12,6 +12,10 @@ export interface PlaceholderOption {
readonly autoResolvable?: boolean; readonly autoResolvable?: boolean;
} }
// CIBG-GAP EXTENSION: Tekstgebied — CIBG has no rich-text/WYSIWYG pattern (a
// contenteditable editor with formatting + placeholder chips); hand-rolled
// surface (toolbar + chip styling), see cibg-gaps.mdx. Buttons still use the
// vendored .btn-ghost class (WP-10).
/** /**
* Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`. * Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.
* *
@@ -95,7 +99,7 @@ export interface PlaceholderOption {
<div class="rte-toolbar" role="toolbar" [attr.aria-label]="toolbarLabel()"> <div class="rte-toolbar" role="toolbar" [attr.aria-label]="toolbarLabel()">
<button <button
type="button" type="button"
class="btn btn-outline-secondary btn-sm" class="btn btn-ghost"
(mousedown)="$event.preventDefault()" (mousedown)="$event.preventDefault()"
(click)="format('bold')" (click)="format('bold')"
[attr.aria-label]="boldLabel()" [attr.aria-label]="boldLabel()"
@@ -104,7 +108,7 @@ export interface PlaceholderOption {
</button> </button>
<button <button
type="button" type="button"
class="btn btn-outline-secondary btn-sm" class="btn btn-ghost"
(mousedown)="$event.preventDefault()" (mousedown)="$event.preventDefault()"
(click)="format('italic')" (click)="format('italic')"
[attr.aria-label]="italicLabel()" [attr.aria-label]="italicLabel()"
@@ -113,7 +117,7 @@ export interface PlaceholderOption {
</button> </button>
<button <button
type="button" type="button"
class="btn btn-outline-secondary btn-sm" class="btn btn-ghost"
(mousedown)="$event.preventDefault()" (mousedown)="$event.preventDefault()"
(click)="format('underline')" (click)="format('underline')"
[attr.aria-label]="underlineLabel()" [attr.aria-label]="underlineLabel()"
@@ -123,7 +127,7 @@ export interface PlaceholderOption {
<span class="rte-sep" aria-hidden="true"></span> <span class="rte-sep" aria-hidden="true"></span>
<button <button
type="button" type="button"
class="btn btn-outline-secondary btn-sm" class="btn btn-ghost"
(mousedown)="$event.preventDefault()" (mousedown)="$event.preventDefault()"
(click)="list('bullet')" (click)="list('bullet')"
[attr.aria-label]="bulletListLabel()" [attr.aria-label]="bulletListLabel()"
@@ -132,7 +136,7 @@ export interface PlaceholderOption {
</button> </button>
<button <button
type="button" type="button"
class="btn btn-outline-secondary btn-sm" class="btn btn-ghost"
(mousedown)="$event.preventDefault()" (mousedown)="$event.preventDefault()"
(click)="list('number')" (click)="list('number')"
[attr.aria-label]="numberListLabel()" [attr.aria-label]="numberListLabel()"

View File

@@ -34,6 +34,10 @@ const meta: Meta<RichTextEditorComponent> = {
props: args, props: args,
template: `<app-rich-text-editor [content]="content" [placeholders]="placeholders" [editable]="editable"></app-rich-text-editor>`, template: `<app-rich-text-editor [content]="content" [placeholders]="placeholders" [editable]="editable"></app-rich-text-editor>`,
}), }),
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
}; };
export default meta; export default meta;
type Story = StoryObj<RichTextEditorComponent>; type Story = StoryObj<RichTextEditorComponent>;

View File

@@ -1,5 +1,7 @@
import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/core'; import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/core';
// CIBG-GAP EXTENSION: Laadindicatie — no vendored loading-skeleton class exists
// (verified absent from huisstijl.min.css); hand-rolled shimmer, see cibg-gaps.mdx.
/** Atom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes /** Atom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes
on fast responses. Render `count` lines shaped roughly like the content. */ on fast responses. Render `count` lines shaped roughly like the content. */
@Component({ @Component({

View File

@@ -5,6 +5,10 @@ const meta: Meta<SkeletonComponent> = {
title: 'Atoms/Skeleton', title: 'Atoms/Skeleton',
component: SkeletonComponent, component: SkeletonComponent,
args: { delay: 0 }, args: { delay: 0 },
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
}; };
export default meta; export default meta;
type Story = StoryObj<SkeletonComponent>; type Story = StoryObj<SkeletonComponent>;

View File

@@ -1,5 +1,7 @@
import { Component, OnDestroy, OnInit, input, signal } from '@angular/core'; import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
// CIBG-GAP EXTENSION: Laadindicatie — no vendored loading-spinner class exists
// (verified absent from huisstijl.min.css); hand-rolled, see cibg-gaps.mdx.
/** Atom: spinner that only appears after `delay` ms — fast responses never /** Atom: spinner that only appears after `delay` ms — fast responses never
flash a spinner, slow ones get feedback. */ flash a spinner, slow ones get feedback. */
@Component({ @Component({

View File

@@ -4,6 +4,10 @@ import { SpinnerComponent } from './spinner.component';
const meta: Meta<SpinnerComponent> = { const meta: Meta<SpinnerComponent> = {
title: 'Atoms/Spinner', title: 'Atoms/Spinner',
component: SpinnerComponent, component: SpinnerComponent,
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
}; };
export default meta; export default meta;
type Story = StoryObj<SpinnerComponent>; type Story = StoryObj<SpinnerComponent>;

View File

@@ -1,5 +1,7 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
// CIBG-GAP EXTENSION: n/a — deliberate custom surface, not Bootstrap's `.badge`
// (whose pill padding/colour don't fit a status dot); see cibg-gaps.mdx.
/** Atom: a coloured dot + label. Purely presentational and domain-free — the /** Atom: a coloured dot + label. Purely presentational and domain-free — the
caller decides what colour and label mean (e.g. via registration.policy). caller decides what colour and label mean (e.g. via registration.policy).
This keeps the shared UI kernel free of any domain knowledge. */ This keeps the shared UI kernel free of any domain knowledge. */

View File

@@ -4,6 +4,10 @@ import { StatusBadgeComponent } from './status-badge.component';
const meta: Meta<StatusBadgeComponent> = { const meta: Meta<StatusBadgeComponent> = {
title: 'Shared UI/Status Badge', title: 'Shared UI/Status Badge',
component: StatusBadgeComponent, component: StatusBadgeComponent,
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
}; };
export default meta; export default meta;
type Story = StoryObj<StatusBadgeComponent>; type Story = StoryObj<StatusBadgeComponent>;

View File

@@ -1,14 +1,14 @@
import { Component, input, output } from '@angular/core'; import { Component, input, output } from '@angular/core';
import type { DeliveryChannel, UploadState } from '@shared/upload/upload.machine'; import type { DeliveryChannel, UploadState } from '@shared/upload/upload.machine';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DocumentCategoryComponent } from '../document-category/document-category.component'; import { DocumentCategoryComponent } from '../document-category/document-category.component';
import { UploadStatusBannerComponent } from '../upload-status-banner/upload-status-banner.component';
/** Organism: the full document-upload step — the category list, or a load-error /** Organism: the full document-upload step — the category list, or a load-error
banner. Pure UI: re-exposes the category events, tagging each with its category banner. Pure UI: re-exposes the category events, tagging each with its category
where the parent needs it. The container wires these to the upload reducer. */ where the parent needs it. The container wires these to the upload reducer. */
@Component({ @Component({
selector: 'app-document-upload', selector: 'app-document-upload',
imports: [DocumentCategoryComponent, UploadStatusBannerComponent], imports: [DocumentCategoryComponent, AlertComponent],
styles: [ styles: [
` `
:host { :host {
@@ -20,10 +20,10 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
], ],
template: ` template: `
@if (state().categoriesError) { @if (state().categoriesError) {
<app-upload-status-banner type="error" [message]="state().categoriesError!" /> <app-alert type="error">{{ state().categoriesError }}</app-alert>
} @else { } @else {
@if (state().backgroundSyncAvailable === false && state().categories.length > 0) { @if (state().backgroundSyncAvailable === false && state().categories.length > 0) {
<app-upload-status-banner type="info" [message]="foregroundOnlyMessage" /> <app-alert type="info">{{ foregroundOnlyMessage }}</app-alert>
} }
@for (c of state().categories; track c.categoryId) { @for (c of state().categories; track c.categoryId) {
<app-document-category <app-document-category

View File

@@ -56,3 +56,7 @@ export const Default: Story = { args: { state } };
export const LoadError: Story = { export const LoadError: Story = {
args: { state: { ...state, categoriesError: 'De categorieën konden niet worden geladen.' } }, args: { state: { ...state, categoriesError: 'De categorieën konden niet worden geladen.' } },
}; };
export const ForegroundOnly: Story = {
args: { state: { ...state, backgroundSyncAvailable: false } },
};

View File

@@ -1,25 +0,0 @@
import { Component, computed, input } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component';
type BannerType = 'info' | 'warning' | 'error';
type AlertType = 'info' | 'ok' | 'warning' | 'error';
/** Molecule: a polite, announced status banner. Wraps the alert atom and maps the
banner type to an alert type. Pure UI: the container computes the message. */
@Component({
selector: 'app-upload-status-banner',
imports: [AlertComponent],
template: `
<div aria-live="polite">
<app-alert [type]="alertType()">{{ message() }}</app-alert>
</div>
`,
})
export class UploadStatusBannerComponent {
message = input.required<string>();
type = input<BannerType>('info');
protected readonly alertType = computed<AlertType>(() =>
this.type() === 'info' ? 'info' : this.type(),
);
}

View File

@@ -1,21 +0,0 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { UploadStatusBannerComponent } from './upload-status-banner.component';
const meta: Meta<UploadStatusBannerComponent> = {
title: 'Molecules/UploadStatusBanner',
component: UploadStatusBannerComponent,
render: (args) => ({
props: args,
template: `<app-upload-status-banner [message]="message" [type]="type" />`,
}),
};
export default meta;
type Story = StoryObj<UploadStatusBannerComponent>;
export const Info: Story = {
args: { type: 'info', message: 'Uw documenten worden geüpload.' },
};
export const Error: Story = {
args: { type: 'error', message: 'De categorieën konden niet worden geladen.' },
};

View File

@@ -223,9 +223,9 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
> >
<p class="tag plain">Success</p> <p class="tag plain">Success</p>
<app-async [resource]="successRes" [isEmpty]="isEmpty" <app-async [resource]="successRes" [isEmpty]="isEmpty"
><ng-template appAsyncLoaded let-v ><ng-template appAsyncLoaded
><ul> ><ul>
@for (i of v; track i) { @for (i of successRes.value(); track i) {
<li>{{ i }}</li> <li>{{ i }}</li>
} }
</ul></ng-template </ul></ng-template
@@ -258,16 +258,17 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
placeholder="Typ een postcode, bijv. 1234 AB" placeholder="Typ een postcode, bijv. 1234 AB"
/> />
</div> </div>
<div class="card" [class.card--good]="parsed().ok" [class.card--bad]="!parsed().ok"> @let r = parsed();
@if (parsed().ok) { <div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
@if (r.ok) {
<p class="tag good">ok</p> <p class="tag good">ok</p>
<pre>Postcode ="{{ $any(parsed()).value }}"</pre> <pre>Postcode ="{{ r.value }}"</pre>
<p class="note"> <p class="note">
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string. Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
</p> </p>
} @else { } @else {
<p class="tag bad">err</p> <p class="tag bad">err</p>
<pre>{{ $any(parsed()).error }}</pre> <pre>{{ r.error }}</pre>
} }
</div> </div>
</div> </div>

52
src/docs/cibg-gaps.mdx Normal file
View File

@@ -0,0 +1,52 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/CIBG Gap Register" />
# CIBG gap register
CIBG Huisstijl (ADR-0003) is the design system of record — a component wraps a vendored class
before it hand-rolls anything. **Grep the vendored CSS
(`public/cibg-huisstijl/css/huisstijl.min.css`) before adding new surface CSS to a component.**
When no vendored pattern exists, the component is a **CIBG-gap extension**: allowed, but only
marked so every deviation from the design system is auditable.
## Marker format
```ts
// CIBG-GAP EXTENSION: <closest CIBG concept, or "n/a"> — <why hand-rolled>
```
placed above the `@Component` decorator, plus `parameters: { cibgGap: true }` and a
"CIBG-gap extension" line in the story's `docs.description.component`.
## The register
| Component | Closest CIBG concept | Why hand-rolled |
| --- | --- | --- |
| `skeleton` | Laadindicatie | No loading-skeleton class in the vendored build. |
| `spinner` | Laadindicatie | No loading-spinner class in the vendored build. |
| `rich-text-editor` | Tekstgebied | No rich-text/WYSIWYG pattern; toolbar buttons still use vendored `.btn-ghost` (WP-10). |
| `wizard-shell` (error summary only) | Foutmelding | No error-summary/Veldvalidatie list class; renders inside a vendored `.feedback-error` alert. |
| `application-link` (non-navigating row) | Aanvragen | The vendored `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row` mirrors it from tokens for the informational (non-link) case. |
| `debug-state` | n/a | Dev-only tool, deliberately off-theme — see the component's own `ponytail:` note. |
| `status-badge` | n/a | Deliberate custom status dot, not Bootstrap's `.badge` (pill padding/colour don't fit). |
| `card` (`.app-card`) | n/a | No vendored generic-card class; prefer the vendored **Datablock** (`app-data-block`, WP-12) for application/user data. |
| `placeholder-chip` | n/a | No vendored inline-chip/tag class. |
Not a gap: `confirmation` renders entirely with vendored `.confirmation*` classes (no `styles:
[...]` block) — its header comment names the pattern, no marker needed. The `upload/` suite
renders entirely with vendored classes (`.file-picker-drop-area`, `.btn-upload`, …) — reworked
onto them rather than marked (see WP-11's correction note). `task-list`, `application-list`, and
`choice-list` each wrap a distinct vendored pattern (Keuzelijst / Aanvragen / Keuzelijst) and name
it in their own header comment — no marker needed, they don't hand-roll surface CSS.
## Hygiene
`upload-status-banner` (a 23-line near-identity wrapper over `app-alert` with one consumer) was
deleted; its consumer (`document-upload`) now uses `<app-alert>` directly.
## Keeping this register honest
No automated check diffs this table against the markers in code (skipped as not worth a CI
script for a table this small — reviewed at PR time instead, same as any other doc). If markers
and this table drift, trust the code and fix the table.

100
src/docs/machines.mdx Normal file
View File

@@ -0,0 +1,100 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/State Machines (TEA)" />
# State machines (The Elm Architecture, in Angular)
Every form or wizard with validation or submission in this app is wired the **same
way**: one Model, one Msg union, one pure `reduce`, one command per side effect. Pick any
one — `herregistratie.machine.ts` is the fullest worked example — and the shape
transfers everywhere else.
## Model / Msg / reduce
```ts
// Model — everything the UI needs to render, as ONE tagged union
export type WizardState = { tag: 'step1'; draft: Draft } | { tag: 'step2'; valid: Valid } | …;
// Msg — every way the Model is allowed to change
export type WizardMsg = { tag: 'FieldChanged'; field: string; value: string } | { tag: 'NextStep' } | …;
// reduce — PURE: (current, message) -> next. No I/O, no Date.now(), no randomness.
export function reduce(s: WizardState, m: WizardMsg): WizardState { … }
```
Because the whole state is one value, a bug reproduces from a message log; because
`reduce` is pure, every transition is a one-line assertion in a spec — no `TestBed`, no
mocked HTTP, just `expect(reduce(state, msg)).toEqual(next)`.
## Commands: side effects stay OUT of the reducer
`reduce` only ever answers "what is the new state" — it never calls `fetch`. A
**command** (an `application/submit-*.ts` file, or a store method) does the I/O, then
dispatches a message describing the outcome:
```ts
// command = "go do it, then say what happened" — reduce never sees the HTTP call itself
async function submit(store: Store<WizardState, WizardMsg>) {
const r = await adapter.submit(toDto(store.model()));
store.dispatch(r.ok ? { tag: 'SubmitConfirmed', referentie: r.value } : { tag: 'SubmitFailed', error: r.error });
}
```
This is also how a machine receives **server-owned config** without becoming aware of
HTTP: `intake.machine.ts`'s scholing threshold has an offline fallback
(`SCHOLING_THRESHOLD_DEFAULT`) baked into the model, and a plain `SetPolicy` message
that overwrites it once the real value arrives — the machine doesn't know or care that
the value came from a `resource()` fetch.
## `createStore`: the one wiring idiom
```ts
private store = createStore<WizardState, WizardMsg>(initial, reduce);
readonly model = this.store.model; // Signal<WizardState> — template reads this
dispatch = this.store.dispatch; // template calls this, on click/input/etc — never mutates
```
A page or component **never** hand-rolls `signal(initialModel)` plus its own local
`dispatch` function that calls `reduce` inline — that's the same idea reinvented with a
worse name, and it's the thing a newcomer copies if two idioms are visible side by side.
Wire every machine through `createStore`, full stop.
`dispatch` uses `model.update(…)`, not `model.set(reduce(model(), msg))` — the latter
reads `model()` *inside* the call, which means an `effect()` that both reads `model` and
calls `dispatch` would subscribe to its own write and livelock. `.update()`'s callback
receives the current value directly, untracked.
## Naming
- A top-level machine's types are **context-prefixed**: `ChangeRequestState`,
`ChangeRequestMsg`, `WizardState`, `WizardMsg` — never bare `State`/`Msg`. A bare name
reads fine in the one file that defines it and then collides (or forces an import
alias) the moment two machines are open side by side.
- A top-level machine exports `initial` (the starting Model) and `reduce` — unprefixed,
since the file/module already disambiguates them at the import site
(`import { initial, reduce } from './herregistratie.machine'`).
- A **composable sub-machine** — one embedded *inside* a parent Model, like
`upload.machine.ts`'s upload-widget state living inside the registratie wizard's own
Model — keeps **prefixed value exports** instead: `initialUpload`, `reduceUpload`.
The parent machine already imports several machines' `initial`/`reduce`; prefixing the
sub-machine's exports avoids a wall of `as` import aliases at the composition site.
## Derive, don't store
If a value can be computed from the Model, it is **not** a field on the Model. The
wizard's visible steps are `visibleSteps(answers)`, a pure function of the current
answers — not a `visibleSteps: Step[]` field someone has to remember to keep in sync
every time an answer changes. The reflex: before adding a field, ask "could this just be
a function of what I already have?"
## Where RemoteData fits in
A machine owns the **domain** lifecycle of what it holds once it exists (draft →
submitted → approved, in the brief's case). It should generally *not* also own the
**fetch** lifecycle (loading/failed) for the initial GET that produces it — that's a
generic concern `RemoteData` already models once, consistently, across the app (see
[Foundations/RemoteData & Async](?path=/docs/foundations-remotedata-async--docs)). Where
a machine's own state happens to have `loading`/`failed` tags that purely mirror that
fetch, project them onto a `RemoteData` at the store layer for `<app-async>` to render
(`BriefStore.remoteData` is the worked example) rather than teaching every consumer to
hand-roll a `@switch` over the machine's own tags.

View File

@@ -0,0 +1,111 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/Parse, don't validate" />
# Parse, don't validate
The wire is untrusted. A `boolean`/`string` field coming back from `fetch` is typed `unknown`
until something checks it — casting it away with `as` doesn't check anything, it just tells the
compiler to stop complaining. This repo's rule: every response crosses the FE⇄BE seam through a
hand-written `parse*` function that returns a `Result<string, T>` (`src/app/shared/kernel/fp.ts`).
Once you hold the parsed value, you never re-check it — the type _is_ the proof.
## Two places this shows up
**Value objects** (`src/app/registratie/domain/value-objects/`) parse a single user-entered
field — `Postcode`, `Uren`, `BigNummer` — from a raw string into a branded type.
**Boundary parsers** (`*.adapter.ts` in every `infrastructure/`) parse a whole DTO — or one
enum-ish field inside it — from the generated `ApiClient`'s response into the domain shape the
rest of the app trusts.
```ts
parsePostcode(raw) // Result<string, Postcode>
|> mapErr(toLocalizedMessage) // swap raw msg → UI copy
|> map(toDomain) // only runs on success
```
## The failure mode this closes: the silent `as` cast
An `as SomeUnion` cast on a wire value compiles even when the value doesn't match — the tag
just gets forwarded as-is, and something far away breaks on an "impossible" case. A validated
parse turns that into an explicit `Failure` at the boundary, right where the untrusted data
enters.
### Before/after: `big-register.adapter.ts`
```ts
// before — the wire's `type` string is trusted outright
function toAantekening(n: AantekeningDto): Aantekening {
return { type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '' };
}
```
```ts
// after — an unrecognized type is a Result you can spec, not a silently-wrong tag
const AANTEKENING_TYPES: readonly AantekeningType[] = ['Specialisme', 'Aantekening'];
export function parseAantekening(n: AantekeningDto): Result<string, Aantekening> {
if (!n.type || !AANTEKENING_TYPES.includes(n.type as AantekeningType))
return err(`aantekening: unknown type ${n.type}`);
return ok({ type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '' });
}
```
The resource loader throws on `Failure`, which Angular's `resource()` turns into its error
state — the same `Failure` a `RemoteData` consumer already renders, no new plumbing.
### Before/after: `brief.adapter.ts`
```ts
// before — `dto.scope` is checked, then re-cast anyway
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep'))
return err('passage: bad shape');
// … scope: dto.scope as PassageScope
```
```ts
// after — split the guard so TS narrows `scope` on its own; no cast needed
if (dto.scope !== 'global' && dto.scope !== 'beroep')
return err(`passage: unknown scope ${dto.scope}`);
// … scope: dto.scope // already narrowed to PassageScope
```
Splitting a compound `if` into two single-condition guards is often enough to make the cast
disappear entirely — the compiler was already able to prove the narrowing, the `||` was just
hiding it.
### Before/after: `intake-policy.adapter.ts`
```ts
// before — the resource exposes the raw DTO; consumers reach into it with `?.`
policyResource() {
return resource({ loader: () => this.client.policy() });
}
```
```ts
// after — a domain-side type + a validated parse; the resource never surfaces raw wire shape
export interface IntakePolicy { readonly scholingThreshold: number }
export function parseIntakePolicy(json: unknown): Result<string, IntakePolicy> {
if (typeof json !== 'object' || json === null) return err('intake-policy: not an object');
const dto = json as { scholingThreshold?: unknown };
if (typeof dto.scholingThreshold !== 'number')
return err('intake-policy: missing scholingThreshold');
return ok({ scholingThreshold: dto.scholingThreshold });
}
```
## The sanctioned exception
Narrowing `unknown` to `Partial<Dto>` so you can *start* checking fields is fine — that's not a
trust decision, it's just giving the compiler a shape to probe (`const dto = json as
Partial<DashboardViewDto>`, see `dashboard-view.adapter.ts`). What's never fine is casting a
field to its final domain type without having checked it first.
## Spec every parser like a decision table
Each parser gets a spec covering: a valid shape, a missing required field, and — for
tagged/enum-ish values — an unknown tag. See `big-register.adapter.spec.ts`,
`intake-policy.adapter.spec.ts`, and the scope-rejection case in `brief.adapter.spec.ts`.

97
src/docs/remote-data.mdx Normal file
View File

@@ -0,0 +1,97 @@
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as AsyncStories from '../app/shared/ui/async/async.stories';
<Meta title="Foundations/RemoteData & Async" />
# 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<E, T> = { 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`/`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: `<app-async>`
<Canvas of={AsyncStories.Loading} />
<Canvas of={AsyncStories.ErrorState} />
`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). `<ng-template appAsyncLoaded let-p>` sits on a *different*
node than `<app-async [data]="…">`, 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
<!-- in the template, inside <ng-template appAsyncLoaded> -->
@if (loaded(); as s) {
<app-letter-composer [brief]="s.brief" ... />
}
```
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 `<app-async>` to render, the way `BriefStore.remoteData` does — the machine
keeps deciding what the *letter* is doing, `RemoteData` keeps deciding what the *fetch* is
doing.