From 97d9697a84da2bc9281835efc2e9086c9a33ec91 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 16:08:08 +0200 Subject: [PATCH 01/46] docs(readable-codebase): scaffold the arc backlog (RD-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the approved readable-codebase plan into the ticket protocol this repo already uses for long arcs (docs/project/backlog/README.md, 75 work packages driven to done through it). Adds docs/project/readable-codebase/ with: - README.md — the ledger: session protocol, GREEN definition, restart recovery, the agent loop, and an Order table of 35 tickets with their dependencies and status. - PLAN.md — the design record, committed so the repository holds the complete state. A fresh session needs nothing outside it. Two properties make the arc restart-safe. Each ticket updates its own Status line and its README row in the same commit as its code, so the ledger and git log cannot desync. And the plan lives in the repository, so recovery is three commands and needs no prior context. Records one correction found while scaffolding: the 22 abandoned agent checkouts under .claude/worktrees/ are live registered git worktrees, not orphaned directories. RD-15 must use `git worktree remove` and verify each branch tip is an ancestor of main first. Spot-checked four; all are already merged via 637d500. Co-Authored-By: Claude Opus 5 --- docs/project/readable-codebase/PLAN.md | 895 +++++++++++++++++++++++ docs/project/readable-codebase/README.md | 180 +++++ 2 files changed, 1075 insertions(+) create mode 100644 docs/project/readable-codebase/PLAN.md create mode 100644 docs/project/readable-codebase/README.md diff --git a/docs/project/readable-codebase/PLAN.md b/docs/project/readable-codebase/PLAN.md new file mode 100644 index 0000000..5cf650a --- /dev/null +++ b/docs/project/readable-codebase/PLAN.md @@ -0,0 +1,895 @@ +# Make the rest of the codebase as readable as the dashboard + +> This is the **living** design record for the arc, committed so that the repository holds +> the complete state and a fresh session needs nothing outside it. Corrections found while +> executing are folded in and marked. See [`README.md`](README.md) for the ticket ledger and +> the session protocol. + +## Context + +The dashboard refactor (branch `refactor/readable-dashboard`, 3 commits, pushed) cut +`dashboard.page.ts` from 340 lines to 42 by splitting it into six per-concern section +components. It was built as a **reference implementation**: prove the pattern on one screen, +then hold the rest of the app to the budget it establishes. + +This plan applies that result to the rest of the codebase. It is grounded in fresh +measurement, not in the original plan's estimates — several of which turned out to be stale +(see "Corrections" at the end). + +Two findings reframe the work: + +1. **Pages are already thin.** 17 pages, median 88 lines; only the sanctioned showcase + teaching page exceeds 250. The "page → sections" split is essentially done. The remaining + bulk sits one layer down, in **organisms**. +2. **The worst problem is not size, it is a silent-failure idiom.** `runIfSubmitting` is + copy-pasted into 5 components under 2 names and must be called by hand right after + `dispatch`. Forgetting it fails silently. That is a correctness risk, not a cosmetic one. + +**Sequencing matters, and the phase numbers are not quite the running order:** + +1. **Phase 3a first** — the `max-lines` rule plus `reportUnusedDisableDirectives: 'error'`. It + is one small commit, it lands green today, and it stops every later phase from adding a new + oversized file. Cheap insurance bought before the spending starts. +2. **Then Phase 0** (put the dashboard in its right context) — small, self-contained, and it + settles where the section files live before anything else edits them. +3. **Then Phase 1** (idioms) — it _deletes_ code that the Phase 3 splits would otherwise have + to carry: the copy-pasted submit plumbing, the `WizardStatus` switch, several `computed`s. + Split first and you split code you are about to delete. +4. **Then Phase 3b–3i** (the splits), **Phase 2** (mechanical sweeps, independent — fit + anywhere), **Phase 4** (layer move), **Phase 5** (docs) last, so it documents the end state + once rather than tracking each step. + +--- + +## Execution: how this survives a restart and runs on Sonnet agents + +This document is a **design record**. It is not executable as-is: a fresh Sonnet agent has +none of the conversation that produced it, and nothing here records progress. So the first +ticket converts it into the artifact this repo already uses for exactly this. + +### Reuse the existing protocol, do not invent one + +`docs/project/backlog/README.md` is a proven mechanism — 75 work packages driven to `done` +through it, and its own notes say the tickets are _"self-contained (each WP file carries its +own current-state handoff) and sized for a fresh Sonnet session."_ Copy it wholesale: + +- **One ticket file per commit**, using that README's existing template (Status / Why / Read + first / Decisions (pre-made, don't relitigate) / Files / Steps / Acceptance criteria / + Verification / Out of scope / Risks). +- **`Status: todo | in-progress | done`** inside each ticket file. +- **A README with an Order table** carrying every ticket, its dependencies and its status. +- **A runnable GREEN one-liner** as the global definition of done. + +New home: **`docs/project/readable-codebase/`**, prefix **`RD-NN`**. It must not extend +`docs/project/backlog/`, because Phase 5 archives that directory — a finished arc gets +archived, a new arc gets its own folder. `RD-` also avoids collision with the existing +`WP-`/`RB-` prefixes, which matters because Phase 2 greps for those. + +### The one property that makes a restart safe + +**Each ticket updates its own `Status:` line and the README row in the _same commit_ as its +code.** Never in a follow-up commit. That makes `git log` and the ledger impossible to +desync: whatever is committed is done, whatever is not is not. + +Recovery for a fresh session with zero context is three commands: + +```bash +git log --oneline -8 +grep -rn '^Status:' docs/project/readable-codebase/RD-*.md | grep -v done # next work +npm run ci # is HEAD green? +``` + +### Making each ticket Sonnet-executable + +An agent reads _its own ticket_, not this whole document. So each ticket must be +self-contained. Ticket files are written **just in time by the supervisor**, immediately +before delegating, and land in that ticket's own commit — not all 35 up front, which would be +speculative. Three rules when writing one: + +1. **Copy the decision, never a pointer to it.** The `Decisions (pre-made, don't relitigate)` + block carries the verdict from this plan verbatim. No agent re-derives "effect map vs full + Elm" — that is settled here, with reasons, and re-opening it wastes an Opus-shaped judgment + on a Sonnet-shaped task. +2. **Inline the traps that apply to _that_ ticket.** The Risks section below is global; an + agent will not read it. The `Seed` exemption belongs in RD-05's Decisions block, the + longest-key-first sed order in RD-27's, the parameterised-`$localize` rule in RD-25's and + RD-26's. A trap left only in a global list is a trap that fires. +3. **State acceptance as a command, not a sentence.** `npm run lint` exits non-zero, or the + file is under 250 rule-lines, or `npx eslint --report-unused-disable-directives` is clean. + "Lands ~230 lines" is a design estimate and is not checkable; do not put it in Acceptance. + +### GREEN for this arc + +```bash +npm run ci +``` + +Plus, for any ticket touching a story, an `.mdx`, or `libs/shared/src/ui/**`: + +```bash +npm run ci --full # the only thing that builds Storybook and catches a broken MDX import +``` + +Phase 4's move commit **must** run `--full`. So must anything in Phase 3 that moves a template. + +### The agent loop + +One supervisor session drives it; one `developer` agent (Sonnet) executes each ticket. +Per iteration: + +1. Read the README Order table. Pick the first `todo` whose dependencies are all `done`. +2. Spawn **one** `developer` agent with a fixed prompt: _"Read CLAUDE.md, then + `docs/project/readable-codebase/README.md`, then `RD-NN.md` and its Read-first list. + Execute it. End GREEN. Update the ticket's Status and the README row in the same commit. + Do not start another ticket."_ +3. Verify with a `task-runner` agent (Haiku): `npm run ci`, `git log -1 --stat`, and that the + `Status:` line now reads `done`. +4. Green → next iteration. Red → stop and surface. Never mark a ticket done on an agent's + word alone; the check is the exit code. + +For unattended running, `/loop` with that iteration as its prompt works — the ledger is the +state, so a loop that dies mid-arc resumes from the ledger with nothing lost. + +### Sequential by default — and why parallel is worse here + +Every ticket must end `npm run ci` green **on the branch**, and three properties make +concurrent writes to one branch actively hostile: + +- `libs/shared/docs/behaviour-spec.mdx` and `showcase/snippets.generated.ts` are regenerated + and **drift-checked** by CI. Two agents both regenerating conflict by construction. +- Every ticket writes the same README Order row table — contention on literally every + iteration. +- The file sets overlap heavily: the three wizards appear in RD-07, RD-08, RD-20, RD-22 and + RD-23. + +**Where parallelism does pay:** genuinely disjoint tickets, in separate git worktrees +(`isolation: "worktree"` on the Agent tool), merged deliberately. Good candidates: the ticket +sweep (RD-18/RD-19 — 186 files, semantically touching nothing), and the Phase 5 doc tickets. +Cap it at two at a time. + +Evidence for caution: `.claude/worktrees/` currently holds **22 abandoned agent checkouts at +4.7 GB** (RD-09 deletes them). Parallel worktree agents have been used in this repo before and +left the debris behind. Use them deliberately, and clean up. + +### The ticket table (RD-01 materialises this verbatim as the README Order table) + +Each row becomes one `RD-NN-.md` and one commit. "Deps" must all be `done` before a +ticket is picked. The phase sections below this table are the source for each ticket's +`Decisions` block. + +| ID | Ticket | Deps | Source | `--full`? | +| ----- | ------------------------------------------------------------------------------------ | ---------- | --------- | --------- | +| RD-01 | Scaffold `docs/project/readable-codebase/` — README + all RD files | — | Execution | | +| RD-02 | `max-lines` rule + `reportUnusedDisableDirectives: 'error'` + 7 disables | 01 | 3a | | +| RD-03 | `overzicht` context: page + 2 nav sections, dep-cruiser edge, `HEADER_ADMIN_LINKS` | 02 | 0 | yes | +| RD-04 | Story titles → `Domein//`; add stories only where >1 state | 03 | 0 | yes | +| RD-05 | `createStore` gains the effect map + 5 specs | 02 | 1a | | +| RD-06 | **Bug fix:** 2 single-step forms → effect map + retry affordance | 05 | 1a | yes | +| RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | 1a | | +| RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | 1a | yes | +| RD-09 | **Docs + generator:** `plop-templates/form-machine.hbs`, ARCHITECTURE, fp-tea, skill | 08 | 1c | | +| RD-10 | `WizardStatus` → `WizardPhase` (payload-carrying) | 08 | 1b#4 | yes | +| RD-11 | Fold the projection into `remote-data.ts`; PascalCase the 3 machines | 01 | 1b#3 | | +| RD-12 | `ActionState` → `action` on `BriefState.Loaded` | 11 | 1b#2a | | +| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | 1b#2a,#6 | | +| RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | 1b#2b | | +| RD-15 | Delete `.claude/worktrees/` (22 checkouts, 4.7 GB) | 01 | 2.1 | | +| RD-16 | `parseDashboardView` returns `BigProfile`; delete `DashboardView` | 01 | 2.2 | | +| RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | 2.3 | | +| RD-18 | Ticket sweep, frontend — 181 refs / 100 files | 01 | 2.4 | | +| RD-19 | Ticket sweep, backend — 370 refs / 86 files | 01 | 2.4 | | +| RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | 3c | | +| RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | 3h | yes | +| RD-22 | `intake-wizard` → 3 step components | 08, 20 | 3c | yes | +| RD-23 | `registratie-wizard` → 3 steps + upload-controller move | 08, 20 | 3c | yes | +| RD-24 | `concepts.page` → 6 sections + `concept-card` + 2 globals + `--app-code-*` | 02 | 3g | yes | +| RD-25 | `org-template-editor` → `sample-letter.ts` + labels + 2 children | 02 | 3e, 3f | yes | +| RD-26 | `letter-canvas` → labels + `letter-line`; keep its disable | 02 | 3d, 3e | yes | +| RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | 4a | yes | +| RD-28 | Layer-tag fixes (`async` missing, `breadcrumb` Chrome) + beheer doc rule | 27 | 4a, 4b | | +| RD-29 | The 3 ladder rules in `.dependency-cruiser.base.js` | 27 | 4c | | +| RD-30 | Archive `backlog/` + `refactor-backlog-setup/` (16,300 lines) + archive README | 01 | 5.1 | | +| RD-31 | `ARCHITECTURE.md` §6a — symbols not lines, 2 dead paths, new section names | 03, 08, 16 | 5.2 | | +| RD-32 | `fp-tea-atomic-design.md` — 11 broken paths + the broken anchor | 27 | 5.3 | | +| RD-33 | CLAUDE.md + `atomic-design.mdx` + `ui-component` skill | 03, 27, 29 | 5.4-5 | yes | +| RD-34 | _(optional)_ `NO_SUBORGS`/`NO_TABLES` → `RemoteData.Empty` | 11 | 1b | | +| RD-35 | _(optional, last, alone)_ upload `type:` → `tag:` | 27 | 1b#5 | | + +Recommended running order is the ID order; it already respects every dependency. RD-15 through +RD-19 are independent of everything and can be pulled forward whenever a short session needs +filling — RD-15 in particular makes every later search faster and should go early. + +Two ordering traps the table encodes but an agent should be told outright: + +- **RD-01 must precede RD-30**, because RD-01 copies its ticket template _out of_ the very + directory RD-30 archives. +- **Four tickets edit the same two doc files in different sections** — RD-09 rewrites the + submit-idiom teaching (`ARCHITECTURE.md` §2d area, `fp-tea` §338-350), while RD-31 rewrites + `ARCHITECTURE.md` §6a and RD-32 fixes `fp-tea`'s paths. Sequential is fine; never run these + two pairs in parallel worktrees. + +--- + +## Phase 0 — Put the dashboard in the right context + +The dashboard is the portal home, but it lives inside `registratie`, a context that +`.dependency-cruiser.ssp.js` declares as `registratie: []` — permitted to import no other +context. Three concrete symptoms: + +- Its six sections span four concerns: registratie data (3), aanvragen (1), cross-context + action links to `/herregistratie` `/intake` `/brief` `/concepts` (1), admin links to + `/beheer/*` (1). +- The cross-context coupling is **invisible to the linter**, because + `wat-wilt-u-doen.section.ts` links by route _string_. `dep:check` passes and gives false + assurance on exactly this file. +- `beheer-links.section.ts:6` imports `ADMIN_LINKS` from `../../../shell/nav.config` — a + context reaching into the app frame. `app.config.ts:72` **already** provides that same + constant to the shared site header through the `HEADER_ADMIN_LINKS` token. + +### Steps + +1. Scaffold a context with `npm run gen:context` (`plop context`) named **`overzicht`** + (Dutch, per CLAUDE.md: domain contexts are Dutch; the page's own title is + "Mijn overzicht"). +2. Move the **page** and the two **portal-level navigation** sections into it: + - `overzicht/ui/overzicht.page.ts` (was `registratie/ui/dashboard.page.ts`) + - `overzicht/ui/wat-wilt-u-doen.section.ts` + - `overzicht/ui/beheer-links.section.ts` +3. **Leave the four data sections in `registratie/ui/dashboard/`** — `mijn-aanvragen`, + `wat-moet-ik-regelen`, `mijn-registratie`, `specialismen` render registratie data and + belong beside their store. This separation is only possible _because_ of the split; it is + the refactor's first real payoff. +4. Declare the edge in `.dependency-cruiser.ssp.js`: `overzicht: ['registratie']` — the + second sanctioned cross-feature edge, mirroring `herregistratie: ['registratie']`. +5. `beheer-links.section.ts` **injects `HEADER_ADMIN_LINKS`** instead of importing + `shell/nav.config`, removing the context→shell reach-in. +6. Consider giving `wat-wilt-u-doen`'s action list the same treatment — a token beside + `NAV_ITEMS`/`ADMIN_LINKS` in `shell/nav.config`. Route strings on a landing page are + legitimate, but the list is app-frame copy, not registratie's. +7. Update `app.routes.ts:19` to `loadComponent: () => import('@overzicht/ui/overzicht.page')`, + and add the `@overzicht/*` alias to `apps/ssp/tsconfig.json`. + +**Keep the `/dashboard` route** — it is user-visible and in the e2e specs. Renaming it to +`/overzicht` is a separate, optional change needing a redirect. + +Also settle the two deviations the refactor left behind: + +- **Story titles — fix.** They are `Domein/Registratie/Dashboard/`; CLAUDE.md specifies + `Domein//`, "full stop", and all 41 other story files comply. Retitle to + `Domein/Registratie/` for the four that stay and `Domein/Overzicht/` for those + that move. Safe: only `layers.mdx` deep-links a story id, and not one of these three. +- **8 imports — accept, do not fix.** CLAUDE.md has no import-count rule; ≤6 was a proxy + metric, and one `import` per rendered section is exactly right. The only way down is a + `DASHBOARD_SECTIONS` const spread into `imports:`, which trades a self-documenting array for + an indirection and adds a barrel-shaped thing to a repo that deliberately has none. +- Three of six sections have no story (`beheer-links`, `wat-moet-ik-regelen`, + `wat-wilt-u-doen`). Add one only where the section has more than one visual state. + +--- + +## Phase 1 — One submit idiom, and two state encodings instead of six + +> **This phase fixes two live bugs.** It is not only hygiene. + +### 1a. Submit + +`runIfSubmitting` is a `private async` method duplicated across 5 components — named +`runIfIndienen` in the registratie wizard — invoked at 8 call sites, always as: + +```ts +this.dispatch({ tag: 'Submit' }); // the reducer decides +this.runIfSubmitting(); // then re-read state() and re-check the tag it hoped for +``` + +- `behandeling/ui/besluit-form/besluit-form.component.ts:129` +- `registratie/ui/change-request-form/change-request-form.component.ts:175` +- `herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts:275` +- `herregistratie/ui/intake-wizard/intake-wizard.component.ts:386` +- `registratie/ui/registratie-wizard/registratie-wizard.component.ts:634` + +#### The two bugs (fix these regardless of the rest) + +`besluit-form.component.ts` and `change-request-form.component.ts` never dispatch `Retry`, +although `besluit.machine.ts:64` and `change-request.machine.ts:50` both export it. On a +failed submit, `Failed` falls into the `@else` branch that renders the form, but `editing()` +is `null` there, so `besluit()`/`toelichting()` return `''` — **the user sees an emptied +form**. The still-enabled submit button dispatches `Submit`, which the reducer no-ops from +`Failed`. Unrecoverable dead end, reachable from any failed submit, in both components. + +Other defects to close in the same pass: + +- Step-boundary logic lives in the component (`s.step < 3 ? Next : Submit`), beside the + reducer's own exported `next`/`submit`. +- The two herregistratie wizards interleave optimistic store calls the reducer knows nothing + about (`herregistratie-wizard.component.ts:277,282,285`; + `intake-wizard.component.ts:388,396,399`). `registratie-wizard` has no optimistic flag. + +#### Design: `createStore` gains an effect map + +Constraint: **`reduce` stays pure** (CLAUDE.md). The fix lands in the `dispatch` wrapper in +`libs/shared/src/application/store.ts` — inside the one sanctioned wiring idiom, not beside +it. + +```ts +export type StoreEffects = Model extends { tag: string } + ? { + [K in Model['tag']]?: ( + state: Extract, + store: Store, + ) => unknown; + } + : never; + +export function createStore( + init: Model, + update: (model: Model, msg: Msg) => Model, + effects?: StoreEffects, +): Store; +``` + +Four load-bearing choices: + +- **A conditional type, not a `Model extends {tag}` constraint** — the constraint would break + `store.spec.ts:8`'s `createStore(0, (n, m) => n + m)`. With the conditional, `Model = number` + resolves to `never`, so effects are a compile error there and omitting them stays legal. +- **Keys are `Model['tag']`** — a renamed or typo'd state tag becomes a compile error. +- **The narrowed state is argument one** — this is what deletes the + `const s = this.state(); if (s.tag !== 'Submitting') return;` preamble at all 8 sites. The + body cannot run in the wrong state, so it cannot re-guess. +- **The store is argument two** — the effect needs `dispatch`, but `createStore(...)` runs in a + field initializer before `this.store` exists. + +**The trigger rule is where the design lives.** Run `effects[next.tag]` when **both**: + +1. `prev.tag !== next.tag` — the store _entered_ the tag. A `Submit` that fails validation is + `Editing → Editing`: no fire. A second `Submit` while `Submitting` is a reducer no-op: no + fire, so **double-submit protection falls out of the rule**. `Retry` is `Failed → +Submitting`: fires, so `onRetry` needs no special case. +2. **the msg tag is not `Seed`.** This single line is what stops the five `Submitting` + Storybook stories from firing real HTTP (see Risks), and stops `draftSync.onResume` → + `Seed` from re-submitting a resumed draft. + +Implementation note for whoever writes it: capture `prev`/`next` inside the `model.update(...)` +callback and invoke the effect **after** `update` returns. Do not read `model()` inside +`dispatch` — the comment at `store.ts:24-28` explains the livelock, and +`store.spec.ts:16-30` exists because that bug already happened once. + +**Effect bodies do not move.** They stay as private component methods, registered in the map; +the `begin*`/`confirm*`/`rollback*` calls stay inside them. The effect slot _is_ the sanctioned +place for effects, so "side effects stay out of the reducer" holds unchanged. Per-site diff: +delete two guard lines, take the narrowed state, register one map entry. + +```ts +private store = createStore(initial, reduce, { + Submitting: (s, store) => this.submitBesluit(s, store), +}); +``` + +**`onPrimary` → a `Primary` msg** on the 3 wizard machines: +`primary(s) = isLastStep(s) ? submit(s) : next(s)`, composed from each machine's own already- +exported `next`/`submit`/`currentStep`. Keep `Next` and `Submit` in the unions (templates, +specs and the showcase use them). Then `onPrimary()` is one dispatch, `onRetry()` is one +dispatch, and the shell's outputs map 1:1 onto messages — which is what +`.claude/skills/form-machine/SKILL.md:74-78` already claims. + +**Retry affordance:** reuse the existing id `@@wizard.opnieuwProberen` with byte-identical +source text. It already has an English target in **both** `messages.en.xlf` files, so no new +translation is needed. + +#### Rejected alternatives (recorded, not re-litigated) + +- **An Angular `effect()` watching state** — reject. `store.spec.ts:16-30` exists because this + exact pattern livelocked the app. Worse, a signal effect is a latest-value notification, not + an event stream: two dispatches in one tick coalesce, so a transient `Submitting` can be + observed as never having happened — a silently dropped submit, the very failure being fixed. +- **Full Elm `reduce -> [state, Cmd]`** — the honest end state and the only option with + statically exhaustive effect coverage, but disproportionate here: 9 machines, 9 specs, every + dispatch site. And `domain/` may not import Angular (lint-enforced), so a `Cmd` cannot carry + `BigProfileStore.beginHerregistratie` — it becomes a symbolic tag plus a `switch` in the + component, which is the guard being deleted, relocated. Keep as the documented upgrade path. + +### 1b. Six encodings of "in flight / ok / failed" → two + +Two survive: **`RemoteData`** for "data I fetched", and **the machine's own state union** +for "where this thing is". Everything else either folds into one of those or is an honest +exception with a reason. + +| # | Encoding | Where | Disposition | +| --- | ------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `RemoteData` | `application/remote-data.ts` (91 lines), 19 files | **Keep**, and absorb #3 so one file is where a lifecycle becomes async state | +| 2a | `ActionState` | `application/action-state.ts`, 2 producers | **Delete** — both stores collapse it to `busy` + `lastError` byte-identically; zero consumers keep the union. Becomes an `action` field on each machine's `Loaded` variant | +| 2b | `SaveState` | same file | **Keep** — it has **2 genuine 4-way consumers** (`brief.page.ts:149-160`, `org-template.page.ts:102-113`). Move it to `debounced-save.ts`, beside its only producer, then delete `action-state.ts` | +| 3 | `LoadLifecycle` + `machineRemoteData` | `application/machine-remote-data.ts` (24 lines), 3 identical call sites | **Relocate, not delete** — fold into `remote-data.ts` as `fromLoadLifecycle`, keyed PascalCase, beside the existing `fromResource` | +| 4 | `WizardStatus` | `layout/wizard-shell/wizard-shell.component.ts:19` | **Replace with a payload-carrying `WizardPhase`** — the 3 switches drop the error payload, which then travels as a second `errorMessage` input | +| 5 | `UploadStatus` | `domain/upload.machine.ts:11-18` | **Keep** — it _is_ encoding #2 for a sub-machine, and its payloads (`progressPct`, `documentId`, `reason`) are consumed by 3 UI components. Only the `type:` dialect is off; optional, last, alone | +| 6 | 5 ad-hoc flags | see below | **1 folds, 4 keep** | + +**Why #3 relocates rather than deletes:** the mapping has to exist somewhere, because +`` takes `RemoteData`. Deleting the module re-inlines a 6-line switch in 3 stores — +recreating the duplication WP-31 removed. Relocating still wins the whole prize: the lowercase +constraint dies, the dialect drift resolves, and the survivor sits beside `fromResource` as +what it actually is — **a `RemoteData` constructor, not a sixth encoding**. + +**The causal chain that makes the dialect fix free:** `brief`, `org-template` and +`stamdata-editor` are the only three state unions in the repo with lowercase tags, their Msg +tags are PascalCase in the same file, and the _only_ thing pinning them is +`machineRemoteData`'s structural `S extends LoadLifecycle`. Relocate the projection with +PascalCase keys and the drift resolves itself — no separate renaming pass. + +**#4 — the shell genuinely needs the payload.** Replace `WizardStatus` + `errorMessage` with +one input: `{ tag:'Editing' } | { tag:'Submitting' } | { tag:'Submitted' } | { tag:'Failed'; +message: string }`. The 3 mapping `computed`s stay — `Answering` and registratie's Dutch +`Invullen/Indienen/Ingediend/Mislukt` are not the shell's vocabulary — but now carry the +message, so the 3 duplicated `errorMessage` computeds fold in and the second input disappears. +`@switch` cannot narrow, so read `Failed` via the existing `whenTag` (`kernel/fp.ts:27`). +`wizard-shell.stories.ts` must change in the same commit. + +**#6 — one folds, four keep.** Only `org-template.store.ts:59` `pendingPublish` is a genuine +illegal-state pair (`pendingPublish && busy` is representable and meaningless) — it becomes a +fourth `action` variant. The other four are **correctly modelled as they are**: +`big-profile.store.ts:61` `pending` is a lone boolean the dashboard reads _after_ the wizard is +destroyed, so it cannot be derived from the machine; and `aanvragen.store.ts:28`, +`admin-cases.store.ts:26`, `feature-flags.page.ts:96` are one-shot **action** errors sitting +beside a successfully-loaded list. Folding those into the list's `RemoteData` would make the +error _replace_ the list, since `Failure` carries no value — precisely the RB-20 behaviour +those comments exist to prevent. Add a comment saying so, and leave them. + +**A win this reveals:** `org-template.store.ts:29,129` (`NO_SUBORGS`) and `stamdata.store.ts` +(`NO_TABLES`) dispatch `LoadFailed` for what is semantically **`Empty`**. Once the projection +is explicit, a distinct state → `RemoteData.Empty` is a few lines, and `` already +renders it via `emptyText`. Optional, own commit, needs one new `$localize` id. + +### 1c. Commit order + +Ticket mapping: **A1=RD-05, A2=RD-06, A3=RD-07, A4=RD-08, A5=RD-09, B4=RD-10, B3=RD-11, +B2a=RD-12, B2b=RD-13, B2c=RD-14, B6=RD-34, B5=RD-35.** + +Submit first — it settles the final shape of the 5 components, and 1b#4 touches 3 of them. + +| # | Commit | Notes | +| ----- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| A1 | `createStore` gains the effect map + specs | No call sites change. Behaviour-neutral, so risk is isolated to one commit | +| A2 | Migrate the 2 single-step forms; **add the retry affordance** | Closes both bugs. Reuses `@@wizard.opnieuwProberen`, no new xlf target | +| A3 | Add `Primary` to the 3 wizard machines + specs | Pure domain. `check:seam` greps `BESLUIT_TAGS` and `SCHOLING_THRESHOLD_DEFAULT` — keep both greppable | +| A4 | Migrate the 3 wizards to the effect map + `Primary` | `runIfSubmitting`/`runIfIndienen` become registered effects | +| A5 | **Docs and the generator, same diff** | See below — non-optional | +| B4 | `WizardStatus` → `WizardPhase` | Drops the `errorMessage` input; update `wizard-shell.stories.ts` | +| B3 | Fold the projection into `remote-data.ts`; PascalCase the 3 machines | Widest mechanical commit; 77 lowercase literals | +| B2a/b | `ActionState` → `action` on `Loaded` (brief, then org-template + `pendingPublish`) | Must follow B3 or the same tags get renamed twice | +| B2c | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | Type-only | +| B6 | _(optional)_ `NO_SUBORGS`/`NO_TABLES` → `Empty` | Needs a new `$localize` id per app that renders it | +| B5 | _(optional, last, alone)_ upload `type:` → `tag:` | ~20 files; see the sed hazard in Risks | + +**A5 is not optional.** `docs/reference/architecture/ARCHITECTURE.md:308-322` and `:574`, and +`docs/reference/fp-tea-atomic-design.md:338-350`, both teach `runIfSubmitting` verbatim — and +`plop-templates/form-machine.hbs` **generates it**, as does +`.claude/skills/form-machine/SKILL.md:65-78`. If the generator is not updated, the next form +scaffolded in this repo recreates the bug. Update all five in the same commit as A4. + +**Scope discipline:** A1 + A2 alone deliver "dispatching cannot silently skip the effect" and +fix both bugs. If the budget shrinks, stop after A5; B3 and B2 are hygiene, not correctness. + +--- + +## Phase 2 — Mechanical sweeps (no behaviour change) + +1. **Remove `.claude/worktrees/`** — 22 abandoned `agent-` checkouts, **4.7 GB**, + gitignored (`.gitignore:64`). They are why unqualified repo-wide `grep`/`find` return ~23× + inflated counts, which taxes every future search by a human or an agent. + + **Correction made while executing RD-01:** these are **live registered git worktrees**, not + orphaned directories. Each has a `worktree-agent-` branch carrying real RB-xx commits. + So `rm -rf` is wrong — it leaves 22 broken worktree registrations behind. Use + `git worktree remove` per worktree, then delete each branch, then `git worktree prune`. + + Verified during RD-01: the commits are already reachable from `main` (spot-checked + `95bb773`, `80de261`, `dfc6c41`, `ce95294` with `git merge-base --is-ancestor`), because + `637d500` merged the whole RB-01..RB-33 arc. **RD-15 must re-verify all 22 before removing + any** — check every branch tip is an ancestor of `main`, and stop if one is not. + +2. **Finish Step 2's name collapse** (committed as done, but did not land): + `parseDashboardView` still returns `DashboardView` + (`registratie/infrastructure/dashboard-view.adapter.ts:119`), and + `big-profile.store.ts` pays twice — line 35 computes `RemoteData`, + line 45 re-maps to `RemoteData`. Make the parse return `BigProfile` + directly and delete the intermediate. One payload, one name. +3. **`successOf` / `successOr` sweep** — 10 inline unwraps remain in 8 files. They do not all + want the same helper: + - `undefined` fallback → existing `successOf`: `beoordeling.page.ts:78` + - `[]` / `null` fallback → add `successOr(rd, fallback)`: `werkvoorraad.page.ts:61`, + `admin-cases.page.ts:83`, `audit.page.ts:99`, `feature-flags.store.ts:27`, + `registratie-wizard.component.ts:509`, `mijn-aanvragen.section.ts:93` + - boolean predicates → leave as-is: `access.store.ts:36`, `feature-flags.store.ts:51` + - `big-profile.store.ts:57` hand-rolls `map` — **use the existing `map`** from + `remote-data.ts` +4. **Ticket-comment sweep, frontend and backend** (user-selected scope): **551 refs across + 186 files** — 181 in `apps`+`libs` (100 files), 370 in `backend/` (86 files: 70 `.cs`, + plus `Dockerfile`, 4 `.sh`, 4 `.yml`, 2 `.md`). Strip `WP-`/`RB-` and keep the surrounding + sentence; git blame holds the provenance. **Keep all 90 `ADR-000x` refs** across 68 files — + those point at documents that exist. Note `CD-` appears nowhere in the repo. + +--- + +## Phase 3 — The guard first, then 7 splits + +### 3a. The guard goes FIRST, not last + +Putting the rule ahead of the splits stops the refactor itself from adding a new 300-line +file. In `eslint.config.mjs` (which today has no per-folder rules at all): + +```js +{ + files: ['{apps,libs}/**/*.{page,component,section,step}.ts'], + rules: { 'max-lines': ['error', { max: 250, skipBlankLines: true, skipComments: true }] }, +}, +``` + +Two corrections to the approved plan's Step 8, both load-bearing: + +- **The glob must include `section` and `step`.** `*.{page,component}.ts` does **not** match + `*.section.ts` — the file kind the dashboard refactor invented, and the kind Phase 3 creates + most of, would escape the guard entirely. +- **Add `linterOptions: { reportUnusedDisableDirectives: 'error' }`** in the same commit. + ESLint 9 only _warns_ by default and `npm run lint` does not fail on warnings. At `error`, + **every split commit is forced to delete its own `eslint-disable` or lint fails** — the + exemption list cannot rot into permanent debt. The repo has zero disables outside the + generated `api-client.ts` and is clean under this flag today, so it lands green. + +### 3b. Seven offenders, not nine + +Measured with the real rule (`skipBlankLines`, `skipComments`), not `wc -l`: + +| `wc -l` | rule | File | Axis | +| ------- | ------- | ------------------------------------ | ---------------------------------------------- | +| 644 | **574** | `registratie-wizard.component.ts` | **per step** | +| 496 | **472** | `showcase/concepts.page.ts` | **per teaching section** | +| 463 | **414** | `letter-canvas.component.ts` | **labels + one extraction, then stays exempt** | +| 406 | **368** | `intake-wizard.component.ts` | **per step** | +| 353 | **329** | `org-template-editor.component.ts` | **per output cluster** | +| 293 | **253** | `rich-text-editor.component.ts` | over by 3 — move 2 helpers | +| 288 | **252** | `herregistratie-wizard.component.ts` | over by 2 — one shared helper | +| 267 | 232 | `behandel-scherm.component.ts` | **already compliant — leave alone** | +| 262 | 236 | `stamdata-table-editor.component.ts` | **already compliant — leave alone** | + +### 3c. The step contract already exists — do not invent one + +For the wizards, the pattern to copy is **not** the dashboard. It is +`registratie/ui/address-fields/address-fields.component.ts`, which this very wizard already +composes and whose header comment _is_ the contract, verbatim: _"Pure & presentational — +values in via `value`, errors in via `errors`, every keystroke out via `fieldChange`. No +store, no services, no internal state; the container owns the Model and decides what a change +means."_ Two containers already reuse it. + +So: **inputs down, one narrow output up, `dispatch` never passed down.** + +- `registratie-wizard` → `adres.step.ts` (~90), `beroep.step.ts` (~130), `controle.step.ts` + (~110); parent lands ~230. `RegistratieLookupStore` is `providedIn: 'root'`, so the beroep + step injects the same instance and owns its own `` over the DUO lookup — this is + the one place the dashboard's axis _does_ apply. **Moving the upload controller is what gets + the parent under 250**: `createUploadController` takes a `dispatch` callback, so the step + creates its own with `dispatch: (msg) => this.uploadMsg.emit(msg)` — one output, not five. + The BRP prefill `effect` **stays in the parent**, because it writes to the machine. +- `intake-wizard` → `buitenland.step.ts`, `werk.step.ts`, `review.step.ts`; parent ~200. + `scholingZichtbaar` is **not** an input — each step takes the threshold and calls the pure + `lageUren(answers, threshold)` itself ("derive, don't store"). +- `herregistratie-wizard` is over by **two lines**. Do not split its steps for symmetry — its + whole template is ~100 lines. Extract one shared pure helper instead: + `layout/wizard-shell/wizard-errors.ts` with `toWizardErrors()` + spec, beside the existing + `naarStapLabel` that lives there for exactly this reason. Removes ~6 lines from all three + wizards. **Do not** touch the three `shellStatus` switches — the tags genuinely differ per + machine, and an exhaustive switch is the house style. +- `wizard-shell` (205 lines) **already provides the whole frame** — stepper, error summary, + `
`, navigation, submitting/submitted/failed, a11y focus. The steps slot into its + existing default ``. **Nothing new in `libs/shared`.** + +**Corollary: give the new steps no stories.** Each wizard's existing story already mounts +every step by seeding the machine. The dashboard got this right too — 3 stories for 6 +sections, only where there was async state to show. + +### 3d. `letter-canvas` — a misapplied rule, not a split + +20 of its 28 `input()`s are pure `$localize` labels and **no caller overrides a single one** +across all 4 call sites. The CLAUDE.md rule they were built for — _"Shared/English components +must not hardcode Dutch — expose copy as `input()`s"_ — governs `libs/shared`, **not** a Dutch +domain component in `brief/ui/`. Inline them as `i18n="@@id"` in the template; same id, same +source text means **zero `messages.en.xlf` edits**. + +Do **not** collapse them into a config object or an injection token. +`HEADER_NAV_ITEMS`/`DEBUG_PANEL` exist because two apps genuinely differ; here nothing +differs, so a token adds a provider and an indirection to solve a problem nobody has. + +One extraction earns its keep: `letter-line.component.ts` (~70 lines out) — the `#line` +template plus the sample/diff helpers. It replaces six 4-line `ngTemplateOutlet` incantations +with three one-line tags and is the only part with logic worth a spec. + +That leaves ~329: 77 lines of CSS and 204 lines of _one letter_. Splitting it into +letterhead/body/signature/footer makes "what does the letter look like" a five-file question +for no behavioural seam. **Keep one `/* eslint-disable max-lines */`** with an honest reason. +It becomes the only disable in the repo, and `reportUnusedDisableDirectives` keeps it honest. + +### 3e. The `$localize` boundary that governs 3d and 3f + +**Plain messages move to the template; parameterised ones stay in TS.** The xlf stores +interpolations as ``; moving such a message into a +template renames the placeholder to `INTERPOLATION` and **breaks the translation merge**, so +`ng build --localize` fails. Only 4 messages are affected: `orgTemplate.margins`, +`orgTemplate.invalid`, `orgTemplate.publish.impact`, `wizard.naarStap`. + +### 3f. `org-template-editor` — split by output cluster + +The 11 `output()`s are the tell; each child takes one mutation family: + +- `SAMPLE_LETTER_BRIEF` (44 lines) → `brief/domain/sample-letter.ts`. It is a **dead export** + (used only in its own file) and it is production content, not a fixture — so it must **not** + go near `brief.testing.ts`, or dependency-cruiser's `no-testing-in-production` rule fails. +- 11 of 13 label inputs → template `i18n` (they are declared `protected`, so they were never + bindable — constants wearing `input()` ceremony). The two parameterised ones stay per 3e. +- `logo-upload.component.ts` (~34 out) and `version-history.component.ts` (~18 out). +- Parent drops 11 outputs → 5 and lands ~222. No exemption. + +### 3g. `concepts.page.ts` — split by section, but decompose the CSS by owner + +A per-section split does **not** fix the 142-line `styles:` block, because Angular scopes +styles per component: the page's `.card` cannot style a child's DOM. So: + +- `.section` → **delete**, use the existing global `.app-section`. +- `.lead`, `.cols` → two new globals beside `.app-text-subtle`/`.app-stack` in + `libs/shared/styles.scss`, whose own comment says it exists to centralise these idioms. +- `.card`, `.tag*`, `.note`, `pre` (~70 lines) → owned once by `concept-card.component.ts`, + used ~11 times, which also deletes ~11 copies of the card boilerplate. + +**Watch the colour guard.** `scripts/check-tokens.sh` greps only `--include='*.component.ts'` +— which is why this page currently gets away with `#1e2430`, `#fff`, `#e5e5e5`. Moving that +CSS into a `*.component.ts` brings it under the guard **for the first time**, so in the same +commit: drop the `var(--rhc-x, #hex)` fallbacks (all 16 tokens are defined) and add +`--app-code-bg/-fg/-keyword/-string/-comment` for the `pre` palette, following the existing +`--app-devpanel-*` precedent added for this exact reason. + +### 3h. `rich-text-editor` — over by three + +`rich-text-dom.ts` already exists beside it with its own spec, so the seam is built. Move the +selection/range surgery out of `deleteAdjacentChip`/`insert` into it. Cheapest of the seven, +and it converts two untested imperative-DOM branches into spec cases. + +### 3i. Order + +1. the rule + `reportUnusedDisableDirectives` + 7 disables +2. `wizard-errors.ts` + spec, adopted by all three wizards → delete that disable +3. `rich-text-dom` helpers + spec → delete that disable +4. `intake-wizard` → 3 steps +5. `registratie-wizard` → 3 steps + the upload-controller move +6. `concepts.page` → 6 sections + `concept-card` + 2 globals + `--app-code-*` tokens +7. `org-template-editor` → `sample-letter.ts` + labels + 2 children +8. `letter-canvas` → labels + `letter-line`; **keep** its disable, rewrite the reason + +Steps 2–8 are independent; only 2 must precede 4 and 5. + +--- + +## Phase 4 — Make the folder equal the layer, then enforce the ladder + +### 4a. Move (`libs/shared/src/ui/` only) + +``` +ui/atoms/ 12 flat + upload/{delivery-channel-toggle,document-chip,file-input, + upload-progress-bar,upload-status-icon} (17) +ui/molecules/ 13 flat + upload/single-upload (14) +ui/organisms/ upload/{document-category,document-upload} (2) +``` + +`upload/` **splits by layer but keeps its feature subfolder inside each layer**. It satisfies +decision #2 literally, costs the same 6 relative-import rewrites as a flat split, keeps a +genuinely cohesive group together, and makes "5 atoms + 1 molecule + 2 organisms" visible in +the tree instead of hidden in story titles. No barrel — the repo has none and does not need +one. + +**`layout/` does not move.** CLAUDE.md §5 _explicitly_ enumerates `libs/shared/layout` +components getting Atoms…Templates buckets, so `layout/` is sanctioned to hold several layers; +its organisms are chrome only its own templates compose. Instead, fix the two mislabels: +`async.component.ts` is missing its `/** Molecule: */` tag, and `breadcrumb.component.ts` says +`/** Chrome: */` where its title says Molecules. + +**`libs/beheer/src/ui/` — fix the doc, not the code.** Its story title is +`Domein/Beheer/Stamdata Table Editor` while CLAUDE.md §5 and `layers.mdx` say +`Design System/…`. The code is right: `libs/beheer` _is_ a bounded context that lives in +`libs/` only to be shared by two apps. Amend the two doc lines. That dissolves the +"two taxonomies" oddity and beheer correctly needs no layer folders. + +**Mechanics, one commit for all of `ui/`:** 33 whole-directory `git mv`s break zero relative +imports except the 6 inside `upload/`; then rewrite the 28 distinct specifier strings +(179 occurrences, 59 files) **longest-key-first**, so `upload//` is processed before any +bare `upload/`. Confirmed: no edits to `angular.json`, `eslint.config.mjs`, `plopfile.mjs`, +`e2e/`, or any tsconfig; both Storybook globs are recursive; dependency-cruiser's +`ui-not-infrastructure` pattern still matches a nested path; and `check-tokens.sh`'s CIBG-GAP +check keys on the **directory basename**, which a parent-folder move preserves. + +Verify with `npm run typecheck` (4 tsconfigs — catches every missed specifier), `dep:check`, +`test`, then **`npm run ci --full`**. `git diff --stat -M` should show only renames plus +one-line import edits. + +### 4b. Keep all 78 layer-tag comments + +Reversal of my earlier claim. The tag prefixes a real one-line description +(`/** Atom: thin wrapper over CIBG .btn — typed variant API. */`); deleting the word leaves +the sentence and buys nothing. And only the 32 in `ui/` are made redundant by folders — the 25 +organisms and 9 pages in `apps/**/ui/` have no layer folder and a title that deliberately +omits the layer, so there the comment is the **sole** carrier. A three-way redundancy that has +never once disagreed is cheap documentation. + +### 4c. The ladder rules are the real prize + +The folder move is what makes this _expressible_; this is what makes it _enforced_. Today +nothing stops an atom importing an organism. Add to `.dependency-cruiser.base.js`: + +- `atoms-compose-nothing-above`: `ui/atoms/` → `ui/(molecules|organisms)/` forbidden +- `molecules-below-organisms`: `ui/molecules/` → `ui/organisms/` forbidden +- `design-system-not-layout`: `ui/` → `layout/` forbidden + +Two details that matter: **forbid upward only, never "atoms are leaves"** — same-layer edges +are legitimate and exist today (`masked-value → button`, `review-section → data-block`, +`task-list → choice-link`); and `pathNot` must exempt `\.(spec|stories)\.ts$`, because +`async.stories.ts` composes `skeleton` and a story may legitimately reach for context. + +Zero upward edges exist today, so all three land green immediately. Dependency-cruiser rather +than ESLint: it is where every other boundary rule lives and it emits the architecture graph. + +--- + +## Phase 5 — Fix the docs that describe this flow + +1. **Archive the finished backlog.** `git mv docs/project/backlog` and + `docs/project/refactor-backlog-setup` under `docs/project/archive/`. Verified: **all 74 + WP files are `Status: done`**; the two trees are 6,982 + 9,318 = **16,300 of the docs + tree's 20,317 lines**. Add a ~15-line `archive/README.md`: this is historical, git holds + the rest. +2. **`ARCHITECTURE.md` §6a** ("The request lifecycle today", line 542) is the best onboarding + artifact in the repo and has rotted: + - 15 `L` line citations, now wrong — `Program.cs` L80 lands on a `// WP-60:` comment + about client timeouts, not `/dashboard-view`; L120 lands mid-expression. **Cite symbols, + not lines.** + - Two cited paths do not exist: `src/environments/environment.ts` (pre-monorepo) and + `proxy.conf.json`. + - Its read-walkthrough shows `` on the dashboard page; + after the split that markup lives in `mijn-registratie.section.ts`. + - It states the boundary yields `RemoteData` — wrong once Phase 2.2 lands. +3. **`docs/reference/fp-tea-atomic-design.md`** — 11 pre-monorepo `src/app/…` paths, all + broken; one (`submit-herregistratie.ts`) points at a deleted file; and a broken anchor at + line 427 (`#1-the-big-picture-three-contexts-four-layers` vs the actual + "two apps, cross-app libraries"). +4. Update CLAUDE.md for the new `overzicht` context, the `max-lines` budget, the `libs/beheer` + title rule (4a), and the step-component contract (3c). +5. `libs/shared/docs/atomic-design.mdx` gains the step contract and the layer table; also fix + its stale claim that `eslint.config.mjs` enforces the layer rules — dependency-cruiser + does. Fix the stale `src/app/shared/ui/...` paths in `.claude/skills/ui-component/SKILL.md`. + +--- + +## Risks + +1. **The Storybook trap (highest).** All 5 components mount their `Submitting`/`Indienen` state + via `Seed` in stories that use a real `provideHttpClient()` with **no request mocking** + (`besluit-form.stories.ts:33`, `herregistratie-wizard.stories.ts:70`, + `intake-wizard.stories.ts:38`, `change-request-form.stories.ts:34`, + `registratie-wizard.stories.ts:88`). Without the `Seed` exemption in the trigger rule they + fire real network calls, flip to `Failed`, and red the `storybook-a11y` job + (`npm run ci --full`). Verifying those 5 stories still show a spinner **is** the exemption's + acceptance test. +2. **`dispatch` becomes effectful, and two dispatch sites sit inside Angular `effect()`s** — + `intake-wizard.component.ts:363` (`SetPolicy`) and `registratie-wizard.component.ts:~596` + (`PrefillAdres`). Both land on an unchanged tag, so nothing fires, and both are already + `untracked`. **Never key an effect on the editing tag** — that is the livelock. +3. **The `type:` → `tag:` sed hazard (B5).** `type` is also a legitimate _field_ name in that + neighbourhood — `rejectReason(cat, { type: file.type, sizeMb })` — and + `FileRejected.reason` has the literal value `'type'`. A blind rename breaks upload + validation silently. File-by-file with the type-checker, or defer. +4. **The ticket sweep is 186 files, including `backend/`'s `Dockerfile`, 4 `.sh` and 4 `.yml`.** + Do not blanket-`sed`. A `WP-`/`RB-` token could appear in a string that matters (seed data, + a test name, a migration id) rather than a comment. Review the diff per file group, and + keep all 90 `ADR-000x` refs. +5. **CI drift gates bite mechanically.** `scripts/ci-local.sh:33-34` diffs + `showcase/snippets.generated.ts` (fed by `// #region showcase:` markers — `remote-data.ts:30` + carries `showcase:fold`, `intake.machine.ts:59` carries `showcase:steps`) and + `libs/shared/docs/behaviour-spec.mdx`. Run `gen:snippets` / `gen:behaviour-spec` in the + **same commit** as any change that moves a region or a spec title. +6. **`action` inside `Loaded` narrows where "busy" can exist.** Re-check every current + `actionState.set({tag:'Busy'})` site for reachability from a non-loaded state. Note a + `BriefLoaded` reload then resets `action` to `Idle`, which is a behaviour _improvement_: a + stale error can no longer outlive a reload. +7. **Highest risk in Phase 4: a broken MDX story import passes `npm run ci` and fails CI.** + `libs/shared/docs/*.mdx` has **8 relative story imports** across `a11y.mdx`, + `atomic-design.mdx`, `remote-data.mdx` and `fp-in-ui.mdx` of the form + `../src/ui//.stories`. These break on the move, and only `build-storybook` + catches them — which is **not** in the default `npm run ci`, only `--full`. Fix all 8 in the + move commit and run `npm run ci --full` before pushing it. +8. **`check-tokens.sh` has blind spots in both directions.** It greps only + `--include='*.component.ts'`. Moving CSS from a `*.page.ts` into a `*.component.ts` newly + _exposes_ it (see 3g); moving CSS to a `.styles.ts` or `.scss` newly _hides_ it. Decide + deliberately, and widen the glob in the same commit if you move CSS out. +9. **`styles: [importedConst]` is unproven in this repo** — 47 of 47 components use inline + literals and only 2 `.scss` files exist. If the `letter-canvas` style extraction is + attempted, prove it with `ng build` first; the fallback is the single `eslint-disable`. +10. **Behaviour drift while moving 200+ template lines.** Move template text byte-identically + and let `git diff -M` prove it. The wizards' specs cover `reduce`, not the markup, so the + markup's only guards are review and the axe run in `ci --full`. +11. **A step component reaching for the store.** Passing `dispatch` down is tempting and would + let a step dispatch `Submit`. Use outputs, per `address-fields`. If overridden, type the + input as `Extract` so an illegal dispatch is + unrepresentable. + +## Verification + +Per commit: + +1. `npm run ci` — lint (incl. the new `max-lines`), typecheck, `dep:check`, format, tokens, + both apps' tests, `ng build --localize` (catches a missing `messages.en.xlf` target for + any moved `$localize` string), audit, backend `dotnet test`, API-client drift. +2. `cd backend && dotnet test` for anything touching the wire. + +End to end, after Phase 0 and Phase 3: + +3. `npm start`, open `http://localhost:4200/dashboard`. All six sections render as before. +4. `?scenario=loading`, `=empty`, `=error`, `=slow` — each section shows its **own** state, + not one page-wide spinner. +5. `?role=admin` — the Beheer section appears; without it, absent. +6. Resume and cancel a concept aanvraag — optimistic update and error path both work. +7. Walk each wizard end to end after Phase 1: submit, retry after a failure, and the + step-boundary transitions. +8. `npm run storybook` and `npm run storybook:behandelportal` — moved and new stories render, + a11y addon clean. + +--- + +## Corrections to the original plan's claims + +Measured against the current tree, not assumed: + +- **Step 2 did not fully land** — the `DashboardViewDto → DashboardView → BigProfile` chain is + intact (Phase 2.2). +- **7 files exceed 250 lines, not 8.** The plan counted by `wc -l`; the rule as specified uses + `skipBlankLines` + `skipComments`. `concepts.page.ts` (472) was missing from its list, but + `behandel-scherm` (232) and `stamdata-table-editor` (236) were on it and already pass. +- **551 ticket refs across 186 files**, not 478/170. **`CD-` does not exist** anywhere. +- **Only 2 of the 4 named adapters make no HTTP call.** `letter-preview.adapter.ts` and + `reveal-bignummer.adapter.ts` both `fetch` for real (deliberately hand-written, not the + generated client). Only `MedewerkerAdapter` and `DigidAdapter` are pure stand-ins, and both + already carry a `// ponytail: fake …` label. Renaming those two is cosmetic — low priority. +- **`registratie-wizard`'s Dutch tags (`Invullen`/`Indienen`/`Ingediend`/`Mislukt`) are + correct**, not drift — CLAUDE.md requires Dutch domain contexts. Do not "fix" them. +- **Storybook titles and header comments agree in all but two cases** across the 41 story + files, so the folder=layer move is mechanical, not a taxonomy debate. The two: + `async.component.ts` has no tag at all, and `breadcrumb.component.ts` says `/** Chrome: */`. + +And four corrections to claims made **earlier in this same investigation**, caught by reading +the consumers and the rule semantics rather than the definitions: + +- **"~62 of the 77 layer-tag comments become deletable" — withdrawn.** Keep all 78 (the count + was also one short). The tag prefixes a real description, so deleting the word leaves the + sentence; and only the 32 in `libs/shared/src/ui/` are made redundant by folders. Elsewhere + the comment is the sole carrier of the layer. +- **`libs/shared/src/ui/` holds 17 atoms, not 16.** + +- **`SaveState` is not redundant.** It has 2 genuine consumers that keep all four cases + (`brief.page.ts:149-160`, `org-template.page.ts:102-113`). Only `ActionState` collapses, so + `action-state.ts` must be **split, not deleted**. +- **"Five ad-hoc boolean/nullable-string pairs" was overstated.** Only + `org-template.store.ts:59` is a genuine illegal-state pair. Three of the five + (`aanvragen.store.ts:28`, `admin-cases.store.ts:26`, `feature-flags.page.ts:96`) are a lone + `signal` with **no boolean partner** — an action error beside a loaded list, + on a different axis from the fetch, correctly modelled as it stands. + +## Deliberately out of scope + +- Renaming the `/dashboard` route to `/overzicht` (needs a redirect; user-visible). +- Splitting `libs/shared/src/layout/` by layer (see 4a for why not). Cost if you disagree: 10 + distinct specifiers, 34 occurrences, 28 files. +- Splitting `herregistratie-wizard`'s three steps for symmetry with the other two wizards. It + is over budget by two lines and its template is ~100 lines. Worth noting as a consistency + follow-up, not as work. +- `behandel-scherm.component.ts` and `stamdata-table-editor.component.ts` — both already pass + the rule. Leave them alone. +- The 7 non-component files over 250 lines (`brief.adapter.ts` 408, `upload.machine.spec.ts` + 364, `brief.store.spec.ts` 357, …). The glob deliberately does not reach them. +- A `scripts/check-layers.sh` asserting folder == tag == story title. Cheap (~15 lines, + modelled on the CIBG-GAP check) but it only catches doc typos. +- The admin `Case`/`Zaak` vocabulary rename — a separate read model. +- NgRx, real auth, runtime DTO validation on every endpoint (CLAUDE.md "out of scope"). diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md new file mode 100644 index 0000000..1cee96b --- /dev/null +++ b/docs/project/readable-codebase/README.md @@ -0,0 +1,180 @@ +# Readable codebase — apply the dashboard pattern to the rest of the app + +The dashboard refactor cut `dashboard.page.ts` from 340 lines to 42. It was built as a +**reference implementation**: prove the pattern on one screen, then hold the rest of the app +to the budget it establishes. + +This arc applies that result. The full design record, with every measurement and every +rejected alternative, is [`PLAN.md`](PLAN.md). Each ticket below is one commit. + +Two findings shape the work: + +1. **Pages are already thin** (17 pages, median 88 lines). The remaining bulk sits one layer + down, in organisms. +2. **The worst problem is not size.** `runIfSubmitting` is copy-pasted into 5 components and + must be called by hand after `dispatch`. Forgetting it fails silently. RD-06 fixes two + user-facing bugs that follow from it. + +## Session protocol + +- **One ticket per session.** Read `CLAUDE.md`, this README, the ticket file, and the + ticket's "Read first" list — then execute. Do not start the next ticket in the same + session. +- The **Decisions** block in each ticket is pre-made. Do not relitigate it. `PLAN.md` records + why, including the alternatives that were rejected and the reasons. +- **Ticket files are written just in time, not all 35 up front.** Writing one means choosing + which decisions and which traps from `PLAN.md` belong in it, so the **supervisor** writes + the ticket file (an Opus-shaped job) immediately before delegating it. The file lands in + that ticket's own commit. Generating all 35 in advance would be speculative — later tickets + are better written once the earlier ones have taught us something. +- **Match the model to the step** (see CLAUDE.md, "Model routing for agent delegation"). + Executing a ticket is written for the `developer` agent (Sonnet). Read-only checks go to + `task-runner` (Haiku). Escalate to `planner` (Opus) only if a Decisions block turns out to + be wrong — in which case stop, and fix `PLAN.md` first. +- A ticket ends **GREEN**, with its acceptance criteria checked, its `Status:` set to `done`, + **and its README row updated — all in the same commit as the code.** Never in a follow-up + commit. This is what makes a restart safe: whatever is committed is done, and whatever is + not is not. +- **`Status: done` carries no commit hash**, because a commit cannot contain its own hash. The + commit is recoverable when you need it: + `git log --oneline --diff-filter=A -- docs/project/readable-codebase/RD-NN-*.md`. +- No ticket leaves a check disabled without an inline reason **and** a reference to the + ticket that removes it. + +## GREEN (global definition of done) + +```bash +npm run ci +``` + +For any ticket whose "`--full`?" column says yes — it touches a story, an `.mdx`, or +`libs/shared/src/ui/**` — additionally: + +```bash +npm run ci --full +``` + +`npm run ci` does **not** build Storybook. Only `--full` does, and a broken `.mdx` story +import is invisible until it runs. RD-27 in particular must not be pushed without it. + +## Recovery after a restart + +A fresh session with no context needs three commands: + +```bash +git log --oneline -8 +grep -rn '^Status:' docs/project/readable-codebase/RD-*.md | grep -v done # next work +npm run ci # is HEAD green? +``` + +Then read `PLAN.md` for the design record, and the first `todo` ticket for the work. + +## The agent loop + +One supervisor session drives it; one `developer` agent executes each ticket: + +1. Read the Order table. Pick the first `todo` whose dependencies are all `done`. +2. Spawn **one** `developer` agent: _"Read `CLAUDE.md`, then + `docs/project/readable-codebase/README.md`, then `RD-NN.md` and its Read-first list. + Execute it. End GREEN. Update the ticket Status and the README row in the same commit as + the code. Do not start another ticket."_ +3. Verify with `task-runner`: `npm run ci`, `git log -1 --stat`, and that `Status:` now reads + `done`. Never mark a ticket done on an agent's report alone — the check is the exit code. +4. Green: next iteration. Red: stop and surface it. + +**Run tickets sequentially.** Three properties make concurrent writes to one branch hostile: +`behaviour-spec.mdx` and `snippets.generated.ts` are regenerated and drift-checked, so two +agents regenerating conflict by construction; every ticket writes this README's Order table; +and the file sets overlap (the three wizards appear in RD-06, RD-07, RD-22 and RD-23). + +Parallel work pays only for genuinely disjoint tickets, in separate git worktrees, merged +deliberately — RD-18/RD-19 (the ticket sweep) and the Phase 5 doc tickets qualify. Cap at +two. Note that RD-15 exists because 22 abandoned agent worktrees are still on disk. + +## Order + +| ID | Ticket | Deps | `--full`? | Status | +| ----- | ---------------------------------------------------------------------------- | ---------- | --------- | ------ | +| RD-01 | Scaffold this backlog: README, PLAN, ticket template | — | | done | +| RD-02 | `max-lines` rule + `reportUnusedDisableDirectives` + 7 disables | 01 | | todo | +| RD-03 | `overzicht` context: page + 2 nav sections, boundary edge, admin-links token | 02 | yes | todo | +| RD-04 | Story titles to `Domein//`; add the missing stories | 03 | yes | todo | +| RD-05 | `createStore` gains the effect map + specs | 02 | | todo | +| RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | todo | +| RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | todo | +| RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | todo | +| RD-09 | **Docs + generator:** `form-machine.hbs`, ARCHITECTURE, fp-tea, skill | 08 | | todo | +| RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | todo | +| RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | todo | +| RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | todo | +| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | todo | +| RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | todo | +| RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | todo | +| RD-16 | `parseDashboardView` returns `BigProfile`; delete `DashboardView` | 01 | | todo | +| RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | todo | +| RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | | todo | +| RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | todo | +| RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | todo | +| RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | todo | +| RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | todo | +| RD-23 | `registratie-wizard` to 3 steps + the upload-controller move | 08, 20 | yes | todo | +| RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens | 02 | yes | todo | +| RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children | 02 | yes | todo | +| RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable | 02 | yes | todo | +| RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | yes | todo | +| RD-28 | Layer-tag fixes + the `libs/beheer` title rule | 27 | | todo | +| RD-29 | The 3 atomic-ladder rules in dependency-cruiser | 27 | | todo | +| RD-30 | Archive the finished backlogs (16,300 lines) + an archive README | 01 | | todo | +| RD-31 | `ARCHITECTURE.md` section 6a: symbols not lines, 2 dead paths, new names | 03, 08, 16 | | todo | +| RD-32 | `fp-tea-atomic-design.md`: 11 broken paths + the broken anchor | 27 | | todo | +| RD-33 | CLAUDE.md + `atomic-design.mdx` + the `ui-component` skill | 03, 27, 29 | yes | todo | +| RD-34 | _(optional)_ `NO_SUBORGS`/`NO_TABLES` become `RemoteData.Empty` | 11 | | todo | +| RD-35 | _(optional, last, alone)_ upload `type:` discriminant to `tag:` | 27 | | todo | + +The ID order already respects every dependency, so it is the recommended running order. + +**Independent tickets.** RD-15 through RD-19 depend only on RD-01. Pull them forward to fill +a short session. Take RD-15 early: it makes every later repository search faster. + +**Two ordering traps the table encodes.** RD-01 must precede RD-30, because RD-01 copies its +ticket template out of the directory that RD-30 archives. And four tickets edit the same two +documents in different sections — RD-09 rewrites the submit-idiom teaching, while RD-31 and +RD-32 fix section 6a and the stale paths. Sequential is fine. Never put those pairs in +parallel worktrees. + +## Ticket template + +```markdown +# RD-NN — Title + +Status: todo | in-progress | done +Source: PLAN.md section + +## Why + +## Read first + +## Decisions (pre-made, don't relitigate) + +## Files + +## Steps + +## Acceptance criteria + +## Verification + +## Out of scope + +## Risks +``` + +Three rules when you write a ticket file, because the agent reads its ticket and not +`PLAN.md`: + +1. **Copy the decision, never a pointer to it.** The verdict goes in the Decisions block, + verbatim. +2. **Inline the traps that apply to that ticket.** A trap recorded only in `PLAN.md`'s global + Risks section is a trap that fires. +3. **State acceptance as a command, not a sentence.** "Lands about 230 lines" is a design + estimate and nothing can check it. `npm run lint` has an exit code. From b9d572cfdc65cce41af3466449f9f3f431df7711 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 16:17:07 +0200 Subject: [PATCH 02/46] feat: add max-lines guard with self-cleaning exemptions (RD-02) The dashboard refactor proved a page can be 42 lines. This rule holds the rest of the app to that budget, before the split work in RD-20 through RD-26 begins. Add max-lines at 250 (skipBlankLines, skipComments), scoped to {apps,libs}/**/*.{page,component,section,step}.ts. The glob includes section and step, the file kinds the dashboard refactor invented, so new files from this arc do not escape the guard. Add linterOptions.reportUnusedDisableDirectives: 'error' repo-wide. ESLint 9 only warns on an unused disable by default, so a stale exemption would not fail the build. At 'error', every later file split must delete its own exemption or the build goes red. Add a dated /* eslint-disable max-lines */ header to each of the seven files that exceed the budget today, each with a reason and the ticket that removes it. letter-canvas keeps its disable after RD-26, because most of its lines are CSS and the rest is one letter. Co-Authored-By: Claude Sonnet 5 --- .../letter-canvas/letter-canvas.component.ts | 1 + .../org-template-editor.component.ts | 1 + .../herregistratie-wizard.component.ts | 1 + .../intake-wizard/intake-wizard.component.ts | 1 + .../registratie-wizard.component.ts | 1 + apps/ssp/src/app/showcase/concepts.page.ts | 1 + .../RD-02-max-lines-guard.md | 118 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- eslint.config.mjs | 19 +++ .../rich-text-editor.component.ts | 1 + 10 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 docs/project/readable-codebase/RD-02-max-lines-guard.md diff --git a/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts b/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts index 07ac186..b5aca9a 100644 --- a/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts +++ b/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ // 77 lines are CSS, the rest is one letter — RD-26 rewrites this reason, keeps the disable import { Component, DestroyRef, diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts index 2690e0f..7cd9b69 100644 --- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts +++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ // sample letter + labels + editor in one file — removed by RD-25 import { Component, computed, input, output } from '@angular/core'; import { DatePipe } from '@angular/common'; import { HeadingComponent } from '@shared/ui/heading/heading.component'; diff --git a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index 986dae0..4aaf099 100644 --- a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ // single-step wizard shell — removed by RD-20 import { Component, computed, inject, input } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; diff --git a/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts b/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts index 1bdb683..81f8414 100644 --- a/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts +++ b/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ // one wizard shell for the intake steps — removed by RD-22 import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index 25e3b19..26cc230 100644 --- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ // one wizard shell for 3 steps + upload — removed by RD-23 import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; diff --git a/apps/ssp/src/app/showcase/concepts.page.ts b/apps/ssp/src/app/showcase/concepts.page.ts index cb4eeee..829a216 100644 --- a/apps/ssp/src/app/showcase/concepts.page.ts +++ b/apps/ssp/src/app/showcase/concepts.page.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ // teaching page covering every concept — removed by RD-24 import { Component, computed, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import type { Resource } from '@angular/core'; diff --git a/docs/project/readable-codebase/RD-02-max-lines-guard.md b/docs/project/readable-codebase/RD-02-max-lines-guard.md new file mode 100644 index 0000000..f57c767 --- /dev/null +++ b/docs/project/readable-codebase/RD-02-max-lines-guard.md @@ -0,0 +1,118 @@ +# RD-02 — `max-lines` guard with a self-cleaning exemption list + +Status: done +Source: PLAN.md section 3a + +## Why + +The dashboard refactor proved a page can be 42 lines. Nothing stops the next one being 340 +again, and nothing stops this arc itself adding a new oversized file. The guard therefore +lands **before** the work it protects, not after. + +Seven files exceed the budget today. Each becomes a visible, dated to-do rather than a +silent one. + +## Read first + +- `eslint.config.mjs` — a flat array of 4 config objects. You add a 5th. +- `docs/project/readable-codebase/README.md` — the session protocol and GREEN. +- CLAUDE.md, "Enforced, not just hoped-for". + +## Decisions (pre-made, don't relitigate) + +1. **The rule.** `max-lines` at 250, `skipBlankLines: true`, `skipComments: true`, scoped to + `['{apps,libs}/**/*.{page,component,section,step}.ts']`. + +2. **The glob must include `section` and `step`.** `*.{page,component}.ts` does **not** match + `*.section.ts` — the file kind the dashboard refactor invented, and the kind RD-22 through + RD-26 create most of. Omitting them lets every new file this arc produces escape the guard. + This is the single most important line of the ticket. + +3. **Add `linterOptions: { reportUnusedDisableDirectives: 'error' }`, in this same commit.** + ESLint 9 only _warns_ by default, and `npm run lint` does not fail on warnings. At `error`, + a disable that is no longer needed becomes a lint failure — so **every later split commit + is forced to delete its own exemption or go red.** The exemption list cannot rot into + permanent debt. Verified clean on the current tree, so it lands green. + +4. **250 is chosen because the dashboard proves it is reachable**, not from a style guide. + +5. **Seven files, not eight or nine.** The original plan counted with `wc -l`; this rule + counts without blank lines or comments. `behandel-scherm.component.ts` (232) and + `stamdata-table-editor.component.ts` (236) were on the old list and **already pass** — + leave them alone. + +6. **Each disable names its reason and the ticket that removes it**, per CLAUDE.md's standing + rule. Use the exact mapping in Files below. Note `letter-canvas` is the one file whose + disable is **expected to stay** — RD-26 rewrites its reason rather than deleting it, + because 77 of its lines are CSS and the rest is one letter (PLAN.md 3d). + +## Files + +Add the config block to `eslint.config.mjs`, then a `/* eslint-disable max-lines */` header +with a one-line reason to each of these seven. Verified counts under the rule: + +| Rule lines | File | Removed by | +| ---------- | --------------------------------------------------------------------------------------------- | ----------------------- | +| 574 | `apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` | RD-23 | +| 472 | `apps/ssp/src/app/showcase/concepts.page.ts` | RD-24 | +| 414 | `apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts` | RD-26 (rewrites, keeps) | +| 368 | `apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` | RD-22 | +| 329 | `apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts` | RD-25 | +| 253 | `libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts` | RD-21 | +| 252 | `apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts` | RD-20 | + +## Steps + +1. Add the 5th config object to `eslint.config.mjs` with the rule from decision 1, and a + short comment saying why 250 and that the exemptions are the to-do list. +2. Add `linterOptions: { reportUnusedDisableDirectives: 'error' }` so it applies repo-wide. +3. Add the seven disable headers, each of the form + `/* eslint-disable max-lines */ // — removed by RD-NN`. +4. Update this ticket's `Status:` to `done` and the README's RD-02 row to `done`. +5. Commit all of it together. + +## Acceptance criteria + +Commands, not judgements: + +```bash +npm run lint # exits 0 +npm run ci # exits 0 +``` + +Then prove the guard actually bites, and that the glob covers section files: + +```bash +# 1. A new oversized section file must FAIL. Expect a max-lines error, then delete the file. +printf '/* x */\nexport class X {\n%s\n}\n' "$(for i in $(seq 260); do echo " p$i = $i;"; done)" \ + > apps/ssp/src/app/registratie/ui/dashboard/tmp-probe.section.ts +npx eslint apps/ssp/src/app/registratie/ui/dashboard/tmp-probe.section.ts # must report max-lines +rm apps/ssp/src/app/registratie/ui/dashboard/tmp-probe.section.ts + +# 2. Removing any one disable must FAIL (proves all 7 are load-bearing, none is decoration). +``` + +## Verification + +`npm run ci`. This ticket touches no story, no `.mdx` and no `libs/shared/src/ui/**` +_content_, so `--full` is not required — but `rich-text-editor.component.ts` does live under +`libs/shared/src/ui/`, and adding a comment line to it is harmless. Skip `--full`. + +## Out of scope + +- Splitting any of the seven files. That is RD-20 through RD-26. +- `behandel-scherm.component.ts` and `stamdata-table-editor.component.ts` — already compliant. +- The 7 non-component files over 250 lines (`brief.adapter.ts` 408, `upload.machine.spec.ts` + 364, `brief.store.spec.ts` 357, `registratie-wizard.machine.spec.ts` 287, + `registratie-wizard.machine.ts` 285, `upload.machine.ts` 256). The glob deliberately does + not reach specs, adapters or machines — this budget is about components. + +## Risks + +- **The inline-template processor can make `max-lines` fire twice per file** (once for the + `.ts`, once for the extracted virtual `.html`). Measured: it does **not** with this glob, + because the virtual path ends in `.html` and the glob ends in `.ts`. If a doubled report + appears, the glob is wrong — do not "fix" it by raising the limit. +- **`reportUnusedDisableDirectives` is repo-wide**, so it also polices the generated + `api-client.ts` header. That file is already in `ignores`, so it is out of reach. Confirmed + clean before this change. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 1cee96b..ac91dc6 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -96,7 +96,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | ID | Ticket | Deps | `--full`? | Status | | ----- | ---------------------------------------------------------------------------- | ---------- | --------- | ------ | | RD-01 | Scaffold this backlog: README, PLAN, ticket template | — | | done | -| RD-02 | `max-lines` rule + `reportUnusedDisableDirectives` + 7 disables | 01 | | todo | +| RD-02 | `max-lines` rule + `reportUnusedDisableDirectives` + 7 disables | 01 | | done | | RD-03 | `overzicht` context: page + 2 nav sections, boundary edge, admin-links token | 02 | yes | todo | | RD-04 | Story titles to `Domein//`; add the missing stories | 03 | yes | todo | | RD-05 | `createStore` gains the effect map + specs | 02 | | todo | diff --git a/eslint.config.mjs b/eslint.config.mjs index 80ffb5c..392e245 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,13 @@ import angular from 'angular-eslint'; * `herregistratie` uncovered). */ export default [ + // A disable directive that no rule needs any more is a lint failure, repo-wide (no + // `files` key, so this applies everywhere — ESLint 9 flat config rule). This forces + // every later file split in the readable-codebase arc (RD-02) to delete its own + // `max-lines` exemption instead of leaving it as permanent debt. + { + linterOptions: { reportUnusedDisableDirectives: 'error' }, + }, { ignores: [ 'dist/**', @@ -48,4 +55,16 @@ export default [ ...c, files: ['{apps,libs}/**/*.html'], })), + + // The dashboard refactor proved a page can be 42 lines. This rule holds the rest of + // the app to that budget: 250 is reachable, not a style-guide default. The glob must + // include `section` and `step` — the dashboard refactor's own file kinds — or every + // file this arc creates escapes the guard. Each exemption below names its reason and + // the ticket that removes it, so the list is a dated to-do, not silent debt. + { + files: ['{apps,libs}/**/*.{page,component,section,step}.ts'], + rules: { + 'max-lines': ['error', { max: 250, skipBlankLines: true, skipComments: true }], + }, + }, ]; diff --git a/libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts b/libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts index 587c0c4..0cd6983 100644 --- a/libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts +++ b/libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ // toolbar + contenteditable logic in one component — removed by RD-21 import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core'; import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text'; import { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom'; From b9fd41128385b16c4829a154a03ec03fd6a24966 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 16:26:11 +0200 Subject: [PATCH 03/46] refactor: move the portal home into its own overzicht context (RD-03) The dashboard page lived inside registratie, a context with no permitted outbound edges. Two of its sections do cross-context navigation: action links to herregistratie/intake/brief/concepts, and an admin links section that imported ADMIN_LINKS directly from the app shell. Move the page and these two navigation sections into a new overzicht context. Declare overzicht: ['registratie'] as the one sanctioned edge, so the four data sections that stay in registratie/ui/dashboard/ remain reachable. The route stays at /dashboard: it is a user-visible URL and four e2e specs assert it, so only the import path and class name change. beheer-links.section.ts now injects HEADER_ADMIN_LINKS, the same token the site header uses, instead of importing the app shell's nav config directly. Used npm run gen:context and corrected its three known-wrong outputs: the overzicht: [] edge, the scaffolded overzicht.page.ts (the real page is the moved dashboard.page.ts), and the scaffolded path: 'overzicht' route. Verified the boundary is enforced, not decorative: a temporary import of a herregistratie file into overzicht.page.ts makes dep:check fail, as expected, then reverted. Co-Authored-By: Claude Sonnet 5 --- .dependency-cruiser.ssp.js | 1 + apps/ssp/src/app/app.routes.ts | 4 +- .../ui}/beheer-links.section.ts | 5 +- .../ui/overzicht.page.ts} | 19 +- .../ui}/wat-wilt-u-doen.section.ts | 0 apps/ssp/tsconfig.json | 1 + .../RD-03-overzicht-context.md | 168 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- 8 files changed, 187 insertions(+), 13 deletions(-) rename apps/ssp/src/app/{registratie/ui/dashboard => overzicht/ui}/beheer-links.section.ts (85%) rename apps/ssp/src/app/{registratie/ui/dashboard.page.ts => overzicht/ui/overzicht.page.ts} (59%) rename apps/ssp/src/app/{registratie/ui/dashboard => overzicht/ui}/wat-wilt-u-doen.section.ts (100%) create mode 100644 docs/project/readable-codebase/RD-03-overzicht-context.md diff --git a/.dependency-cruiser.ssp.js b/.dependency-cruiser.ssp.js index 52df921..c5815c9 100644 --- a/.dependency-cruiser.ssp.js +++ b/.dependency-cruiser.ssp.js @@ -2,6 +2,7 @@ // scaffolding one (see `gen:context`, WP-44). module.exports = require('./.dependency-cruiser.base.js')( { + overzicht: ['registratie'], auth: [], registratie: [], herregistratie: ['registratie'], // the one sanctioned cross-feature edge diff --git a/apps/ssp/src/app/app.routes.ts b/apps/ssp/src/app/app.routes.ts index 67618a5..610ca24 100644 --- a/apps/ssp/src/app/app.routes.ts +++ b/apps/ssp/src/app/app.routes.ts @@ -14,9 +14,11 @@ export const routes: Routes = [ loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage), }, { + // Path stays 'dashboard' on purpose: it is a user-visible URL and four e2e + // specs assert it. The context is `overzicht`; only the path string differs. path: 'dashboard', canActivate: [authGuard], - loadComponent: () => import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage), + loadComponent: () => import('@overzicht/ui/overzicht.page').then((m) => m.OverzichtPage), }, { path: 'registratie', diff --git a/apps/ssp/src/app/registratie/ui/dashboard/beheer-links.section.ts b/apps/ssp/src/app/overzicht/ui/beheer-links.section.ts similarity index 85% rename from apps/ssp/src/app/registratie/ui/dashboard/beheer-links.section.ts rename to apps/ssp/src/app/overzicht/ui/beheer-links.section.ts index cbfacfc..3bb4e36 100644 --- a/apps/ssp/src/app/registratie/ui/dashboard/beheer-links.section.ts +++ b/apps/ssp/src/app/overzicht/ui/beheer-links.section.ts @@ -3,7 +3,7 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { AccessStore } from '@shared/application/access.store'; -import { ADMIN_LINKS } from '../../../shell/nav.config'; +import { HEADER_ADMIN_LINKS } from '@shared/layout/site-header/nav-config'; /** Section: "Beheer" — the admin pages the current principal may reach, capability- gated (never role-derived), the same source + filter the site header uses. @@ -31,5 +31,6 @@ import { ADMIN_LINKS } from '../../../shell/nav.config'; }) export class BeheerLinksSection { private access = inject(AccessStore); - protected adminLinks = computed(() => ADMIN_LINKS.filter((l) => this.access.can(l.cap))); + private rawAdminLinks = inject(HEADER_ADMIN_LINKS); + protected adminLinks = computed(() => this.rawAdminLinks.filter((l) => this.access.can(l.cap))); } diff --git a/apps/ssp/src/app/registratie/ui/dashboard.page.ts b/apps/ssp/src/app/overzicht/ui/overzicht.page.ts similarity index 59% rename from apps/ssp/src/app/registratie/ui/dashboard.page.ts rename to apps/ssp/src/app/overzicht/ui/overzicht.page.ts index 0d4f177..ad66f00 100644 --- a/apps/ssp/src/app/registratie/ui/dashboard.page.ts +++ b/apps/ssp/src/app/overzicht/ui/overzicht.page.ts @@ -1,17 +1,18 @@ import { Component } from '@angular/core'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; -import { MijnAanvragenSection } from './dashboard/mijn-aanvragen.section'; -import { WatMoetIkRegelenSection } from './dashboard/wat-moet-ik-regelen.section'; -import { MijnRegistratieSection } from './dashboard/mijn-registratie.section'; -import { SpecialismenSection } from './dashboard/specialismen.section'; -import { WatWiltUDoenSection } from './dashboard/wat-wilt-u-doen.section'; -import { BeheerLinksSection } from './dashboard/beheer-links.section'; +import { MijnAanvragenSection } from '@registratie/ui/dashboard/mijn-aanvragen.section'; +import { WatMoetIkRegelenSection } from '@registratie/ui/dashboard/wat-moet-ik-regelen.section'; +import { MijnRegistratieSection } from '@registratie/ui/dashboard/mijn-registratie.section'; +import { SpecialismenSection } from '@registratie/ui/dashboard/specialismen.section'; +import { WatWiltUDoenSection } from './wat-wilt-u-doen.section'; +import { BeheerLinksSection } from './beheer-links.section'; /** Page: "Mijn overzicht" — the portal home, following the NL Design System "Mijn omgeving" pattern. Composition only: each section below answers its own data - question (own store, own async state) — see `ui/dashboard/*.section.ts`. */ + question (own store, own async state) — four sections stay in `registratie/ui/dashboard/` + (they render registratie data), two live here (cross-context navigation). */ @Component({ - selector: 'app-dashboard-page', + selector: 'app-overzicht-page', imports: [ PageShellComponent, MijnAanvragenSection, @@ -39,4 +40,4 @@ import { BeheerLinksSection } from './dashboard/beheer-links.section'; `, }) -export class DashboardPage {} +export class OverzichtPage {} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts b/apps/ssp/src/app/overzicht/ui/wat-wilt-u-doen.section.ts similarity index 100% rename from apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts rename to apps/ssp/src/app/overzicht/ui/wat-wilt-u-doen.section.ts diff --git a/apps/ssp/tsconfig.json b/apps/ssp/tsconfig.json index 34dd533..b76b871 100644 --- a/apps/ssp/tsconfig.json +++ b/apps/ssp/tsconfig.json @@ -6,6 +6,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { + "@overzicht/*": ["src/app/overzicht/*"], "@shared/*": ["../../libs/shared/src/*"], "@beheer/*": ["../../libs/beheer/src/*"], "@auth/*": ["src/app/auth/*"], diff --git a/docs/project/readable-codebase/RD-03-overzicht-context.md b/docs/project/readable-codebase/RD-03-overzicht-context.md new file mode 100644 index 0000000..834c2a8 --- /dev/null +++ b/docs/project/readable-codebase/RD-03-overzicht-context.md @@ -0,0 +1,168 @@ +# RD-03 — Move the portal home into its own `overzicht` context + +Status: done +Source: PLAN.md Phase 0 + +## Why + +The dashboard is the portal home, but it lives inside `registratie` — a context that +`.dependency-cruiser.ssp.js` declares as `registratie: []`, permitted to import no other +context. Three concrete symptoms: + +- Its six sections span four concerns: registratie data (3), aanvragen (1), cross-context + action links to `/herregistratie` `/intake` `/brief` `/concepts` (1), and admin links to + `/beheer/*` (1). +- The cross-context coupling is **invisible to the linter**, because + `wat-wilt-u-doen.section.ts` links by route _string_. `npm run dep:check` passes and gives + false assurance on exactly this file. +- `beheer-links.section.ts:6` imports `ADMIN_LINKS` from `../../../shell/nav.config` — a + context reaching into the app frame. + +Splitting the dashboard into sections is what makes this fixable at all. That is the +refactor's first real payoff. + +## Read first + +- `apps/ssp/src/app/registratie/ui/dashboard.page.ts` (42 lines, pure composition) +- `apps/ssp/src/app/registratie/ui/dashboard/` — the six sections +- `.dependency-cruiser.ssp.js` — the `CONTEXT_ALLOWED` map (5 lines) +- `libs/shared/src/layout/site-header/nav-config.ts` — the `HEADER_ADMIN_LINKS` token +- `libs/shared/src/layout/site-header/site-header.component.ts:99` — how to inject it +- `apps/ssp/src/app/app.config.ts:72` — where `ADMIN_LINKS` is already provided to the token +- `plopfile.mjs`, the `context` generator +- CLAUDE.md decision 1 (DDD contexts and layers) + +## Decisions (pre-made, don't relitigate) + +1. **New context name: `overzicht`.** Dutch, per CLAUDE.md ("domain contexts are Dutch"), and + it matches the page's own title, "Mijn overzicht". + +2. **Only the page and the two _navigation_ sections move.** These three: + - `dashboard.page.ts` → `overzicht/ui/overzicht.page.ts` (class `OverzichtPage`, selector + `app-overzicht-page`) + - `dashboard/wat-wilt-u-doen.section.ts` → `overzicht/ui/wat-wilt-u-doen.section.ts` + - `dashboard/beheer-links.section.ts` → `overzicht/ui/beheer-links.section.ts` + +3. **The four data sections STAY in `registratie/ui/dashboard/`** — `mijn-aanvragen`, + `wat-moet-ik-regelen`, `mijn-registratie`, `specialismen`. They render registratie data and + belong beside `BigProfileStore`/`AanvragenStore`. Do not move them, and do not move their + three story files. + +4. **Declare `overzicht: ['registratie']`** in `.dependency-cruiser.ssp.js` — the second + sanctioned cross-feature edge, mirroring `herregistratie: ['registratie']`. The generator + inserts `overzicht: []`; you must change it. + +5. **`beheer-links.section.ts` injects `HEADER_ADMIN_LINKS`**, exactly as + `site-header.component.ts:99` does. Drop the `shell/nav.config` import entirely. + `app.config.ts:72` already provides `ADMIN_LINKS` to that token, and the `AdminLink` doc + comment already says it is "Consumed by the site header's admin nav AND (per app) a + dashboard's own Beheer section". The direct import was always the anomaly. + +6. **KEEP the route at `/dashboard`.** Four e2e specs assert it (`e2e/smoke.spec.ts:18`, + `e2e/brief-v2.spec.ts:33`, `e2e/error-state.spec.ts:16,18`), and it is a user-visible URL. + Only the `loadComponent` import path and the class name change. Add a one-line comment in + `app.routes.ts` saying the path stays `dashboard` deliberately while the context is + `overzicht`, so the mismatch reads as a decision and not an oversight. + +7. **Use `npm run gen:context`, then correct three things.** The generator gets the folders, + the `@overzicht/*` tsconfig alias, and the dep-cruiser entry right. It gets three things + wrong for this case: + - it writes `overzicht: []` → change to `['registratie']` (decision 4) + - it scaffolds a **new** `overzicht.page.ts` from a template → delete it, the real page is + the moved `dashboard.page.ts` + - it adds a **new** `path: 'overzicht'` route → delete that block, and instead repoint the + existing `dashboard` route (decision 6) + +8. **Delete the empty layer folders the generator creates.** `overzicht` has no `domain/`, + `application/`, `infrastructure/` or `contracts/` — it is composition only. Remove those + four `.gitkeep` directories rather than keeping scaffolding for work that does not exist. + +9. **The page keeps its 8 imports.** CLAUDE.md has no import-count rule; one `import` per + rendered section is exactly right. Do not introduce an `OVERZICHT_SECTIONS` const to get + the number down — that trades a self-documenting array for an indirection, and this repo + deliberately has no barrels. + +## Files + +Move (use `git mv` so the rename is visible in review): + +| From | To | +| ---------------------------------------------------------------------- | ---------------------------------------------------------- | +| `apps/ssp/src/app/registratie/ui/dashboard.page.ts` | `apps/ssp/src/app/overzicht/ui/overzicht.page.ts` | +| `apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts` | `apps/ssp/src/app/overzicht/ui/wat-wilt-u-doen.section.ts` | +| `apps/ssp/src/app/registratie/ui/dashboard/beheer-links.section.ts` | `apps/ssp/src/app/overzicht/ui/beheer-links.section.ts` | + +Edit: `apps/ssp/tsconfig.json` (alias), `.dependency-cruiser.ssp.js` (edge), +`apps/ssp/src/app/app.routes.ts` (import path + class + comment), +`apps/ssp/src/app/overzicht/ui/beheer-links.section.ts` (token), +`apps/ssp/src/app/overzicht/ui/overzicht.page.ts` (class, selector, section import paths). + +## Steps + +1. Run `npm run gen:context` and answer `overzicht`. +2. Delete the generated `overzicht/ui/overzicht.page.ts` and the four empty layer folders. +3. Delete the generated `path: 'overzicht'` route block from `app.routes.ts`. +4. Change the generated `overzicht: []` to `overzicht: ['registratie']`. +5. `git mv` the three files per the table above. +6. Rename the page's class to `OverzichtPage` and its selector to `app-overzicht-page`; fix + its six section import paths (four now via `@registratie/ui/dashboard/…`, two local). +7. Repoint the existing `dashboard` route to `@overzicht/ui/overzicht.page` / `OverzichtPage` + and add the comment from decision 6. +8. Rewrite `beheer-links.section.ts` to inject `HEADER_ADMIN_LINKS` per decision 5. +9. Update this ticket's `Status:` to `done` and the README's RD-03 row to `done`. +10. Commit all of it together. + +## Acceptance criteria + +```bash +npm run dep:check # exits 0 — proves overzicht: ['registratie'] is accepted +npm run ci # exits 0 +``` + +Then prove the boundary is real, not decorative: + +```bash +# Add a temporary import of a herregistratie file to overzicht/ui/overzicht.page.ts. +# `npm run dep:check` MUST fail (overzicht may reach registratie only). Then revert. +``` + +And prove the section still renders for an admin: + +```bash +grep -n "HEADER_ADMIN_LINKS" apps/ssp/src/app/overzicht/ui/beheer-links.section.ts # present +grep -rn "shell/nav.config" apps/ssp/src/app/overzicht/ # no match +``` + +## Verification + +`npm run ci`. No story or `.mdx` file moves in this ticket — the three section stories stay +in `registratie` — so `--full` is not strictly required. Run it once anyway, because this is +a structural move and Storybook globs the app's stories by path. + +Manual check, since e2e is not in the local GREEN one-liner: `npm start`, open +`http://localhost:4200/dashboard`, confirm all six sections render, then `?role=admin` and +confirm the Beheer section appears (and is absent without it). + +## Out of scope + +- Renaming the route to `/overzicht`. It needs a redirect and touches four e2e specs. + Recorded in PLAN.md as deliberately out of scope. +- Moving the four data sections (decision 3). +- Story titles — that is RD-04. +- Turning `wat-wilt-u-doen`'s action list into a token beside `NAV_ITEMS`. PLAN.md Phase 0 + step 6 lists it as a "consider"; leave it for now, since route strings on a landing page + are legitimate once the page lives in a context whose job is cross-context linking. + +## Risks + +- **The generator's three wrong outputs** (decision 7). If you skip the corrections, + `dep:check` still passes with the boundary silently missing — the exact failure mode + `plopfile.mjs`'s own comment warns about for `create-frontend.mjs`. +- **`messages.xlf` / `messages.en.xlf` carry `` paths** + that these moves make stale. Nothing checks them and `$localize` ids are the identity, so + `ng build --localize` stays green. Do **not** hand-edit the xlf files to chase paths. +- **Do not change any `$localize` id.** Every id in the three moved files stays byte + identical; only the file's location changes. A changed id needs a new English `` or + the localize gate fails. +- **`git mv`, not delete-and-create**, or the review loses the rename and the diff looks like + 300 new lines. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index ac91dc6..0a24fcb 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -97,7 +97,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | ----- | ---------------------------------------------------------------------------- | ---------- | --------- | ------ | | RD-01 | Scaffold this backlog: README, PLAN, ticket template | — | | done | | RD-02 | `max-lines` rule + `reportUnusedDisableDirectives` + 7 disables | 01 | | done | -| RD-03 | `overzicht` context: page + 2 nav sections, boundary edge, admin-links token | 02 | yes | todo | +| RD-03 | `overzicht` context: page + 2 nav sections, boundary edge, admin-links token | 02 | yes | done | | RD-04 | Story titles to `Domein//`; add the missing stories | 03 | yes | todo | | RD-05 | `createStore` gains the effect map + specs | 02 | | todo | | RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | todo | From 0697afb737e1705ff27509412ed6fe59e6d511da Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 16:27:10 +0200 Subject: [PATCH 04/46] docs: correct the stale cross-feature edge comment (RD-03 follow-up) RD-03 added `overzicht: ['registratie']`, so the comment claiming `herregistratie: ['registratie']` is "the one sanctioned cross-feature edge" became false. There are two, and both point at registratie. State what each edge is for instead of counting them, so the next context added does not make the comment wrong again. Co-Authored-By: Claude Opus 5 --- .dependency-cruiser.ssp.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.dependency-cruiser.ssp.js b/.dependency-cruiser.ssp.js index c5815c9..3fc53b1 100644 --- a/.dependency-cruiser.ssp.js +++ b/.dependency-cruiser.ssp.js @@ -2,10 +2,13 @@ // scaffolding one (see `gen:context`, WP-44). module.exports = require('./.dependency-cruiser.base.js')( { + // Two sanctioned cross-feature edges, both pointing at registratie: the portal home + // composes registratie's dashboard sections (RD-03), and herregistratie builds on a + // registration. Every other context imports only libs/shared and libs/beheer. overzicht: ['registratie'], auth: [], registratie: [], - herregistratie: ['registratie'], // the one sanctioned cross-feature edge + herregistratie: ['registratie'], brief: [], showcase: null, // unrestricted — the sanctioned teaching page; nothing imports it }, From a71887a8436e1581893e0871bf20d12159fc7fd8 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 16:34:28 +0200 Subject: [PATCH 05/46] feat: add an optional effect map to createStore (RD-05) createStore now takes a third, optional StoreEffects map. Each key is a Model tag. The store runs that tag's handler after update() returns, and only when the store enters the tag: the previous tag differs from the new tag, and the message is not Seed (the mount/restore message in every machine that has one). This closes the gap where a component had to call dispatch(msg) and then a private runIfSubmitting() by hand, or state got silently stuck. No call site changes here. RD-06 and RD-08 migrate the 5 components that duplicate that pattern today. The effect map is a conditional type, not a generic constraint, so a tagless Model (store.spec.ts's plain number store) still resolves it to never and needs no third argument. Both tag checks use a typeof/in guard for the same reason. Regenerated libs/shared/docs/behaviour-spec.mdx for the 5 new spec titles. Co-Authored-By: Claude Sonnet 5 --- .../RD-05-store-effect-map.md | 182 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- libs/shared/docs/behaviour-spec.mdx | 7 +- libs/shared/src/application/store.spec.ts | 71 +++++++ libs/shared/src/application/store.ts | 57 +++++- 5 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 docs/project/readable-codebase/RD-05-store-effect-map.md diff --git a/docs/project/readable-codebase/RD-05-store-effect-map.md b/docs/project/readable-codebase/RD-05-store-effect-map.md new file mode 100644 index 0000000..cae74aa --- /dev/null +++ b/docs/project/readable-codebase/RD-05-store-effect-map.md @@ -0,0 +1,182 @@ +# RD-05 — `createStore` gains an effect map + +Status: done +Source: PLAN.md 1a + +## Why + +`runIfSubmitting` is not shared. It is a `private async` method copy-pasted into 5 components +under 2 names, and it must be called by hand immediately after `dispatch`: + +```ts +this.dispatch({ tag: 'Submit' }); // the reducer decides +this.runIfSubmitting(); // then re-read state() and re-check the tag it hoped for +``` + +**Forgetting the second line fails silently.** This ticket makes that impossible by moving the +effect into the one sanctioned wiring idiom, so entering a state runs its effect by +construction. + +This ticket is **behaviour-neutral**: it changes no call site. RD-06 and RD-08 migrate them. +Landing the mechanism alone keeps the risk isolated to one reviewable commit. + +## Read first + +- `libs/shared/src/application/store.ts` — all 33 lines. The comment at lines 27-30 is the + specification for the invocation order below. +- `libs/shared/src/application/store.spec.ts` — both existing tests. **Neither may be + deleted or weakened.** +- `PLAN.md` 1a, including the two rejected alternatives. +- `libs/shared/src/application/submit.ts` — `runResult`, `runSubmit`, `SUBMIT_FAILED`. + +## Decisions (pre-made, don't relitigate) + +1. **The API.** In `libs/shared/src/application/store.ts`: + + ```ts + export type StoreEffects = Model extends { tag: string } + ? { + [K in Model['tag']]?: ( + state: Extract, + store: Store, + ) => unknown; + } + : never; + + export function createStore( + init: Model, + update: (model: Model, msg: Msg) => Model, + effects?: StoreEffects, + ): Store; + ``` + +2. **A conditional type, NOT a `Model extends { tag: string }` constraint.** This is not + stylistic. `store.spec.ts:8` calls `createStore(0, (n: number, m: number) => n + m)`, where + `Model = number` and has no `tag`. A constraint breaks that existing spec. With the + conditional, `Model = number` resolves `StoreEffects` to `never`, so passing effects there + is a compile error while omitting them stays legal. + +3. **Keys are `Model['tag']`**, so a renamed or misspelled state tag is a compile error. This + buys the typo half of exhaustiveness. The completeness half (a full Elm `[state, Cmd]`) is + knowingly not bought — see PLAN.md 1a for why. + +4. **The narrowed state is argument one.** This is what deletes the + `const s = this.state(); if (s.tag !== 'Submitting') return;` preamble at all 8 call sites + in RD-06/RD-08. The body cannot run in the wrong state, so it cannot re-guess it. + +5. **The store is argument two.** The effect needs `dispatch`, but `createStore(...)` runs in + a field initializer before `this.store` is assigned. Build the store object, then close + over it, so the effect is independent of field-declaration order. + +6. **The trigger rule.** Run `effects[next.tag]` when **both** hold: + - `prev.tag !== next.tag` — the store _entered_ the tag. A `Submit` that fails validation + is `Editing → Editing`: no fire. A second `Submit` while `Submitting` is a reducer + no-op: no fire, so **double-submit protection falls out of the rule**. `Retry` is + `Failed → Submitting`: fires, so `onRetry` needs no special case anywhere. + - **the msg tag is not `Seed`.** Without this, the five components that mount a + `Submitting` state in Storybook via `Seed` fire real network calls (see Risks), and + `draftSync.onResume` re-submits a resumed draft. Document it on `StoreEffects` as the + convention it already is: `Seed` is the mount/restore message in all 7 machines that + have one. + +7. **Invocation order.** Capture `prev` and `next` inside the `model.update(...)` callback + into locals, and invoke the effect **after** `update` returns. Do **not** read `model()` + inside `dispatch`, and do **not** invoke the effect inside the updater (a signal write + nested in an updater). `store.ts:27-30` explains why: a tracked read there makes an + effect depend on its own write and livelocks the main thread. It already crashed the + upload wizards once. + +8. **The tag check must be safe on a non-object `Msg`.** Same reason as decision 2 — + `store.spec.ts:8` dispatches plain numbers. Guard with + `typeof msg === 'object' && msg !== null && 'tag' in msg`, never a bare `msg.tag`. The + same applies to reading `prev.tag`/`next.tag` when `Model` is not an object. + +9. **`dispatch` stays `void`-returning.** The effect's promise is floated, exactly as + `this.runIfSubmitting();` is floated today. Every effect body ends in a `Result` from + `runSubmit`/`runResult`, so it cannot throw — state that as the effect contract in the doc + comment rather than adding a try/catch. + +10. **No call site changes in this ticket.** Do not migrate any component. Do not touch the + 5 components or any `*.machine.ts`. + +## Files + +- `libs/shared/src/application/store.ts` — the type, the third parameter, the trigger rule, + and a doc comment covering the effect contract and the `Seed` convention. +- `libs/shared/src/application/store.spec.ts` — 5 new cases, both existing cases untouched. + +## Steps + +1. Add `StoreEffects` per decision 1. +2. Add the optional third parameter and implement the trigger rule per decisions 6-8. +3. Extend the doc comment: what the effect slot is for, the "never throws, returns a + `Result`" contract, and why `Seed` is exempt. +4. Add the 5 spec cases from Acceptance below. +5. Run `npm run gen:behaviour-spec` — **new `it()` titles mean the drift check fails without + it** (see Risks). +6. Update this ticket's `Status:` to `done` and the README's RD-05 row to `done`. +7. Commit all of it together. + +## Acceptance criteria + +Five new plain-function spec cases, no TestBed: + +``` +- fires the effect when the store enters the tag +- does not fire when the tag is unchanged +- does not fire for a Seed message +- a dispatch from inside the effect lands +- the narrowed state is passed to the effect +``` + +Both existing cases still present and passing — in particular +`dispatch from inside an effect does not self-loop`, which is the regression guard for +decision 7. + +```bash +npm test # exits 0 +npm run ci # exits 0 +``` + +Type-level proof that decision 2 holds, i.e. the old call shape still compiles: + +```bash +npm run typecheck # exits 0 — store.spec.ts:8's createStore(0, ...) must still type-check +``` + +## Verification + +`npm run ci`. This ticket touches no story, no `.mdx`, and no component, so `--full` is not +required. + +## Out of scope + +- Migrating any call site. RD-06 (the 2 single-step forms, which is also a bug fix) and RD-08 + (the 3 wizards). +- Adding a `Primary` message to any machine. That is RD-07. +- The full Elm `reduce -> [state, Cmd]` refactor. Rejected in PLAN.md 1a, with reasons; + recorded there as the documented upgrade path if effects ever need asserting inside a domain + spec. +- `upload.machine.ts`'s `type:` discriminant. That is optional RD-35. + +## Risks + +- **The Storybook trap.** All 5 components mount their `Submitting`/`Indienen` state via + `Seed` in stories that use a real `provideHttpClient()` with **no request mocking** + (`besluit-form.stories.ts:33`, `herregistratie-wizard.stories.ts:70`, + `intake-wizard.stories.ts:38`, `change-request-form.stories.ts:34`, + `registratie-wizard.stories.ts:88`). The `Seed` exemption is what stops them firing real + calls and reddening `storybook-a11y`. Nothing migrates in this ticket, so the trap does not + fire yet — but the exemption must be implemented and documented **here**, because RD-06 is + where it would otherwise bite. +- **Two dispatch sites live inside Angular `effect()`s** — `intake-wizard.component.ts:363` + (`SetPolicy`) and `registratie-wizard.component.ts` (`PrefillAdres`). Both land on an + unchanged tag, so nothing fires, and both are already `untracked`. **Never key an effect on + an editing tag** — that is the livelock. +- **`behaviour-spec.mdx` drift.** `scripts/ci-local.sh` regenerates + `libs/shared/docs/behaviour-spec.mdx` from a path-sorted walk of spec titles and fails on + drift. New `it()` titles must be accompanied by `npm run gen:behaviour-spec` in the same + commit. +- **`snippets.generated.ts` drift.** Verified: `store.ts` carries **no** `// #region showcase:` + marker, so this ticket cannot cause snippet drift. (`remote-data.ts:30` has + `showcase:fold`, which RD-11 and RD-17 must respect — not this ticket.) diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 0a24fcb..fb39439 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -99,7 +99,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-02 | `max-lines` rule + `reportUnusedDisableDirectives` + 7 disables | 01 | | done | | RD-03 | `overzicht` context: page + 2 nav sections, boundary edge, admin-links token | 02 | yes | done | | RD-04 | Story titles to `Domein//`; add the missing stories | 03 | yes | todo | -| RD-05 | `createStore` gains the effect map + specs | 02 | | todo | +| RD-05 | `createStore` gains the effect map + specs | 02 | | done | | RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | todo | | RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | todo | | RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | todo | diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 725d977..8301797 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 505 frontend behaviours across +**is** the suite, reshaped for a business reader. 510 frontend behaviours across 9 contexts; 261 backend behaviours across 42 test classes. @@ -752,6 +752,11 @@ classes. - applies the pure update on dispatch - dispatch from inside an effect does not self-loop +- fires the effect when the store enters the tag +- does not fire when the tag is unchanged +- does not fire for a Seed message +- a dispatch from inside the effect lands +- the narrowed state is passed to the effect #### currentRole (dev mechanism) diff --git a/libs/shared/src/application/store.spec.ts b/libs/shared/src/application/store.spec.ts index e93958b..dc59be0 100644 --- a/libs/shared/src/application/store.spec.ts +++ b/libs/shared/src/application/store.spec.ts @@ -29,4 +29,75 @@ describe('createStore', () => { expect(runs).toBe(1); // effect ran once; its own dispatch did not retrigger it expect(store.model()).toBe(1); }); + + type ToggleModel = { tag: 'Off' } | { tag: 'On' }; + type ToggleMsg = { tag: 'Seed' } | { tag: 'Flip' } | { tag: 'Stay' }; + + function reduceToggle(model: ToggleModel, msg: ToggleMsg): ToggleModel { + switch (msg.tag) { + case 'Flip': + return model.tag === 'Off' ? { tag: 'On' } : { tag: 'Off' }; + case 'Seed': + return { tag: 'On' }; // mounts straight into 'On', e.g. restoring a draft + case 'Stay': + return model; + } + } + + it('fires the effect when the store enters the tag', () => { + let fired = false; + const store = createStore({ tag: 'Off' }, reduceToggle, { + On: () => (fired = true), + }); + + store.dispatch({ tag: 'Flip' }); + + expect(fired).toBe(true); + }); + + it('does not fire when the tag is unchanged', () => { + let fired = false; + const store = createStore({ tag: 'Off' }, reduceToggle, { + Off: () => (fired = true), + }); + + store.dispatch({ tag: 'Stay' }); // Off -> Off, no tag change + + expect(fired).toBe(false); + }); + + it('does not fire for a Seed message', () => { + let fired = false; + // Off -> On is a real tag change, but Seed is the mount/restore message + // (e.g. a Storybook story or a resumed draft) and must stay exempt. + const store = createStore({ tag: 'Off' }, reduceToggle, { + On: () => (fired = true), + }); + + store.dispatch({ tag: 'Seed' }); // Off -> On + + expect(store.model()).toEqual({ tag: 'On' }); + expect(fired).toBe(false); + }); + + it('a dispatch from inside the effect lands', () => { + const store = createStore({ tag: 'Off' }, reduceToggle, { + On: (_s, s) => s.dispatch({ tag: 'Flip' }), // On -> Off, from inside the effect + }); + + store.dispatch({ tag: 'Flip' }); // Off -> On, fires the effect above + + expect(store.model()).toEqual({ tag: 'Off' }); + }); + + it('the narrowed state is passed to the effect', () => { + let seen: ToggleModel | undefined; + const store = createStore({ tag: 'Off' }, reduceToggle, { + On: (state) => (seen = state), + }); + + store.dispatch({ tag: 'Flip' }); + + expect(seen).toEqual({ tag: 'On' }); + }); }); diff --git a/libs/shared/src/application/store.ts b/libs/shared/src/application/store.ts index 0e55de7..7ea33f0 100644 --- a/libs/shared/src/application/store.ts +++ b/libs/shared/src/application/store.ts @@ -17,17 +17,62 @@ export interface Store { dispatch(msg: Msg): void; } +/** + * The effect map: one optional handler per state tag, run when the store + * enters that tag (see the trigger rule on `createStore` below). Resolves to + * `never` for a tagless `Model` (e.g. `Model = number` in `store.spec.ts`), so + * a plain-value store still compiles without ever supplying effects. + * + * An effect body must never throw: end it in a `Result` from + * `runSubmit`/`runResult` (`submit.ts`) and let the failure travel as a + * dispatched message, not an exception. `dispatch` floats the effect's + * promise, exactly as the `runIfSubmitting()` call it replaces did. + */ +export type StoreEffects = Model extends { tag: string } + ? { + [K in Model['tag']]?: ( + state: Extract, + store: Store, + ) => unknown; + } + : never; + +function hasTag(value: unknown): value is { tag: unknown } { + return typeof value === 'object' && value !== null && 'tag' in value; +} + export function createStore( init: Model, update: (model: Model, msg: Msg) => Model, + effects?: StoreEffects, ): Store { const model = signal(init); - return { + const store: Store = { model: model.asReadonly(), - // Use `.update` (raw current value, no tracked read) not `set(update(model(), …))`: - // dispatch is a command and must never subscribe its caller to `model`. Reading - // `model()` here inside an effect that also dispatches makes the effect depend on - // its own write and livelock the main thread (crashed the upload wizards). - dispatch: (msg) => model.update((m) => update(m, msg)), + dispatch: (msg) => { + let prev!: Model; + let next!: Model; + // Use `.update` (raw current value, no tracked read) not `set(update(model(), …))`: + // dispatch is a command and must never subscribe its caller to `model`. Reading + // `model()` here inside an effect that also dispatches makes the effect depend on + // its own write and livelock the main thread (crashed the upload wizards). + model.update((m) => { + prev = m; + next = update(m, msg); + return next; + }); + + // Fire the entered tag's effect, but only when the store actually entered it + // (prev.tag !== next.tag) and the message is not `Seed` — the mount/restore + // message in every machine that has one. Without the `Seed` exemption, a + // component that mounts straight into `Submitting` (Storybook, a resumed + // draft) would fire the effect on load, not on user action. + if (!hasTag(next) || !hasTag(prev) || prev.tag === next.tag) return; + if (hasTag(msg) && msg.tag === 'Seed') return; + const handler = (effects as Record | undefined)?.[String(next.tag)] as + ((state: Model, store: Store) => unknown) | undefined; + handler?.(next, store); + }, }; + return store; } From 0c6fd37ed88c076730bbf3784e18b16fea612b25 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 16:55:20 +0200 Subject: [PATCH 06/46] fix: give Failed its own branch in the two single-step forms (RD-06) Before this fix, a failed submit on the besluit-form or the change-request-form left the user stuck. Both templates rendered `Failed` through the same `@else` branch as the editable form. In besluit-form the fields read from `Editing` only, so they went blank. In change-request-form the fields still showed the sent value, but `SetField` only applies to `Editing`, so typing did nothing. In both forms the submit button stayed enabled, but `Submit` is a no-op outside `Editing`. The only escape was a page reload. After this fix, `Failed` gets its own template branch: an error message, a read-only summary of what was sent (an `app-data-block`, reused from the existing BRP-address pattern), and a "Opnieuw proberen" button that dispatches `Retry`. Both machines already handle `Retry` (`Failed -> Submitting` with the preserved data), so no machine change was needed. Both components also move to `createStore`'s effect map (RD-05): the `Submitting` handler replaces the hand-called `runIfSubmitting`, so `onSubmit` is now a single `dispatch`. `runIfSubmitting`/`runIfIndienen` now remain only in the three wizards, migrated later by RD-08. Co-Authored-By: Claude Sonnet 5 --- .../ui/besluit-form/besluit-form.component.ts | 87 ++++++--- .../change-request-form.component.ts | 51 ++++-- .../RD-06-fix-unrecoverable-submit-failure.md | 168 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- 4 files changed, 265 insertions(+), 43 deletions(-) create mode 100644 docs/project/readable-codebase/RD-06-fix-unrecoverable-submit-failure.md diff --git a/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts b/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts index e26a30f..8087f7a 100644 --- a/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts +++ b/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts @@ -6,6 +6,8 @@ import { AlertComponent } from '@shared/ui/alert/alert.component'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component'; +import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; +import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { createStore } from '@shared/application/store'; import { whenTag } from '@shared/kernel/fp'; import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine'; @@ -29,10 +31,35 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit'; FormFieldComponent, TextInputComponent, RadioGroupComponent, + DataBlockComponent, + DataRowComponent, ], template: ` @if (state().tag === 'Submitted') { Het besluit is vastgelegd. + } @else if (state().tag === 'Failed') { + Besluit vastleggen + + Het vastleggen is niet gelukt: + {{ failedError() }} + + +
+ @if (toelichting()) { +
+ } +
+ +
+ Opnieuw proberen +
} @else { Besluit vastleggen @@ -70,13 +97,6 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit'; /> - @if (failedError()) { - Het vastleggen is niet gelukt: - {{ failedError() }} - } - {{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }} @@ -86,7 +106,19 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit'; }) export class BesluitFormComponent { private submit = createSubmitBesluit(); - private store = createStore(initial, reduce); + // Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is + // exempt, so a story mounting straight into `Submitting` does not call the network). + private store = createStore(initial, reduce, { + Submitting: async (s, store) => { + const r = await this.submit(this.id(), s.data); + if (r.ok) { + store.dispatch({ tag: 'SubmitConfirmed' }); + this.decided.emit(); + } else { + store.dispatch({ tag: 'SubmitFailed', error: r.error }); + } + }, + }); id = input.required(); decided = output(); @@ -109,12 +141,33 @@ export class BesluitFormComponent { protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`; protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`; + // Same ids as the form-field labels above, reused for the Failed data-block's row + // keys (the pattern change-request-form already uses for its read-only BRP rows). + protected readonly besluitLabelText = $localize`:@@besluit.besluitLabel:Besluit`; + protected readonly toelichtingLabelText = $localize`:@@besluit.toelichtingLabel:Toelichting`; + private editing = computed(() => whenTag(this.state(), 'Editing')); protected errors = computed(() => this.editing()?.errors ?? {}); protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); - protected besluit = computed(() => this.editing()?.draft.besluit ?? ''); - protected toelichting = computed(() => this.editing()?.draft.toelichting ?? ''); + /** The value shown in the field — the live draft while editing, the parsed value + while submitting/failed (so the user sees what they sent, same idiom as + change-request-form.telefoon()). */ + protected besluit = computed(() => { + const s = this.state(); + if (s.tag === 'Editing') return s.draft.besluit; + if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.besluit; + return ''; + }); + protected toelichting = computed(() => { + const s = this.state(); + if (s.tag === 'Editing') return s.draft.toelichting; + if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.toelichting ?? ''; + return ''; + }); + protected besluitOptieLabel = computed( + () => this.BESLUIT_OPTIONS.find((o) => o.value === this.besluit())?.label ?? '', + ); constructor() { queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() })); @@ -122,19 +175,5 @@ export class BesluitFormComponent { onSubmit() { this.dispatch({ tag: 'Submit' }); - this.runIfSubmitting(); - } - - /** Effect: when we entered Submitting, call the command, then dispatch the outcome. */ - private async runIfSubmitting() { - const s = this.state(); - if (s.tag !== 'Submitting') return; - const r = await this.submit(this.id(), s.data); - if (r.ok) { - this.dispatch({ tag: 'SubmitConfirmed' }); - this.decided.emit(); - } else { - this.dispatch({ tag: 'SubmitFailed', error: r.error }); - } } } diff --git a/apps/ssp/src/app/registratie/ui/change-request-form/change-request-form.component.ts b/apps/ssp/src/app/registratie/ui/change-request-form/change-request-form.component.ts index f4d3a42..c7bf467 100644 --- a/apps/ssp/src/app/registratie/ui/change-request-form/change-request-form.component.ts +++ b/apps/ssp/src/app/registratie/ui/change-request-form/change-request-form.component.ts @@ -65,6 +65,26 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang >Nieuwe wijziging doorgeven + } @else if (state().tag === 'Failed') { + Contactgegevens wijzigen + + Het indienen is niet gelukt: + {{ failedError() }} + + +
+
+ +
+ Opnieuw proberen +
} @else { Contactgegevens wijzigen @@ -108,13 +128,6 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang - @if (failedError()) { - Het indienen is niet gelukt: - {{ failedError() }} - } - {{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }} @@ -127,7 +140,15 @@ export class ChangeRequestFormComponent { // adapter); the UI holds only this bound command. Field initializer = injection // context, like createStore below. private submit = createSubmitChangeRequest(); - private store = createStore(initial, reduce); + // Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is + // exempt, so a story mounting straight into `Submitting` does not call the network). + private store = createStore(initial, reduce, { + Submitting: async (s, store) => { + const r = await this.submit(s.data); + if (r.ok) store.dispatch({ tag: 'SubmitConfirmed', referentie: r.value }); + else store.dispatch({ tag: 'SubmitFailed', error: r.error }); + }, + }); /** BRP address, shown read-only. Undefined until the profile loads. */ brpAdres = input(undefined); @@ -148,6 +169,10 @@ export class ChangeRequestFormComponent { protected readonly postcodeLabel = $localize`:@@address.postcode:Postcode`; protected readonly woonplaatsLabel = $localize`:@@address.woonplaats:Woonplaats`; + // Same id as the telefoon form-field's label above, reused for the Failed + // data-block's row key. + protected readonly telefoonLabelText = $localize`:@@changeRequest.telefoonLabel:Telefoonnummer`; + private editing = computed(() => whenTag(this.state(), 'Editing')); protected errors = computed(() => this.editing()?.errors ?? {}); protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); @@ -168,15 +193,5 @@ export class ChangeRequestFormComponent { onSubmit() { this.dispatch({ tag: 'Submit' }); - this.runIfSubmitting(); - } - - /** Effect: when we entered Submitting, call the command, then dispatch the outcome. */ - private async runIfSubmitting() { - const s = this.state(); - if (s.tag !== 'Submitting') return; - const r = await this.submit(s.data); - if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value }); - else this.dispatch({ tag: 'SubmitFailed', error: r.error }); } } diff --git a/docs/project/readable-codebase/RD-06-fix-unrecoverable-submit-failure.md b/docs/project/readable-codebase/RD-06-fix-unrecoverable-submit-failure.md new file mode 100644 index 0000000..2a7af4f --- /dev/null +++ b/docs/project/readable-codebase/RD-06-fix-unrecoverable-submit-failure.md @@ -0,0 +1,168 @@ +# RD-06 — Fix the unrecoverable submit failure in the two single-step forms + +Status: done +Source: PLAN.md 1a (the two bugs) + +## Why + +**This is a bug fix, not a refactor.** Two forms have an unrecoverable dead end reachable from +any failed submit. Both machines already support recovery; only the UI affordance is missing. + +Verified mechanics, per component: + +**`besluit-form.component.ts` — the fields are wiped.** + +- The template branches on `Submitted` only (line 34); `Failed` falls into the `@else` at + line 36, which renders the editable form. +- `editing = whenTag(state(), 'Editing')` (line 112) is `undefined` in `Failed`, so + `besluit()` and `toelichting()` (lines 116-117) both return `''`. **The user's decision + disappears from the screen.** +- The submit button's only disable condition is `Submitting` (line 80), so in `Failed` it is + **enabled**. +- Clicking dispatches `Submit`, and `besluit.machine.ts:75` is `if (s.tag !== 'Editing') +return s` — **a no-op**. + +**`change-request-form.component.ts` — the fields are frozen.** + +- Same `@if Submitted / @else form` shape (lines 55, 68), same always-enabled submit button + (line 118), same no-op `Submit` (`change-request.machine.ts:62`). +- Different in one way: `telefoon()` (lines 158-163) **does** read `Failed.data`, with the + comment "so the user sees what they sent". So the value stays on screen — but `SetField` is + `Editing`-only (`change-request.machine.ts:60`), so **typing does nothing**. + +Either way the only escape is a page reload. + +**The machines are already correct.** `Failed` carries `data: Valid` +(`besluit.machine.ts:34`, `change-request.machine.ts:32`), and `Retry` maps +`Failed → Submitting` with that preserved data (`besluit.machine.ts:80`, +`change-request.machine.ts:67`). Neither UI ever dispatches it. + +## Read first + +- `libs/shared/src/application/store.ts` — the effect map RD-05 added, and its `Seed` rule +- `apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts` (140 lines) +- `apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts` (92 lines) +- `apps/ssp/src/app/registratie/ui/change-request-form/change-request-form.component.ts` +- `apps/ssp/src/app/registratie/domain/change-request.machine.ts` +- `libs/shared/src/layout/wizard-shell/wizard-shell.component.ts:134` — the retry button and + the `$localize` id to reuse +- Both `*.stories.ts` for the two components + +## Decisions (pre-made, don't relitigate) + +1. **Give `Failed` its own template branch.** Do not let it fall through to the editable form. + One change fixes both symptoms: nothing renders wiped fields, and no dead submit button + exists. Shape: + + ``` + @if (state().tag === 'Submitted') { … } + @else if (state().tag === 'Failed') { error + what was sent + Retry } + @else { the form } + ``` + +2. **Show what was sent, read from `Failed.data`.** `change-request-form.telefoon()` already + does exactly this and says why in its comment. `besluit-form` copies that pattern rather + than inventing one. The user must be able to see what they are retrying. + +3. **The Retry button dispatches `{ tag: 'Retry' }`.** No machine change is needed — both + reducers already handle it, and RD-05's effect map fires on `Failed → Submitting` because + that is a tag transition. `onRetry` needs no effect call of its own. + +4. **Reuse the `$localize` id `@@wizard.opnieuwProberen`** with byte-identical source text + `Opnieuw proberen`. Verified present with an English target in **both** + `apps/ssp/src/locale/messages.en.xlf:2429` and + `apps/behandelportal/src/locale/messages.en.xlf:2373` (`Try +again`). **No new xlf target is needed.** A different source text under the same + id fails extraction, so do not reword it. + +5. **Migrate both components to RD-05's effect map.** Delete `runIfSubmitting` from both; + register the body as `{ Submitting: (s, store) => … }` on `createStore`. The narrowed state + arrives as argument one, so the + `const s = this.state(); if (s.tag !== 'Submitting') return;` preamble goes away. `onSubmit` + becomes a single `dispatch`. + +6. **Do not add a "go back and edit after a failure" path.** Neither machine has a + `Failed → Editing` message, and adding one is scope creep for a bug fix. Retry recovers the + dead end, which is what this ticket is for. Record the edit-after-failure gap as a + follow-up in the Out of scope section. + +7. **Do not change the `Reset` message or the `Seed` contract.** The stories mount states via + `Seed`, and RD-05 exempts `Seed` from firing effects precisely so they do not perform real + HTTP. + +## Files + +- `apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts` +- `apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.stories.ts` +- `apps/ssp/src/app/registratie/ui/change-request-form/change-request-form.component.ts` +- `apps/ssp/src/app/registratie/ui/change-request-form/change-request-form.stories.ts` + +No machine file changes. No xlf changes. + +## Steps + +1. In `besluit-form`, add the `Failed` branch per decisions 1-3, reading the besluit and + toelichting from `Failed.data`. +2. Replace `runIfSubmitting` with an effect-map entry per decision 5; reduce `onSubmit` to one + dispatch. +3. Repeat both steps for `change-request-form`. +4. Add a `Failed` story to each component's `*.stories.ts` so the new branch has a rendered, + axe-checked state. Seed it directly, per decision 7. +5. Update this ticket's `Status:` to `done` and the README's RD-06 row to `done`. +6. Commit all of it together. + +## Acceptance criteria + +```bash +npm run ci # exits 0 +npm run ci --full # exits 0 — required: this ticket adds stories +``` + +Then prove the dead end is gone. There is no automated coverage for this, so verify by +seeding the `Failed` state in Storybook (`npm run storybook` and +`npm run storybook:behandelportal`) and checking all three, for **both** components: + +1. The submitted values are visible — not blank. +2. No enabled control dispatches `Submit`. +3. The Retry button is present, and clicking it leaves `Failed` (it enters `Submitting`). + +Prove the method is gone from both forms rather than renamed. Baseline today is **5 files** +(3 wizards + these 2 forms); note the registratie wizard spells it `runIfIndienen`, so both +names must be matched: + +```bash +grep -rl "runIfSubmitting\|runIfIndienen" apps/ssp apps/behandelportal | sort +# MUST be exactly these 3 (the wizards, migrated later by RD-08): +# apps/ssp/.../herregistratie-wizard/herregistratie-wizard.component.ts +# apps/ssp/.../intake-wizard/intake-wizard.component.ts +# apps/ssp/.../registratie-wizard/registratie-wizard.component.ts +``` + +## Verification + +`npm run ci --full`. `--full` is mandatory here: this ticket adds stories, and only +`build-storybook` plus the axe run exercise them. + +## Out of scope + +- The 3 wizards. That is RD-08, after RD-07 adds `Primary`. +- **Editing after a failure.** Both machines can only `Retry` the same data or `Reset` to + empty. A `Failed → Editing` transition that maps `data` back to a `draft` would be a genuine + UX improvement and needs a new message plus a reducer spec. Recorded here as a follow-up; + not part of this fix. +- `WizardStatus`/`WizardPhase`. That is RD-10. + +## Risks + +- **The Storybook trap.** Both components mount `Submitting` via `Seed` in stories that use a + real `provideHttpClient()` with **no request mocking** (`besluit-form.stories.ts:33`, + `change-request-form.stories.ts:34`). RD-05's `Seed` exemption is what stops those firing + real network calls. If a story flips to `Failed` on load, or `storybook-a11y` goes red, the + exemption is not working — fix that, do not delete the story. +- **Do not reword the retry label.** Same id, same source text, or extraction fails + (decision 4). +- **`behaviour-spec.mdx` drift** if you add or rename any spec. Run + `npm run gen:behaviour-spec` in the same commit if you do. +- **`besluit-form` has no `*.spec.ts`.** Do not add a component TestBed spec for this — the + house tests UI through Storybook (CLAUDE.md decision 5). The machines already have specs, + and this ticket changes no machine. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index fb39439..5355557 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -100,7 +100,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-03 | `overzicht` context: page + 2 nav sections, boundary edge, admin-links token | 02 | yes | done | | RD-04 | Story titles to `Domein//`; add the missing stories | 03 | yes | todo | | RD-05 | `createStore` gains the effect map + specs | 02 | | done | -| RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | todo | +| RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | done | | RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | todo | | RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | todo | | RD-09 | **Docs + generator:** `form-machine.hbs`, ARCHITECTURE, fp-tea, skill | 08 | | todo | From d9c56b0c24784c20cd82efb0576d49e0c2daaa67 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 16:57:37 +0200 Subject: [PATCH 07/46] fix(ci): make `npm run ci --full` actually run the full gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/ci-local.sh gated its two storybook + axe steps on `[[ "${1:-}" == "--full" ]]`, so the flag had to arrive as a positional argument. CLAUDE.md documents `npm run ci --full`, but npm parses that flag itself and exports `npm_config_full=true` instead of passing it through. Proven with `npm run env --full`. The documented command therefore skipped both steps and still printed "local CI passed" — a gate that reported success without running. Anyone following CLAUDE.md has been skipping storybook-a11y. Accept either form. Fixing the script makes every existing document correct, instead of changing four of them and re-teaching the reader. Found while verifying RD-06, which adds stories and so depends on those steps actually running. Co-Authored-By: Claude Opus 5 --- docs/project/readable-codebase/PLAN.md | 10 ++++++++++ scripts/ci-local.sh | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/project/readable-codebase/PLAN.md b/docs/project/readable-codebase/PLAN.md index 5cf650a..ac7373f 100644 --- a/docs/project/readable-codebase/PLAN.md +++ b/docs/project/readable-codebase/PLAN.md @@ -859,6 +859,16 @@ Measured against the current tree, not assumed: files, so the folder=layer move is mechanical, not a taxonomy debate. The two: `async.component.ts` has no tag at all, and `breadcrumb.component.ts` says `/** Chrome: */`. +**A live CI-gate defect, found while executing RD-06 (now fixed).** +`scripts/ci-local.sh` gated its storybook + axe steps on `[[ "${1:-}" == "--full" ]]`, but +CLAUDE.md documents `npm run ci --full` — and npm parses that flag itself, exporting +`npm_config_full=true` instead of passing `--full` through as `$1`. Proven with +`npm run env --full`. So the documented command **skipped both steps and still printed +"local CI passed"**: a gate reporting success without running. The script now accepts either +form, which makes every existing doc correct rather than requiring them all to change. This +matters directly for RD-27, whose highest risk is a broken `.mdx` story import that **only** +`build-storybook` catches. + And four corrections to claims made **earlier in this same investigation**, caught by reading the consumers and the rule semantics rather than the definitions: diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index d80e2ac..a807376 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -34,7 +34,11 @@ step "showcase snippets drift"; npm run gen:snippets; git diff --exit-code app step "behaviour spec drift"; npm run gen:behaviour-spec; git diff --exit-code libs/shared/docs/behaviour-spec.mdx step "api-client drift"; npm run gen:api; git diff --exit-code libs/shared/src/infrastructure/api-client.ts backend/swagger.json -if [[ "${1:-}" == "--full" ]]; then +# Accept both `npm run ci -- --full` (arrives as $1) and `npm run ci --full` (npm parses the +# flag itself and exports npm_config_full=true instead of passing it through). CLAUDE.md +# documents the second form, which used to skip these two steps silently and still print +# "local CI passed" — a gate that reported success without running. +if [[ "${1:-}" == "--full" || "${npm_config_full:-}" == "true" ]]; then step "storybook build + axe (ssp)"; npm run build-storybook; npm run test-storybook:ci step "storybook build + axe (behandelportal)"; npm run build-storybook:behandelportal; npm run test-storybook:ci:behandelportal fi From be1fcb4b40802f58fba1825e97b4cd12e9364f69 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 17:06:02 +0200 Subject: [PATCH 08/46] feat: add Primary message to the 3 wizard machines (RD-07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each wizard component re-derives the step-boundary decision the reducer already owns: advance on a middle step, submit on the last step. This ticket moves that decision into the machine, so RD-08 can replace the component's guard with one dispatch. Add a `Primary` message to each Msg union, and export a `primary(s)` function next to the existing `next`/`submit` pair. `primary` is a three-line branch that delegates to `next`/`submit` and writes no new validation. Each machine tests "last step" in its own vocabulary, per the ticket's Decisions block: `herregistratie` checks `step === 3`, `intake` checks `currentStep(s) === 'review'`, `registratie` checks `currentStep(s) === 'controle'`. `Next` and `Submit` stay in every union and every reducer — `Primary` is purely additive. Add 3 spec cases per machine (9 total): Primary advances from a non-final step, Primary submits from the final step, and Primary is a no-op outside the editing state. Each case also asserts the equivalence the ticket requires for RD-08's migration: `reduce(s, Primary)` equals `reduce(s, Next)` at a non-final step, and equals `reduce(s, Submit)` at the final step. Regenerate `behaviour-spec.mdx` for the 9 new `it()` titles. Co-Authored-By: Claude Sonnet 5 --- .../domain/herregistratie.machine.spec.ts | 20 +++ .../domain/herregistratie.machine.ts | 10 ++ .../domain/intake.machine.spec.ts | 28 ++++ .../herregistratie/domain/intake.machine.ts | 10 ++ .../domain/registratie-wizard.machine.spec.ts | 21 +++ .../domain/registratie-wizard.machine.ts | 10 ++ .../RD-07-primary-message.md | 157 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- libs/shared/docs/behaviour-spec.mdx | 17 +- 9 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 docs/project/readable-codebase/RD-07-primary-message.md diff --git a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts index d90f0ad..2bc2a7f 100644 --- a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts @@ -8,6 +8,7 @@ import { back, gaNaarStap, submit, + primary, resolve, reduce, WizardState, @@ -104,6 +105,25 @@ describe('wizard.machine', () => { }); }); +describe('primary', () => { + it('Primary advances to the next step from a non-final step', () => { + const s = toStep2('4160', '200'); // Editing, step 2 — not the final step + expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' })); + expect(expectTag(primary(s), 'Editing').step).toBe(3); + }); + + it('Primary submits from the final step', () => { + const s = toStep3('4160', '200'); // Editing, step 3 — the final step + expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' })); + expect(primary(s).tag).toBe('Submitting'); + }); + + it('Primary is a no-op from a non-editing state', () => { + const submitting = submit(toStep3('4160', '200')); + expect(primary(submitting)).toBe(submitting); + }); +}); + describe('reduce (message-driven)', () => { it('drives the full happy path via messages', () => { let s: WizardState = initial; diff --git a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts index ffed921..f513e33 100644 --- a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts +++ b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts @@ -121,6 +121,13 @@ export function submit(s: WizardState): WizardState { return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error }; } +/** The primary button's action: advance, or submit from the last step. No-op + outside Editing — this is the one decision `onPrimary()` used to make. */ +export function primary(s: WizardState): WizardState { + if (s.tag !== 'Editing') return s; + return s.step === 3 ? submit(s) : next(s); +} + /** Route an upload sub-message through the pure upload reducer (Editing only). */ export function upload(s: WizardState, msg: UploadMsg): WizardState { if (s.tag !== 'Editing') return s; @@ -152,6 +159,7 @@ export type WizardMsg = | { tag: 'Back' } | { tag: 'GaNaarStap'; step: 1 | 2 | 3 } | { tag: 'Submit' } + | { tag: 'Primary' } | { tag: 'Retry' } | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } @@ -170,6 +178,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState { return gaNaarStap(s, m.step); case 'Submit': return submit(s); + case 'Primary': + return primary(s); case 'Retry': return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; case 'SubmitConfirmed': diff --git a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts index 9987b85..a4a48d8 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts @@ -10,6 +10,7 @@ import { back, gaNaarStap, submit, + primary, resolve, reduce, IntakeState, @@ -207,6 +208,33 @@ describe('submit', () => { }); }); +describe('primary', () => { + // Same fixture as the 'submit' describe block above: buitenland answered 'nee', + // uren high enough to skip the scholing question. + const highUren = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '4160' }, + ); + + it('Primary advances to the next step from a non-final step', () => { + expect(currentStep(expectTag(highUren, 'Answering'))).toBe('buitenland'); // not the final step + expect(reduce(highUren, { tag: 'Primary' })).toEqual(reduce(highUren, { tag: 'Next' })); + expect(expectTag(primary(highUren), 'Answering').cursor).toBe(1); + }); + + it('Primary submits from the final step', () => { + const atReview = reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' }); + expect(currentStep(expectTag(atReview, 'Answering'))).toBe('review'); // the final step + expect(reduce(atReview, { tag: 'Primary' })).toEqual(reduce(atReview, { tag: 'Submit' })); + expect(primary(atReview).tag).toBe('Submitting'); + }); + + it('Primary is a no-op from a non-editing state', () => { + const submitting = submit(reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' })); + expect(primary(submitting)).toBe(submitting); + }); +}); + describe('reduce (message-driven happy path)', () => { it('drives abroad branch end to end', () => { let s: IntakeState = initial; diff --git a/apps/ssp/src/app/herregistratie/domain/intake.machine.ts b/apps/ssp/src/app/herregistratie/domain/intake.machine.ts index e970425..354e744 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.machine.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.machine.ts @@ -200,6 +200,13 @@ export function submit(s: IntakeState): IntakeState { return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error }; } +/** The primary button's action: advance, or submit from the review step. No-op + outside Answering — this is the one decision `onPrimary()` used to make. */ +export function primary(s: IntakeState): IntakeState { + if (s.tag !== 'Answering') return s; + return currentStep(s) === 'review' ? submit(s) : next(s); +} + export function resolve(s: IntakeState, r: Result): IntakeState { if (s.tag !== 'Submitting') return s; return r.ok @@ -213,6 +220,7 @@ export type IntakeMsg = | { tag: 'Back' } | { tag: 'GaNaarStap'; cursor: number } | { tag: 'Submit' } + | { tag: 'Primary' } | { tag: 'Retry' } | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } @@ -231,6 +239,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState { return gaNaarStap(s, m.cursor); case 'Submit': return submit(s); + case 'Primary': + return primary(s); case 'Retry': return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; case 'SubmitConfirmed': diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts index f137d23..59cfa73 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts @@ -17,6 +17,7 @@ import { setField, prefillAdres, submit, + primary, resolve, reduce, } from './registratie-wizard.machine'; @@ -248,6 +249,26 @@ describe('submit', () => { }); }); +describe('primary', () => { + it('Primary advances to the next step from a non-final step', () => { + const s = toBeroepStepWithDiploma(); // Invullen, beroep step — not the final step + expect(currentStep(expectTag(s, 'Invullen'))).toBe('beroep'); + expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' })); + expect(currentStep(expectTag(primary(s), 'Invullen'))).toBe('controle'); + }); + + it('Primary submits from the final step', () => { + const s = toControleStep(); // Invullen, controle step — the final step + expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' })); + expect(primary(s).tag).toBe('Indienen'); + }); + + it('Primary is a no-op from a non-editing state', () => { + const indienen = toIndienen(); + expect(primary(indienen)).toBe(indienen); + }); +}); + describe('reduce (message-driven happy path)', () => { it('adres and correspondentie set, Next advances from adres to beroep', () => { // Given the initial wizard. diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts index 069178a..1158420 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts @@ -287,6 +287,13 @@ export function submit(s: RegistratieState): RegistratieState { return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error }; } +/** The primary button's action: advance, or submit from the controle step. + No-op outside Invullen — this is the one decision `onPrimary()` used to make. */ +export function primary(s: RegistratieState): RegistratieState { + if (s.tag !== 'Invullen') return s; + return currentStep(s) === 'controle' ? submit(s) : next(s); +} + /** Route an upload sub-message through the pure upload reducer (Invullen only). */ export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState { if (s.tag !== 'Invullen') return s; @@ -312,6 +319,7 @@ export type RegistratieMsg = | { tag: 'Back' } | { tag: 'GaNaarStap'; cursor: number } | { tag: 'Submit' } + | { tag: 'Primary' } | { tag: 'Retry' } | { tag: 'SubmitConfirmed'; referentie: string } | { tag: 'SubmitFailed'; error: string } @@ -342,6 +350,8 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState return gaNaarStap(s, m.cursor); case 'Submit': return submit(s); + case 'Primary': + return primary(s); case 'Retry': return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s; case 'SubmitConfirmed': diff --git a/docs/project/readable-codebase/RD-07-primary-message.md b/docs/project/readable-codebase/RD-07-primary-message.md new file mode 100644 index 0000000..a50aa5f --- /dev/null +++ b/docs/project/readable-codebase/RD-07-primary-message.md @@ -0,0 +1,157 @@ +# RD-07 — Move the step-boundary decision into the 3 wizard machines + +Status: done +Source: PLAN.md 1a + +## Why + +Each wizard component decides in the UI whether the primary button means "next step" or +"submit", duplicating a rule the reducer already owns: + +| Component | Line | Body | +| ------------------------------------ | ------- | -------------------------------------------------------------------------------- | +| `herregistratie-wizard.component.ts` | 256-259 | `if (s.tag !== 'Editing') return;` then `s.step < 3 ? Next : Submit` | +| `intake-wizard.component.ts` | 368-371 | `if (s.tag !== 'Answering') return;` then `step() === 'review' ? Submit : Next` | +| `registratie-wizard.component.ts` | 613-616 | `if (s.tag !== 'Invullen') return;` then `step() === 'controle' ? Submit : Next` | + +Each already-exported `next`/`submit` pair holds the real transition, so the component is +re-deriving a decision the machine can make. After this ticket, `onPrimary()` in RD-08 becomes +a single `dispatch({ tag: 'Primary' })`, and the wizard shell's `primary`/`back`/`retry` +outputs map 1:1 onto messages — which is what `.claude/skills/form-machine/SKILL.md:74-78` +already claims they do. + +This ticket is **domain-only**: machines and their specs. No component changes. + +## Read first + +- `apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts` — `next` at 87, + `submit` at 118, `reduce`'s `Next`/`Submit` cases at 165/171 +- `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` — `currentStep` at 86, `next` at + 173, `submit` at 197, cases at 226/232 +- `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts` — `currentStep` at 96, + `next` at 265, `submit` at 284, cases at 337/343 +- The three matching `*.machine.spec.ts` files +- CLAUDE.md decision 3 (state, and the naming rule for machines) + +## Decisions (pre-made, don't relitigate) + +1. **Add `{ tag: 'Primary' }` to each of the three `Msg` unions**, plus an exported + `primary(s)` function, plus a `case 'Primary'` in each `reduce` that delegates to it. Same + shape as the existing `Next`/`Submit` cases. + +2. **Express "last step" in each machine's own vocabulary. Do NOT invent a shared + `isLastStep` helper.** The three state shapes genuinely differ: + - `herregistratie`: `Editing` carries `step: 1 | 2 | 3` and there is **no `STEPS` array** → + the test is `s.step === 3`. + - `intake`: `Answering` carries `cursor: number` against + `STEPS = ['buitenland','werk','review']` → the test is `currentStep(s) === 'review'`. + - `registratie`: `Invullen` carries `cursor: number` against + `STEPS = ['adres','beroep','controle']` → the test is `currentStep(s) === 'controle'`. + + Two of the three could share a `cursor === STEPS.length - 1` form, but the third cannot. + A helper covering two of three, plus a special case, is more to read than three plain + expressions. + +3. **`primary` delegates to the existing exported `next` and `submit`.** Write no new + validation and duplicate no transition logic. The whole function is a branch: + + ```ts + export function primary(s: WizardState): WizardState { + if (s.tag !== 'Editing') return s; + return s.step === 3 ? submit(s) : next(s); + } + ``` + + (…and the equivalent, in its own vocabulary, for the other two.) + +4. **The guard moves into the machine.** `primary` returns `s` unchanged when the state is not + the editing state, so RD-08 can delete the component preamble. Note the editing tag differs + per machine: `Editing`, `Answering`, and `Invullen`. **`Invullen`/`Indienen`/`Ingediend`/ + `Mislukt` are correct Dutch domain tags per CLAUDE.md — do not "fix" them to English.** + +5. **KEEP `Next` and `Submit` in all three `Msg` unions.** Verified: they are dispatched + across **9 spec files**, including `intake.acceptance.spec.ts`, which uses them as a + readable behaviour narrative, and the three `*-has-progress.spec.ts` files. Removing them + would rewrite dozens of spec lines for no gain. `Primary` is purely **additive**. + +6. **Do not touch any component.** RD-08 migrates the three wizards. If you edit a + `*.component.ts` in this ticket, it is out of scope. + +7. **Keep `SCHOLING_THRESHOLD_DEFAULT` greppable.** `npm run check:seam` greps for it as a + top-level `export const` in `intake.machine.ts:43`. Do not move or inline it. + +## Files + +- `apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts` + `.spec.ts` +- `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` + `.spec.ts` +- `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts` + `.spec.ts` + +## Steps + +1. In each machine: add `{ tag: 'Primary' }` to the `Msg` union, export `primary(s)` next to + `next`/`submit`, and add the `case 'Primary'` to `reduce`. +2. In each machine spec: add the three cases from Acceptance below. +3. Run `npm run gen:behaviour-spec` — new `it()` titles otherwise fail the drift check. +4. Update this ticket's `Status:` to `done` and the README's RD-07 row to `done`. +5. Commit all of it together. + +## Acceptance criteria + +Three cases per machine spec (9 total), pure `reduce` calls, no TestBed: + +``` +- Primary advances to the next step from a non-final step +- Primary submits from the final step +- Primary is a no-op from a non-editing state +``` + +The third case is what lets RD-08 delete the component guard, so do not skip it. + +```bash +npm test # exits 0 +npm run ci # exits 0 +``` + +Prove `Primary` produces exactly what the components produce today, so the migration in RD-08 +is behaviour-preserving. For each machine, these must be equal: + +``` +reduce(s, { tag: 'Primary' }) === reduce(s, { tag: 'Next' }) // at a non-final step +reduce(s, { tag: 'Primary' }) === reduce(s, { tag: 'Submit' }) // at the final step +``` + +Prove nothing was removed: + +```bash +grep -c "tag: 'Next'\|tag: 'Submit'" apps/ssp/src/app/herregistratie/domain/intake.machine.ts +# Next and Submit must still be in the union and still handled in reduce +``` + +## Verification + +`npm run ci`. This ticket touches no story, no `.mdx` and no component, so `--full` is not +required. + +## Out of scope + +- The three components. RD-08. +- `besluit.machine.ts` and `change-request.machine.ts` — single-step forms with no step + boundary, so `Primary` would mean nothing there. RD-06 handles those two. +- Removing `Next`/`Submit` (decision 5). +- Renaming the Dutch tags in `registratie-wizard.machine.ts` (decision 4). + +## Risks + +- **`behaviour-spec.mdx` drift.** 9 new `it()` titles across 3 spec files. + `scripts/ci-local.sh` regenerates `libs/shared/docs/behaviour-spec.mdx` from a path-sorted + walk of spec titles and fails on any drift. Run `npm run gen:behaviour-spec` in the same + commit. +- **`check:seam`** greps `SCHOLING_THRESHOLD_DEFAULT` (decision 7) and + `besluit.machine.ts`'s `BESLUIT_TAGS`. Neither should move, but if `check:seam` fails, that + is why. +- **`snippets.generated.ts` drift.** `intake.machine.ts:59` carries a + `// #region showcase:steps` marker around `STEPS`. If your edit moves or splits that region, + run `npm run gen:snippets` in the same commit. +- **Do not make `primary` clever.** It is a three-line branch delegating to two existing + functions. If it grows validation, error mapping, or a cursor calculation, the transition + logic has been duplicated instead of reused. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 5355557..f2ea454 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -101,7 +101,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-04 | Story titles to `Domein//`; add the missing stories | 03 | yes | todo | | RD-05 | `createStore` gains the effect map + specs | 02 | | done | | RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | done | -| RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | todo | +| RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | done | | RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | todo | | RD-09 | **Docs + generator:** `form-machine.hbs`, ARCHITECTURE, fp-tea, skill | 08 | | todo | | RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | todo | diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 8301797..2afe646 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 510 frontend behaviours across +**is** the suite, reshaped for a business reader. 519 frontend behaviours across 9 contexts; 261 backend behaviours across 42 test classes. @@ -383,6 +383,15 @@ classes. - gaNaarStap jumps back to an earlier step, clearing errors - gaNaarStap ignores a same/forward jump and jumps outside Answering +#### primary + +- Primary advances to the next step from a non-final step +- Primary submits from the final step +- Primary is a no-op from a non-editing state +- Primary advances to the next step from a non-final step +- Primary submits from the final step +- Primary is a no-op from a non-editing state + #### reduce (message-driven happy path) - drives abroad branch end to end @@ -605,6 +614,12 @@ classes. - a diploma with questions blocks Next until they are answered - validateAll keeps only the answers to the questions that applied +#### primary + +- Primary advances to the next step from a non-final step +- Primary submits from the final step +- Primary is a no-op from a non-editing state + #### reduce (message-driven happy path) - adres and correspondentie set, Next advances from adres to beroep From d0d6a3764bf1f9abf6fbf5c7915f9d19623f36c3 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 17:14:29 +0200 Subject: [PATCH 09/46] docs: correct the A5 claim about the form-machine generator PLAN.md stated that plop-templates/form-machine.hbs generates runIfSubmitting, and that .claude/skills/form-machine/SKILL.md teaches it, so the next scaffolded form would recreate the bug RD-06 fixed. Both are false. The plop template is machine-only: 74 lines, no @Component, no runIfSubmitting. The skill never mentions it. Two real sites remain, both teaching the deleted idiom in a code block: ARCHITECTURE.md:314 and :574, and fp-tea-atomic-design.md:342. RD-09 is therefore a two-document fix, still worth doing, but not the urgent generator fix this plan claimed. Co-Authored-By: Claude Opus 5 --- docs/project/readable-codebase/PLAN.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/project/readable-codebase/PLAN.md b/docs/project/readable-codebase/PLAN.md index ac7373f..6937271 100644 --- a/docs/project/readable-codebase/PLAN.md +++ b/docs/project/readable-codebase/PLAN.md @@ -460,11 +460,20 @@ Submit first — it settles the final shape of the 5 components, and 1b#4 touche | B6 | _(optional)_ `NO_SUBORGS`/`NO_TABLES` → `Empty` | Needs a new `$localize` id per app that renders it | | B5 | _(optional, last, alone)_ upload `type:` → `tag:` | ~20 files; see the sed hazard in Risks | -**A5 is not optional.** `docs/reference/architecture/ARCHITECTURE.md:308-322` and `:574`, and -`docs/reference/fp-tea-atomic-design.md:338-350`, both teach `runIfSubmitting` verbatim — and -`plop-templates/form-machine.hbs` **generates it**, as does -`.claude/skills/form-machine/SKILL.md:65-78`. If the generator is not updated, the next form -scaffolded in this repo recreates the bug. Update all five in the same commit as A4. +**A5 — corrected while executing RD-09.** The original claim here was that +`plop-templates/form-machine.hbs` **generates** `runIfSubmitting`, and that +`.claude/skills/form-machine/SKILL.md:65-78` teaches it, so that the next scaffolded form +would recreate the bug. **Both are false**, verified by grep: + +- `plop-templates/form-machine.hbs` is **machine-only** — 74 lines, no `@Component`, no + `runIfSubmitting`. No generator recreates the bug. +- `.claude/skills/form-machine/SKILL.md` never mentions it. + +Two real sites remain, and both teach the deleted idiom verbatim in a code block: +`docs/reference/architecture/ARCHITECTURE.md:314` and `:574`, and +`docs/reference/fp-tea-atomic-design.md:342`. So A5/RD-09 is a two-document fix, still worth +doing — a teaching document that teaches a deleted idiom is precisely the rot this arc targets +— but it is not the urgent generator fix this plan originally claimed. **Scope discipline:** A1 + A2 alone deliver "dispatching cannot silently skip the effect" and fix both bugs. If the budget shrinks, stop after A5; B3 and B2 are hygiene, not correctness. From b8aced75e33d3dd0cc02ff1ce2cecb7069efd9ab Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 17:20:52 +0200 Subject: [PATCH 10/46] refactor: migrate the 3 wizards to the effect map and Primary (RD-08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three wizards paired a dispatch with a hand-written effect call (onPrimary/onRetry + runIfSubmitting/runIfIndienen). A missed call failed silently. RD-05 added the effect map and RD-07 added the Primary message; this ticket moves each wizard onto both. Each wizard now registers its submit effect on createStore, keyed by its own submitting tag (Submitting for herregistratie and intake, Indienen for registratie — the type catches a wrong key at compile time). The optimistic begin/confirm/rollback calls stay inside the effect body, unchanged. The template dispatches Primary and Retry directly, matching how Back already worked. onPrimary, onRetry, and runIfSubmitting/runIfIndienen are deleted from all three components. herregistratie-wizard drops under the 250-rule-line budget, so its eslint-disable max-lines header is removed in this same commit (RD-02's self-cleaning mechanism). intake-wizard and registratie-wizard stay over budget and keep theirs, both already flagged for RD-22/RD-23. Three doc comments (in the three machine files, plus one in store.ts) named the deleted onPrimary()/runIfSubmitting() identifiers in prose. Reworded them so the "idiom is gone from the repo" grep check is not defeated by its own explanatory comments. Co-Authored-By: Claude Sonnet 5 --- .../domain/herregistratie.machine.ts | 2 +- .../herregistratie/domain/intake.machine.ts | 2 +- .../herregistratie-wizard.component.ts | 49 ++---- .../intake-wizard/intake-wizard.component.ts | 62 +++---- .../domain/registratie-wizard.machine.ts | 2 +- .../registratie-wizard.component.ts | 42 ++--- .../RD-08-wizards-to-effect-map.md | 165 ++++++++++++++++++ docs/project/readable-codebase/README.md | 15 +- libs/shared/src/application/store.ts | 2 +- 9 files changed, 238 insertions(+), 103 deletions(-) create mode 100644 docs/project/readable-codebase/RD-08-wizards-to-effect-map.md diff --git a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts index f513e33..f603a84 100644 --- a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts +++ b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts @@ -122,7 +122,7 @@ export function submit(s: WizardState): WizardState { } /** The primary button's action: advance, or submit from the last step. No-op - outside Editing — this is the one decision `onPrimary()` used to make. */ + outside Editing — this is the one decision the old component-side handler used to make. */ export function primary(s: WizardState): WizardState { if (s.tag !== 'Editing') return s; return s.step === 3 ? submit(s) : next(s); diff --git a/apps/ssp/src/app/herregistratie/domain/intake.machine.ts b/apps/ssp/src/app/herregistratie/domain/intake.machine.ts index 354e744..883f661 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.machine.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.machine.ts @@ -201,7 +201,7 @@ export function submit(s: IntakeState): IntakeState { } /** The primary button's action: advance, or submit from the review step. No-op - outside Answering — this is the one decision `onPrimary()` used to make. */ + outside Answering — this is the one decision the old component-side handler used to make. */ export function primary(s: IntakeState): IntakeState { if (s.tag !== 'Answering') return s; return currentStep(s) === 'review' ? submit(s) : next(s); diff --git a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index 4aaf099..f93de66 100644 --- a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines */ // single-step wizard shell — removed by RD-20 import { Component, computed, inject, input } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; @@ -56,10 +55,10 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload. [canGoBack]="step() > 1" [errors]="errorList()" [errorMessage]="errorMessage()" - (primary)="onPrimary()" + (primary)="dispatch({ tag: 'Primary' })" (back)="dispatch({ tag: 'Back' })" (cancel)="restart()" - (retry)="onRetry()" + (retry)="dispatch({ tag: 'Retry' })" (goToStep)="goToStep($event)" > @switch (step()) { @@ -149,7 +148,21 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload. }) export class HerregistratieWizardComponent { private profile = inject(BigProfileStore); - private store = createStore(initial, reduce); + // Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is + // exempt, so a story mounting straight into `Submitting` does not call the network). + private store = createStore(initial, reduce, { + Submitting: async (s, store) => { + this.profile.beginHerregistratie(); + const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents }); + if (r.ok) { + store.dispatch({ tag: 'SubmitConfirmed' }); + this.profile.confirmHerregistratie(); + } else { + store.dispatch({ tag: 'SubmitFailed', error: r.error }); + this.profile.rollbackHerregistratie(); + } + }, + }); /** Preview/download link for a completed upload; delegates to the upload controller (application layer), which knows the dev-simulation `demo-*` ids @@ -253,37 +266,9 @@ export class HerregistratieWizardComponent { ); } - onPrimary() { - const s = this.state(); - if (s.tag !== 'Editing') return; - this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' }); - this.runIfSubmitting(); - } - - onRetry() { - this.dispatch({ tag: 'Retry' }); - this.runIfSubmitting(); - } - /** Reset the wizard to a fresh, empty start. */ restart() { this.draftSync.reset(); this.dispatch({ tag: 'Seed', state: initial }); } - - /** The effect: when we entered Submitting, submit through the aanvraag lifecycle, - flip the optimistic cross-page flag, then dispatch the result (commit/rollback). */ - private async runIfSubmitting() { - const s = this.state(); - if (s.tag !== 'Submitting') return; - this.profile.beginHerregistratie(); - const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents }); - if (r.ok) { - this.dispatch({ tag: 'SubmitConfirmed' }); - this.profile.confirmHerregistratie(); - } else { - this.dispatch({ tag: 'SubmitFailed', error: r.error }); - this.profile.rollbackHerregistratie(); - } - } } diff --git a/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts b/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts index 81f8414..72dc03c 100644 --- a/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts +++ b/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -64,10 +64,10 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto [canGoBack]="cursor() > 0" [errors]="errorList()" [errorMessage]="errorMessage()" - (primary)="onPrimary()" + (primary)="dispatch({ tag: 'Primary' })" (back)="dispatch({ tag: 'Back' })" (cancel)="restart()" - (retry)="onRetry()" + (retry)="dispatch({ tag: 'Retry' })" (goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })" > @switch (step()) { @@ -269,7 +269,28 @@ export class IntakeWizardComponent { // Server-owned policy (scholing threshold): fetched from the backend via the // application facade, not hardcoded. The backend stays the authority on submit. private policyStore = inject(IntakePolicyStore); - private store = createStore(initial, reduce); + // Effect fires once, on Answering -> Submitting (RD-05's tag-transition rule; `Seed` is + // exempt, so a story mounting straight into `Submitting` does not call the network). + private store = createStore(initial, reduce, { + Submitting: async (s, store) => { + this.profile.beginHerregistratie(); + // WP-69: the scholing answer rides along so the server can re-validate it as the + // authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by + // JSON.stringify, so a wizard above the threshold sends neither field. + const r = await this.draftSync.submit({ + uren: s.data.uren, + aanvullendeScholing: s.data.aanvullendeScholing, + scholingPunten: s.data.punten, + }); + if (r.ok) { + store.dispatch({ tag: 'SubmitConfirmed' }); + this.profile.confirmHerregistratie(); + } else { + store.dispatch({ tag: 'SubmitFailed', error: r.error }); + this.profile.rollbackHerregistratie(); + } + }, + }); /** Optional seed so Storybook / the showcase can mount any state directly. */ seed = input(initial); @@ -365,43 +386,8 @@ export class IntakeWizardComponent { }); } - onPrimary() { - const s = this.state(); - if (s.tag !== 'Answering') return; - this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' }); - this.runIfSubmitting(); - } - - onRetry() { - this.dispatch({ tag: 'Retry' }); - this.runIfSubmitting(); - } - restart() { this.draftSync.reset(); this.dispatch({ tag: 'Seed', state: initial }); } - - /** The effect: when we enter Submitting, submit through the aanvraag lifecycle, - flip the optimistic cross-page flag, then dispatch the outcome (commit/rollback). */ - private async runIfSubmitting() { - const s = this.state(); - if (s.tag !== 'Submitting') return; - this.profile.beginHerregistratie(); - // WP-69: the scholing answer rides along so the server can re-validate it as the - // authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by - // JSON.stringify, so a wizard above the threshold sends neither field. - const r = await this.draftSync.submit({ - uren: s.data.uren, - aanvullendeScholing: s.data.aanvullendeScholing, - scholingPunten: s.data.punten, - }); - if (r.ok) { - this.dispatch({ tag: 'SubmitConfirmed' }); - this.profile.confirmHerregistratie(); - } else { - this.dispatch({ tag: 'SubmitFailed', error: r.error }); - this.profile.rollbackHerregistratie(); - } - } } diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts index 1158420..d5fd063 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts @@ -288,7 +288,7 @@ export function submit(s: RegistratieState): RegistratieState { } /** The primary button's action: advance, or submit from the controle step. - No-op outside Invullen — this is the one decision `onPrimary()` used to make. */ + No-op outside Invullen — this is the one decision the old component-side handler used to make. */ export function primary(s: RegistratieState): RegistratieState { if (s.tag !== 'Invullen') return s; return currentStep(s) === 'controle' ? submit(s) : next(s); diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index 26cc230..8333e83 100644 --- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -90,10 +90,10 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid'; [errorMessage]="errorMessage()" i18n-submittingLabel="@@regWizard.submitting" submittingLabel="Uw registratie wordt verwerkt…" - (primary)="onPrimary()" + (primary)="dispatch({ tag: 'Primary' })" (back)="dispatch({ tag: 'Back' })" (cancel)="restart()" - (retry)="onRetry()" + (retry)="dispatch({ tag: 'Retry' })" (goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })" > @switch (step()) { @@ -368,7 +368,18 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid'; }) export class RegistratieWizardComponent { private lookup = inject(RegistratieLookupStore); - private store = createStore(initial, reduce); + // Effect fires once, on Invullen -> Indienen (RD-05's tag-transition rule; `Seed` is + // exempt, so a story mounting straight into `Indienen` does not call the network). + private store = createStore(initial, reduce, { + Indienen: async (s, store) => { + const r = await this.draftSync.submit({ + diplomaHerkomst: s.data.diplomaHerkomst, + documents: s.data.documents, + }); + if (r.ok) store.dispatch({ tag: 'SubmitConfirmed', referentie: r.value.referentie ?? '' }); + else store.dispatch({ tag: 'SubmitFailed', error: r.error }); + }, + }); /** Preview/download link for a completed upload; delegates to the upload controller (application layer), which knows the dev-simulation `demo-*` ids @@ -610,18 +621,6 @@ export class RegistratieWizardComponent { // failed submit) now lives in the shared WizardShellComponent. } - onPrimary() { - const s = this.state(); - if (s.tag !== 'Invullen') return; - this.dispatch(this.step() === 'controle' ? { tag: 'Submit' } : { tag: 'Next' }); - this.runIfIndienen(); - } - - onRetry() { - this.dispatch({ tag: 'Retry' }); - this.runIfIndienen(); - } - /** Reset the wizard to a fresh start. Reload the BRP lookup so the address re-prefills, keeping the form and the"vooraf ingevuld" note consistent. */ restart() { @@ -629,17 +628,4 @@ export class RegistratieWizardComponent { this.dispatch({ tag: 'Seed', state: initial }); this.lookup.reloadAdres(); } - - /** The effect: when we enter Indienen, submit through the aanvraag lifecycle - (duo → auto-approve, handmatig → manual), then dispatch the outcome. */ - private async runIfIndienen() { - const s = this.state(); - if (s.tag !== 'Indienen') return; - const r = await this.draftSync.submit({ - diplomaHerkomst: s.data.diplomaHerkomst, - documents: s.data.documents, - }); - if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value.referentie ?? '' }); - else this.dispatch({ tag: 'SubmitFailed', error: r.error }); - } } diff --git a/docs/project/readable-codebase/RD-08-wizards-to-effect-map.md b/docs/project/readable-codebase/RD-08-wizards-to-effect-map.md new file mode 100644 index 0000000..ad0c299 --- /dev/null +++ b/docs/project/readable-codebase/RD-08-wizards-to-effect-map.md @@ -0,0 +1,165 @@ +# RD-08 — Migrate the 3 wizards to the effect map and `Primary` + +Status: done +Source: PLAN.md 1a + +## Why + +This is the ticket that removes the silent-failure idiom from the last three call sites. Each +wizard still pairs a `dispatch` with a hand-written effect call, and forgetting the second +line fails silently: + +```ts +onPrimary() { … this.dispatch(…); } // decides Next vs Submit in the UI +onRetry() { this.dispatch({ tag: 'Retry' }); this.runIfSubmitting(); } +``` + +RD-05 added the effect map; RD-07 added `Primary`. After this ticket the wizard shell's five +outputs all map 1:1 onto messages in the template, and each component loses three methods. + +## Read first + +- `libs/shared/src/application/store.ts` — the effect map, its trigger rule, and the `Seed` + exemption +- The three machines' new `primary` exports (RD-07) +- `apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts` + — `onPrimary` 256, `onRetry` 263, `runIfSubmitting` 276-288 +- `apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` — `onPrimary` + 368, `onRetry` 375, `runIfSubmitting` 387-406 +- `apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` — + `onPrimary` 613, `onRetry` 620, `runIfIndienen` 635-644 +- `docs/project/readable-codebase/RD-06-…md` — the same migration, already done for the two + single-step forms. Copy its shape. + +## Decisions (pre-made, don't relitigate) + +1. **The effect-map key is the machine's own submitting tag, and it is not the same for all + three:** + + | Wizard | Effect-map key | + | ----------------------- | -------------- | + | `herregistratie-wizard` | `Submitting` | + | `intake-wizard` | `Submitting` | + | `registratie-wizard` | **`Indienen`** | + + `StoreEffects` keys are typed as `Model['tag']`, so writing `Submitting` for the registratie + wizard is a **compile error**, not a silent no-op. That is the type doing its job — do not + work around it by widening the type. + +2. **Move each effect body verbatim into the map.** The narrowed state arrives as argument one, + so delete the `const s = this.state(); if (s.tag !== '…') return;` preamble and use the + parameter. Keep everything else identical, including the optimistic store calls. + +3. **The optimistic `begin`/`confirm`/`rollback` calls stay inside the effect body**, exactly + where they are today (`herregistratie-wizard:279,283,286`; `intake-wizard:390,401,404`). + The effect slot is the sanctioned place for side effects, so `reduce` stays pure. The + registratie wizard has **no** optimistic calls — do not add any. + +4. **Delete `onPrimary` and `onRetry` entirely** and dispatch from the template, matching how + `(back)` already does it: + + ```text + (primary)="dispatch({ tag: 'Primary' })" + (retry)="dispatch({ tag: 'Retry' })" + (back)="dispatch({ tag: 'Back' })" <-- already this shape today + ``` + + `Retry` needs no effect call because `Failed → Submitting`/`Indienen` is a tag transition, + so the map fires it. This is the 1:1 output-to-message mapping + `.claude/skills/form-machine/SKILL.md:74-78` already claims. + +5. **Leave the two `dispatch` calls that sit inside Angular `effect()`s alone** — + `intake-wizard`'s `SetPolicy` and `registratie-wizard`'s `PrefillAdres`. Both land on an + unchanged tag, so no effect fires, and both are already `untracked`. Do not key an effect on + an editing tag (`Editing`/`Answering`/`Invullen`) — that is the livelock + `store.spec.ts:18-31` guards against. + +6. **Preserve the WP-69 comment in `intake-wizard`'s effect body verbatim**, including its + ticket reference. It explains why the scholing answer rides along for server-side + re-validation. RD-19 strips ticket prefixes across the repo later; do not do it early and do + not drop the explanation. + +7. **If a wizard drops below 250 rule-lines, delete its `eslint-disable max-lines` header in + this same commit.** See Risks — this is expected for `herregistratie-wizard` and is + RD-02's mechanism working, not a problem to route around. + +## Files + +- `apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts` +- `apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` +- `apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` + +No machine changes. No story changes expected. No xlf changes. + +## Steps + +1. For each wizard: register the effect body on `createStore` under the key from decision 1, + dropping the guard preamble. +2. Delete `runIfSubmitting` / `runIfIndienen`. +3. Delete `onPrimary` and `onRetry`; dispatch `Primary` and `Retry` from the template + (decision 4). +4. Run `npm run lint`. If a file is now under budget, delete its `eslint-disable max-lines` + header (decision 7). +5. Update this ticket's `Status:` to `done` and the README's RD-08 row to `done`. +6. Commit all of it together. + +## Acceptance criteria + +The idiom is gone from the whole repo. Anchor on the **declaration**, not on any occurrence +of the name: + +```bash +grep -rn "runIfSubmitting\|runIfIndienen" apps libs # MUST return nothing +grep -rnE "^ (onPrimary|onRetry)\(\)" apps libs # MUST return nothing +``` + +The second pattern is anchored deliberately. A bare `grep -rn "onPrimary\|onRetry"` **cannot +pass**: `libs/shared/src/application/upload-controller.ts:94` has an unrelated +`onRetry(localId)` method, called from two wizard templates as +`uploadCtl.onRetry($event)`. Those three matches are correct code and must stay. + +Behaviour is unchanged. Walk each wizard end to end with `npm start`: + +1. The primary button advances through every step and submits on the last one. +2. A failed submit shows the error, and Retry re-submits (it must leave `Failed`). +3. Clicking the primary button twice quickly submits **once** — the trigger rule's + tag-transition condition gives this for free. +4. For the two herregistratie wizards, the dashboard's "herregistratie in behandeling" notice + still appears after submit and disappears after a rollback. + +```bash +npm run ci # exits 0 +npm run ci --full # exits 0 — the wizards all have stories +``` + +## Verification + +`npm run ci --full`. `--full` is required: all three wizards have stories, and +`registratie-wizard.stories.ts:88`, `intake-wizard.stories.ts:38` and +`herregistratie-wizard.stories.ts:70` each seed a submitting state. + +## Out of scope + +- Splitting any wizard into step components. RD-22 and RD-23, after RD-20. +- `WizardStatus` → `WizardPhase`. RD-10. +- Stripping the WP-69 ticket reference (decision 6). RD-19. +- Touching `shellStatus`, `errorList`, or any other computed. Only the three methods and the + template bindings change here. + +## Risks + +- **`herregistratie-wizard` is 252 rule-lines and will likely drop below 250.** Deleting three + methods (~21 lines) and adding a map registration (~12) nets roughly −9. Its + `eslint-disable max-lines` then becomes unnecessary and + `reportUnusedDisableDirectives: 'error'` **fails the build**. That is RD-02's self-cleaning + mechanism doing its job: delete the header (decision 7). RD-20 also expects to remove it — + whichever ticket gets there first removes it, and the other finds nothing to do. +- **The Storybook trap.** All three wizards seed a submitting state in their stories with a + real `provideHttpClient()` and no request mocking. RD-05's `Seed` exemption is what stops + them firing real network calls. If a story flips to a failed state on load, or + `storybook-a11y` goes red, the exemption is not working — fix that, do not delete the story. +- **Double-submit protection is now structural, not incidental.** The old code could submit + twice if `dispatch` and the effect call were paired twice. The tag-transition rule prevents + it. Do not add a `busy` guard on top; that would be a second mechanism for one rule. +- **`behaviour-spec.mdx` drift** if you add or rename a spec. Run `npm run gen:behaviour-spec` + in the same commit if so. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index f2ea454..e5f0dca 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -102,7 +102,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-05 | `createStore` gains the effect map + specs | 02 | | done | | RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | done | | RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | done | -| RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | todo | +| RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | done | | RD-09 | **Docs + generator:** `form-machine.hbs`, ARCHITECTURE, fp-tea, skill | 08 | | todo | | RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | todo | | RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | todo | @@ -178,3 +178,16 @@ Three rules when you write a ticket file, because the agent reads its ticket and Risks section is a trap that fires. 3. **State acceptance as a command, not a sentence.** "Lands about 230 lines" is a design estimate and nothing can check it. `npm run lint` has an exit code. +4. **Run every acceptance command against the tree before you hand the ticket over.** A + command that cannot pass is worse than no command: the agent either wastes a cycle or, + worse, "fixes" correct code to satisfy it. Three real misses so far, all in tickets written + by the supervisor: + - RD-06 grepped only `runIfSubmitting`, missing that one wizard spells it `runIfIndienen`. + - RD-08 grepped bare `onPrimary\|onRetry`, which can never return nothing — an unrelated + `uploadCtl.onRetry` exists in `upload-controller.ts`. + - RD-08 said "no machine changes" while also requiring a repo-wide grep to come back + clean, which forced comment edits in three machines. The two instructions contradicted + each other. + + Anchor greps on a declaration (`^ onRetry\(\)`) rather than a name, and make the Files + list agree with the Acceptance commands. diff --git a/libs/shared/src/application/store.ts b/libs/shared/src/application/store.ts index 7ea33f0..e080e7f 100644 --- a/libs/shared/src/application/store.ts +++ b/libs/shared/src/application/store.ts @@ -26,7 +26,7 @@ export interface Store { * An effect body must never throw: end it in a `Result` from * `runSubmit`/`runResult` (`submit.ts`) and let the failure travel as a * dispatched message, not an exception. `dispatch` floats the effect's - * promise, exactly as the `runIfSubmitting()` call it replaces did. + * promise, exactly as the hand-written effect method it replaces did. */ export type StoreEffects = Model extends { tag: string } ? { From d15943bb36b91755b5d72129dfa8e7e2d0db8572 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 17:26:52 +0200 Subject: [PATCH 11/46] docs: teach the effect map, not the deleted submit method (RD-09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RD-05 through RD-08 replaced the hand-called submit method with createStore's effect map. Two teaching documents still showed the old method in a code block, as the answer to "how does a submit happen?". Both blocks also called a function that no longer exists. Rewrite the code block in ARCHITECTURE.md section 2d and its fp-tea-atomic-design.md counterpart. Both now show the effect map, keyed on the Submitting tag, using the same herregistratie worked example with its optimistic begin/confirm/rollback calls. Both use draftSync.submit, the call the two herregistratie wizards make today. State the two properties the old idiom lacked, since they are the reason for the change: entering a state runs its effect, so a dispatch cannot skip it; and double-submit protection is structural, because the effect fires only on a tag transition. Add one sentence on the Seed exemption: a mount or restore message must not trigger a submit. Fix the one runIfSubmitting() hop in the write walkthrough at ARCHITECTURE.md's line 574. The rest of section 6a stays stale on purpose — RD-31 owns it, including its line citations and dead paths. fp-tea-atomic-design.md's broken pre-monorepo paths stay stale too — RD-32 owns those. Set RD-09's Status to done and its README row to done in the same commit. Co-Authored-By: Claude Sonnet 5 --- .../RD-09-docs-effect-idiom.md | 132 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- docs/reference/architecture/ARCHITECTURE.md | 44 ++++-- docs/reference/fp-tea-atomic-design.md | 52 ++++--- 4 files changed, 196 insertions(+), 34 deletions(-) create mode 100644 docs/project/readable-codebase/RD-09-docs-effect-idiom.md diff --git a/docs/project/readable-codebase/RD-09-docs-effect-idiom.md b/docs/project/readable-codebase/RD-09-docs-effect-idiom.md new file mode 100644 index 0000000..ae05f82 --- /dev/null +++ b/docs/project/readable-codebase/RD-09-docs-effect-idiom.md @@ -0,0 +1,132 @@ +# RD-09 — Update the two documents that teach the deleted submit idiom + +Status: done +Source: PLAN.md 1c (A5, as corrected) + +## Why + +RD-05 through RD-08 replaced the hand-called `runIfSubmitting` with `createStore`'s effect +map, and `grep` now finds the method nowhere in `apps/` or `libs/`. Two teaching documents +still present it as the house idiom, in a code block, as the answer to "how does a submit +happen?". + +Both blocks are doubly stale: they teach a deleted idiom **and** call +`submitHerregistratie(s.data)`, a function that no longer exists either. + +A document that teaches a deleted idiom is worse than no document — a reader who follows it +reintroduces the silent-failure bug RD-06 fixed. + +## Read first + +- `libs/shared/src/application/store.ts` — the effect map, the trigger rule, the `Seed` + exemption, and the "must never throw" contract. This is the new idiom to document. +- Any migrated call site, as a worked example — `besluit-form.component.ts` is the smallest. +- `docs/reference/architecture/ARCHITECTURE.md` §2d (the block at 306-322) and the write + walkthrough mention at :574. +- `docs/reference/fp-tea-atomic-design.md` (the block at 336-350). + +## Decisions (pre-made, don't relitigate) + +1. **Scope is exactly two documents.** An earlier version of this plan claimed + `plop-templates/form-machine.hbs` generates `runIfSubmitting` and that + `.claude/skills/form-machine/SKILL.md` teaches it. **Both are false**, verified by grep: + the plop template is machine-only (74 lines, no `@Component`), and the skill never mentions + it. Do not "fix" either file. If you find yourself editing a `.hbs` or a `SKILL.md`, stop. + +2. **Rewrite the code block, do not delete the section.** §2d ("Side effects (HTTP) without + polluting the reducer") and its fp-tea counterpart answer a real question and answer it + well. The answer changed; the question did not. + +3. **The new block shows the effect map.** Same worked example (a herregistratie submit with + its optimistic `begin`/`confirm`/`rollback`), expressed as a registration: + + ```ts + private store = createStore(initial, reduce, { + Submitting: async (s, store) => { … }, + }); + ``` + + Keep the numbered inline comments — they are what makes the block teachable. + +4. **State the two properties the old idiom lacked**, because they are the reason for the + change and a reader should see them: + - Entering the state runs the effect. A `dispatch` cannot silently skip it. + - Double-submit protection is structural: the effect fires only on a tag **transition**, so + a second `Submit` while already submitting is a reducer no-op and fires nothing. + +5. **Document the `Seed` exemption where the idiom is taught**, in one sentence: a mount or + restore message must not trigger a submit. That is the non-obvious part of the rule, and + `store.ts` explains it in full for anyone who needs more. + +6. **Fix `ARCHITECTURE.md:574`'s write walkthrough** too — it opens with `runIfSubmitting() +(§2d) → createSubmitChangeRequest`. Replace the first hop with the effect map. Leave the + rest of §6a alone: **RD-31 rewrites that section**, including its stale `L` citations and + two dead file paths. Touch only the `runIfSubmitting` hop here. + +7. **Drop the `submitHerregistratie(...)` call from both blocks.** That module no longer + exists (`submit-herregistratie.ts` was deleted). Use `draftSync.submit(...)`, which is what + the two herregistratie wizards actually call today. + +## Files + +- `docs/reference/architecture/ARCHITECTURE.md` (§2d block, and the one hop at :574) +- `docs/reference/fp-tea-atomic-design.md` (the block at 336-350) + +No code files. No `.hbs`. No `SKILL.md`. + +## Steps + +1. Rewrite `ARCHITECTURE.md` §2d's code block per decisions 3-5. +2. Fix the first hop of the write walkthrough at :574 per decision 6. +3. Rewrite the equivalent block in `fp-tea-atomic-design.md` per decisions 3-5 and 7. +4. Update this ticket's `Status:` to `done` and the README's RD-09 row to `done`. +5. Commit all of it together. + +## Acceptance criteria + +The deleted idiom is no longer taught anywhere: + +```bash +grep -rn "runIfSubmitting\|runIfIndienen" docs/ apps/ libs/ .claude/ # MUST return nothing +grep -rn "submitHerregistratie" docs/ # MUST return nothing +``` + +Every code identifier the new blocks name must exist: + +```bash +grep -n "StoreEffects\|effects?" libs/shared/src/application/store.ts # the API is real +grep -rn "draftSync.submit" apps/ssp | head -3 # the call is real +``` + +```bash +npm run ci # exits 0 +``` + +## Verification + +`npm run ci`. This ticket touches only two Markdown files under `docs/reference/`, so +`--full` is not required — neither is globbed by Storybook. + +Read both rewritten blocks end to end and check a reader could follow them without opening +`store.ts`. That is the actual acceptance test for a teaching document, and no command +checks it. + +## Out of scope + +- The rest of `ARCHITECTURE.md` §6a — its stale `L` line citations, its two non-existent + file paths, and its post-RD-16 `parseDashboardView` claim. **RD-31.** +- `fp-tea-atomic-design.md`'s 11 broken pre-monorepo `src/app/…` paths and its broken anchor. + **RD-32.** +- `plop-templates/form-machine.hbs` and `.claude/skills/form-machine/SKILL.md` (decision 1). +- CLAUDE.md. **RD-33.** + +## Risks + +- **Do not chase the other staleness in these two files.** Both are also targets of RD-31 and + RD-32. Editing their line citations or paths here creates a conflict with those tickets for + no gain. Change only what teaches the submit idiom. +- **`fp-tea-atomic-design.md`'s surrounding paths are already broken** (they use the + pre-monorepo `src/app/…` prefix). Leaving them broken in this commit is correct — RD-32 owns + them. Do not half-fix the file. +- **Prettier formats Markdown.** Run `npx prettier --write` on both files, or `format:check` + fails. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index e5f0dca..ce5be1a 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -103,7 +103,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | done | | RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | done | | RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | done | -| RD-09 | **Docs + generator:** `form-machine.hbs`, ARCHITECTURE, fp-tea, skill | 08 | | todo | +| RD-09 | **Docs + generator:** `form-machine.hbs`, ARCHITECTURE, fp-tea, skill | 08 | | done | | RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | todo | | RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | todo | | RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | todo | diff --git a/docs/reference/architecture/ARCHITECTURE.md b/docs/reference/architecture/ARCHITECTURE.md index fa86ef5..e9186cb 100644 --- a/docs/reference/architecture/ARCHITECTURE.md +++ b/docs/reference/architecture/ARCHITECTURE.md @@ -307,20 +307,42 @@ In the template you don't mutate anything — you send messages: ### 2d. Side effects (HTTP) without polluting the reducer `reduce` is pure — it must not call the network. So how does a submit happen? -The component has a small **command** method that does the impure work and then -sends messages describing the outcome: +`createStore` (`libs/shared/src/application/store.ts`) takes an optional **effect +map**: one handler per state tag, registered next to the reducer, run when the +store **enters** that tag: ```ts -async runIfSubmitting() { - if (this.state().tag !== 'Submitting') return; - this.profile.beginHerregistratie(); // 1. optimistic (see below) - const r = await submitHerregistratie(s.data); // 2. the actual call - if (r.ok) { this.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); } - else { this.dispatch({ tag: 'SubmitFailed', error: r.error }); this.profile.rollbackHerregistratie(); } -} +private store = createStore(initial, reduce, { + Submitting: async (s, store) => { + this.profile.beginHerregistratie(); // 1. optimistic (see below) + const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents }); // 2. the actual call + if (r.ok) { + store.dispatch({ tag: 'SubmitConfirmed' }); // 3. tell the reducer what happened + this.profile.confirmHerregistratie(); + } else { + store.dispatch({ tag: 'SubmitFailed', error: r.error }); + this.profile.rollbackHerregistratie(); + } + }, +}); ``` -So the split is: **reducer = "what the new state is", command = "go do the thing, +Two properties follow from "run on entry", and they are the reason this replaced an +earlier idiom where a component called a hand-written submit method by hand after +every `dispatch`: + +1. **Entering the state runs the effect.** A `dispatch` cannot silently skip it — + there is no separate call to forget. +2. **Double-submit protection is structural.** The effect fires only on a tag + **transition** (`Editing → Submitting`). A second `Submit` message while the + store is already in `Submitting` is a reducer no-op, so no second effect fires. + +One message is exempt from this rule: `Seed`, the mount/restore message every +wizard sends on load. A `Seed` transition into `Submitting` (a resumed draft, a +Storybook story) must not trigger a submit, so `createStore` skips the effect for +it — see `store.ts` for the full contract. + +So the split is: **reducer = "what the new state is", effect = "go do the thing, then tell the reducer what happened."** ### 2e. Optimistic update + rollback, and shared state across pages @@ -571,7 +593,7 @@ client.dashboardView() })` → GET `/api/v1/dashboard-view` → `httpClientFetch` → proxy → backend → back through the `parseDashboardView(json): Result` trust boundary → `RemoteData` → rendered. -**A write (change address):** `runIfSubmitting()` (§2d) → `createSubmitChangeRequest` +**A write (change address):** the `Submitting` effect (§2d) → `createSubmitChangeRequest` ([`submit-change-request.ts`](../../../apps/ssp/src/app/registratie/application/submit-change-request.ts)) → `runSubmit` — the one try/catch that mints the `Idempotency-Key` and maps RFC-7807 ProblemDetails → string ([`submit.ts`](../../../libs/shared/src/application/submit.ts)) → diff --git a/docs/reference/fp-tea-atomic-design.md b/docs/reference/fp-tea-atomic-design.md index 08dfa06..610e355 100644 --- a/docs/reference/fp-tea-atomic-design.md +++ b/docs/reference/fp-tea-atomic-design.md @@ -332,35 +332,43 @@ sends messages on events — it never mutates: That is the loop: `state → template → event → dispatch(Msg) → reduce → new state → template`. -### 4d. Effects → a command that dispatches the outcome +### 4d. Effects → the store's effect map dispatches the outcome -`reduce` is pure, so it can't call the network. The component holds a small **command** -method. It does the impure work, then dispatches a `Msg` describing what happened — the -result re-enters through the same pure loop: +`reduce` is pure, so it can't call the network. `createStore` +(`libs/shared/src/application/store.ts`) takes an optional **effect map**: one +handler per state tag, run when the store **enters** that tag. The handler does the +impure work, then dispatches a `Msg` describing what happened — the result re-enters +through the same pure loop: ```ts -private async runIfSubmitting() { - const s = this.state(); - if (s.tag !== 'Submitting') return; - this.profile.beginHerregistratie(); // optimistic flag (shared store) - const r = await submitHerregistratie(s.data); // the actual I/O — a Result - if (r.ok) { this.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); } - else { this.dispatch({ tag: 'SubmitFailed', error: r.error }); this.profile.rollbackHerregistratie(); } -} +private store = createStore(initial, reduce, { + Submitting: async (s, store) => { + this.profile.beginHerregistratie(); // optimistic flag (shared store) + const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents }); // the actual I/O — a Result + if (r.ok) { + store.dispatch({ tag: 'SubmitConfirmed' }); + this.profile.confirmHerregistratie(); + } else { + store.dispatch({ tag: 'SubmitFailed', error: r.error }); + this.profile.rollbackHerregistratie(); + } + }, +}); ``` -The command itself (`src/app/herregistratie/application/submit-herregistratie.ts`) -returns a `Result` — success-or-error as a value, never a thrown exception: +Running the effect **on entry**, instead of a component calling a submit method by +hand after every `dispatch`, gives two properties the hand-called version lacked: -```ts -export async function submitHerregistratie(data: Valid): Promise> { - await new Promise((r) => setTimeout(r, 800)); - if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.'); - return ok(undefined); -} -``` +1. **Entering the state runs the effect.** A `dispatch` cannot silently skip it. +2. **Double-submit protection is structural.** The effect fires only on a tag + **transition** (`Editing → Submitting`). A second `Submit` message while the + store is already `Submitting` is a reducer no-op, so no second effect fires. -The split, in one line: **reducer = "what the new state is"; command = "go do the +One message is exempt: `Seed`, the mount/restore message every wizard sends on +load. A `Seed` transition into `Submitting` (a resumed draft, a Storybook story) +must not trigger a submit, so `createStore` skips the effect for it. + +The split, in one line: **reducer = "what the new state is"; effect = "go do the thing, then say what happened."** Incoming effects (an arriving HTTP value, a server-owned config) are wired with `effect()` and `untracked()` so the dispatch doesn't loop on its own write — see the BRP prefill and policy-threshold effects in From 84cbf3f7d809b84ccaa7ad4770f3346ffc859a2a Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 17:28:37 +0200 Subject: [PATCH 12/46] docs: use `git grep` for acceptance checks, and fix RD-09's ledger row Four acceptance commands in this backlog could not pass as written. The fourth, in RD-09, reached this backlog's own ticket files and 22 gitignored worktrees, so satisfying it literally would have rewritten the history of completed tickets. The general fix is `git grep` instead of `grep -r`: it searches tracked files only, so untracked and gitignored paths cannot pollute the result. Measured here, `grep -r` finds 132 hits under .claude/ where `git grep` finds none. RD-17, RD-18 and RD-19 are repo-wide sweeps and depend on this. Also correct RD-09's Order row. It claimed the ticket covered a generator and a skill file; neither teaches the deleted idiom, as recorded in PLAN.md. Co-Authored-By: Claude Opus 5 --- docs/project/readable-codebase/README.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index ce5be1a..b377bbb 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -103,7 +103,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | done | | RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | done | | RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | done | -| RD-09 | **Docs + generator:** `form-machine.hbs`, ARCHITECTURE, fp-tea, skill | 08 | | done | +| RD-09 | Teach the effect map: ARCHITECTURE §2d + fp-tea (2 docs, no generator) | 08 | | done | | RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | todo | | RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | todo | | RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | todo | @@ -180,7 +180,7 @@ Three rules when you write a ticket file, because the agent reads its ticket and estimate and nothing can check it. `npm run lint` has an exit code. 4. **Run every acceptance command against the tree before you hand the ticket over.** A command that cannot pass is worse than no command: the agent either wastes a cycle or, - worse, "fixes" correct code to satisfy it. Three real misses so far, all in tickets written + worse, "fixes" correct code to satisfy it. Four real misses so far, all in tickets written by the supervisor: - RD-06 grepped only `runIfSubmitting`, missing that one wizard spells it `runIfIndienen`. - RD-08 grepped bare `onPrimary\|onRetry`, which can never return nothing — an unrelated @@ -188,6 +188,18 @@ Three rules when you write a ticket file, because the agent reads its ticket and - RD-08 said "no machine changes" while also requiring a repo-wide grep to come back clean, which forced comment edits in three machines. The two instructions contradicted each other. + - RD-09 grepped `docs/ apps/ libs/ .claude/`, which also matched this backlog's own ticket + files (they name the deleted method as the history of `done` work) and 22 gitignored + abandoned worktrees. Satisfying it literally would have corrupted completed-ticket + history. - Anchor greps on a declaration (`^ onRetry\(\)`) rather than a name, and make the Files - list agree with the Acceptance commands. + Three habits that prevent all four: + + - **Use `git grep`, not `grep -r`.** It searches tracked files only, so untracked and + gitignored paths never pollute the result. Measured on this repo: `grep -r` finds 132 + hits under `.claude/`, `git grep` finds 0. This matters most for RD-17, RD-18 and RD-19, + which are repo-wide sweeps. + - **Anchor on a declaration** (`^ onRetry\(\)`), not on a name that may legitimately + appear elsewhere. + - **Keep the Files list consistent with the Acceptance commands.** If a command reaches a + file the ticket says not to touch, one of the two is wrong. From 11664d2efa6d42392f72b1d4d0e946144148b3a9 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 17:45:05 +0200 Subject: [PATCH 13/46] refactor: WizardStatus to a payload-carrying WizardPhase (RD-10) The wizard shell took two inputs to say one thing: a flat WizardStatus string and a separate errorMessage input. Each wizard needed three computeds (failedError, errorMessage, shellStatus) to take the state apart and put it back together for the shell. WizardPhase replaces both inputs with one discriminated union. Its Failed variant carries the message directly, so no data travels through a second channel. Each wizard now maps its own tags onto WizardPhase in one computed, composing the localized failure prefix at the same spot errorMessage did before. The three machines and their own vocabulary (Editing/Answering/Invullen, Indienen/Ingediend/Mislukt) are unchanged; only the shell's input contract changes. The shell reads the Failed message via the existing whenTag helper, because @switch cannot narrow a union in an Angular template. Both $localize ids (wizard.indienenMislukt, regWizard.indienenMislukt) keep byte-identical source text, so no locale file changes. Co-Authored-By: Claude Sonnet 5 --- .../herregistratie-wizard.component.ts | 27 +-- .../intake-wizard/intake-wizard.component.ts | 27 +-- .../registratie-wizard.component.ts | 30 +-- .../readable-codebase/RD-10-wizard-phase.md | 178 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- .../wizard-shell/wizard-shell.component.ts | 39 +++- .../wizard-shell/wizard-shell.stories.ts | 18 +- 7 files changed, 261 insertions(+), 60 deletions(-) create mode 100644 docs/project/readable-codebase/RD-10-wizard-phase.md diff --git a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index f93de66..f2bf768 100644 --- a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -6,7 +6,7 @@ import { AlertComponent } from '@shared/ui/alert/alert.component'; import { WizardShellComponent, WizardError, - WizardStatus, + WizardPhase, naarStapLabel, } from '@shared/layout/wizard-shell/wizard-shell.component'; import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component'; @@ -50,11 +50,10 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload. [stepTitle]="stepTitle()" i18n-processName="@@herregWizard.processName" processName="Herregistratie aanvragen" - [status]="shellStatus()" + [phase]="phase()" [primaryLabel]="primaryLabel()" [canGoBack]="step() > 1" [errors]="errorList()" - [errorMessage]="errorMessage()" (primary)="dispatch({ tag: 'Primary' })" (back)="dispatch({ tag: 'Back' })" (cancel)="restart()" @@ -214,7 +213,6 @@ export class HerregistratieWizardComponent { protected errJaren = computed(() => this.editing()?.errors.jaren ?? ''); protected errPunten = computed(() => this.editing()?.errors.punten ?? ''); protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? ''); - protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); protected uploadCtl = createUploadController({ wizardId: 'herregistratie', getUpload: () => this.upload(), @@ -234,19 +232,22 @@ export class HerregistratieWizardComponent { protected goToStep(index: number) { this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 }); } - protected errorMessage = computed( - () => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`, - ); - protected shellStatus = computed(() => { - switch (this.state().tag) { + /** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary, + composing the localized failure prefix so the `Failed` message arrives intact. */ + protected phase = computed(() => { + const s = this.state(); + switch (s.tag) { case 'Editing': - return 'editing'; + return { tag: 'Editing' }; case 'Submitting': - return 'submitting'; + return { tag: 'Submitting' }; case 'Submitted': - return 'submitted'; + return { tag: 'Submitted' }; case 'Failed': - return 'failed'; + return { + tag: 'Failed', + message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`, + }; } }); /** Current step's field errors, flattened for the shell's error summary. */ diff --git a/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts b/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts index 72dc03c..82bd2ed 100644 --- a/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts +++ b/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -12,7 +12,7 @@ import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.comp import { WizardShellComponent, WizardError, - WizardStatus, + WizardPhase, naarStapLabel, } from '@shared/layout/wizard-shell/wizard-shell.component'; import { createStore } from '@shared/application/store'; @@ -59,11 +59,10 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto [stepTitle]="stepTitle()" i18n-processName="@@intake.processName" processName="Herregistratie-intake" - [status]="shellStatus()" + [phase]="phase()" [primaryLabel]="primaryLabel()" [canGoBack]="cursor() > 0" [errors]="errorList()" - [errorMessage]="errorMessage()" (primary)="dispatch({ tag: 'Primary' })" (back)="dispatch({ tag: 'Back' })" (cancel)="restart()" @@ -323,7 +322,6 @@ export class IntakeWizardComponent { ); /** Whether the inline scholing question is shown (and required) in the 'werk' step. */ protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold())); - protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); // --- Presentational wiring for the shared wizard shell --------------------- readonly stepLabels = [ @@ -342,19 +340,22 @@ export class IntakeWizardComponent { const next = this.cursor() + 1; return naarStapLabel(next + 1, this.stepLabels[next]); }); - protected errorMessage = computed( - () => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`, - ); - protected shellStatus = computed(() => { - switch (this.state().tag) { + /** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary, + composing the localized failure prefix so the `Failed` message arrives intact. */ + protected phase = computed(() => { + const s = this.state(); + switch (s.tag) { case 'Answering': - return 'editing'; + return { tag: 'Editing' }; case 'Submitting': - return 'submitting'; + return { tag: 'Submitting' }; case 'Submitted': - return 'submitted'; + return { tag: 'Submitted' }; case 'Failed': - return 'failed'; + return { + tag: 'Failed', + message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`, + }; } }); /** Current step's field errors, flattened for the shell's error summary. The diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index 8333e83..3f64c28 100644 --- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -14,7 +14,7 @@ import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.comp import { WizardShellComponent, WizardError, - WizardStatus, + WizardPhase, naarStapLabel, } from '@shared/layout/wizard-shell/wizard-shell.component'; import { ASYNC } from '@shared/ui/async/async.component'; @@ -83,11 +83,10 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid'; [stepTitle]="stepTitle()" i18n-processName="@@regWizard.processName" processName="Inschrijven in het BIG-register" - [status]="shellStatus()" + [phase]="phase()" [primaryLabel]="primaryLabel()" [canGoBack]="cursor() > 0" [errors]="errorList()" - [errorMessage]="errorMessage()" i18n-submittingLabel="@@regWizard.submitting" submittingLabel="Uw registratie wordt verwerkt…" (primary)="dispatch({ tag: 'Primary' })" @@ -440,7 +439,6 @@ export class RegistratieWizardComponent { () => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)], ); protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? ''); - protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? ''); // --- Presentational wiring for the shared wizard shell --------------------- protected primaryLabel = computed(() => { @@ -448,21 +446,23 @@ export class RegistratieWizardComponent { const next = this.cursor() + 1; return naarStapLabel(next + 1, this.stepLabels[next]); }); - protected errorMessage = computed( - () => - $localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` + - ` ${this.failedError()}`, - ); - protected shellStatus = computed(() => { - switch (this.state().tag) { + /** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary, + composing the localized failure prefix so the `Failed` message arrives intact. */ + protected phase = computed(() => { + const s = this.state(); + switch (s.tag) { case 'Invullen': - return 'editing'; + return { tag: 'Editing' }; case 'Indienen': - return 'submitting'; + return { tag: 'Submitting' }; case 'Ingediend': - return 'submitted'; + return { tag: 'Submitted' }; case 'Mislukt': - return 'failed'; + return { + tag: 'Failed', + message: + $localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` + ` ${s.error}`, + }; } }); /** Current step's errors (incl. per-question), flattened for the error summary. */ diff --git a/docs/project/readable-codebase/RD-10-wizard-phase.md b/docs/project/readable-codebase/RD-10-wizard-phase.md new file mode 100644 index 0000000..1469079 --- /dev/null +++ b/docs/project/readable-codebase/RD-10-wizard-phase.md @@ -0,0 +1,178 @@ +# RD-10 — Let the wizard shell carry the error, not drop it + +Status: done +Source: PLAN.md 1b#4 + +## Why + +`WizardStatus` is a payload-free string union: + +```ts +export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed'; +``` + +Each wizard flattens its own state tag down to it with an identical 12-line switch, which +**throws the error away**. The error then has to travel separately, through a second +`errorMessage` input, and each wizard needs three computeds to take apart and reassemble what +one union could have carried intact: + +| Wizard | `failedError` | `errorMessage` | `shellStatus` | +| ----------------------- | ------------- | -------------- | ------------- | +| `herregistratie-wizard` | 217 | 238 | 240-251 | +| `intake-wizard` | 326 | 346 | 348-359 | +| `registratie-wizard` | 443 | 453 | 456-467 | + +Nine computeds and three switches exist because the type at the seam is too weak. One +payload-carrying union replaces all of it with three computeds — one per wizard. + +## Read first + +- `libs/shared/src/layout/wizard-shell/wizard-shell.component.ts` — `WizardStatus` at 19, + `status` input at 148, `errorMessage` input at 152, the `@switch` at 56-133 (the + `@case ('failed')` at 131 is the only consumer of `errorMessage`) +- `libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts` — `base` at ~39 and the five + stories that set `status` +- The three `shellStatus`/`errorMessage`/`failedError` computeds listed above +- `libs/shared/src/kernel/fp.ts:27` — `whenTag`, which returns `Extract<…> | null` + +## Decisions (pre-made, don't relitigate) + +1. **Replace `WizardStatus` and the `errorMessage` input with one payload-carrying union:** + + ```ts + export type WizardPhase = + | { tag: 'Editing' } + | { tag: 'Submitting' } + | { tag: 'Submitted' } + | { tag: 'Failed'; message: string }; + ``` + + The shell takes `phase = input.required()`. The `errorMessage` input is + **deleted** — nothing else reads it. + +2. **Keep the three mapping computeds. Do not try to remove them.** Each machine's tags are + its own and genuinely differ — `Editing`/`Answering`/`Invullen`, and registratie's Dutch + `Invullen`/`Indienen`/`Ingediend`/`Mislukt`. Those are not the shell's vocabulary and must + not become it. What changes is that each mapping now returns a `WizardPhase` carrying the + message, so **`failedError` and `errorMessage` fold into it** and each wizard goes from + three computeds to one. + +3. **Compose the localized prefix inside the new computed**, exactly as `errorMessage` does + today, so both ids survive byte-identically: + - `@@wizard.indienenMislukt` — "Indienen mislukt:" (herregistratie and intake) + - `@@regWizard.indienenMislukt` — "Het indienen is niet gelukt:" (registratie) + + The prefix differs per wizard, which is exactly why the mapping stays in the wizard. **Do + not** move either string into the shell, and do not reword them — same id with different + source text fails extraction. + +4. **`@switch` cannot narrow a union in an Angular template.** So the `Failed` branch reads + the message through the existing helper: `whenTag(this.phase(), 'Failed')?.message ?? ''`. + Note `whenTag` returns `| null`, not `| undefined`. Do not add a new narrowing helper — + this is the idiom all five form components already use. + +5. **Update `wizard-shell.stories.ts` in the same commit.** `base` carries + `errorMessage: ''` and five stories set `status:`; all become `phase:`. The failed story's + message ("Het indienen is niet gelukt: netwerkfout.") moves inside the phase object. Both + Storybook instances glob this file. + +6. **Do not touch `errorList` or `WizardError`.** Those carry the current step's _field_ + errors for the shell's error summary — a different concern from the submit failure, on a + different axis. They stay exactly as they are. + +## Files + +- `libs/shared/src/layout/wizard-shell/wizard-shell.component.ts` +- `libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts` +- `apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts` +- `apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` +- `apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` + +No machine changes. No xlf changes. + +## Steps + +1. Add `WizardPhase` to the shell, swap `status` + `errorMessage` for one `phase` input, and + read the failed message per decision 4. +2. Delete `WizardStatus`. +3. In each wizard, collapse `failedError` + `errorMessage` + `shellStatus` into one + `phase` computed returning a `WizardPhase`, keeping the localized prefix composition. +4. Update the shell's stories per decision 5. +5. Update this ticket's `Status:` to `done` and the README's RD-10 row to `done`. +6. Commit all of it together. + +## Acceptance criteria + +The weak type is gone, and no wizard still needs three computeds to say one thing. These +commands were dry-run against the tree before this ticket was written, and the last two are +**path-scoped deliberately** — an unscoped version of either can never return nothing: + +```bash +git grep -n "WizardStatus" -- apps libs # MUST return nothing + +W=libs/shared/src/layout/wizard-shell +H=apps/ssp/src/app/herregistratie/ui +R=apps/ssp/src/app/registratie/ui/registratie-wizard +git grep -n "errorMessage" -- $W $H $R # MUST return nothing +git grep -n "failedError" -- $H $R # MUST return nothing +``` + +Why the scoping, so nobody "fixes" correct code to satisfy a bad check: + +- **`errorMessage` legitimately exists elsewhere** — `brief/infrastructure/letter-preview.adapter.ts`, + its spec, `reveal-bignummer.adapter.ts`, and the generated `libs/shared/docs/behaviour-spec.mdx`. + All unrelated to this seam. Leave them. +- **`failedError` legitimately survives in the two single-step forms** — + `besluit-form.component.ts` and `change-request-form.component.ts`. RD-06 gave those their + own `Failed` branch and they keep their own computed. This ticket touches only the three + wizards. + +Both `$localize` ids survive unchanged, so no new translation is needed. Scope to source — +an unscoped `-- apps` also matches the three locale files, which must **not** change: + +```bash +git grep -l "wizard.indienenMislukt" -- $H # exactly 2: herregistratie + intake +git grep -l "regWizard.indienenMislukt" -- $R # exactly 1: registratie + +# The locale files must be untouched by this ticket: +git diff --name-only HEAD | git grep -c "locale/messages" || true # expect no locale diff +``` + +For reference, the ids already exist in `apps/ssp/src/locale/messages.xlf`, +`apps/ssp/src/locale/messages.en.xlf` and `apps/behandelportal/src/locale/messages.en.xlf`. +Keeping the source text byte-identical is what lets all three stay as they are. + +```bash +npm run ci # exits 0 +npm run ci --full # exits 0 — required, this changes stories +``` + +Then confirm the error still reaches the user: seed each wizard's failed state in Storybook +and check the alert shows the full message, prefix included. That is the behaviour this +ticket exists to protect, and the type change is what makes losing it impossible. + +## Verification + +`npm run ci --full`. `--full` is mandatory: this edits `wizard-shell.stories.ts`, and only +`build-storybook` plus the axe run exercise it. Both Storybook instances glob the shared +library, so both must build. + +## Out of scope + +- `errorList` / `WizardError` (decision 6). +- The three machines. This ticket changes only the UI seam. +- Splitting any wizard into steps. RD-22 and RD-23. +- `UploadStatus`'s `type:` discriminant. Optional RD-35. + +## Risks + +- **`ng build --localize` fails on a changed `$localize` id or source text.** Keep both + template literals byte-identical and only move where they are composed (decision 3). +- **`whenTag` returns `null`, not `undefined`.** `?? ''` covers both, but a `=== undefined` + check would silently fail. +- **`input.required` has no default**, unlike the `errorMessage = input('')` it replaces. + Every call site must pass `phase`, including all five stories. A missed story fails at + runtime in Storybook, not at compile time — which is why `--full` is mandatory here. +- **Do not let the shell learn the machines' tags.** If `WizardPhase` grows an `Invullen` or + `Answering` member, the mapping has leaked into the shared layer and the change has made + things worse. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index b377bbb..56482a1 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -104,7 +104,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | done | | RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | done | | RD-09 | Teach the effect map: ARCHITECTURE §2d + fp-tea (2 docs, no generator) | 08 | | done | -| RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | todo | +| RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | done | | RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | todo | | RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | todo | | RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | todo | diff --git a/libs/shared/src/layout/wizard-shell/wizard-shell.component.ts b/libs/shared/src/layout/wizard-shell/wizard-shell.component.ts index ab478ac..453dfc4 100644 --- a/libs/shared/src/layout/wizard-shell/wizard-shell.component.ts +++ b/libs/shared/src/layout/wizard-shell/wizard-shell.component.ts @@ -1,9 +1,19 @@ -import { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core'; +import { + Component, + ElementRef, + computed, + effect, + input, + output, + untracked, + viewChild, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { SpinnerComponent } from '@shared/ui/spinner/spinner.component'; import { StepperComponent } from '@shared/ui/stepper/stepper.component'; +import { whenTag } from '@shared/kernel/fp'; /** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk". Shared so every wizard's `primaryLabel` reads the same way. */ @@ -16,7 +26,13 @@ export interface WizardError { readonly message: string; } -export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed'; +/** The wizard shell's lifecycle union. The `Failed` variant carries the localized + message intact, so the shell needs no separate input to say what went wrong. */ +export type WizardPhase = + | { tag: 'Editing' } + | { tag: 'Submitting' } + | { tag: 'Submitted' } + | { tag: 'Failed'; message: string }; /** * Template: the canonical shell every wizard renders into, so they cannot drift. @@ -52,8 +68,8 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed'; `, ], template: ` - @switch (status()) { - @case ('editing') { + @switch (phase().tag) { + @case ('Editing') { } - @case ('submitting') { + @case ('Submitting') { {{ submittingLabel() }} } - @case ('submitted') { + @case ('Submitted') { } - @case ('failed') { - {{ errorMessage() }} + @case ('Failed') { + {{ failedMessage() }}
Opnieuw proberen(); /** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */ processName = input(''); - status = input.required(); + phase = input.required(); primaryLabel = input.required(); canGoBack = input(false); errors = input([]); - errorMessage = input(''); submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`); + /** The `Failed` message, or '' otherwise. `@switch` can't narrow a union in a + template, so the narrowing happens here via the shared `whenTag` helper. */ + protected failedMessage = computed(() => whenTag(this.phase(), 'Failed')?.message ?? ''); + primary = output(); back = output(); cancel = output(); diff --git a/libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts b/libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts index a0267be..460b603 100644 --- a/libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts +++ b/libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts @@ -8,8 +8,8 @@ const meta: Meta = { props: args, template: `

Voorbeeld-stapinhoud (de stapvelden worden hier geprojecteerd).

Uw aanvraag is ontvangen.

@@ -36,23 +36,25 @@ const base = { primaryLabel: 'Volgende', canGoBack: true, errors: [], - errorMessage: '', goToStep: () => {}, }; -export const Editing: Story = { args: { ...base, status: 'editing' } }; +export const Editing: Story = { args: { ...base, phase: { tag: 'Editing' } } }; export const EditingMetFouten: Story = { args: { ...base, - status: 'editing', + phase: { tag: 'Editing' }, errors: [ { id: 'uren', message: 'Vul het aantal gewerkte uren in.' }, { id: 'diploma', message: 'Kies een diploma.' }, ], }, }; -export const Submitting: Story = { args: { ...base, status: 'submitting' } }; -export const Submitted: Story = { args: { ...base, status: 'submitted' } }; +export const Submitting: Story = { args: { ...base, phase: { tag: 'Submitting' } } }; +export const Submitted: Story = { args: { ...base, phase: { tag: 'Submitted' } } }; export const Failed: Story = { - args: { ...base, status: 'failed', errorMessage: 'Het indienen is niet gelukt: netwerkfout.' }, + args: { + ...base, + phase: { tag: 'Failed', message: 'Het indienen is niet gelukt: netwerkfout.' }, + }, }; From 827c655c1b4701bc25927b4e6d21901af2035089 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 18:07:00 +0200 Subject: [PATCH 14/46] refactor: fold machine-remote-data into remote-data.ts, PascalCase load lifecycle (RD-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `machine-remote-data.ts` defined a third encoding of an in-flight fetch: `LoadLifecycle`. It had three call sites, all one identical line, and the type was never imported by name. Move the mapping into `remote-data.ts` as `fromLoadLifecycle`, beside its neighbour `fromResource` — a `RemoteData` constructor, not a sixth encoding. The lowercase `loading`/`failed`/`loaded` tags on `BriefState`, `OrgTemplateState` and `StamdataEditorState` existed only because `LoadLifecycle` required them. Now that the constraint is inline and PascalCase, the three machines' load-lifecycle tags become `Loading`, `Failed` and `Loaded` — matching their own PascalCase message tags in the same file. `stamdata-editor.machine.spec.ts` no longer asserts a PascalCase message producing a lowercase state. `BriefStatus` (the letter's draft/submitted/approved/rejected/sent status, parsed off the wire from `BriefViewDto`) is a separate tag family and is untouched — its tag count stays 54 before and after this change. Delete `machine-remote-data.ts` and merge its spec into `remote-data.spec.ts`. Regenerate `behaviour-spec.mdx` (the `machineRemoteData` section heading becomes `fromLoadLifecycle`) and confirm `gen:snippets` reports no drift, since `remote-data.ts` carries a showcase region. Co-Authored-By: Claude Sonnet 5 --- .../app/brief/application/brief.store.spec.ts | 6 +- .../src/app/brief/application/brief.store.ts | 12 +- .../brief/application/org-template.store.ts | 10 +- .../app/brief/domain/brief.machine.spec.ts | 20 +- .../ssp/src/app/brief/domain/brief.machine.ts | 18 +- .../brief/domain/org-template.machine.spec.ts | 30 +-- .../app/brief/domain/org-template.machine.ts | 22 +-- apps/ssp/src/app/brief/ui/brief.page.ts | 2 +- .../RD-11-fold-load-lifecycle.md | 171 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- libs/beheer/src/application/stamdata.store.ts | 8 +- .../domain/stamdata-editor.machine.spec.ts | 18 +- .../src/domain/stamdata-editor.machine.ts | 20 +- libs/shared/docs/behaviour-spec.mdx | 12 +- libs/shared/docs/remote-data.mdx | 8 +- .../application/machine-remote-data.spec.ts | 20 -- .../src/application/machine-remote-data.ts | 24 --- .../src/application/remote-data.spec.ts | 19 +- libs/shared/src/application/remote-data.ts | 20 ++ 19 files changed, 303 insertions(+), 139 deletions(-) create mode 100644 docs/project/readable-codebase/RD-11-fold-load-lifecycle.md delete mode 100644 libs/shared/src/application/machine-remote-data.spec.ts delete mode 100644 libs/shared/src/application/machine-remote-data.ts diff --git a/apps/ssp/src/app/brief/application/brief.store.spec.ts b/apps/ssp/src/app/brief/application/brief.store.spec.ts index 2a493f8..b27dd61 100644 --- a/apps/ssp/src/app/brief/application/brief.store.spec.ts +++ b/apps/ssp/src/app/brief/application/brief.store.spec.ts @@ -179,7 +179,7 @@ const filledView: BriefView = { ...view, brief: filledBrief }; function loadedBrief(store: BriefStore): Brief { const s = store.model(); - if (s.tag !== 'loaded') throw new Error('not loaded'); + if (s.tag !== 'Loaded') throw new Error('not loaded'); return s.brief; } @@ -431,7 +431,7 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => { // Then reset() ran exactly once, and the store ends up loaded from its result. expect(reset).toHaveBeenCalledTimes(1); - expect(store.model().tag).toBe('loaded'); + expect(store.model().tag).toBe('Loaded'); }); it('a second 404 does not drive a second reset()', async () => { @@ -447,6 +447,6 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => { // Then reset() ran exactly once — the once-only bound holds across calls, not // just within one — and the second 404 surfaces as an ordinary load failure. expect(reset).toHaveBeenCalledTimes(1); - expect(store.model()).toEqual({ tag: 'failed', reason: BRIEF_LOAD_FAILED }); + expect(store.model()).toEqual({ tag: 'Failed', reason: BRIEF_LOAD_FAILED }); }); }); diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts index 80ab564..15b166a 100644 --- a/apps/ssp/src/app/brief/application/brief.store.ts +++ b/apps/ssp/src/app/brief/application/brief.store.ts @@ -4,7 +4,7 @@ import { createStore } from '@shared/application/store'; import { ActionState, SaveState } from '@shared/application/action-state'; import { createHistory } from '@shared/application/history'; import { createDebouncedSave } from '@shared/application/debounced-save'; -import { machineRemoteData } from '@shared/application/machine-remote-data'; +import { fromLoadLifecycle } from '@shared/application/remote-data'; import { Brief, CaseContext, @@ -29,7 +29,7 @@ import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; * outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/ * `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never * stored. The permission flags come from the server's decision DTO (PRD-0002 phase - * P1) via `BriefState.loaded.decisions` — this store never computes them itself. + * P1) via `BriefState.Loaded.decisions` — this store never computes them itself. */ @Injectable({ providedIn: 'root' }) export class BriefStore implements PendingSave { @@ -95,11 +95,11 @@ export class BriefStore implements PendingSave { /** The load lifecycle as `RemoteData`, for `` — the machine keeps owning the letter's own domain lifecycle (draft/submitted/approved/…); this is purely a projection of its loading/failed tags onto the shared async seam. */ - readonly remoteData = computed(() => machineRemoteData(this.model())); + readonly remoteData = computed(() => fromLoadLifecycle(this.model())); private brief = computed(() => { const s = this.model(); - return s.tag === 'loaded' ? s.brief : null; + return s.tag === 'Loaded' ? s.brief : null; }); readonly canEdit = computed(() => this.decisions()?.canEdit ?? false); @@ -111,7 +111,7 @@ export class BriefStore implements PendingSave { private decisions = computed(() => { const s = this.model(); - return s.tag === 'loaded' ? s.decisions : null; + return s.tag === 'Loaded' ? s.decisions : null; }); readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : [])); readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : [])); @@ -182,7 +182,7 @@ export class BriefStore implements PendingSave { } private restore(step: (current: Brief) => Brief | undefined) { const s = this.model(); - if (s.tag !== 'loaded') return; + if (s.tag !== 'Loaded') return; const target = step(s.brief); if (target === undefined) return; this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } }); diff --git a/apps/ssp/src/app/brief/application/org-template.store.ts b/apps/ssp/src/app/brief/application/org-template.store.ts index 67bb1fe..daf40a7 100644 --- a/apps/ssp/src/app/brief/application/org-template.store.ts +++ b/apps/ssp/src/app/brief/application/org-template.store.ts @@ -2,7 +2,7 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { createStore } from '@shared/application/store'; import { ActionState, SaveState } from '@shared/application/action-state'; import { createDebouncedSave } from '@shared/application/debounced-save'; -import { machineRemoteData } from '@shared/application/machine-remote-data'; +import { fromLoadLifecycle } from '@shared/application/remote-data'; import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter'; import { UploadShellService } from '@shared/application/upload-shell.service'; import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine'; @@ -22,7 +22,7 @@ import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter'; import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; -type LoadedState = Extract; +type LoadedState = Extract; const LOGO_CATEGORY = 'org-logo'; const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`; @@ -58,11 +58,11 @@ export class OrgTemplateStore implements PendingSave { /** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */ readonly pendingPublish = signal(false); - readonly remoteData = computed(() => machineRemoteData(this.model())); + readonly remoteData = computed(() => fromLoadLifecycle(this.model())); private loaded = computed(() => { const s = this.model(); - return s.tag === 'loaded' ? s : null; + return s.tag === 'Loaded' ? s : null; }); readonly draft = computed(() => this.loaded()?.draft ?? null); readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload); @@ -101,7 +101,7 @@ export class OrgTemplateStore implements PendingSave { // the length guard makes it idempotent (no dispatch loop). effect(() => { const s = this.model(); - if (s.tag !== 'loaded' || s.upload.categories.length > 0) return; + if (s.tag !== 'Loaded' || s.upload.categories.length > 0) return; const status = this.categoriesRes.status(); if (status === 'resolved' || status === 'local') this.dispatchUpload({ diff --git a/apps/ssp/src/app/brief/domain/brief.machine.spec.ts b/apps/ssp/src/app/brief/domain/brief.machine.spec.ts index 5ce6a1d..1593c83 100644 --- a/apps/ssp/src/app/brief/domain/brief.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/brief.machine.spec.ts @@ -76,7 +76,7 @@ const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sectio }); const sectionBlocks = (s: BriefState, key: string) => - s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : []; + s.tag === 'Loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : []; const passageIds = (s: BriefState, key: string) => sectionBlocks(s, key) @@ -92,12 +92,12 @@ describe('brief.machine reduce', () => { availablePassages: [], decisions, }).tag, - ).toBe('loaded'); + ).toBe('Loaded'); }); it('BriefLoadFailed moves loading to failed with the reason', () => { expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({ - tag: 'failed', + tag: 'Failed', reason: 'x', }); }); @@ -210,7 +210,7 @@ describe('brief.machine reduce', () => { comments: 'graag aanpassen', }); const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' }); - expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft'); + expect(next.tag === 'Loaded' && next.brief.status.tag).toBe('draft'); expect(sectionBlocks(next, 'slot')).toHaveLength(1); }); @@ -220,7 +220,7 @@ describe('brief.machine reduce', () => { // fill the required section via the besluit, then submit const filled = reduce(loaded(), besluit('positief')); const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions }); - expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({ + expect(submitted.tag === 'Loaded' && submitted.brief.status).toEqual({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't', @@ -232,7 +232,7 @@ describe('brief.machine reduce', () => { // approve from draft is a no-op expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded()); const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions }); - expect(approved.tag === 'loaded' && approved.brief.status).toEqual({ + expect(approved.tag === 'Loaded' && approved.brief.status).toEqual({ tag: 'approved', approvedBy: 'u2', approvedAt: 't2', @@ -248,7 +248,7 @@ describe('brief.machine reduce', () => { comments: 'nee', decisions, }); - expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({ + expect(rejected.tag === 'Loaded' && rejected.brief.status).toEqual({ tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't2', @@ -262,7 +262,7 @@ describe('brief.machine reduce', () => { // send from submitted is a no-op expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted); const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions }); - expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' }); + expect(sent.tag === 'Loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' }); }); it('a status transition replaces decisions with the fresh server value', () => { @@ -280,10 +280,10 @@ describe('brief.machine reduce', () => { at: 't2', decisions: staleApprover, }); - expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover); + expect(approved.tag === 'Loaded' && approved.decisions).toEqual(staleApprover); }); }); function initialLoading(): BriefState { - return { tag: 'loading' }; + return { tag: 'Loading' }; } diff --git a/apps/ssp/src/app/brief/domain/brief.machine.ts b/apps/ssp/src/app/brief/domain/brief.machine.ts index 0821e7a..1834265 100644 --- a/apps/ssp/src/app/brief/domain/brief.machine.ts +++ b/apps/ssp/src/app/brief/domain/brief.machine.ts @@ -37,16 +37,16 @@ import { passagesForBesluit } from './besluit'; */ export type BriefState = - | { tag: 'loading' } + | { tag: 'Loading' } | { - tag: 'loaded'; + tag: 'Loaded'; brief: Brief; availablePassages: readonly LibraryPassage[]; decisions: BriefDecisions; } - | { tag: 'failed'; reason: string }; + | { tag: 'Failed'; reason: string }; -export const initial: BriefState = { tag: 'loading' }; +export const initial: BriefState = { tag: 'Loading' }; export type BriefMsg = | { @@ -110,7 +110,7 @@ function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBl /** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */ function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState { - if (s.tag !== 'loaded' || !isEditable(s.brief.status)) return s; + if (s.tag !== 'Loaded' || !isEditable(s.brief.status)) return s; let brief = f(s.brief); if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } }; return { ...s, brief }; @@ -189,13 +189,13 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState { switch (m.tag) { case 'BriefLoaded': return { - tag: 'loaded', + tag: 'Loaded', brief: m.brief, availablePassages: m.availablePassages, decisions: m.decisions, }; case 'BriefLoadFailed': - return { tag: 'failed', reason: m.reason }; + return { tag: 'Failed', reason: m.reason }; case 'Seed': return m.state; @@ -203,7 +203,7 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState { // drafter's free text. `availablePassages` lives on the loaded state, so this stays pure. case 'BesluitSelected': return withEdit(s, (b) => - s.tag === 'loaded' && isSectionEditable(b, 'kern') + s.tag === 'Loaded' && isSectionEditable(b, 'kern') ? composeKern(b, s.availablePassages, m.besluit, m.reasons) : b, ); @@ -275,6 +275,6 @@ function transition( decisions: BriefDecisions, guard: (b: Brief) => boolean = () => true, ): BriefState { - if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s; + if (s.tag !== 'Loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s; return { ...s, brief: { ...s.brief, status: next() }, decisions }; } diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts index 1b8f24b..1ceb8f8 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts @@ -26,7 +26,7 @@ const view = (over: Partial = {}): OrgTemplateAdminView => }); const loaded = (): OrgTemplateState => - reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() }); + reduce({ tag: 'Loading' }, { tag: 'DraftLoaded', view: view() }); const logoCategory: DocumentCategory = { categoryId: 'org-logo', @@ -41,7 +41,7 @@ const logoCategory: DocumentCategory = { describe('org-template.machine', () => { it('DraftLoaded moves to loaded with the draft, clean', () => { - const s = expectTag(loaded(), 'loaded'); + const s = expectTag(loaded(), 'Loaded'); expect(s.draft.orgName).toBe('CIBG'); expect(s.subOrgId).toBe('cibg-registers'); expect(s.unsentBriefs).toBe(2); @@ -49,14 +49,14 @@ describe('org-template.machine', () => { }); it('LoadFailed carries the reason', () => { - const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' }); - expect(s).toEqual({ tag: 'failed', reason: 'boom' }); + const s = reduce({ tag: 'Loading' }, { tag: 'LoadFailed', reason: 'boom' }); + expect(s).toEqual({ tag: 'Failed', reason: 'boom' }); }); it('FieldEdited edits the draft and marks dirty', () => { const s = expectTag( reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }), - 'loaded', + 'Loaded', ); expect(s.draft.orgName).toBe('CIBG Nieuw'); expect(s.dirty).toBe(true); @@ -65,7 +65,7 @@ describe('org-template.machine', () => { it('MarginEdited edits one edge and marks dirty', () => { const s = expectTag( reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }), - 'loaded', + 'Loaded', ); expect(s.draft.margins.topMm).toBe(40); expect(s.draft.margins.leftMm).toBe(20); @@ -75,9 +75,9 @@ describe('org-template.machine', () => { it('DraftSaved clears dirty when the saved draft is the current one', () => { const edited = expectTag( reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }), - 'loaded', + 'Loaded', ); - const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'loaded'); + const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'Loaded'); expect(s.dirty).toBe(false); expect(s.draft.orgName).toBe('X'); }); @@ -85,20 +85,20 @@ describe('org-template.machine', () => { it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => { const editing = expectTag( reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }), - 'loaded', + 'Loaded', ); const savedDraft = editing.draft; // a further edit changes the draft reference before the save resolves const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' }); - const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'loaded'); + const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'Loaded'); expect(s.dirty).toBe(true); }); it('edits are no-ops in non-loaded states', () => { expect( - reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }), + reduce({ tag: 'Loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }), ).toEqual({ - tag: 'loading', + tag: 'Loading', }); }); @@ -122,7 +122,7 @@ describe('org-template.machine', () => { tag: 'Upload', msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' }, }), - 'loaded', + 'Loaded', ); expect(done.draft.logoDocumentId).toBe('doc-1'); expect(done.dirty).toBe(true); @@ -138,7 +138,7 @@ describe('org-template.machine', () => { tag: 'Upload', msg: { type: 'UploadRemoved', localId: 'a' }, }), - 'loaded', + 'Loaded', ); expect(removed.draft.logoDocumentId).toBeUndefined(); expect(removed.dirty).toBe(true); @@ -154,7 +154,7 @@ describe('org-template.machine', () => { tag: 'DraftLoaded', view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }), }), - 'loaded', + 'Loaded', ); expect(switched.upload.categories).toHaveLength(1); expect(switched.upload.uploads).toHaveLength(0); diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.ts b/apps/ssp/src/app/brief/domain/org-template.machine.ts index 7932941..2682973 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.ts @@ -22,10 +22,10 @@ export type OrgTemplateTextField = | 'signatureClosing'; export type OrgTemplateState = - | { tag: 'loading' } - | { tag: 'failed'; reason: string } + | { tag: 'Loading' } + | { tag: 'Failed'; reason: string } | { - tag: 'loaded'; + tag: 'Loaded'; subOrgId: string; draft: OrgTemplate; publishedVersion: number; @@ -36,7 +36,7 @@ export type OrgTemplateState = upload: UploadState; }; -export const initial: OrgTemplateState = { tag: 'loading' }; +export const initial: OrgTemplateState = { tag: 'Loading' }; export type OrgTemplateMsg = | { tag: 'Loading' } @@ -51,18 +51,18 @@ export type OrgTemplateMsg = /** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */ function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState { - return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s; + return s.tag === 'Loaded' ? { ...s, draft: f(s.draft), dirty: true } : s; } export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState { switch (m.tag) { case 'Loading': - return { tag: 'loading' }; + return { tag: 'Loading' }; case 'LoadFailed': - return { tag: 'failed', reason: m.reason }; + return { tag: 'Failed', reason: m.reason }; case 'DraftLoaded': return { - tag: 'loaded', + tag: 'Loaded', subOrgId: m.view.draft.subOrgId, draft: m.view.draft, publishedVersion: m.view.publishedVersion, @@ -71,16 +71,16 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState dirty: false, // Keep the loaded logo category across sub-org switches (it's the same // `org-logo` category, loaded once); drop only any in-flight/finished uploads. - upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload, + upload: s.tag === 'Loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload, }; case 'FieldEdited': return editDraft(s, (d) => ({ ...d, [m.field]: m.value })); case 'MarginEdited': return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } })); case 'DraftSaved': - return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s; + return s.tag === 'Loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s; case 'Upload': { - if (s.tag !== 'loaded') return s; + if (s.tag !== 'Loaded') return s; const upload = reduceUpload(s.upload, m.msg); // A completed/removed logo upload also updates the draft's logoDocumentId. if (m.msg.type === 'UploadComplete') diff --git a/apps/ssp/src/app/brief/ui/brief.page.ts b/apps/ssp/src/app/brief/ui/brief.page.ts index 3022fd7..b5f1a48 100644 --- a/apps/ssp/src/app/brief/ui/brief.page.ts +++ b/apps/ssp/src/app/brief/ui/brief.page.ts @@ -172,7 +172,7 @@ export class BriefPage { Success value is unwrapped here instead of through `let-`. */ protected readonly loaded = computed(() => { const s = this.model(); - return s.tag === 'loaded' ? s : undefined; + return s.tag === 'Loaded' ? s : undefined; }); protected reload() { diff --git a/docs/project/readable-codebase/RD-11-fold-load-lifecycle.md b/docs/project/readable-codebase/RD-11-fold-load-lifecycle.md new file mode 100644 index 0000000..aa1495d --- /dev/null +++ b/docs/project/readable-codebase/RD-11-fold-load-lifecycle.md @@ -0,0 +1,171 @@ +# RD-11 — Fold the lifecycle projection into `remote-data.ts`, and PascalCase the 3 machines + +Status: done +Source: PLAN.md 1b#3 + +## Why + +`machine-remote-data.ts` is 24 lines defining a **third** encoding of "in flight / ok / +failed": `LoadLifecycle = { tag: 'loading' } | { tag: 'failed'; reason } | { tag: 'loaded' }`. +It has three call sites, all the identical line, and `LoadLifecycle` is never imported by +name anywhere — it is a purely structural constraint. + +That constraint is the **only** reason three machines carry lowercase state tags while their +message tags are PascalCase in the same file. `stamdata-editor.machine.spec.ts:61` shows the +confusion in one line today: + +```ts +expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'loading' }); +``` + +A PascalCase message producing a lowercase state. Relocate the projection with PascalCase +keys and the dialect drift resolves itself — no separate renaming pass, and one named concept +disappears. + +## Read first + +- `libs/shared/src/application/machine-remote-data.ts` — all 24 lines +- `libs/shared/src/application/machine-remote-data.spec.ts` — 20 lines, to be merged +- `libs/shared/src/application/remote-data.ts` — note `fromResource`, the neighbour and + precedent for the new function +- `libs/shared/docs/remote-data.mdx:72` — teaches `s.tag === 'loaded'`, so it must change too +- `apps/ssp/src/app/brief/domain/brief.ts:68` — **`BriefStatus`. Read this before renaming + anything.** See decision 4. + +## Decisions (pre-made, don't relitigate) + +1. **Relocate, do not simply delete.** The mapping has to exist somewhere, because + `` takes a `RemoteData`. Deleting the module re-inlines a 6-line switch in three + stores, recreating the duplication WP-31 removed. Move it into `remote-data.ts` as + `fromLoadLifecycle`, beside `fromResource`, where it reads as what it is: **a `RemoteData` + constructor, not a sixth encoding.** Keep the `Extract` Success + payload so all three call sites stay one line. + +2. **Key it PascalCase**: `Loading | Failed{reason} | Loaded`. Merge + `machine-remote-data.spec.ts` into `remote-data.spec.ts` and delete both old files. + +3. **Rename only the three load-lifecycle tags, and catch all four syntactic forms.** Measured + counts of the construction form alone (39 across 11 files) understate it. The forms are: + + | Form | Example | + | ------------- | ---------------------------------------------- | + | construction | `tag: 'loading'` | + | comparison | `s.tag === 'loaded'`, `s.tag !== 'loaded'` | + | type-level | `Extract` | + | documentation | `remote-data.mdx:72` | + + Files in scope: the three machines (`brief.machine.ts`, `org-template.machine.ts`, + `stamdata-editor.machine.ts`), their three specs, `brief.store.ts`, `brief.store.spec.ts`, + `org-template.store.ts`, `stamdata.store.ts`, `brief.page.ts`, and `remote-data.mdx`. + +4. **`BriefStatus` IS NOT IN SCOPE. This is the one way to break this ticket.** + `brief.machine.ts` contains **two** independent lowercase tag families: + - `BriefState`'s load lifecycle — `loading`/`failed`/`loaded` — **rename these** + - `BriefStatus`'s letter status — `draft`/`submitted`/`approved`/`rejected`/`sent`, defined + in `brief.ts:68` — **leave these alone** + + `brief.machine.ts:278` has both in one line: + `if (s.tag !== 'loaded' || s.brief.status.tag !== from …)`. The first is in scope, the + second is not. `BriefStatus` is parsed off the wire from `BriefViewDto`, so renaming its + tags breaks the parse boundary and the backend contract. **Never rename by "all lowercase + tags in this file".** + +5. **Three more collision sites must not be touched.** They use the same words for unrelated + things, and anchoring on `tag: '` already excludes them — but verify rather than assume: + - `scenario.ts` / `scenario.interceptor.ts` — `'loading'` is a `?scenario=` **URL param + value**, not a state tag + - `upload.machine.ts` and the four upload UI components — `UploadStatus` discriminates on + **`type:`**, not `tag:`, with `'failed'`/`'complete'`/`'uploading'` + - `registratie-lookup.store.ts` — `'loading'` is an Angular `resource()` status + +6. **Do not touch `ActionState`, `SaveState`, or `pendingPublish`.** RD-12, RD-13 and RD-14 + own those, and they must follow this ticket or the same tags get renamed twice. + +## Files + +Add to / edit: `libs/shared/src/application/remote-data.ts` (+ `.spec.ts`), +`libs/shared/docs/remote-data.mdx`. +Delete: `libs/shared/src/application/machine-remote-data.ts` (+ `.spec.ts`). +Rename tags in: `brief.machine.ts` (+ spec), `org-template.machine.ts` (+ spec), +`stamdata-editor.machine.ts` (+ spec), `brief.store.ts` (+ spec), `org-template.store.ts`, +`stamdata.store.ts`, `brief.page.ts`. + +## Steps + +1. Add `fromLoadLifecycle` to `remote-data.ts` with PascalCase keys (decisions 1-2). +2. Rename the load-lifecycle tags across the files in decision 3, one file at a time, letting + the type-checker find the next site. **Do not blanket-sed.** +3. Point the three stores at `fromLoadLifecycle`; delete `machine-remote-data.ts` and merge + its spec cases into `remote-data.spec.ts`. +4. Update `remote-data.mdx:72`. +5. Run `npm run gen:behaviour-spec` — `behaviour-spec.mdx:839` has a `machineRemoteData` + section that must become the new name. +6. Update this ticket's `Status:` to `done` and the README's RD-11 row to `done`. +7. Commit all of it together. + +## Acceptance criteria + +The third encoding is gone and nothing lowercase survives in the three machines: + +```bash +git grep -n "machineRemoteData\|LoadLifecycle" -- apps libs # MUST return nothing +ls libs/shared/src/application/machine-remote-data* # MUST be "No such file" + +M="apps/ssp/src/app/brief/domain apps/ssp/src/app/brief/application \ + apps/ssp/src/app/brief/ui libs/beheer/src/domain libs/beheer/src/application" +git grep -n "tag: 'loading'\|tag: 'failed'\|tag: 'loaded'" -- $M # MUST return nothing +git grep -n "tag === 'loaded'\|tag !== 'loaded'" -- $M # MUST return nothing +``` + +`BriefStatus` is untouched — this is the check that matters most (decision 4): + +```bash +# Measured before this ticket was written: the total is exactly 54. It MUST still be 54. +git grep -c "tag: 'draft'\|tag: 'submitted'\|tag: 'approved'\|tag: 'rejected'\|tag: 'sent'" \ + -- apps/ssp/src/app/brief | awk -F: '{s+=$NF} END {print s}' # MUST print 54 + +git diff --stat apps/ssp/src/app/brief/domain/brief.ts # MUST be empty — brief.ts unchanged +``` + +If that number moves, you have renamed a wire contract. Stop and revert rather than adjusting +the number. + +The collision sites are untouched: + +```bash +git diff --name-only | git grep -c "scenario\|upload" || true # expect no such files +``` + +```bash +npm run ci # exits 0 +``` + +## Verification + +`npm run ci`. Also run `npm run ci -- --full` **with an explicit long timeout**: this edits +`remote-data.mdx`, which Storybook globs, and a broken MDX import is invisible to plain `ci`. + +Note for whoever runs it: an 8-minute command cannot complete in the default 120s foreground +window and the harness will move it to the background. Pass `timeout: 600000` on the Bash +call so it runs to completion in the foreground, then commit. + +## Out of scope + +- `ActionState` / `SaveState` / `pendingPublish` — RD-12, RD-13, RD-14 (decision 6). +- `BriefStatus` (decision 4). If a `BriefStatus` tag changes, the ticket has failed. +- `UploadStatus`'s `type:` discriminant — optional RD-35. +- The `NO_SUBORGS`/`NO_TABLES`-should-be-`Empty` finding — optional RD-34. + +## Risks + +- **`BriefStatus` (decision 4) is the failure mode to fear.** Its tags are a wire contract. + Rename by union, never by file. +- **Do not blanket-sed `'loading'`/`'failed'`/`'loaded'`.** Five files legitimately use those + words for other purposes (decision 5). Renaming one file at a time and following the + type-checker is slower and correct. +- **`brief.store.ts` and `brief.page.ts` use only the comparison form**, so a + construction-only grep misses them. That is why decision 3 lists four forms. +- **`behaviour-spec.mdx` drift**: it has a `machineRemoteData` section heading at :839 which + changes with the function name. Run `gen:behaviour-spec` in the same commit. +- **`remote-data.ts` carries a `// #region showcase:fold` marker at :30.** If your edit moves + or splits that region, run `npm run gen:snippets` in the same commit too. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 56482a1..b2447fd 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -105,7 +105,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | done | | RD-09 | Teach the effect map: ARCHITECTURE §2d + fp-tea (2 docs, no generator) | 08 | | done | | RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | done | -| RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | todo | +| RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | done | | RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | todo | | RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | todo | | RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | todo | diff --git a/libs/beheer/src/application/stamdata.store.ts b/libs/beheer/src/application/stamdata.store.ts index 0574b13..2ef9343 100644 --- a/libs/beheer/src/application/stamdata.store.ts +++ b/libs/beheer/src/application/stamdata.store.ts @@ -1,6 +1,6 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { createStore } from '@shared/application/store'; -import { machineRemoteData } from '@shared/application/machine-remote-data'; +import { fromLoadLifecycle } from '@shared/application/remote-data'; import { createHistory } from '@shared/application/history'; import { ChangeCounts, @@ -20,7 +20,7 @@ import { import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter'; import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; -type LoadedState = Extract; +type LoadedState = Extract; /** * Root singleton for the stamdata maintenance editor (ADR-0004). The Elm machine owns the @@ -42,11 +42,11 @@ export class StamdataStore { so toggling it never round-trips or drops unsaved edits (see domain `activeOn`). */ readonly previewDate = signal(''); - readonly remoteData = computed(() => machineRemoteData(this.model())); + readonly remoteData = computed(() => fromLoadLifecycle(this.model())); private loaded = computed(() => { const s = this.model(); - return s.tag === 'loaded' ? s : null; + return s.tag === 'Loaded' ? s : null; }); readonly table = computed(() => this.loaded()?.table ?? null); readonly rows = computed(() => this.loaded()?.rows ?? []); diff --git a/libs/beheer/src/domain/stamdata-editor.machine.spec.ts b/libs/beheer/src/domain/stamdata-editor.machine.spec.ts index f6cf4af..ba4bd9d 100644 --- a/libs/beheer/src/domain/stamdata-editor.machine.spec.ts +++ b/libs/beheer/src/domain/stamdata-editor.machine.spec.ts @@ -24,40 +24,40 @@ const seedLoaded = (): StamdataEditorState => describe('stamdata-editor reduce', () => { it('Loaded snapshots original independently of rows', () => { const s = seedLoaded(); - expect(s.tag).toBe('loaded'); - if (s.tag !== 'loaded') return; + expect(s.tag).toBe('Loaded'); + if (s.tag !== 'Loaded') return; const edited = reduce(s, { tag: 'CellEdited', row: 0, column: 'beroep', value: 'Chirurg' }); - if (edited.tag !== 'loaded') return; + if (edited.tag !== 'Loaded') return; expect(edited.rows[0]['beroep']).toBe('Chirurg'); expect(edited.original[0]['beroep']).toBe('Arts'); // snapshot untouched → diff works }); it('RowAdded appends an empty row shaped by the schema', () => { const s = reduce(seedLoaded(), { tag: 'RowAdded' }); - if (s.tag !== 'loaded') return; + if (s.tag !== 'Loaded') return; expect(s.rows).toHaveLength(2); expect(s.rows[1]).toEqual({ program: '', beroep: '', geldigVan: '', geldigTot: '' }); }); it('RowRemoved drops the row at the index', () => { const s = reduce(reduce(seedLoaded(), { tag: 'RowAdded' }), { tag: 'RowRemoved', row: 0 }); - if (s.tag !== 'loaded') return; + if (s.tag !== 'Loaded') return; expect(s.rows).toHaveLength(1); }); it('edit messages are ignored unless loaded', () => { expect(reduce(initial, { tag: 'RowAdded' })).toBe(initial); expect( - reduce({ tag: 'failed', reason: 'x' }, { tag: 'CellEdited', row: 0, column: 'a', value: 'b' }) + reduce({ tag: 'Failed', reason: 'x' }, { tag: 'CellEdited', row: 0, column: 'a', value: 'b' }) .tag, - ).toBe('failed'); + ).toBe('Failed'); }); it('LoadFailed and Loading transition regardless of prior state', () => { expect(reduce(seedLoaded(), { tag: 'LoadFailed', reason: 'boom' })).toEqual({ - tag: 'failed', + tag: 'Failed', reason: 'boom', }); - expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'loading' }); + expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'Loading' }); }); }); diff --git a/libs/beheer/src/domain/stamdata-editor.machine.ts b/libs/beheer/src/domain/stamdata-editor.machine.ts index 524808a..f908dbf 100644 --- a/libs/beheer/src/domain/stamdata-editor.machine.ts +++ b/libs/beheer/src/domain/stamdata-editor.machine.ts @@ -11,9 +11,9 @@ import { StamRow, StamTable, emptyRow } from '@beheer/domain/stamdata'; * apply path is a reviewed PR, not a runtime write — ADR-0004). */ export type StamdataEditorState = - | { tag: 'loading' } - | { tag: 'failed'; reason: string } - | { tag: 'loaded'; table: StamTable; rows: StamRow[]; original: readonly StamRow[] }; + | { tag: 'Loading' } + | { tag: 'Failed'; reason: string } + | { tag: 'Loaded'; table: StamTable; rows: StamRow[]; original: readonly StamRow[] }; export type StamdataEditorMsg = | { tag: 'Loading' } @@ -24,29 +24,29 @@ export type StamdataEditorMsg = | { tag: 'RowRemoved'; row: number } | { tag: 'Seed'; state: StamdataEditorState }; // mount a specific state (stories/tests) -export const initial: StamdataEditorState = { tag: 'loading' }; +export const initial: StamdataEditorState = { tag: 'Loading' }; const copy = (rows: readonly StamRow[]): StamRow[] => rows.map((r) => ({ ...r })); export function reduce(s: StamdataEditorState, m: StamdataEditorMsg): StamdataEditorState { switch (m.tag) { case 'Loading': - return { tag: 'loading' }; + return { tag: 'Loading' }; case 'Loaded': // original is an independent snapshot so later edits never mutate it (drives the diff). - return { tag: 'loaded', table: m.table, rows: copy(m.rows), original: copy(m.rows) }; + return { tag: 'Loaded', table: m.table, rows: copy(m.rows), original: copy(m.rows) }; case 'LoadFailed': - return { tag: 'failed', reason: m.reason }; + return { tag: 'Failed', reason: m.reason }; case 'CellEdited': - if (s.tag !== 'loaded') return s; + if (s.tag !== 'Loaded') return s; return { ...s, rows: s.rows.map((r, i) => (i === m.row ? { ...r, [m.column]: m.value } : r)), }; case 'RowAdded': - return s.tag === 'loaded' ? { ...s, rows: [...s.rows, emptyRow(s.table)] } : s; + return s.tag === 'Loaded' ? { ...s, rows: [...s.rows, emptyRow(s.table)] } : s; case 'RowRemoved': - return s.tag === 'loaded' ? { ...s, rows: s.rows.filter((_, i) => i !== m.row) } : s; + return s.tag === 'Loaded' ? { ...s, rows: s.rows.filter((_, i) => i !== m.row) } : s; case 'Seed': return m.state; default: diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 2afe646..9b596e3 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -819,6 +819,12 @@ classes. - is empty-safe: undefined, null, and empty string all yield the empty string - returns empty for an unparseable string rather than "Invalid Date" +#### fromLoadLifecycle + +- maps Loading → Loading +- maps Failed → Failure carrying an Error with the reason +- maps Loaded → Success carrying the whole loaded state + #### httpClientFetch - sends the pending idempotency key as a header for a write, not a fresh one per attempt @@ -836,12 +842,6 @@ classes. - keeps query + hash on both targets - the root maps nl → / and en → /en/ -#### machineRemoteData - -- maps loading → Loading -- maps failed → Failure carrying an Error with the reason -- maps loaded → Success carrying the whole loaded state - #### parseBsn (elfproef) - accepts a valid BSN (passes the elfproef) diff --git a/libs/shared/docs/remote-data.mdx b/libs/shared/docs/remote-data.mdx index 8e65744..5530db2 100644 --- a/libs/shared/docs/remote-data.mdx +++ b/libs/shared/docs/remote-data.mdx @@ -69,7 +69,7 @@ The idiom this repo uses instead — see `brief.page.ts`, `dashboard.page.ts`, // in the component class protected readonly loaded = computed(() => { const s = this.model(); // or store.someRemoteData() - return s.tag === 'loaded' ? s : undefined; + return s.tag === 'Loaded' ? s : undefined; }); ``` @@ -94,8 +94,8 @@ timing/outcome of `/api/*` calls. Try it on `/brief` or `/dashboard`. A store's own state machine (its `*.machine.ts`) should own the **domain** lifecycle of what it holds (draft → submitted → approved, in the brief's case) — not the network fetch's loading/failure, which is a generic concern `RemoteData` already models. Where a -machine's own `loading`/`failed` tags purely mirror the fetch (nothing extra beyond "not -loaded yet" / "the GET failed"), project them onto a `RemoteData` computed at the store -layer for `` to render, the way `BriefStore.remoteData` does — the machine +machine's own `Loading`/`Failed`/`Loaded` tags purely mirror the fetch (nothing extra +beyond "not loaded yet" / "the GET failed"), project them with `fromLoadLifecycle` at the +store layer for `` to render, the way `BriefStore.remoteData` does — the machine keeps deciding what the _letter_ is doing, `RemoteData` keeps deciding what the _fetch_ is doing. diff --git a/libs/shared/src/application/machine-remote-data.spec.ts b/libs/shared/src/application/machine-remote-data.spec.ts deleted file mode 100644 index b0c11fd..0000000 --- a/libs/shared/src/application/machine-remote-data.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { machineRemoteData } from './machine-remote-data'; -import { loading, success } from '../testing/remote-data'; - -describe('machineRemoteData', () => { - it('maps loading → Loading', () => { - expect(machineRemoteData({ tag: 'loading' })).toEqual(loading()); - }); - - it('maps failed → Failure carrying an Error with the reason', () => { - const rd = machineRemoteData({ tag: 'failed', reason: 'boom' }); - expect(rd.tag).toBe('Failure'); - if (rd.tag === 'Failure') expect(rd.error.message).toBe('boom'); - }); - - it('maps loaded → Success carrying the whole loaded state', () => { - const loaded = { tag: 'loaded', foo: 42 } as const; - expect(machineRemoteData(loaded)).toEqual(success(loaded)); - }); -}); diff --git a/libs/shared/src/application/machine-remote-data.ts b/libs/shared/src/application/machine-remote-data.ts deleted file mode 100644 index 8b1c3cc..0000000 --- a/libs/shared/src/application/machine-remote-data.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { RemoteData } from '@shared/application/remote-data'; - -/** The standard load-lifecycle tags an editor machine exposes. */ -export type LoadLifecycle = - { tag: 'loading' } | { tag: 'failed'; reason: string } | { tag: 'loaded' }; - -/** - * Project an Elm-machine state onto `RemoteData` for the `` seam. The machine - * keeps owning its own domain lifecycle (draft/submitted/…); this is purely the - * loading/failed/loaded → async mapping, which was byte-identical across BriefStore, - * OrgTemplateStore and StamdataStore (WP-31). Wrap the call in a `computed`. - */ -export function machineRemoteData( - s: S, -): RemoteData> { - switch (s.tag) { - case 'loading': - return { tag: 'Loading' }; - case 'failed': - return { tag: 'Failure', error: new Error(s.reason) }; - default: // 'loaded' - return { tag: 'Success', value: s as Extract }; - } -} diff --git a/libs/shared/src/application/remote-data.spec.ts b/libs/shared/src/application/remote-data.spec.ts index 45e2d05..323ddbd 100644 --- a/libs/shared/src/application/remote-data.spec.ts +++ b/libs/shared/src/application/remote-data.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { RemoteData, map2, map, successOf } from './remote-data'; +import { RemoteData, fromLoadLifecycle, map2, map, successOf } from './remote-data'; import { loading, failure, empty, success } from '../testing/remote-data'; const loadingRd: RemoteData = loading(); @@ -33,3 +33,20 @@ describe('successOf', () => { expect(successOf(empty())).toBeUndefined(); }); }); + +describe('fromLoadLifecycle', () => { + it('maps Loading → Loading', () => { + expect(fromLoadLifecycle({ tag: 'Loading' })).toEqual(loading()); + }); + + it('maps Failed → Failure carrying an Error with the reason', () => { + const rd = fromLoadLifecycle({ tag: 'Failed', reason: 'boom' }); + expect(rd.tag).toBe('Failure'); + if (rd.tag === 'Failure') expect(rd.error.message).toBe('boom'); + }); + + it('maps Loaded → Success carrying the whole loaded state', () => { + const loadedState = { tag: 'Loaded', foo: 42 } as const; + expect(fromLoadLifecycle(loadedState)).toEqual(success(loadedState)); + }); +}); diff --git a/libs/shared/src/application/remote-data.ts b/libs/shared/src/application/remote-data.ts index 2b9b4c1..6f35d15 100644 --- a/libs/shared/src/application/remote-data.ts +++ b/libs/shared/src/application/remote-data.ts @@ -27,6 +27,26 @@ export function fromResource( return { tag: 'Loading' }; } +/** + * Project an Elm-machine's load lifecycle onto `RemoteData`, for the `` seam. The + * machine keeps owning its own domain lifecycle (draft/submitted/…); this is purely the + * Loading/Failed/Loaded → async mapping, which was byte-identical across BriefStore, + * OrgTemplateStore and StamdataStore (WP-31). A `RemoteData` constructor, not a sixth + * encoding — wrap the call in a `computed`. + */ +export function fromLoadLifecycle< + S extends { tag: 'Loading' } | { tag: 'Failed'; reason: string } | { tag: 'Loaded' }, +>(s: S): RemoteData> { + switch (s.tag) { + case 'Loading': + return { tag: 'Loading' }; + case 'Failed': + return { tag: 'Failure', error: new Error(s.reason) }; + default: // 'Loaded' + return { tag: 'Success', value: s as Extract }; + } +} + // #region showcase:fold /** Exhaustive fold: you must handle every case, checked at compile time. */ export function foldRemote( From 43f62ddfeee54adb4abef4b9398255a412ace494 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 18:08:21 +0200 Subject: [PATCH 15/46] docs: record the fifth acceptance-check miss, and the stale SQLite trap RD-11 asserted that `git grep "machineRemoteData\|LoadLifecycle"` returns nothing, but the replacement it mandates is named `fromLoadLifecycle`, which contains the old name as a substring. The check could never pass. When a new name contains the old one, anchor on word boundaries. Add a fourth habit: prefer a number over a prohibition for anything that must not change. "Do not rename BriefStatus" invites reasoning around it. "This count must still be 54, and if it moves, revert rather than adjust the number" does not. RD-11 renamed tags across 19 files with a wire contract in the same file, and the count held. Also record a real trap found during RD-11: stale gitignored bigregister.db artifacts fail backend tests with "no such table" on an otherwise clean tree. A 0-byte file months old failed 6 tests. Co-Authored-By: Claude Opus 5 --- docs/project/readable-codebase/README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index b2447fd..8aa4961 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -192,8 +192,13 @@ Three rules when you write a ticket file, because the agent reads its ticket and files (they name the deleted method as the history of `done` work) and 22 gitignored abandoned worktrees. Satisfying it literally would have corrupted completed-ticket history. + - RD-11 asserted `git grep "machineRemoteData\|LoadLifecycle"` returns nothing, but the + replacement it mandates is named **`fromLoadLifecycle`** — which contains the old name as + a substring. The check can never pass. **When the new name contains the old one, anchor + on word boundaries**: `git grep -w machineRemoteData` and + `git grep -nE "(^|[^a-zA-Z])LoadLifecycle\b"`. - Three habits that prevent all four: + Four habits that prevent all five: - **Use `git grep`, not `grep -r`.** It searches tracked files only, so untracked and gitignored paths never pollute the result. Measured on this repo: `grep -r` finds 132 @@ -203,3 +208,20 @@ Three rules when you write a ticket file, because the agent reads its ticket and appear elsewhere. - **Keep the Files list consistent with the Acceptance commands.** If a command reaches a file the ticket says not to touch, one of the two is wrong. + - **Prefer a number over a prohibition for anything that must not change.** "Do not rename + `BriefStatus`" invites reasoning around it; "this count must still be 54, and if it moves, + revert rather than adjust the number" does not. RD-11 renamed tags across 19 files with a + wire contract in the same file — on the same line in one place — and the count held. + +## Troubleshooting + +**`dotnet test` fails with `SQLite Error 1: 'no such table: '`.** Stale, gitignored +`bigregister.db` artifacts from an old build. Found during RD-11, where a 0-byte file dated +months earlier failed 6 backend tests on an otherwise clean tree. Delete all three and re-run: + +```bash +rm -f backend/bigregister.db backend/src/BigRegister.Api/bigregister.db \ + backend/tests/BigRegister.Tests/bin/Debug/net10.0/bigregister.db +``` + +These are build artifacts, not fixtures — removing them is always safe. From 02d41536df4dde4a0437b627140869cb7c10f62f Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 18:27:09 +0200 Subject: [PATCH 16/46] refactor(brief): move the action lifecycle into the machine (RD-12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action lifecycle (Idle | Busy | Failed) lived in an imperative store-level signal, set from ten call sites outside the reducer. The reducer could not enforce which action transitions are legal. Add `action` to `BriefState.Loaded`, driven by three new messages (ActionStarted, ActionFinished, ActionFailed) and handled in `reduce`. Replace every `actionState.set(...)` call in `brief.store.ts` with the matching `dispatch`. `BriefLoaded` resets `action` to Idle, so a fresh load clears a stale action error instead of letting it outlive the reload. `busy` and `lastError` stay as `computed`s on the store with a byte-identical public signature — they are the render seam for four components and two page templates, and the union belongs in the machine, not the components. `revealBigNummer` still sets only `Failed`, never `Busy` — an existing asymmetry, not changed here. `SaveState`, `org-template.store.ts`, and `pendingPublish` are out of scope (RD-13, RD-14). Co-Authored-By: Claude Sonnet 5 --- .../src/app/brief/application/brief.store.ts | 40 +++-- .../app/brief/domain/brief.machine.spec.ts | 33 ++++ .../ssp/src/app/brief/domain/brief.machine.ts | 22 ++- .../RD-12-brief-action-in-machine.md | 163 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- libs/shared/docs/behaviour-spec.mdx | 7 +- 6 files changed, 248 insertions(+), 19 deletions(-) create mode 100644 docs/project/readable-codebase/RD-12-brief-action-in-machine.md diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts index 15b166a..83c04bf 100644 --- a/apps/ssp/src/app/brief/application/brief.store.ts +++ b/apps/ssp/src/app/brief/application/brief.store.ts @@ -1,7 +1,7 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { Result } from '@shared/kernel/fp'; import { createStore } from '@shared/application/store'; -import { ActionState, SaveState } from '@shared/application/action-state'; +import { SaveState } from '@shared/application/action-state'; import { createHistory } from '@shared/application/history'; import { createDebouncedSave } from '@shared/application/debounced-save'; import { fromLoadLifecycle } from '@shared/application/remote-data'; @@ -41,11 +41,16 @@ export class BriefStore implements PendingSave { readonly model = this.store.model; - private actionState = signal({ tag: 'Idle' }); - readonly busy = computed(() => this.actionState().tag === 'Busy'); + /** The one-shot action lifecycle now lives on the machine's `Loaded.action` (RD-12); + these stay as plain `computed`s so the render seam (four `busy = input(...)` + components, two page templates) keeps a byte-identical boolean/string API. */ + readonly busy = computed(() => { + const s = this.model(); + return s.tag === 'Loaded' && s.action.tag === 'Busy'; + }); readonly lastError = computed(() => { - const s = this.actionState(); - return s.tag === 'Failed' ? s.error : null; + const s = this.model(); + return s.tag === 'Loaded' && s.action.tag === 'Failed' ? s.action.error : null; }); /** Surfaced autosave state for the indicator + aria-live region. */ @@ -212,7 +217,9 @@ export class BriefStore implements PendingSave { if (r.ok) { this.saveState.set({ tag: 'Saved' }); } else { - this.actionState.set({ tag: 'Failed', error: r.error }); + // The autosave failure legitimately surfaces in two places: the small save + // indicator below (kept as-is) and the action error line (RD-12). + this.store.dispatch({ tag: 'ActionFailed', error: r.error }); this.saveState.set({ tag: 'Error' }); } } @@ -224,19 +231,19 @@ export class BriefStore implements PendingSave { /** Demo "start over": recreate the brief server-side and load the fresh view. */ async resetDemo() { - this.actionState.set({ tag: 'Busy' }); + this.store.dispatch({ tag: 'ActionStarted' }); this.debouncedSave.cancel(); const r = await this.adapter.reset(); this.saveState.set({ tag: 'Idle' }); if (r.ok) { - this.actionState.set({ tag: 'Idle' }); + this.store.dispatch({ tag: 'ActionFinished' }); this.orgTemplate.set(r.value.orgTemplate); this.caseContext.set(r.value.caseContext); this.history.clear(); this.rejectionSnapshot.set(null); this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); } else { - this.actionState.set({ tag: 'Failed', error: r.error }); + this.store.dispatch({ tag: 'ActionFailed', error: r.error }); } } @@ -249,13 +256,13 @@ export class BriefStore implements PendingSave { letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the object URL is never revoked. */ async previewLetter() { - this.actionState.set({ tag: 'Busy' }); + this.store.dispatch({ tag: 'ActionStarted' }); const r = await this.previewAdapter.preview(); if (!r.ok) { - this.actionState.set({ tag: 'Failed', error: r.error }); + this.store.dispatch({ tag: 'ActionFailed', error: r.error }); return; } - this.actionState.set({ tag: 'Idle' }); + this.store.dispatch({ tag: 'ActionFinished' }); this.blobPresenter.open(r.value); } @@ -269,7 +276,8 @@ export class BriefStore implements PendingSave { async revealBigNummer() { const r = await this.revealAdapter.reveal(true); if (!r.ok) { - this.actionState.set({ tag: 'Failed', error: r.error }); + // Never sets Busy — an existing asymmetry (RD-12), not fixed here. + this.store.dispatch({ tag: 'ActionFailed', error: r.error }); return; } this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c)); @@ -278,15 +286,15 @@ export class BriefStore implements PendingSave { // A transition: flush any pending save, call the server (authoritative), then mirror // the returned status through the pure reducer's guarded transition. private async transition(action: () => Promise>) { - this.actionState.set({ tag: 'Busy' }); + this.store.dispatch({ tag: 'ActionStarted' }); this.debouncedSave.cancel(); await this.flushSave(); const r = await action(); if (!r.ok) { - this.actionState.set({ tag: 'Failed', error: r.error }); + this.store.dispatch({ tag: 'ActionFailed', error: r.error }); return; } - this.actionState.set({ tag: 'Idle' }); + this.store.dispatch({ tag: 'ActionFinished' }); this.applyServerStatus(r.value); } diff --git a/apps/ssp/src/app/brief/domain/brief.machine.spec.ts b/apps/ssp/src/app/brief/domain/brief.machine.spec.ts index 1593c83..230e74a 100644 --- a/apps/ssp/src/app/brief/domain/brief.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/brief.machine.spec.ts @@ -265,6 +265,39 @@ describe('brief.machine reduce', () => { expect(sent.tag === 'Loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' }); }); + // --- RD-12: the action lifecycle lives on `Loaded.action`, driven by three msgs --- + + it('ActionStarted moves a loaded brief to Busy', () => { + const s = reduce(loaded(), { tag: 'ActionStarted' }); + expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Busy' }); + }); + + it('ActionFailed carries the error', () => { + const s = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' }); + expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Failed', error: 'niet gelukt' }); + }); + + it('ActionFinished returns to Idle', () => { + const busy = reduce(loaded(), { tag: 'ActionStarted' }); + const s = reduce(busy, { tag: 'ActionFinished' }); + expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Idle' }); + }); + + it('BriefLoaded resets a stale action error to Idle', () => { + const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' }); + const reloaded = reduce(failed, { + tag: 'BriefLoaded', + brief: briefWith({ tag: 'draft' }), + availablePassages: lib, + decisions, + }); + expect(reloaded.tag === 'Loaded' && reloaded.action).toEqual({ tag: 'Idle' }); + }); + + it('an action message is a no-op when the brief is not loaded', () => { + expect(reduce(initialLoading(), { tag: 'ActionStarted' })).toEqual(initialLoading()); + }); + it('a status transition replaces decisions with the fresh server value', () => { const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' }); const staleApprover: BriefDecisions = { diff --git a/apps/ssp/src/app/brief/domain/brief.machine.ts b/apps/ssp/src/app/brief/domain/brief.machine.ts index 1834265..cf824da 100644 --- a/apps/ssp/src/app/brief/domain/brief.machine.ts +++ b/apps/ssp/src/app/brief/domain/brief.machine.ts @@ -36,6 +36,10 @@ import { passagesForBesluit } from './besluit'; * structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`). */ +/** The one-shot action lifecycle (submit/approve/reject/send/preview/reveal/reset), + owned by the reducer instead of an imperative store-level signal (RD-12). */ +export type BriefActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string }; + export type BriefState = | { tag: 'Loading' } | { @@ -43,6 +47,7 @@ export type BriefState = brief: Brief; availablePassages: readonly LibraryPassage[]; decisions: BriefDecisions; + action: BriefActionState; } | { tag: 'Failed'; reason: string }; @@ -65,7 +70,10 @@ export type BriefMsg = | { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved | { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected | { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent - | { tag: 'Seed'; state: BriefState }; + | { tag: 'Seed'; state: BriefState } + | { tag: 'ActionStarted' } // a one-shot action (submit/approve/preview/…) began + | { tag: 'ActionFinished' } // it completed successfully + | { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show /** Edits are allowed only in these statuses; editing a rejected letter reopens it. */ function isEditable(status: BriefStatus): boolean { @@ -193,6 +201,9 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState { brief: m.brief, availablePassages: m.availablePassages, decisions: m.decisions, + // A fresh load clears a stale action error rather than letting it outlive + // the reload (RD-12, decision 4). + action: { tag: 'Idle' }, }; case 'BriefLoadFailed': return { tag: 'Failed', reason: m.reason }; @@ -260,6 +271,15 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState { case 'Sent': return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions); + // The action lifecycle (RD-12): a no-op unless a brief is loaded, since there is + // nothing to attach the action state to otherwise. + case 'ActionStarted': + return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s; + case 'ActionFinished': + return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s; + case 'ActionFailed': + return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s; + default: return assertNever(m); } diff --git a/docs/project/readable-codebase/RD-12-brief-action-in-machine.md b/docs/project/readable-codebase/RD-12-brief-action-in-machine.md new file mode 100644 index 0000000..bc2ff2f --- /dev/null +++ b/docs/project/readable-codebase/RD-12-brief-action-in-machine.md @@ -0,0 +1,163 @@ +# RD-12 — Move the brief's action lifecycle into the machine + +Status: done +Source: PLAN.md 1b#2a + +## Why + +`brief.store.ts` keeps the action lifecycle in a store-level signal, set imperatively from +about ten places entirely outside the reducer: + +```ts +private actionState = signal({ tag: 'Idle' }); +readonly busy = computed(() => this.actionState().tag === 'Busy'); +readonly lastError = computed(() => { … }); +``` + +So the machine cannot enforce which action transitions are legal, and `ActionState` has two +producers and **zero** consumers that keep the union — both stores immediately collapse it +back to a boolean plus a nullable string, the exact shape its own doc comment says it exists +to remove. + +Move it into the machine's `Loaded` state and the reducer owns it, like every other state +change in this house. + +## Read first + +- `libs/shared/src/application/action-state.ts` — 9 lines, both types. **Only `ActionState` + is in scope**; `SaveState` is RD-14's. +- `apps/ssp/src/app/brief/application/brief.store.ts` — `actionState` at 44, `busy` at 45, + `lastError` at 46, and the setter sites in `flushSave` (207), `resetDemo` (226), + `previewLetter` (251), `revealBigNummer` (269) and `transition` (280) +- `apps/ssp/src/app/brief/domain/brief.machine.ts` — the `Loaded` variant (PascalCase since + RD-11) and `reduce` +- `apps/ssp/src/app/brief/ui/brief.page.ts:50-126` — the `` wrapper. Decision 2 + depends on it. + +## Decisions (pre-made, don't relitigate) + +1. **`action` becomes a field on `BriefState.Loaded`**, carrying the same three cases + (`Idle | Busy | Failed{error}`), driven by three new messages — `ActionStarted`, + `ActionFinished`, `ActionFailed` — handled in `reduce`. The imperative + `actionState.set(...)` calls become `dispatch(...)`. + +2. **This is safe because every action trigger is template-gated, and that was verified, not + assumed.** `brief.page.ts:55` opens ``, which renders only when + `remoteData()` is `Success` — i.e. when the machine is `Loaded`. All three entry points sit + inside it: the reset button (`:83`), `previewLetter` (`:101`, `:120`) and `revealBigNummer` + (`:102`). `transition` backs submit/approve/reject/send, reachable only from the same + surface, and `flushSave` runs from the debounced autosave, which only fires while editing a + loaded brief. + + **If you add an action trigger outside that slot, this design breaks.** Do not add one. + +3. **`busy` and `lastError` stay as `computed`s on the store.** They are the render seam, not + a second encoding: four components take `busy = input(...)` — `behandel-scherm`, + `letter-composer`, `org-template-editor`, `rejection-comments` — and two pages read + `store.busy()`/`store.lastError()` directly. A boolean is right at that boundary; the union + is right in the machine. **Do not push the union down into the components** — it would churn + four components and their stories for no gain. + +4. **`BriefLoaded` resetting `action` to `Idle` is intended.** A fresh load clears a stale + action error, which is a small behaviour _improvement_: today a failed action's message can + outlive a reload. Let the reducer do it, and say so in a comment. + +5. **`flushSave` sets both `saveState` and `actionState` today. Keep both.** The autosave + failure legitimately surfaces in two places — the small save indicator and the action error + line. Only the `actionState` half becomes a dispatch here; leave `saveState` exactly as it + is. + +6. **Do not touch `org-template.store.ts`, `pendingPublish`, or `SaveState`.** RD-13 folds + org-template (including `pendingPublish`, the one genuine illegal-state pair), and RD-14 + moves `SaveState` and deletes `action-state.ts`. `action-state.ts` therefore still exists + after this ticket, exporting only `SaveState` plus an `ActionState` that brief no longer + imports. + +## Files + +- `apps/ssp/src/app/brief/domain/brief.machine.ts` (+ `.spec.ts`) +- `apps/ssp/src/app/brief/application/brief.store.ts` (+ `.spec.ts`) + +Not `action-state.ts` (RD-14 deletes it). Not `org-template.store.ts` (RD-13). No UI files. + +## Steps + +1. Add `action` to `BriefState.Loaded` and the three messages to `BriefMsg`; handle them in + `reduce`, including the `BriefLoaded` reset from decision 4. +2. Add reducer spec cases (see Acceptance). +3. Replace each `actionState.set(...)` in `brief.store.ts` with the matching `dispatch`. +4. Re-point `busy` and `lastError` at the machine's `Loaded.action`, keeping their public + signatures identical so no UI file changes. +5. Run `npm run gen:behaviour-spec` — new `it()` titles otherwise fail the drift check. +6. Update this ticket's `Status:` to `done` and the README's RD-12 row to `done`. +7. Commit all of it together. + +## Acceptance criteria + +Dry-run against the tree before handover, with the measured baselines: `brief.store.ts` has +**14** `actionState` occurrences and `brief.machine.ts` has **0** action messages; both must +invert. `saveState` is **5** and must stay 5. The `busy`/`lastError` declarations are **2** and +must stay 2. + +```bash +B=apps/ssp/src/app/brief +git grep -c "actionState" -- $B/application/brief.store.ts # MUST return nothing +git grep -n "ActionState" -- $B # MUST return nothing +git grep -c "ActionStarted\|ActionFinished\|ActionFailed" -- $B/domain/brief.machine.ts # >= 3 +``` + +The render seam is unchanged, so no UI file was touched: + +```bash +git diff --name-only HEAD | grep -c "brief/ui/" || true # MUST be 0 +git grep -c "readonly busy\|readonly lastError" -- $B/application/brief.store.ts # still 2 +``` + +`SaveState` and org-template are untouched (decision 6): + +```bash +git diff --name-only HEAD | grep -cE "action-state|org-template" || true # MUST be 0 +git grep -c "saveState" -- $B/application/brief.store.ts # unchanged: still 5 +``` + +New reducer cases: + +``` +- ActionStarted moves a loaded brief to Busy +- ActionFailed carries the error +- ActionFinished returns to Idle +- BriefLoaded resets a stale action error to Idle +- an action message is a no-op when the brief is not loaded +``` + +```bash +npm run ci # exits 0 +``` + +## Verification + +`npm run ci`. No story, no `.mdx`, no `libs/shared/src/ui/**`, so `--full` is not required. + +If you do run the full gate, pass `timeout: 600000` on the Bash call — it takes about 8 +minutes and the harness backgrounds anything longer than 120s, which would end your turn with +the work uncommitted. + +## Out of scope + +- `org-template.store.ts` and `pendingPublish` — RD-13. +- `SaveState`, and deleting `action-state.ts` — RD-14. +- The four `busy = input(...)` components and their stories (decision 3). +- The `NO_SUBORGS`/`NO_TABLES`-should-be-`Empty` finding — optional RD-34. + +## Risks + +- **Decision 2 is the load-bearing assumption.** It holds today because of one + ``. Re-read `brief.page.ts:50-126` and confirm before you start; + if any trigger has moved outside that slot since this ticket was written, stop and say so + rather than adding a guard that changes behaviour. +- **`revealBigNummer` sets only `Failed`, never `Busy`.** Do not "fix" that asymmetry here — + it is existing behaviour, and changing it is a separate decision. +- **Keep `busy`/`lastError` signatures byte-identical.** They are read from two page templates; + a renamed or re-typed member turns a pure refactor into a UI change. +- **`behaviour-spec.mdx` drift** from the new spec titles. Run `gen:behaviour-spec` in the same + commit. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 8aa4961..2b2e9f0 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -106,7 +106,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-09 | Teach the effect map: ARCHITECTURE §2d + fp-tea (2 docs, no generator) | 08 | | done | | RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | done | | RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | done | -| RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | todo | +| RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | done | | RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | todo | | RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | todo | | RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | todo | diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 9b596e3..d647d16 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 519 frontend behaviours across +**is** the suite, reshaped for a business reader. 524 frontend behaviours across 9 contexts; 261 backend behaviours across 42 test classes. @@ -260,6 +260,11 @@ classes. - approve fires only from submitted - reject fires from submitted, carrying comments - send fires only from approved +- ActionStarted moves a loaded brief to Busy +- ActionFailed carries the error +- ActionFinished returns to Idle +- BriefLoaded resets a stale action error to Idle +- an action message is a no-op when the brief is not loaded - a status transition replaces decisions with the fresh server value #### diffBlocks From 8e5f48c5d2c80293402f98f38079529a4960a39d Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 18:28:13 +0200 Subject: [PATCH 17/46] docs: scope acceptance checks to the ticket's files, not a directory The sixth acceptance-check miss, and the first the executing agent caught using these rules rather than the supervisor. RD-12 asserted `git grep "ActionState" -- apps/ssp/src/app/brief` returns nothing, but org-template.store.ts lives in that directory and is deliberately out of scope until RD-13. The check could never pass. Sharpen the existing habit into a mechanical step: name the files, never a parent directory. The agent also caught the RD-11 substring trap in its own work, where the new type BriefActionState contains ActionState, and word-anchored the check instead of renaming away from the natural name. Co-Authored-By: Claude Opus 5 --- docs/project/readable-codebase/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 2b2e9f0..d0d3d3c 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -206,8 +206,12 @@ Three rules when you write a ticket file, because the agent reads its ticket and which are repo-wide sweeps. - **Anchor on a declaration** (`^ onRetry\(\)`), not on a name that may legitimately appear elsewhere. - - **Keep the Files list consistent with the Acceptance commands.** If a command reaches a - file the ticket says not to touch, one of the two is wrong. + - **Scope every acceptance command to the ticket's Files list, never to a parent + directory.** This is the habit most often broken, including by the supervisor in RD-12: + the check `git grep "ActionState" -- apps/ssp/src/app/brief` cannot pass, because + `org-template.store.ts` lives in that directory and is deliberately out of scope until + RD-13. Name the files. If a command reaches a file the ticket says not to touch, one of + the two is wrong. - **Prefer a number over a prohibition for anything that must not change.** "Do not rename `BriefStatus`" invites reasoning around it; "this count must still be 54, and if it moves, revert rather than adjust the number" does not. RD-11 renamed tags across 19 files with a From c599fee8e2becc8638bbec7ad33895bbe938572b Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 18:38:23 +0200 Subject: [PATCH 18/46] refactor: fold org-template's action lifecycle + pendingPublish into one union (RD-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change, org-template.store.ts held the action lifecycle in an actionState signal and the publish impact-confirm gate in an independent pendingPublish signal. The two were representable in combination, so pendingPublish === true and busy === true could both hold at once. That state was meaningless: the UI would show the publish-impact confirmation while a publish was already in flight. OrgTemplateState.Loaded now carries one action field, a four-variant union (Idle | ConfirmingPublish | Busy | Failed). ActionStarted overwrites the field straight to Busy from any prior tag, so ConfirmingPublish and Busy can never coexist — not by convention, but because one field can only hold one tag. requestPublish and cancelPublish become dispatches (PublishRequested/PublishCancelled); as the reducer already no-ops outside Loaded, this changes no behaviour. The other four commands (confirmPublish, rollback, proefbrief, flushSave) keep their existing loaded() guards. busy, lastError and pendingPublish stay on the store as computed values reading the new union, with byte-identical public signatures — no file under brief/ui/ changes. Ran gen:behaviour-spec for the six new reducer cases. Co-Authored-By: Claude Sonnet 5 --- .../brief/application/org-template.store.ts | 53 +++--- .../brief/domain/org-template.machine.spec.ts | 40 +++++ .../app/brief/domain/org-template.machine.ts | 45 ++++- .../RD-13-org-template-action.md | 166 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- libs/shared/docs/behaviour-spec.mdx | 8 +- 6 files changed, 290 insertions(+), 24 deletions(-) create mode 100644 docs/project/readable-codebase/RD-13-org-template-action.md diff --git a/apps/ssp/src/app/brief/application/org-template.store.ts b/apps/ssp/src/app/brief/application/org-template.store.ts index daf40a7..2834d5d 100644 --- a/apps/ssp/src/app/brief/application/org-template.store.ts +++ b/apps/ssp/src/app/brief/application/org-template.store.ts @@ -1,6 +1,6 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { createStore } from '@shared/application/store'; -import { ActionState, SaveState } from '@shared/application/action-state'; +import { SaveState } from '@shared/application/action-state'; import { createDebouncedSave } from '@shared/application/debounced-save'; import { fromLoadLifecycle } from '@shared/application/remote-data'; import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter'; @@ -13,6 +13,7 @@ import { SubOrgSummary, } from '@brief/domain/org-template'; import { + OrgTemplateActionState, OrgTemplateMsg, OrgTemplateState, initial, @@ -47,17 +48,23 @@ export class OrgTemplateStore implements PendingSave { readonly subOrgs = signal([]); readonly selectedSubOrgId = signal(null); - private actionState = signal({ tag: 'Idle' }); - readonly busy = computed(() => this.actionState().tag === 'Busy'); + /** The one-shot action lifecycle and the publish impact-confirm gate now live on + the machine's `Loaded.action` as one four-variant union (RD-13); these stay as + plain `computed`s so the render seam (the editor organism's `input()`s, the + page template) keeps a byte-identical boolean/string API. */ + private action = computed(() => this.loaded()?.action ?? { tag: 'Idle' }); + readonly busy = computed(() => this.action().tag === 'Busy'); readonly lastError = computed(() => { - const s = this.actionState(); - return s.tag === 'Failed' ? s.error : null; + const a = this.action(); + return a.tag === 'Failed' ? a.error : null; }); + /** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). + Before RD-13 this was an independent boolean, so it could be `true` at the same + time `busy` was `true` — representable and meaningless. It is now derived from + the same union `busy` reads, so the two are mutually exclusive by construction. */ + readonly pendingPublish = computed(() => this.action().tag === 'ConfirmingPublish'); readonly saveState = signal({ tag: 'Idle' }); - /** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */ - readonly pendingPublish = signal(false); - readonly remoteData = computed(() => fromLoadLifecycle(this.model())); private loaded = computed(() => { @@ -165,60 +172,64 @@ export class OrgTemplateStore implements PendingSave { this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft }); } else { this.saveState.set({ tag: 'Error' }); - this.actionState.set({ tag: 'Failed', error: r.error }); + this.store.dispatch({ tag: 'ActionFailed', error: r.error }); } } // --- publish (impact-confirm) / rollback / proefbrief --- + // RD-13: `requestPublish`/`cancelPublish` are the only two commands here that do + // NOT guard on `loaded()` — as dispatches they no-op outside `Loaded` by + // construction (the reducer's own guard), so behaviour is unchanged. requestPublish() { - this.pendingPublish.set(true); + this.store.dispatch({ tag: 'PublishRequested' }); } cancelPublish() { - this.pendingPublish.set(false); + this.store.dispatch({ tag: 'PublishCancelled' }); } async confirmPublish() { const s = this.loaded(); if (!s) return; - this.pendingPublish.set(false); - this.actionState.set({ tag: 'Busy' }); + // ActionStarted overwrites `action` straight to Busy, so ConfirmingPublish and + // Busy are never simultaneously true (RD-13). + this.store.dispatch({ tag: 'ActionStarted' }); this.debouncedSave.cancel(); await this.flushSave(); // publish the saved draft — flush any pending edit first const r = await this.adapter.publish(s.subOrgId); if (!r.ok) { - this.actionState.set({ tag: 'Failed', error: r.error }); + this.store.dispatch({ tag: 'ActionFailed', error: r.error }); return; } - this.actionState.set({ tag: 'Idle' }); + this.store.dispatch({ tag: 'ActionFinished' }); await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0 } async rollback(version: number) { const s = this.loaded(); if (!s) return; - this.actionState.set({ tag: 'Busy' }); + this.store.dispatch({ tag: 'ActionStarted' }); this.debouncedSave.cancel(); const r = await this.adapter.rollback(s.subOrgId, version); if (!r.ok) { - this.actionState.set({ tag: 'Failed', error: r.error }); + this.store.dispatch({ tag: 'ActionFailed', error: r.error }); return; } - this.actionState.set({ tag: 'Idle' }); + this.store.dispatch({ tag: 'ActionFinished' }); this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft } async proefbrief() { const s = this.loaded(); if (!s) return; - this.actionState.set({ tag: 'Busy' }); + this.store.dispatch({ tag: 'ActionStarted' }); this.debouncedSave.cancel(); await this.flushSave(); // the proefbrief renders the server's draft const r = await this.adapter.proefbrief(s.subOrgId); if (!r.ok) { - this.actionState.set({ tag: 'Failed', error: r.error }); + this.store.dispatch({ tag: 'ActionFailed', error: r.error }); return; } - this.actionState.set({ tag: 'Idle' }); + this.store.dispatch({ tag: 'ActionFinished' }); this.blobPresenter.open(r.value); } diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts index 1ceb8f8..e94519a 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts @@ -160,4 +160,44 @@ describe('org-template.machine', () => { expect(switched.upload.uploads).toHaveLength(0); expect(switched.subOrgId).toBe('cibg-vakbekwaamheid'); }); + + // --- the action lifecycle + publish impact-confirm gate, folded into one union (RD-13) --- + + it('PublishRequested moves a loaded template to ConfirmingPublish', () => { + const s = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded'); + expect(s.action).toEqual({ tag: 'ConfirmingPublish' }); + }); + + it('PublishCancelled returns to Idle', () => { + const confirming = reduce(loaded(), { tag: 'PublishRequested' }); + const s = expectTag(reduce(confirming, { tag: 'PublishCancelled' }), 'Loaded'); + expect(s.action).toEqual({ tag: 'Idle' }); + }); + + it('ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist', () => { + const confirming = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded'); + expect(confirming.action.tag).toBe('ConfirmingPublish'); + const s = expectTag(reduce(confirming, { tag: 'ActionStarted' }), 'Loaded'); + expect(s.action).toEqual({ tag: 'Busy' }); + }); + + it('ActionFailed carries the error', () => { + const busy = reduce(loaded(), { tag: 'ActionStarted' }); + const s = expectTag(reduce(busy, { tag: 'ActionFailed', error: 'mislukt' }), 'Loaded'); + expect(s.action).toEqual({ tag: 'Failed', error: 'mislukt' }); + }); + + it('DraftLoaded resets a stale action error to Idle', () => { + const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'mislukt' }); + const s = expectTag(reduce(failed, { tag: 'DraftLoaded', view: view() }), 'Loaded'); + expect(s.action).toEqual({ tag: 'Idle' }); + }); + + it('an action message is a no-op when the template is not loaded', () => { + expect(reduce({ tag: 'Loading' }, { tag: 'PublishRequested' })).toEqual({ tag: 'Loading' }); + expect(reduce({ tag: 'Loading' }, { tag: 'ActionStarted' })).toEqual({ tag: 'Loading' }); + expect(reduce({ tag: 'Loading' }, { tag: 'ActionFailed', error: 'x' })).toEqual({ + tag: 'Loading', + }); + }); }); diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.ts b/apps/ssp/src/app/brief/domain/org-template.machine.ts index 2682973..8c63953 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.ts @@ -9,6 +9,13 @@ import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/dom * `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is * the composable upload sub-machine folded in, exactly like the wizards fold * `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`. + * + * `action` (RD-13) owns the one-shot action lifecycle AND the publish impact-confirm + * gate as ONE four-variant union, replacing two independent store-level signals + * (`actionState` + `pendingPublish`). Before RD-13, `pendingPublish === true && busy + * === true` was representable and meaningless — the confirm dialog could show while a + * publish was already in flight. A single field with one tag at a time makes that + * combination unrepresentable. */ /** The org-identity text fields editable directly on the letter canvas. */ @@ -21,6 +28,17 @@ export type OrgTemplateTextField = | 'signatureRole' | 'signatureClosing'; +/** The one-shot action lifecycle (publish/rollback/proefbrief), plus the publish + impact-confirm gate, owned by the reducer instead of two independent store-level + signals (RD-13). `ConfirmingPublish` is a variant of this SAME union, so + "confirming a publish while one is already in flight" is unrepresentable — no + state can ever carry both at once. */ +export type OrgTemplateActionState = + | { tag: 'Idle' } + | { tag: 'ConfirmingPublish' } + | { tag: 'Busy' } + | { tag: 'Failed'; error: string }; + export type OrgTemplateState = | { tag: 'Loading' } | { tag: 'Failed'; reason: string } @@ -34,6 +52,7 @@ export type OrgTemplateState = dirty: boolean; /** Logo upload sub-state (single file, `org-logo` category). */ upload: UploadState; + action: OrgTemplateActionState; }; export const initial: OrgTemplateState = { tag: 'Loading' }; @@ -47,7 +66,12 @@ export type OrgTemplateMsg = /** Carries the draft that was saved: clears `dirty` only if no edit landed during the round-trip (reference-equal), so a concurrent edit keeps its pending save. */ | { tag: 'DraftSaved'; savedDraft: OrgTemplate } - | { tag: 'Upload'; msg: UploadMsg }; + | { tag: 'Upload'; msg: UploadMsg } + | { tag: 'PublishRequested' } // opens the publish impact-confirm gate + | { tag: 'PublishCancelled' } // closes it without publishing + | { tag: 'ActionStarted' } // a one-shot action (publish/rollback/proefbrief) began + | { tag: 'ActionFinished' } // it completed successfully + | { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show /** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */ function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState { @@ -72,6 +96,9 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState // Keep the loaded logo category across sub-org switches (it's the same // `org-logo` category, loaded once); drop only any in-flight/finished uploads. upload: s.tag === 'Loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload, + // A fresh load clears a stale action error rather than letting it outlive + // the reload (RD-13, same as brief's RD-12). + action: { tag: 'Idle' }, }; case 'FieldEdited': return editDraft(s, (d) => ({ ...d, [m.field]: m.value })); @@ -96,6 +123,22 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState } return { ...s, upload }; } + + // The action lifecycle (RD-13): a no-op unless a template is loaded, since there + // is nothing to attach the action state to otherwise. `ConfirmingPublish` and + // `Busy` are variants of one field, so ActionStarted overwriting it to `Busy` is + // what makes the two mutually exclusive by construction — not by convention. + case 'PublishRequested': + return s.tag === 'Loaded' ? { ...s, action: { tag: 'ConfirmingPublish' } } : s; + case 'PublishCancelled': + return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s; + case 'ActionStarted': + return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s; + case 'ActionFinished': + return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s; + case 'ActionFailed': + return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s; + default: return assertNever(m); } diff --git a/docs/project/readable-codebase/RD-13-org-template-action.md b/docs/project/readable-codebase/RD-13-org-template-action.md new file mode 100644 index 0000000..5ffba00 --- /dev/null +++ b/docs/project/readable-codebase/RD-13-org-template-action.md @@ -0,0 +1,166 @@ +# RD-13 — Fold org-template's action lifecycle and `pendingPublish` into one union + +Status: done +Source: PLAN.md 1b#2a and 1b#6 + +## Why + +`org-template.store.ts` repeats the pattern RD-12 removed from brief — an `actionState` +signal set imperatively from 13 places — and adds the arc's **one genuine illegal-state +pair**: + +```ts +private actionState = signal({ tag: 'Idle' }); // Idle | Busy | Failed +readonly pendingPublish = signal(false); // independent boolean +``` + +Nothing prevents `pendingPublish === true` _and_ `busy === true` at the same time. That state +is representable and meaningless: the UI would show the publish-impact confirmation while a +publish is already in flight. Two independent signals cannot express "these are mutually +exclusive"; one union can. + +## Read first + +- `docs/project/readable-codebase/RD-12-brief-action-in-machine.md` — the same migration, + already done and green for brief. Copy its shape. +- `apps/ssp/src/app/brief/application/org-template.store.ts` — `actionState` at 50, `busy` at + 51, `lastError` at 52, `saveState` at 56, `pendingPublish` at 59, and the publish flow at + 174-193 +- `apps/ssp/src/app/brief/domain/org-template.machine.ts` — the `Loaded` variant at 27-35 + (PascalCase since RD-11) +- `apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts:245,282` and + `org-template.page.ts:59` — the render seam that must not change + +## Decisions (pre-made, don't relitigate) + +1. **One four-variant union on `OrgTemplateState.Loaded`:** + + ```ts + action: { tag: 'Idle' } | { tag: 'ConfirmingPublish' } | { tag: 'Busy' } | { tag: 'Failed'; error: string } + ``` + + `ConfirmingPublish` is the fourth variant that absorbs `pendingPublish`. This is the whole + point of the ticket: after it, "confirming" and "busy" are mutually exclusive **by + construction**, not by convention. + +2. **`requestPublish` and `cancelPublish` become dispatches.** They are the only two commands + in this store that do **not** guard on `loaded()` today — they just set the boolean. As + messages (`PublishRequested`, `PublishCancelled`) they no-op outside `Loaded`, which is the + correct behaviour and means you do not add a guard that changes anything. + +3. **The other commands keep their existing `const s = this.loaded(); if (!s) return;` + guards** — `confirmPublish` (181), `rollback` (197), and the two at 158 and 211. Do not + remove them; they are stronger than brief's template gate and remain correct. + +4. **`pendingPublish`, `busy` and `lastError` all stay as store members with byte-identical + public signatures.** `pendingPublish` becomes + `computed(() => this.action().tag === 'ConfirmingPublish')` rather than a `signal`. The + render seam must not move: `org-template-editor.component.ts:282` takes + `pendingPublish = input(false)`, `:245` renders on it, `org-template.page.ts:59` passes it, + and two story args set it. **No file under `brief/ui/` may change.** + +5. **`flushSave` sets both `saveState` and `actionState`** (lines 161-169). Convert only the + `actionState` half. `saveState` must still number 5 occurrences. + +6. **`action-state.ts` still exists after this ticket.** RD-14 moves `SaveState` into + `debounced-save.ts` and deletes the file. Do not delete it here, and do not touch + `SaveState`. + +7. **Do not revisit `NO_SUBORGS`.** `org-template.store.ts:29,129` dispatches `LoadFailed` for + what is semantically `Empty`. That is a real finding and it is optional RD-34, not this + ticket. + +## Files + +- `apps/ssp/src/app/brief/domain/org-template.machine.ts` (+ `.spec.ts`) +- `apps/ssp/src/app/brief/application/org-template.store.ts` + +Not `action-state.ts` (RD-14). Not `brief.machine.ts` or `brief.store.ts` (RD-12, done). No UI +files. + +## Steps + +1. Add the four-variant `action` field to `OrgTemplateState.Loaded` and the messages to + `OrgTemplateMsg`: `PublishRequested`, `PublishCancelled`, `ActionStarted`, + `ActionFinished`, `ActionFailed`. +2. Handle them in `reduce`, each a no-op outside `Loaded`. `DraftLoaded` resets `action` to + `Idle`, matching RD-12's deliberate reset. +3. Add reducer spec cases (see Acceptance), including the mutual-exclusion case. +4. Replace the 13 `actionState.set(...)` and 4 `pendingPublish.set(...)` sites with dispatches. +5. Re-point `busy`, `lastError` and `pendingPublish` at `Loaded.action`, keeping signatures + identical. +6. Run `npm run gen:behaviour-spec` — new spec titles otherwise fail the drift check. +7. Update this ticket's `Status:` to `done` and the README's RD-13 row to `done`. +8. Commit all of it together. + +## Acceptance criteria + +Measured baselines, dry-run before handover. Commands are scoped to **this ticket's two +files**, never to the `brief/` directory — `action-state.ts` and other files legitimately +still reference these names. + +```bash +S=apps/ssp/src/app/brief/application/org-template.store.ts +M=apps/ssp/src/app/brief/domain/org-template.machine.ts + +git grep -c "actionState" -- $S # was 13 -> MUST return nothing +git grep -cw "ActionState" -- $S # MUST return nothing (word-anchored: a new + # OrgTemplateActionState would contain the old name) +git grep -c "pendingPublish" -- $S # was 4 (a signal) -> now exactly 1 (a computed) +git grep -c "saveState" -- $S # unchanged: still 5 +git grep -c "readonly busy\|readonly lastError" -- $S # unchanged: still 2 +git grep -c "ConfirmingPublish" -- $M # >= 1 +``` + +The render seam did not move: + +```bash +git diff --name-only 8e5f48c | grep -c "brief/ui/" || true # MUST be 0 +``` + +New reducer cases, the third being the point of the ticket: + +``` +- PublishRequested moves a loaded template to ConfirmingPublish +- PublishCancelled returns to Idle +- ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist +- ActionFailed carries the error +- DraftLoaded resets a stale action error to Idle +- an action message is a no-op when the template is not loaded +``` + +```bash +npm run ci # exits 0 +``` + +## Verification + +`npm run ci`. No story, no `.mdx`, no `libs/shared/src/ui/**`, so `--full` is not required. + +If you run the full gate anyway, pass `timeout: 600000` on the Bash call — it takes about 8 +minutes, and the harness backgrounds anything over 120s, which would end your turn with the +work uncommitted. + +If `dotnet test` fails with `SQLite Error 1: 'no such table: …'`, that is the stale +`bigregister.db` artifact documented in this README's Troubleshooting section. It is unrelated +to your change. + +## Out of scope + +- `SaveState` and deleting `action-state.ts` — RD-14. +- `NO_SUBORGS` becoming `Empty` — optional RD-34 (decision 7). +- Any file under `brief/ui/`, and the four `busy = input(...)` components. +- `brief.machine.ts` / `brief.store.ts` — RD-12 already did those. + +## Risks + +- **The mutual-exclusion case is the acceptance test that matters.** If your reducer lets + `ConfirmingPublish` and `Busy` coexist in any way, the ticket has not achieved its purpose + even if every grep passes. +- **`pendingPublish` changes from a `signal` to a `computed`.** Anything that _writes_ it must + become a dispatch. A leftover `.set()` call will not compile, which is the desired outcome. +- **Keep `busy`/`lastError`/`pendingPublish` signatures byte-identical.** All three are read + from a page template; renaming or re-typing one turns a pure refactor into a UI change and + breaks two stories. +- **`behaviour-spec.mdx` drift** from the new spec titles. Run `gen:behaviour-spec` in the same + commit. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index d0d3d3c..80560a4 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -107,7 +107,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | done | | RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | done | | RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | done | -| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | todo | +| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | done | | RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | todo | | RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | todo | | RD-16 | `parseDashboardView` returns `BigProfile`; delete `DashboardView` | 01 | | todo | diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index d647d16..11698eb 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 524 frontend behaviours across +**is** the suite, reshaped for a business reader. 530 frontend behaviours across 9 contexts; 261 backend behaviours across 42 test classes. @@ -308,6 +308,12 @@ classes. - a completed logo upload sets logoDocumentId + dirty - removing the logo clears logoDocumentId + dirty - DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads +- PublishRequested moves a loaded template to ConfirmingPublish +- PublishCancelled returns to Idle +- ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist +- ActionFailed carries the error +- DraftLoaded resets a stale action error to Idle +- an action message is a no-op when the template is not loaded #### parseOrgTemplateAdminView From a8c7a573fca0b8297be2208263ac8430e24e1eaa Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 18:45:04 +0200 Subject: [PATCH 19/46] refactor: move SaveState into debounced-save.ts, delete action-state.ts (RD-14) RD-12 and RD-13 moved both ActionState consumers into their machines. This left ActionState with zero users, so this ticket deletes it outright. SaveState is different. It has two four-way consumers (brief.page.ts and org-template.page.ts) that still switch on all four cases, so it survives. It moves into debounced-save.ts, next to createDebouncedSave, the only function that sets it. Both store imports merge into the existing debounced-save import line. The doc comments that named ActionState are reworded, since the type no longer exists. No store's public saveState signature changes, so brief/ui/ needs no edit. This completes the phase. Two encodings survive: RemoteData for fetched data, and each machine's own state union. SaveState stays as an explicitly justified third encoding, for a separate concern (debounced autosave status) that the other two do not cover. Co-Authored-By: Claude Sonnet 5 --- .../src/app/brief/application/brief.store.ts | 3 +- .../brief/application/org-template.store.ts | 3 +- ...D-14-move-savestate-delete-action-state.md | 126 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- libs/shared/src/application/action-state.ts | 9 -- libs/shared/src/application/debounced-save.ts | 7 +- 6 files changed, 135 insertions(+), 15 deletions(-) create mode 100644 docs/project/readable-codebase/RD-14-move-savestate-delete-action-state.md delete mode 100644 libs/shared/src/application/action-state.ts diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts index 83c04bf..a556947 100644 --- a/apps/ssp/src/app/brief/application/brief.store.ts +++ b/apps/ssp/src/app/brief/application/brief.store.ts @@ -1,9 +1,8 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { Result } from '@shared/kernel/fp'; import { createStore } from '@shared/application/store'; -import { SaveState } from '@shared/application/action-state'; import { createHistory } from '@shared/application/history'; -import { createDebouncedSave } from '@shared/application/debounced-save'; +import { SaveState, createDebouncedSave } from '@shared/application/debounced-save'; import { fromLoadLifecycle } from '@shared/application/remote-data'; import { Brief, diff --git a/apps/ssp/src/app/brief/application/org-template.store.ts b/apps/ssp/src/app/brief/application/org-template.store.ts index 2834d5d..87ce84a 100644 --- a/apps/ssp/src/app/brief/application/org-template.store.ts +++ b/apps/ssp/src/app/brief/application/org-template.store.ts @@ -1,7 +1,6 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { createStore } from '@shared/application/store'; -import { SaveState } from '@shared/application/action-state'; -import { createDebouncedSave } from '@shared/application/debounced-save'; +import { SaveState, createDebouncedSave } from '@shared/application/debounced-save'; import { fromLoadLifecycle } from '@shared/application/remote-data'; import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter'; import { UploadShellService } from '@shared/application/upload-shell.service'; diff --git a/docs/project/readable-codebase/RD-14-move-savestate-delete-action-state.md b/docs/project/readable-codebase/RD-14-move-savestate-delete-action-state.md new file mode 100644 index 0000000..cd5cf17 --- /dev/null +++ b/docs/project/readable-codebase/RD-14-move-savestate-delete-action-state.md @@ -0,0 +1,126 @@ +# RD-14 — Move `SaveState` beside its producer, delete `action-state.ts` + +Status: done +Source: PLAN.md 1b#2b + +## Why + +RD-12 and RD-13 moved both `ActionState` consumers into their machines, so **`ActionState` +now has zero real users.** Word-anchored, it survives only in its own definition and in one +doc-comment mention. + +`SaveState` is different and must survive: it has two genuine consumers that keep all four +cases (`brief.page.ts:150` and `org-template.page.ts:102` both `switch` on it, and +`brief.page.ts:77` reads `=== 'Error'`). The original plan called for deleting both types; +that was corrected once the consumers were read. + +So the file's remaining job is to hold one type whose only producer lives elsewhere. Move +`SaveState` next to `createDebouncedSave`, which is what sets it, and the file has no reason +to exist. + +## Read first + +- `libs/shared/src/application/action-state.ts` — 9 lines, both types +- `libs/shared/src/application/debounced-save.ts` — `SaveState`'s new home; note the comment + at line 16, which names `ActionState` +- `apps/ssp/src/app/brief/application/brief.store.ts:4,57` and + `org-template.store.ts:3,66` — the two importers + +## Decisions (pre-made, don't relitigate) + +1. **Delete `ActionState` outright.** Zero users after RD-12 and RD-13. Do not deprecate it, + do not keep a re-export. + +2. **Move `SaveState` verbatim into `debounced-save.ts`**, keeping its doc comment. That file + already owns the debounced-autosave concern and `createDebouncedSave` is the only thing + that drives the state, so the type belongs beside it. Keep the four cases exactly as they + are — `Idle | Saving | Saved | Error`. + +3. **Delete `libs/shared/src/application/action-state.ts`.** Nothing else lives in it. + +4. **Update the two store imports** to `@shared/application/debounced-save`. Both stores + already import from that module for `createDebouncedSave`, so this should merge into an + existing import line rather than adding one. + +5. **Reword `debounced-save.ts:16`**, which currently reads "it touches that store's + `SaveState`/`ActionState` + adapter". Drop the `ActionState` half — the type will not + exist. + +6. **Change no UI file and no page.** `saveState`'s public signature on both stores stays + identical, so the three consumer sites need no edit. + +## Files + +- `libs/shared/src/application/debounced-save.ts` — gains `SaveState`, comment reworded +- `libs/shared/src/application/action-state.ts` — **deleted** +- `apps/ssp/src/app/brief/application/brief.store.ts` — import only +- `apps/ssp/src/app/brief/application/org-template.store.ts` — import only + +No spec files. No UI files. No machine files. + +## Steps + +1. Move the `SaveState` declaration and its doc comment into `debounced-save.ts`. +2. Reword the `ActionState` mention at line 16 (decision 5). +3. Re-point both store imports (decision 4). +4. `git rm libs/shared/src/application/action-state.ts`. +5. Update this ticket's `Status:` to `done` and the README's RD-14 row to `done`. +6. Commit all of it together. + +## Acceptance criteria + +Measured baselines, dry-run before handover. + +```bash +# The file is gone, and nothing imports it. +ls libs/shared/src/application/action-state.ts # MUST be "No such file" +git grep -l "application/action-state" -- apps libs # was 2 files -> MUST return nothing + +# ActionState is gone entirely, word-anchored (a name containing it would defeat a bare grep). +git grep -nw "ActionState" -- apps libs # MUST return nothing + +# SaveState survives, in its new home, with all four cases. Anchor on the DECLARATION: +# a bare `-w SaveState` grep already returns 1 today, from the line-16 comment. +D=libs/shared/src/application/debounced-save.ts +git grep -c "export type SaveState" -- $D # was 0 -> MUST be 1 +git grep -c "'Idle'\|'Saving'\|'Saved'\|'Error'" -- $D # MUST be >= 4 + +# The render seam did not move: the three consumer sites are untouched. +git diff --name-only c599fee | grep -c "brief/ui/" || true # MUST be 0 +git grep -c "readonly saveState" -- \ + apps/ssp/src/app/brief/application/brief.store.ts \ + apps/ssp/src/app/brief/application/org-template.store.ts # still 1 each +``` + +```bash +npm run ci # exits 0 +``` + +## Verification + +`npm run ci`. No story, no `.mdx`, no `libs/shared/src/ui/**`, so `--full` is not required. + +`dep:check` matters here: `debounced-save.ts` is in `libs/shared/src/application`, the same +layer `action-state.ts` was in, so no boundary changes. If `dep:check` fails, the type landed +in the wrong layer. + +If `dotnet test` fails with `SQLite Error 1: 'no such table: …'`, that is the stale +`bigregister.db` artifact in this README's Troubleshooting section, unrelated to your change. + +## Out of scope + +- Anything under `brief/ui/` (decision 6). +- The machines. RD-12 and RD-13 already moved the action lifecycles. +- `NO_SUBORGS` becoming `Empty` — optional RD-34. +- `UploadStatus`'s `type:` discriminant — optional RD-35. + +## Risks + +- **Do not delete `SaveState` along with the file.** It has two four-way consumers. The + original plan said to delete both types; reading the consumers corrected that, and this + ticket is the corrected version. +- **Merge into the existing `debounced-save` import** in both stores rather than adding a + second import line from the same module — lint will not complain, but it reads badly. +- **This is the last ticket that touches `action-state.ts`.** After it, the phase's claim + holds: two encodings survive, `RemoteData` for fetched data and each machine's own state + union, plus `SaveState` as an explicitly-justified third for a different concern. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 80560a4..c0edf56 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -108,7 +108,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | done | | RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | done | | RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | done | -| RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | todo | +| RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | done | | RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | todo | | RD-16 | `parseDashboardView` returns `BigProfile`; delete `DashboardView` | 01 | | todo | | RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | todo | diff --git a/libs/shared/src/application/action-state.ts b/libs/shared/src/application/action-state.ts deleted file mode 100644 index ca63529..0000000 --- a/libs/shared/src/application/action-state.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Transient state of a one-shot action (submit/approve/publish/reset/…): one tagged - union instead of a busy boolean + a nullable error sitting side by side. Shared by the - editor stores (WP-31). */ -export type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string }; - -/** Debounced-autosave indicator, shown in a small status line near a toolbar — a separate - concern from ActionState (a stale autosave error doesn't block submit/approve), but - tag-aligned with it for one consistent idiom. */ -export type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' }; diff --git a/libs/shared/src/application/debounced-save.ts b/libs/shared/src/application/debounced-save.ts index 7b48206..823f87e 100644 --- a/libs/shared/src/application/debounced-save.ts +++ b/libs/shared/src/application/debounced-save.ts @@ -1,3 +1,8 @@ +/** Debounced-autosave indicator, shown in a small status line near a toolbar — a separate + concern from a store's one-shot action lifecycle (a stale autosave error doesn't block + submit/approve), but tag-aligned with it for one consistent idiom. */ +export type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' }; + export interface DebouncedSave { /** (Re)arm the debounce timer; no-op when `canSave()` is false. */ schedule(): void; @@ -13,7 +18,7 @@ export interface DebouncedSave { /** * The debounced-autosave timer shared by the editor stores (WP-31). It owns ONLY the timer * bookkeeping; the actual write + save-state transitions live in the caller's `flush` - * (store-specific — it touches that store's SaveState/ActionState + adapter). The handle is + * (store-specific — it touches that store's SaveState + adapter). The handle is * nulled the moment it fires, so `hasPendingSave()` means "a write is still owed". Integrates * with the `PendingSave` seam (pending-saves.ts): a store delegates hasPendingSave/flushPending * here so the CanDeactivate guard / beforeunload handler can flush a pending edit. From c45d1bc0ffaee945ca21669f053ddd7c49435437 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 18:46:00 +0200 Subject: [PATCH 20/46] docs: `git grep -c` counts lines, not occurrences (the seventh miss) RD-14 asserted that a grep for the four SaveState tags would count at least 4. All four live on one line of a single-line type declaration, so the honest answer is 1. The executing agent refused to reformat the type across four lines to satisfy the number, which would have fought prettier and violated the ticket's own "move verbatim" decision, and flagged the check instead. Use `grep -o | wc -l` when you mean occurrences. Say "lines" when you mean lines. This is the seventh miscalibrated acceptance command in this backlog, and the third an executing agent caught using these rules rather than the supervisor. Co-Authored-By: Claude Opus 5 --- docs/project/readable-codebase/README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index c0edf56..a2f9559 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -180,7 +180,7 @@ Three rules when you write a ticket file, because the agent reads its ticket and estimate and nothing can check it. `npm run lint` has an exit code. 4. **Run every acceptance command against the tree before you hand the ticket over.** A command that cannot pass is worse than no command: the agent either wastes a cycle or, - worse, "fixes" correct code to satisfy it. Four real misses so far, all in tickets written + worse, "fixes" correct code to satisfy it. Seven real misses so far, all in tickets written by the supervisor: - RD-06 grepped only `runIfSubmitting`, missing that one wizard spells it `runIfIndienen`. - RD-08 grepped bare `onPrimary\|onRetry`, which can never return nothing — an unrelated @@ -206,6 +206,12 @@ Three rules when you write a ticket file, because the agent reads its ticket and which are repo-wide sweeps. - **Anchor on a declaration** (`^ onRetry\(\)`), not on a name that may legitimately appear elsewhere. + - **`git grep -c` counts matching LINES, not occurrences.** RD-14 asserted + `git grep -c "'Idle'\|'Saving'\|'Saved'\|'Error'"` would be `>= 4`, but all four tags + live on one line of a single-line type declaration, so the honest answer is `1`. The + agent correctly refused to reformat the type across four lines to satisfy the number. + When you want occurrences, use `grep -o … | wc -l`; when a line count is what you mean, + say so. - **Scope every acceptance command to the ticket's Files list, never to a parent directory.** This is the habit most often broken, including by the supervisor in RD-12: the check `git grep "ActionState" -- apps/ssp/src/app/brief` cannot pass, because From fb7b531fdf3992bab6c95418d131444468ad6d10 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 18:48:52 +0200 Subject: [PATCH 21/46] chore: remove 22 abandoned agent worktrees (RD-15) The repository carried 22 abandoned agent worktrees under .claude/worktrees/, left behind by past agent runs. They are gitignored, so they never reached a commit, but they stayed on disk and every unqualified repository-wide grep or find walked all 22 copies of the source tree. Measured before: 48,005 files under .claude/worktrees/, against 856 tracked in the repository. An unqualified search walked 56 times more files than the repository contains. The verification gate confirmed both safety conditions before removal: all 22 worktree-agent-* branch tips were already ancestors of main, and all 22 worktrees were clean (unmerged: 0, dirty: 0). Removal steps: - git worktree remove for each of the 22 worktrees (no rm -rf, so the registrations in .git/worktrees/ stay consistent) - git branch -d for each worktree-agent-* branch (lowercase -d, so an unmerged branch would block deletion instead of being force-deleted) - git worktree prune to clear administrative entries Measured after: 0 files under .claude/worktrees/, .claude/ shrank from 4.7 GB to 72 KB. The 856 tracked files are unchanged. HEAD is unchanged from before the removals. npm run ci exits 0. This ticket changes no tracked source file. The diff is this ticket file and the README row, because the work is entirely in gitignored paths and local branch refs. Co-Authored-By: Claude Sonnet 5 --- .../RD-15-remove-abandoned-worktrees.md | 142 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 docs/project/readable-codebase/RD-15-remove-abandoned-worktrees.md diff --git a/docs/project/readable-codebase/RD-15-remove-abandoned-worktrees.md b/docs/project/readable-codebase/RD-15-remove-abandoned-worktrees.md new file mode 100644 index 0000000..cb93c9e --- /dev/null +++ b/docs/project/readable-codebase/RD-15-remove-abandoned-worktrees.md @@ -0,0 +1,142 @@ +# RD-15 — Remove the 22 abandoned agent worktrees + +Status: done +Source: PLAN.md 2.1 + +## Why + +`.claude/worktrees/` holds **22 abandoned agent checkouts totalling 4.7 GB**, left behind by +past agent runs. They are gitignored (`.gitignore:64`), so they never reach a commit — but +they are on disk, and every unqualified repository-wide `grep -r` or `find` walks all 22 +copies of the source tree. + +That is a real tax on every future search, by a person or an agent, and it is larger than it +looks. Measured: + +| | files | +| -------------------------------- | ---------- | +| under `.claude/worktrees/` | **48,005** | +| tracked in the actual repository | **856** | + +An unqualified `grep -r` or `find` therefore walks **56× more files than the repository +contains**. This ticket removes the cause; the `git grep` habit in the ticket-authoring rules +above handles the symptom. + +## Read first + +- `.gitignore:64` — confirms the directory is ignored +- `git worktree list` — 23 entries: the main working tree plus the 22 to remove +- The verification block below. **Run it before removing anything.** + +## Decisions (pre-made, don't relitigate) + +1. **Use `git worktree remove`, never `rm -rf`.** These are **live registered git + worktrees**, not orphaned directories — each has a real `worktree-agent-` branch. An + `rm -rf` leaves 22 broken registrations behind in `.git/worktrees/`, which is worse than + the disk usage. This correction was made while executing RD-01, where the original plan + assumed they were plain directories. + +2. **Delete each `worktree-agent-*` branch too**, after removing its worktree. A worktree + removal does not delete the branch it had checked out, and 22 stale branches in + `git branch` are their own kind of noise. + +3. **Finish with `git worktree prune`** to clear any leftover administrative entries. + +4. **Re-verify before removing, even though it was verified when this ticket was written.** + This is the only destructive ticket in the arc. Both gates passed at authoring time — all + 22 branch tips are ancestors of `main` (the RB-01..RB-33 arc was merged in `637d500`), and + all 22 working trees are clean. **If either gate fails for any worktree, stop and report + it; do not use `--force`.** + +5. **This ticket changes no tracked file.** Its commit contains only this ticket file and the + README row. That is correct and expected — the work is entirely in gitignored paths and + local branch refs. + +## Files + +- `docs/project/readable-codebase/RD-15-remove-abandoned-worktrees.md` (this file) +- `docs/project/readable-codebase/README.md` (the RD-15 row) + +No source files. No configuration. `.gitignore` is already correct and must not change. + +## Steps + +1. Run the verification block below. Do not proceed unless it reports `unmerged: 0` and + `dirty: 0`. +2. For each worktree: `git worktree remove .claude/worktrees/`. +3. For each branch: `git branch -d worktree-agent-` (lowercase `-d`, which refuses to + delete anything unmerged — that is a second safety net, so do **not** use `-D`). +4. `git worktree prune`. +5. Confirm `.claude/worktrees/` is gone or empty. +6. Update this ticket's `Status:` to `done` and the README's RD-15 row to `done`. +7. Commit. + +## The verification gate — run this first + +```bash +cd /home/eho/repos/atomic-design-poc +unmerged=0 +for b in $(git branch --list 'worktree-agent-*' --format='%(refname:short)'); do + git merge-base --is-ancestor "$(git rev-parse "$b")" main 2>/dev/null \ + || { echo "UNMERGED: $b"; unmerged=$((unmerged+1)); } +done +dirty=0 +for d in .claude/worktrees/agent-*; do + [ -d "$d" ] || continue + out=$(git -C "$d" status --porcelain 2>/dev/null | grep -v '^?? node_modules') + [ -z "$out" ] || { echo "DIRTY: $(basename "$d")"; dirty=$((dirty+1)); } +done +echo "unmerged: $unmerged dirty: $dirty" +``` + +Expected, and what was measured when this ticket was written: `unmerged: 0 dirty: 0`. + +## Acceptance criteria + +```bash +git worktree list | wc -l # MUST be 1 (the main tree only) +git branch --list 'worktree-agent-*' | wc -l # MUST be 0 +ls .claude/worktrees 2>/dev/null | wc -l # MUST be 0 +du -sh .claude 2>/dev/null # was 4.7G under worktrees/ +``` + +The repository is still intact — this is the check that matters after a destructive step: + +```bash +git status --short # only the two doc files +git log --oneline -1 # HEAD unchanged from before your removals +npm run ci # exits 0 +``` + +Show the payoff, since it is the reason for the ticket: + +```bash +find .claude/worktrees -type f 2>/dev/null | wc -l # was 48005 -> MUST be 0 +git ls-files | wc -l # unchanged: 856 tracked files +``` + +## Verification + +`npm run ci`. No source file changes, so `--full` is not required — but run plain `ci` anyway, +because removing worktrees touches `.git` administrative state and the point is to prove the +repository is unharmed. + +## Out of scope + +- `.gitignore` — already correct at line 64. +- Any worktree that fails a gate. Report it instead (decision 4). +- Preventing future accumulation. Worth doing, but it is a change to how agents are launched, + not a cleanup, and no ticket covers it yet. Note it as a follow-up. + +## Risks + +- **This is the arc's only destructive ticket.** The two gates in decision 4 are what make it + safe. Run them, and stop on any failure. +- **`git branch -d`, never `-D`.** Lowercase refuses unmerged branches, which duplicates the + first gate at the moment of deletion. If `-d` refuses a branch, that branch has commits not + in `main` — stop and report it. +- **`git worktree remove` refuses a dirty worktree** unless forced. Do not force. A refusal + means the second gate missed something. +- **Do not delete `node_modules` anywhere else** while cleaning up. The verification block + deliberately ignores untracked `node_modules` inside a worktree, because that is build + output, not work. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index a2f9559..726fcf0 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -109,7 +109,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | done | | RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | done | | RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | done | -| RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | todo | +| RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | done | | RD-16 | `parseDashboardView` returns `BigProfile`; delete `DashboardView` | 01 | | todo | | RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | todo | | RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | | todo | From c36d9e3ff08b322eb2e2c4e595ff8a782ee6c94f Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 18:50:38 +0200 Subject: [PATCH 22/46] =?UTF-8?q?docs:=20drop=20RD-16=20=E2=80=94=20the=20?= =?UTF-8?q?instruction=20would=20have=20discarded=20server=20decisions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RD-16 was to make parseDashboardView return BigProfile directly, on the plan's claim that DashboardViewDto, DashboardView and BigProfile were three names for one payload. Reading the type disproves it. DashboardView is a pair of BigProfile and HerregistratieDecisions. BigProfile is { registration, person } and has nowhere to put decisions, so returning it directly would silently drop the server-computed herregistratie eligibility — the value ADR-0001 says the front end must render rather than recompute. The store's two map calls are not a redundant hop either. They project one aggregate into two independently consumed signals, and six files read them separately. Also withdraw the earlier correction that "Step 2 did not fully land". That claim came from reading the parse signature without reading the type it returns. Commit 42e7a1e did the right parts, including moving HerregistratieDecisions into domain, and correctly left alone the part that would have been wrong. Co-Authored-By: Claude Opus 5 --- docs/project/readable-codebase/PLAN.md | 47 +++++++++++++++++++----- docs/project/readable-codebase/README.md | 2 +- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/docs/project/readable-codebase/PLAN.md b/docs/project/readable-codebase/PLAN.md index 6937271..ae69abc 100644 --- a/docs/project/readable-codebase/PLAN.md +++ b/docs/project/readable-codebase/PLAN.md @@ -174,7 +174,7 @@ ticket is picked. The phase sections below this table are the source for each ti | RD-13 | Same for org-template, folding `pendingPublish` in | 12 | 1b#2a,#6 | | | RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | 1b#2b | | | RD-15 | Delete `.claude/worktrees/` (22 checkouts, 4.7 GB) | 01 | 2.1 | | -| RD-16 | `parseDashboardView` returns `BigProfile`; delete `DashboardView` | 01 | 2.2 | | +| RD-16 | ~~`parseDashboardView` returns `BigProfile`~~ DROPPED — would discard decisions | 01 | 2.2 | | | RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | 2.3 | | | RD-18 | Ticket sweep, frontend — 181 refs / 100 files | 01 | 2.4 | | | RD-19 | Ticket sweep, backend — 370 refs / 86 files | 01 | 2.4 | | @@ -496,12 +496,38 @@ fix both bugs. If the budget shrinks, stop after A5; B3 and B2 are hygiene, not `637d500` merged the whole RB-01..RB-33 arc. **RD-15 must re-verify all 22 before removing any** — check every branch tip is an ancestor of `main`, and stop if one is not. -2. **Finish Step 2's name collapse** (committed as done, but did not land): - `parseDashboardView` still returns `DashboardView` - (`registratie/infrastructure/dashboard-view.adapter.ts:119`), and - `big-profile.store.ts` pays twice — line 35 computes `RemoteData`, - line 45 re-maps to `RemoteData`. Make the parse return `BigProfile` - directly and delete the intermediate. One payload, one name. +2. ~~**Finish Step 2's name collapse.**~~ **DROPPED while executing RD-16 — the instruction + was wrong, and following it would have introduced a bug.** + + This plan claimed `DashboardViewDto → DashboardView → BigProfile` was "three names for one + payload" and that `parseDashboardView` should return `BigProfile` directly. Reading the + type disproves it: + + ```ts + export interface DashboardView { + profile: BigProfile; + decisions: HerregistratieDecisions; + } + ``` + + `DashboardView` is a **pair**, and `BigProfile` is `{ registration, person }` — one + _member_ of that pair, with nowhere to put `decisions`. Returning `BigProfile` directly + would silently discard the server-computed herregistratie eligibility, which is exactly + what ADR-0001 says the front end must render rather than recompute. + + The store's two `map` calls are not a redundant hop either: they project one aggregate into + two independently-consumed signals, and six files consume them separately — for example + `mijn-registratie.section.ts` takes `profile` while `wat-moet-ik-regelen.section.ts` takes + `decisions`. + + So the three names are a wire DTO, a screen-shaped aggregate, and a component of that + aggregate. Three different things, correctly named. + + **The other half of Step 2 was already done correctly:** `HerregistratieDecisions` lives in + `registratie/domain/registration.ts:40`, not in `contracts/`, and only one hand-written + contracts file remains (`duo-diplomas.dto.ts`, a different endpoint). Commit `42e7a1e` did + the parts that were right and correctly left alone the part that would have been wrong. + 3. **`successOf` / `successOr` sweep** — 10 inline unwraps remain in 8 files. They do not all want the same helper: - `undefined` fallback → existing `successOf`: `beoordeling.page.ts:78` @@ -852,8 +878,11 @@ End to end, after Phase 0 and Phase 3: Measured against the current tree, not assumed: -- **Step 2 did not fully land** — the `DashboardViewDto → DashboardView → BigProfile` chain is - intact (Phase 2.2). +- ~~**Step 2 did not fully land.**~~ **This correction was itself wrong, and is withdrawn.** + The chain is intact because it _should_ be: `DashboardView` is a pair of `BigProfile` and + `HerregistratieDecisions`, not a third name for either. Collapsing it would discard the + server-computed decisions. The claim was made by reading the parse signature without reading + the type it returns. See Phase 2.2, now dropped. - **7 files exceed 250 lines, not 8.** The plan counted by `wc -l`; the rule as specified uses `skipBlankLines` + `skipComments`. `concepts.page.ts` (472) was missing from its list, but `behandel-scherm` (232) and `stamdata-table-editor` (236) were on it and already pass. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 726fcf0..3618dec 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -110,7 +110,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | done | | RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | done | | RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | done | -| RD-16 | `parseDashboardView` returns `BigProfile`; delete `DashboardView` | 01 | | todo | +| RD-16 | ~~`parseDashboardView` returns `BigProfile`~~ — DROPPED, see PLAN.md 2.2 | 01 | | n/a | | RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | todo | | RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | | todo | | RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | todo | From e221834f6ec652e316f80c0ed2c81d17ae3e4e26 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 20:33:56 +0200 Subject: [PATCH 23/46] refactor: add successOr, sweep remaining inline unwraps (RD-17) Eight sites hand-rolled `rd.tag === 'Success' ? rd.value : fallback`. Six take the new `successOr(rd, fallback)`, one takes the existing `successOf`, and one (`big-profile.store.ts`) uses the existing `map`, since it returns a RemoteData rather than an unwrapped value. Co-Authored-By: Claude Sonnet 5 --- .../app/behandeling/ui/beoordeling.page.ts | 6 +- .../app/behandeling/ui/werkvoorraad.page.ts | 6 +- .../application/big-profile.store.ts | 10 +- .../app/registratie/ui/admin-cases.page.ts | 6 +- .../ui/dashboard/mijn-aanvragen.section.ts | 8 +- .../registratie-wizard.component.ts | 7 +- .../RD-17-successor-sweep.md | 160 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- libs/beheer/src/ui/audit.page.ts | 6 +- libs/shared/docs/behaviour-spec.mdx | 7 +- .../src/application/feature-flags.store.ts | 7 +- .../src/application/remote-data.spec.ts | 14 +- libs/shared/src/application/remote-data.ts | 10 ++ 13 files changed, 212 insertions(+), 37 deletions(-) create mode 100644 docs/project/readable-codebase/RD-17-successor-sweep.md diff --git a/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts b/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts index cf8971e..8922f77 100644 --- a/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts +++ b/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts @@ -1,5 +1,6 @@ import { Component, computed, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { successOf } from '@shared/application/remote-data'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; @@ -73,10 +74,7 @@ export class BeoordelingPage { protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`; protected rows = detailRows; - protected readonly view = computed(() => { - const rd = this.store.view(); - return rd.tag === 'Success' ? rd.value : undefined; - }); + protected readonly view = computed(() => successOf(this.store.view())); constructor() { void this.store.load(this.id); diff --git a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts index ce8ed3f..b52e8f6 100644 --- a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts +++ b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts @@ -5,6 +5,7 @@ import { ButtonComponent } from '@shared/ui/button/button.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { AccessStore } from '@shared/application/access.store'; +import { successOr } from '@shared/application/remote-data'; import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store'; import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component'; @@ -56,10 +57,7 @@ export class WerkvoorraadPage { protected access = inject(AccessStore); protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen')); - protected items = computed(() => { - const rd = this.store.items(); - return rd.tag === 'Success' ? rd.value : []; - }); + protected items = computed(() => successOr(this.store.items(), [])); protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`; protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`; diff --git a/apps/ssp/src/app/registratie/application/big-profile.store.ts b/apps/ssp/src/app/registratie/application/big-profile.store.ts index c590e96..81c3255 100644 --- a/apps/ssp/src/app/registratie/application/big-profile.store.ts +++ b/apps/ssp/src/app/registratie/application/big-profile.store.ts @@ -52,10 +52,12 @@ export class BigProfileStore { ); /** Specialisms/notes stay a separate stream (they have their own empty state). */ - readonly aantekeningen = computed>(() => { - const rd = fromResource(this.aantekeningenRes, (v) => !v || v.length === 0); - return rd.tag === 'Success' ? { tag: 'Success', value: rd.value ?? [] } : rd; - }); + readonly aantekeningen = computed>(() => + map( + fromResource(this.aantekeningenRes, (v) => !v || v.length === 0), + (v) => v ?? [], + ), + ); // --- Optimistic herregistratie state, shared with the dashboard ----------- private pending = signal(false); diff --git a/apps/ssp/src/app/registratie/ui/admin-cases.page.ts b/apps/ssp/src/app/registratie/ui/admin-cases.page.ts index 8f83fad..cc051ac 100644 --- a/apps/ssp/src/app/registratie/ui/admin-cases.page.ts +++ b/apps/ssp/src/app/registratie/ui/admin-cases.page.ts @@ -6,6 +6,7 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { AccessStore } from '@shared/application/access.store'; +import { successOr } from '@shared/application/remote-data'; import { formatDatumNl } from '@shared/kernel/datum'; import { Aanvraag } from '@registratie/domain/aanvraag'; import { TYPE_LABELS, statusLabel, referentie } from '@registratie/domain/aanvraag-view'; @@ -78,10 +79,7 @@ export class AdminCasesPage { protected access = inject(AccessStore); protected canManage = computed(() => this.access.can('cases:manage')); - protected cases = computed(() => { - const rd = this.store.cases(); - return rd.tag === 'Success' ? rd.value : []; - }); + protected cases = computed(() => successOr(this.store.cases(), [])); protected heading = $localize`:@@adminCases.heading:Aanvragen beheren`; protected intro = $localize`:@@adminCases.intro:Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt.`; diff --git a/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.ts index 7fce4b6..feabd9e 100644 --- a/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.ts +++ b/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.ts @@ -6,6 +6,7 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ASYNC } from '@shared/ui/async/async.component'; +import { successOr } from '@shared/application/remote-data'; import { AanvragenStore } from '@registratie/application/aanvragen.store'; import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag'; import { @@ -88,10 +89,9 @@ export class MijnAanvragenSection { protected submittedRow = submittedRow; - protected aanvragen = computed(() => { - const rd = this.store.aanvragen(); - return rd.tag === 'Success' ? sortForDashboard(rd.value) : []; - }); + protected aanvragen = computed(() => + sortForDashboard(successOr(this.store.aanvragen(), [])), + ); protected concepten_ = computed(() => concepten(this.aanvragen())); protected ingediend_ = computed(() => ingediend(this.aanvragen())); diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index 3f64c28..50a1d4c 100644 --- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -21,7 +21,7 @@ import { ASYNC } from '@shared/ui/async/async.component'; import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component'; import { createStore } from '@shared/application/store'; import { whenTag } from '@shared/kernel/fp'; -import { RemoteData } from '@shared/application/remote-data'; +import { RemoteData, successOr } from '@shared/application/remote-data'; import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store'; import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto'; import { @@ -516,10 +516,7 @@ export class RegistratieWizardComponent { inside it too: ``'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(() => { - const rd = this.lookupRd(); - return rd.tag === 'Success' ? rd.value : null; - }); + protected duoData = computed(() => successOr(this.lookupRd(), null)); readonly jaNee = JA_NEE; diff --git a/docs/project/readable-codebase/RD-17-successor-sweep.md b/docs/project/readable-codebase/RD-17-successor-sweep.md new file mode 100644 index 0000000..6a27daa --- /dev/null +++ b/docs/project/readable-codebase/RD-17-successor-sweep.md @@ -0,0 +1,160 @@ +# RD-17 — Add `successOr`, and sweep the remaining inline unwraps + +Status: done +Source: PLAN.md 2.3 + +## Why + +`successOf` landed with the dashboard refactor and removed the repeated +`rd.tag === 'Success' ? rd.value : undefined` from five sites. **Eight more inline unwraps +remain**, and they do not all want the same helper — the fallbacks genuinely differ. One +helper is missing, and one site hand-rolls a function that already exists. + +## Already in the working tree (an interrupted session did steps 1-2) + +`git status` shows four modified files. Do not redo this work; check it, then continue. + +- `remote-data.ts` — `successOr` exists at 119 with the three-parameter signature and a doc + comment. Done. +- `remote-data.spec.ts` — `successOr` cases mirror `successOf`'s. Done. +- `behandeling/ui/beoordeling.page.ts` — converted to `successOf`. Done. +- `behandeling/ui/werkvoorraad.page.ts` — converted to `successOr(rd, [])`. Done. + +Six sites in the decision-2 table remain. + +## Read first + +- `libs/shared/src/application/remote-data.ts` — `successOf` at 109, `map` at 78, and the + `// #region showcase:fold` marker at 50-69. `successOr` goes next to `successOf`, well clear + of that region. +- `libs/shared/src/application/remote-data.spec.ts` — `successOf` already has cases; mirror + them. + +## Decisions (pre-made, don't relitigate) + +1. **Add `successOr` with three type parameters, not two:** + + ```ts + export function successOr(rd: RemoteData, fallback: F): T | F { + return rd.tag === 'Success' ? rd.value : fallback; + } + ``` + + The third parameter is load-bearing. Call sites pass `[]` and `null`, neither of which is + assignable to `T`, so a two-parameter `successOr(rd, fallback: T): T` would not + compile at those sites. + +2. **The eight in-scope sites, and which helper each takes:** + + | Site | Today | Becomes | + | ------------------------------------------------------- | -------------------------------------------------- | ------------------------------------- | + | `behandeling/ui/beoordeling.page.ts:78` | `? rd.value : undefined` | `successOf(rd)` | + | `behandeling/ui/werkvoorraad.page.ts:61` | `? rd.value : []` | `successOr(rd, [])` | + | `registratie/ui/admin-cases.page.ts:83` | `? rd.value : []` | `successOr(rd, [])` | + | `beheer/src/ui/audit.page.ts:99` | `? rd.value : []` | `successOr(rd, [])` | + | `shared/application/feature-flags.store.ts:27` | `? rd.value : []` | `successOr(rd, [])` | + | `registratie/ui/dashboard/mijn-aanvragen.section.ts:93` | `? sortForDashboard(rd.value) : []` | `sortForDashboard(successOr(rd, []))` | + | `registratie/ui/registratie-wizard/…component.ts:521` | `? rd.value : null` | `successOr(rd, null)` | + | `registratie/application/big-profile.store.ts:57` | `? { tag: 'Success', value: rd.value ?? [] } : rd` | **`map(rd, (v) => v ?? [])`** | + +3. **The last row is the interesting one.** `big-profile.store.ts:57` re-wraps a `Success` + and passes everything else through — that is exactly `map`, which has existed in this file + since before the arc started. Use the existing function; do not reach for `successOr` + there, because the site returns a `RemoteData`, not an unwrapped value. + +4. **`mijn-aanvragen.section.ts` folds a map into its unwrap.** `sortForDashboard(successOr(rd, []))` + is equivalent because `sortForDashboard([])` is `[]`, and it reads better than nesting + `map`. Keep the sort outside. + +5. **Leave the two boolean predicates alone.** `access.store.ts:36` + (`&& rd.value.includes(capability)`) and `feature-flags.store.ts:51` + (`&& (rd.value.find(…)?.enabled ?? false)`) answer a yes/no question rather than unwrapping + a value. `successOr(rd, []).includes(x)` would work but allocates an array to answer a + boolean, and reads no better. Not a win. + +6. **Four sites inside `remote-data.ts` itself are not call sites** — they are the bodies of + `map`, `andThen`, `successOf` and the new `successOr`. Obviously do not rewrite a function + in terms of itself. + +7. **Leave the six spec-file occurrences alone.** `aanvragen.store.spec.ts` and + `admin-cases.store.spec.ts` use `s.tag === 'Success' && s.value.map(…)` inside `expect(…)`. + That is an assertion idiom; replacing it would obscure what the test checks. + +## Files + +- `libs/shared/src/application/remote-data.ts` (+ `.spec.ts`) — the new helper +- The eight files in decision 2 + +## Steps + +1. Add `successOr` next to `successOf` per decision 1, with a doc comment saying when to + reach for it rather than `successOf` or `map`. +2. Add spec cases mirroring `successOf`'s. +3. Convert the eight sites per the table. One file at a time; let the type-checker confirm + each. +4. Run `npm run gen:behaviour-spec` — new spec titles otherwise fail the drift check. +5. Update this ticket's `Status:` to `done` and the README's RD-17 row to `done`. +6. Commit all of it together. + +## Acceptance criteria + +Measured baselines, dry-run before handover. The ternary form appears **11** times in the +working tree; **4** of those are the bodies of `map`, `andThen`, `successOf` and `successOr` +inside `remote-data.ts` and must survive, so the target is exactly 4. + +```bash +git grep -c "tag === 'Success' ?" -- apps libs | awk -F: '{s+=$NF} END {print s}' # is 11 -> MUST be 4 +git grep -c "tag === 'Success' ?" -- libs/shared/src/application/remote-data.ts # MUST still be 4 +``` + +The new helper exists and is used: + +```bash +git grep -c "export function successOr" -- libs/shared/src/application/remote-data.ts # MUST be 1 +git grep -l "successOr" -- apps libs | wc -l # >= 7 +``` + +The two predicates and the specs are untouched (decisions 5 and 7): + +```bash +git grep -c "tag === 'Success' &&" -- apps libs | awk -F: '{s+=$NF} END {print s}' # unchanged: 8 +``` + +`big-profile.store.ts` uses the existing `map`, not a new helper (decision 3): + +```bash +git grep -n "map(" -- apps/ssp/src/app/registratie/application/big-profile.store.ts # >= 3 (2 existing + the new one) +``` + +```bash +npm run ci # exits 0 +``` + +## Verification + +`npm run ci`. This edits no story and no `.mdx`, but it **does** edit +`libs/shared/src/application/remote-data.ts`, which is not under `libs/shared/src/ui/**` — so +`--full` is not required by the README's rule. + +Verified for you: the `// #region showcase:fold` marker sits at lines 50-69, well above +`successOf` at 109, so adding a function there cannot cause snippet drift. If you move +anything inside that region, run `npm run gen:snippets` in the same commit. + +## Out of scope + +- The two boolean predicates (decision 5). +- The six spec-file assertions (decision 7). +- `remote-data.ts`'s own three internal uses (decision 6). +- Adding any further combinator. `successOf`, `successOr`, `map`, `map2` and `andThen` cover + every site here; a sixth would be speculative. + +## Risks + +- **Do not give `successOr` two type parameters.** Call sites pass `[]` and `null`; a + `fallback: T` signature fails to compile at exactly the sites this ticket exists to fix. +- **`big-profile.store.ts:57` is a `map`, not a `successOr`.** It returns a `RemoteData`. Using + `successOr` there would change the member's type and break its consumers. +- **`behaviour-spec.mdx` drift** from the new spec titles. Run `gen:behaviour-spec` in the + same commit. +- **Watch the `?? []` inside `big-profile.store.ts:57`.** The value being mapped is + nullable; the `?? []` must move inside the `map` callback, not disappear. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 3618dec..3ef5e9e 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -111,7 +111,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | done | | RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | done | | RD-16 | ~~`parseDashboardView` returns `BigProfile`~~ — DROPPED, see PLAN.md 2.2 | 01 | | n/a | -| RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | todo | +| RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | done | | RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | | todo | | RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | todo | | RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | todo | diff --git a/libs/beheer/src/ui/audit.page.ts b/libs/beheer/src/ui/audit.page.ts index d88f301..505a702 100644 --- a/libs/beheer/src/ui/audit.page.ts +++ b/libs/beheer/src/ui/audit.page.ts @@ -5,6 +5,7 @@ import { AlertComponent } from '@shared/ui/alert/alert.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { AccessStore } from '@shared/application/access.store'; +import { successOr } from '@shared/application/remote-data'; import { AuditStore } from '@beheer/application/audit.store'; /** @@ -94,10 +95,7 @@ export class AuditPage { protected access = inject(AccessStore); protected canRead = computed(() => this.access.can('cases:manage')); - protected entries = computed(() => { - const rd = this.store.entries(); - return rd.tag === 'Success' ? rd.value : []; - }); + protected entries = computed(() => successOr(this.store.entries(), [])); protected heading = $localize`:@@audit.heading:Auditlog`; protected intro = $localize`:@@audit.intro:Toegangs- en inzagebeslissingen (autorisatie en het tonen van afgeschermde gegevens). Vastgelegd zonder persoonsgegevens.`; diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 11698eb..dd037a7 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 530 frontend behaviours across +**is** the suite, reshaped for a business reader. 532 frontend behaviours across 9 contexts; 261 backend behaviours across 42 test classes. @@ -968,6 +968,11 @@ classes. - unwraps a Success value - is undefined for every other state +#### successOr + +- unwraps a Success value +- is the fallback for every other state + #### upload lifecycle messages - queued → progress → complete diff --git a/libs/shared/src/application/feature-flags.store.ts b/libs/shared/src/application/feature-flags.store.ts index 5f57b5f..925ca73 100644 --- a/libs/shared/src/application/feature-flags.store.ts +++ b/libs/shared/src/application/feature-flags.store.ts @@ -1,5 +1,5 @@ import { Injectable, computed, inject, signal } from '@angular/core'; -import { RemoteData } from '@shared/application/remote-data'; +import { RemoteData, successOr } from '@shared/application/remote-data'; import { runSubmit } from '@shared/application/submit'; import { Result, ok, err } from '@shared/kernel/fp'; import { FeatureFlag } from '@shared/domain/feature-flag'; @@ -22,10 +22,7 @@ export class FeatureFlagStore { readonly flags = this.state.asReadonly(); /** The resolved list (empty until loaded) — for the admin toggle UI. */ - readonly all = computed(() => { - const rd = this.state(); - return rd.tag === 'Success' ? rd.value : []; - }); + readonly all = computed(() => successOr(this.state(), [])); constructor() { void this.load(); diff --git a/libs/shared/src/application/remote-data.spec.ts b/libs/shared/src/application/remote-data.spec.ts index 323ddbd..ff7eaea 100644 --- a/libs/shared/src/application/remote-data.spec.ts +++ b/libs/shared/src/application/remote-data.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { RemoteData, fromLoadLifecycle, map2, map, successOf } from './remote-data'; +import { RemoteData, fromLoadLifecycle, map2, map, successOf, successOr } from './remote-data'; import { loading, failure, empty, success } from '../testing/remote-data'; const loadingRd: RemoteData = loading(); @@ -34,6 +34,18 @@ describe('successOf', () => { }); }); +describe('successOr', () => { + it('unwraps a Success value', () => { + expect(successOr(ok(2), 0)).toBe(2); + }); + + it('is the fallback for every other state', () => { + expect(successOr(loadingRd, [])).toEqual([]); + expect(successOr(failureRd, [])).toEqual([]); + expect(successOr(empty(), null)).toBeNull(); + }); +}); + describe('fromLoadLifecycle', () => { it('maps Loading → Loading', () => { expect(fromLoadLifecycle({ tag: 'Loading' })).toEqual(loading()); diff --git a/libs/shared/src/application/remote-data.ts b/libs/shared/src/application/remote-data.ts index 6f35d15..093e277 100644 --- a/libs/shared/src/application/remote-data.ts +++ b/libs/shared/src/application/remote-data.ts @@ -109,3 +109,13 @@ export function andThen( export function successOf(rd: RemoteData): T | undefined { return rd.tag === 'Success' ? rd.value : undefined; } + +/** Unwrap a Success value, or a caller-supplied `fallback` for every other state. + Reach for this over `successOf` when `undefined` is not a usable value at the + call site (e.g. a list the template iterates, which wants `[]`). Reach for + `map` instead when the site needs to stay a `RemoteData`, not an unwrapped + value. Three type parameters: `fallback` need not be assignable to `T` + (an empty array is not a `T[]` at the type level, only at the value level). */ +export function successOr(rd: RemoteData, fallback: F): T | F { + return rd.tag === 'Success' ? rd.value : fallback; +} From fb2e58ab87e83a6cbef1e81c71a6558e09c4e09f Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 20:41:32 +0200 Subject: [PATCH 24/46] docs: record the concurrent-agent trap that RD-17 hit `/clear` ends the supervisor's context. It does not stop the developer agent that session spawned. A fresh supervisor read the half-finished tree as an interrupted session and dispatched a second agent onto the same ticket. Both agents then ran `dotnet test` in one checkout, which truncated the shared SQLite file and produced the stale-database failure the Troubleshooting section already records under a different cause. Also record that a bare `dotnet test` runs the OpenZaak integration test that both CI scripts filter out. Co-Authored-By: Claude Opus 5 --- docs/project/readable-codebase/README.md | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 3ef5e9e..814d637 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -235,3 +235,31 @@ rm -f backend/bigregister.db backend/src/BigRegister.Api/bigregister.db \ ``` These are build artifacts, not fixtures — removing them is always safe. + +A second cause reaches the same error: **two agents running `dotnet test` at the same time in +one checkout.** They share those files, so one run truncates the other's database. RD-17 hit +this. The fix is the same three deletions, but the cause is concurrency — see the next entry. + +**`dotnet test` reports one failure in `OpenZaakIntegrationTests`.** That test carries +`[Trait("Category", "Integration")]` and needs the live OpenZaak harness. Both `ci.yml` and +`scripts/ci-local.sh` filter it out. A bare `dotnet test` does not. Run `npm run ci` — a bare +`dotnet test` is the wrong command, not a red build. + +**An unexplained dirty tree may be a running agent, not an interrupted session.** `/clear` +ends the supervisor's context. It does **not** stop the `developer` agent that session +spawned. That agent keeps writing to the same branch. In RD-17 a fresh supervisor read the +half-finished tree as an interrupted session, wrote a "continue from here" ticket, and +dispatched a second agent onto the work the first was still doing. Both agents then ran the +backend tests together, which produced the stale-database failure above. + +Nothing was lost, because the second agent committed first and the first agent checked before +committing rather than committing blindly. That was luck. Before you dispatch, confirm no +agent is live: + +```bash +git status --short # modified files nobody in THIS session touched = suspect +git log --oneline -1 # did an agent already commit the ticket? +``` + +If the tree is dirty and this session did not dirty it, find the agent before you write a +ticket around the evidence it leaves. From 3895588b9a839289091a674fe4c66afe2991b520 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 20:56:17 +0200 Subject: [PATCH 25/46] refactor: story titles to Domein//; add 2 missing stories (RD-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three dashboard-section stories used a fourth title segment (`Domein/Registratie/Dashboard/`) that the sidebar rule does not have. Drop the `Dashboard/` segment so all story titles follow the one rule from CLAUDE.md decision 5. Add the two missing stories for sections that have more than one visual state: `wat-moet-ik-regelen` (4 states) and `wat-wilt-u-doen` (2 states, the first story in the new `Domein/Overzicht/` bucket). `beheer-links` gets no story — it has one visual state and its other branch renders nothing. The `MetTaken` story disables the a11y addon with the same reason and WP-11 reference already used on `task-list.stories.ts` and `choice-list.stories.ts`: `app-choice-link`'s host sits between the keuzelijst `
    ` and its `
  • `, a pre-existing structural gap this ticket does not fix. Co-Authored-By: Claude Sonnet 5 --- .../ui/wat-wilt-u-doen.section.stories.ts | 28 +++ .../mijn-aanvragen.section.stories.ts | 2 +- .../mijn-registratie.section.stories.ts | 2 +- .../dashboard/specialismen.section.stories.ts | 2 +- .../wat-moet-ik-regelen.section.stories.ts | 109 +++++++++++ .../readable-codebase/RD-04-story-titles.md | 170 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- 7 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 apps/ssp/src/app/overzicht/ui/wat-wilt-u-doen.section.stories.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.stories.ts create mode 100644 docs/project/readable-codebase/RD-04-story-titles.md diff --git a/apps/ssp/src/app/overzicht/ui/wat-wilt-u-doen.section.stories.ts b/apps/ssp/src/app/overzicht/ui/wat-wilt-u-doen.section.stories.ts new file mode 100644 index 0000000..a458e7b --- /dev/null +++ b/apps/ssp/src/app/overzicht/ui/wat-wilt-u-doen.section.stories.ts @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { applicationConfig } from '@storybook/angular'; +import { provideRouter } from '@angular/router'; +import { WatWiltUDoenSection } from './wat-wilt-u-doen.section'; +import { FeatureFlagStore } from '@shared/application/feature-flags.store'; + +const meta: Meta = { + title: 'Domein/Overzicht/Wat Wilt U Doen', + component: WatWiltUDoenSection, + decorators: [applicationConfig({ providers: [provideRouter([])] })], +}; +export default meta; +type Story = StoryObj; + +export const InschrijvingOpen: Story = { + decorators: [ + applicationConfig({ + providers: [{ provide: FeatureFlagStore, useValue: { enabled: () => true } }], + }), + ], +}; +export const InschrijvingDicht: Story = { + decorators: [ + applicationConfig({ + providers: [{ provide: FeatureFlagStore, useValue: { enabled: () => false } }], + }), + ], +}; diff --git a/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts b/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts index ae81b5e..a8e3d4f 100644 --- a/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts +++ b/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts @@ -34,7 +34,7 @@ function storeStub(aanvragen: RemoteData, lastErr } const meta: Meta = { - title: 'Domein/Registratie/Dashboard/Mijn Aanvragen', + title: 'Domein/Registratie/Mijn Aanvragen', component: MijnAanvragenSection, decorators: [applicationConfig({ providers: [provideRouter([])] })], }; diff --git a/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts b/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts index 58ec2f1..24a94f5 100644 --- a/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts +++ b/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts @@ -28,7 +28,7 @@ function storeStub(profileRd: RemoteData) { } const meta: Meta = { - title: 'Domein/Registratie/Dashboard/Mijn Registratie', + title: 'Domein/Registratie/Mijn Registratie', component: MijnRegistratieSection, }; export default meta; diff --git a/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts b/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts index 9912e39..1ec519c 100644 --- a/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts +++ b/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts @@ -17,7 +17,7 @@ function storeStub(aantekeningen: RemoteData) } const meta: Meta = { - title: 'Domein/Registratie/Dashboard/Specialismen', + title: 'Domein/Registratie/Specialismen', component: SpecialismenSection, }; export default meta; diff --git a/apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.stories.ts b/apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.stories.ts new file mode 100644 index 0000000..05fed6a --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.stories.ts @@ -0,0 +1,109 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { applicationConfig } from '@storybook/angular'; +import { WatMoetIkRegelenSection } from './wat-moet-ik-regelen.section'; +import { BigProfileStore } from '@registratie/application/big-profile.store'; +import { BigProfile } from '@registratie/domain/big-profile'; +import { HerregistratieDecisions } from '@registratie/domain/registration'; +import { RemoteData } from '@shared/application/remote-data'; +import { loading, success } from '@shared/testing/remote-data'; + +const profile: BigProfile = { + registration: { + bigNummer: '19012345601', + naam: 'Dr. A. (Anna) de Vries', + beroep: 'Arts', + registratiedatum: '2012-09-01', + geboortedatum: '1985-03-14', + status: { tag: 'Geregistreerd', herregistratieDatum: '2027-09-01' }, + }, + person: { + naam: 'Dr. A. (Anna) de Vries', + geboortedatum: '1985-03-14', + adres: { straat: 'Rijksweg 1', postcode: '2514 EA', woonplaats: 'Den Haag' }, + }, +}; + +/** Minimal store stand-in — only the members the section's template and class read. */ +function storeStub( + profileRd: RemoteData, + decisionsRd: RemoteData, + pendingHerregistratie: boolean, +) { + return { + profile: () => profileRd, + decisions: () => decisionsRd, + pendingHerregistratie: () => pendingHerregistratie, + reloadProfile: () => {}, + }; +} + +const meta: Meta = { + title: 'Domein/Registratie/Wat Moet Ik Regelen', + component: WatMoetIkRegelenSection, +}; +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + decorators: [ + applicationConfig({ + providers: [{ provide: BigProfileStore, useValue: storeStub(loading(), loading(), false) }], + }), + ], +}; +export const MetTaken: Story = { + decorators: [ + applicationConfig({ + providers: [ + { + provide: BigProfileStore, + useValue: storeStub( + success(profile), + success({ eligibleForHerregistratie: true }), + false, + ), + }, + ], + }), + ], + parameters: { + // Structural: app-choice-link's host sits between the keuzelijst
      and its
    • + // — axe's list/listitem rule needs them adjacent regardless of `display:contents`. + // Same pre-existing gap as task-list.stories.ts and choice-list.stories.ts. WP-11 + // (CIBG markup fidelity) reworks this markup; see + // docs/project/backlog/WP-11-markup-fidelity.md. + a11y: { disable: true }, + }, +}; +export const NietsOpenstaand: Story = { + decorators: [ + applicationConfig({ + providers: [ + { + provide: BigProfileStore, + useValue: storeStub( + success(profile), + success({ eligibleForHerregistratie: false }), + false, + ), + }, + ], + }), + ], +}; +export const InBehandeling: Story = { + decorators: [ + applicationConfig({ + providers: [ + { + provide: BigProfileStore, + useValue: storeStub( + success(profile), + success({ eligibleForHerregistratie: false }), + true, + ), + }, + ], + }), + ], +}; diff --git a/docs/project/readable-codebase/RD-04-story-titles.md b/docs/project/readable-codebase/RD-04-story-titles.md new file mode 100644 index 0000000..e831ec0 --- /dev/null +++ b/docs/project/readable-codebase/RD-04-story-titles.md @@ -0,0 +1,170 @@ +# RD-04 — Story titles to `Domein//`, and the two missing stories + +Status: done +Source: PLAN.md Phase 0 ("Also settle the two deviations the refactor left behind") + +## Why + +CLAUDE.md decision 5 sets one title rule for a component in an app context's `ui/`: +`Domein//` — "full stop, regardless of which atomic layer it is". The three +dashboard-section stories break it. They read `Domein/Registratie/Dashboard/`, a fourth +segment that invents a sidebar folder the rule does not have. All 66 other story files comply. + +RD-03 split the dashboard into two contexts but did not touch the stories. Three of the six +sections still have no story at all. + +## Read first + +- `apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts` — the shape + to copy: a `storeStub` returning only the members the template reads, one + `applicationConfig` decorator per story, and `loading`/`success`/`failure` from + `@shared/testing/remote-data`. +- `apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.ts` — needs a story. +- `apps/ssp/src/app/overzicht/ui/wat-wilt-u-doen.section.ts` — needs a story. +- `apps/ssp/src/app/registratie/domain/tasks.ts` — `tasksFromProfile`, which decides whether + the task list renders full or empty. +- CLAUDE.md decision 5, the paragraph starting "Story titles mirror the sidebar's". + +## Decisions (pre-made, don't relitigate) + +1. **Retitle the three existing stories. Drop the `Dashboard/` segment only.** + + | File | From | To | + | ----------------------------------------------- | ----------------------------------------------- | ------------------------------------- | + | `dashboard/mijn-aanvragen.section.stories.ts` | `Domein/Registratie/Dashboard/Mijn Aanvragen` | `Domein/Registratie/Mijn Aanvragen` | + | `dashboard/mijn-registratie.section.stories.ts` | `Domein/Registratie/Dashboard/Mijn Registratie` | `Domein/Registratie/Mijn Registratie` | + | `dashboard/specialismen.section.stories.ts` | `Domein/Registratie/Dashboard/Specialismen` | `Domein/Registratie/Specialismen` | + + The files stay where they are. RD-03 decision 3 keeps the four data sections in + `registratie/ui/dashboard/`; the folder is not the title. + +2. **Add exactly two story files, not three.** PLAN's rule is "add one only where the section + has more than one visual state". + + - `wat-moet-ik-regelen.section.stories.ts`, titled `Domein/Registratie/Wat Moet Ik Regelen` + — four states. + - `wat-wilt-u-doen.section.stories.ts`, titled `Domein/Overzicht/Wat Wilt U Doen` — two + states. This creates the `Domein/Overzicht/` sidebar bucket; it is the first story in + that context. + +3. **`beheer-links.section.ts` gets no story.** It has one visual state. Its other branch + renders nothing at all — `@if (adminLinks().length)` wraps the whole template — and a story + whose canvas is blank documents nothing and gives the a11y addon nothing to check. The + capability filter it applies (`AccessStore.can`) is already covered where it can actually be + asserted, in the application layer. Do not add the file. + +4. **`wat-moet-ik-regelen` gets four stories**, from a stub with the four members its template + and class read — `profile()`, `decisions()`, `pendingHerregistratie()`, `reloadProfile()`: + + | Story | `profile()` | `decisions()` | `pendingHerregistratie()` | Renders | + | ----------------- | ------------ | ----------------------------------------------- | ------------------------- | ------------------------------ | + | `Loading` | `loading()` | `loading()` | `false` | two skeleton bars | + | `MetTaken` | `success(p)` | `success({ eligibleForHerregistratie: true })` | `false` | the task list, one task | + | `NietsOpenstaand` | `success(p)` | `success({ eligibleForHerregistratie: false })` | `false` | the "niets openstaan" text | + | `InBehandeling` | `success(p)` | `success({ eligibleForHerregistratie: false })` | `true` | the info alert above the above | + + `Failed` is deliberately absent — ``'s failure template is already exercised by + `Mijn Registratie`'s `Failed` story, and this section adds nothing to it. + +5. **The task count follows from `tasksFromProfile`, so pick the profile deliberately.** With + `status.tag === 'Geregistreerd'`, the list is empty unless `eligibleForHerregistratie` is + true. That is what separates `MetTaken` from `NietsOpenstaand` — the eligibility flag, not + the profile. Reuse the `profile` fixture from `mijn-registratie.section.stories.ts` + verbatim (copy it; do not export it from the other story file and import it — story files + in this repo are self-contained). + +6. **`wat-wilt-u-doen` gets two stories**, from a `FeatureFlagStore` stub whose only member is + `enabled`: + + | Story | stub | Renders | + | ------------------- | -------------------------- | -------------------------------- | + | `InschrijvingOpen` | `{ enabled: () => true }` | six actions, "Inschrijven" first | + | `InschrijvingDicht` | `{ enabled: () => false }` | five actions, no "Inschrijven" | + +7. **`libs/beheer`'s title stays wrong here.** `Domein/Beheer/Stamdata Table Editor` is a + different deviation with a different verdict (the doc changes, not the code). RD-28 owns it. + Do not touch it. + +## Files + +- `apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts` (title only) +- `apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts` (title only) +- `apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts` (title only) +- `apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.stories.ts` (new) +- `apps/ssp/src/app/overzicht/ui/wat-wilt-u-doen.section.stories.ts` (new) + +## Steps + +1. Retitle the three files per decision 1. Nothing else in those files changes. +2. Write `wat-moet-ik-regelen.section.stories.ts` per decisions 4 and 5. +3. Write `wat-wilt-u-doen.section.stories.ts` per decision 6. +4. `git add` all five files, then run the acceptance commands. +5. Update this ticket's `Status:` to `done` and the README's RD-04 row to `done`. +6. Commit all of it together. + +## Acceptance criteria + +Dry-run against the tree before handover; the "is" numbers are measured, not estimated. + +**Run these after `git add`.** `git grep` and `git ls-files` read tracked files, so a new +story file that is not yet staged does not exist as far as they are concerned. + +```bash +git grep -l "Domein/Registratie/Dashboard" -- apps | wc -l # is 3 -> MUST be 0 +git grep -h "title: 'Domein" -- '*.stories.ts' | grep -c "Domein/[^/]*/[^/]*/" # is 3 -> MUST be 0 +git ls-files '*.stories.ts' | wc -l # is 69 -> MUST be 71 +``` + +The second command counts title lines with a fourth path segment. It is the general form of +the rule, so it also catches a retitle that invents a different extra segment. + +The two new stories exist, with the exact titles from decisions 2, 4 and 6: + +```bash +git grep -c "title: 'Domein/Registratie/Wat Moet Ik Regelen'" -- apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.stories.ts # MUST be 1 +git grep -c "title: 'Domein/Overzicht/Wat Wilt U Doen'" -- apps/ssp/src/app/overzicht/ui/wat-wilt-u-doen.section.stories.ts # MUST be 1 +``` + +`beheer-links` still has no story (decision 3): + +```bash +git ls-files 'apps/ssp/src/app/overzicht/ui/beheer-links.section.stories.ts' | wc -l # MUST be 0 +``` + +```bash +npm run ci --full # exits 0 +``` + +## Verification + +**`--full` is required.** This ticket adds two story files, and `npm run ci` does not build +Storybook. A story that fails to compile, or a decorator with a missing provider, is invisible +until the storybook-a11y job runs. Run `npm run ci --full`, not `npm run ci`. + +Verified for you: `layers.mdx` deep-links exactly two story ids — +`design-system-molecules-application-link--navigatie` and +`domein-registratie-aanvraag-block--concept`. Neither is a dashboard section, so the three +retitles break no link. Do not add a new deep link. + +## Out of scope + +- Moving any story file. The four data sections stay in `registratie/ui/dashboard/` + (RD-03 decision 3). +- A story for `beheer-links.section.ts` (decision 3). +- `libs/beheer`'s `Domein/Beheer/…` title (decision 7, RD-28 owns it). +- The dashboard's 8 imports. PLAN settled this: accept, do not fix. +- Renaming the `dashboard/` folder. The name is stale now that the page lives in `overzicht`, + but no rule requires the folder to match a title, and the rename would touch every import. + +## Risks + +- **A story title is a URL.** Storybook derives the story id from the title, so a retitle + changes the id. Verified above that nothing links to these three; do not extend the retitle + to a story outside the table without re-checking. +- **`success(...)` needs the decisions shape, not a boolean.** `store.decisions()` is a + `RemoteData`. The stub returns + `success({ eligibleForHerregistratie: true })`, not `success(true)`. +- **The stub must cover every member the class reads, not only the template.** + `WatMoetIkRegelenSection` reads `decisions()` in a `computed`, which the template never + names. A stub missing it throws at render time, and only `--full` catches that. +- **Do not import a fixture across story files** (decision 5). Copy it. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 814d637..f218c8d 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -98,7 +98,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-01 | Scaffold this backlog: README, PLAN, ticket template | — | | done | | RD-02 | `max-lines` rule + `reportUnusedDisableDirectives` + 7 disables | 01 | | done | | RD-03 | `overzicht` context: page + 2 nav sections, boundary edge, admin-links token | 02 | yes | done | -| RD-04 | Story titles to `Domein//`; add the missing stories | 03 | yes | todo | +| RD-04 | Story titles to `Domein//`; add the missing stories | 03 | yes | done | | RD-05 | `createStore` gains the effect map + specs | 02 | | done | | RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | done | | RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | done | From dd11eafe50491997fc241ecad79af3cc51f1e865 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 21:23:07 +0200 Subject: [PATCH 26/46] refactor: strip WP-/RB- ticket refs from apps and libs (RD-18) 204 WP-NN/RB-NN comments named a closed ticket instead of the code they sit next to. git blame already records history and stays correct when code moves; the comment does not. This sweep removes the reference and keeps the sentence, across 95 files in apps/ and libs/ plus the behaviour-spec generator's header text. Eleven references stay: five story files justify an a11y disable per the README's rule, and one line in a11y.mdx documents that convention. Two sentences needed a rewrite, not a deletion, so the reference's meaning survives its removal. behaviour-spec.mdx is regenerated, not hand-edited. Co-Authored-By: Claude Sonnet 5 --- apps/behandelportal/src/app/app.routes.ts | 6 +- .../auth/infrastructure/medewerker.adapter.ts | 2 +- .../ui/login-form/login-form.component.ts | 2 +- .../application/beoordeling.store.ts | 2 +- .../application/werkvoorraad.store.ts | 2 +- .../behandeling/domain/beoordeling-view.ts | 4 +- .../src/app/behandeling/domain/beoordeling.ts | 4 +- .../app/behandeling/domain/besluit.machine.ts | 2 +- .../behandeling/domain/werkvoorraad-item.ts | 2 +- .../infrastructure/beoordeling.adapter.ts | 2 +- .../infrastructure/besluit.adapter.ts | 2 +- .../infrastructure/werkvoorraad.adapter.ts | 2 +- .../beoordeling-documenten.component.ts | 2 +- .../app/behandeling/ui/beoordeling.page.ts | 4 +- .../ui/besluit-form/besluit-form.component.ts | 2 +- .../werkvoorraad-list.component.ts | 4 +- .../app/behandeling/ui/werkvoorraad.page.ts | 6 +- .../src/app/shell/nav.config.ts | 2 +- apps/ssp/src/app/app.routes.ts | 8 +- .../app/auth/infrastructure/digid.adapter.ts | 2 +- .../app/brief/application/brief.store.spec.ts | 14 +- .../src/app/brief/application/brief.store.ts | 16 +- .../application/org-template.store.spec.ts | 6 +- .../brief/application/org-template.store.ts | 4 +- apps/ssp/src/app/brief/domain/brief-diff.ts | 2 +- .../app/brief/domain/org-template.machine.ts | 2 +- apps/ssp/src/app/brief/domain/org-template.ts | 6 +- .../app/brief/infrastructure/brief.adapter.ts | 8 +- .../infrastructure/letter-preview.adapter.ts | 12 +- apps/ssp/src/app/brief/ui/brief.page.ts | 4 +- .../letter-canvas/letter-canvas.component.ts | 16 +- .../ui/letter-canvas/letter-canvas.stories.ts | 8 +- .../letter-composer.component.ts | 2 +- .../letter-composer.stories.ts | 2 +- .../org-template-editor.component.ts | 2 +- .../org-template-editor.stories.ts | 4 +- .../ssp/src/app/brief/ui/org-template.page.ts | 2 +- .../passage-picker.component.ts | 6 +- .../domain/intake.acceptance.spec.ts | 4 +- .../domain/intake.machine.spec.ts | 4 +- .../herregistratie/domain/intake.machine.ts | 6 +- .../intake-wizard/intake-wizard.component.ts | 2 +- .../application/aanvragen.store.spec.ts | 4 +- .../application/aanvragen.store.ts | 6 +- .../application/admin-cases.store.spec.ts | 4 +- .../application/admin-cases.store.ts | 8 +- .../application/draft-sync.spec.ts | 2 +- .../app/registratie/application/draft-sync.ts | 2 +- .../registratie/application/find-concept.ts | 2 +- .../src/app/registratie/domain/aanvraag.ts | 6 +- .../domain/change-request.machine.ts | 2 +- .../infrastructure/aanvragen.adapter.ts | 6 +- .../infrastructure/change-request.adapter.ts | 2 +- .../app/registratie/ui/admin-cases.page.ts | 4 +- .../change-request-form.component.ts | 2 +- .../debug-state/debug-state.component.ts | 4 +- apps/ssp/src/app/showcase/highlight-ts.ts | 2 +- apps/ssp/src/index.html | 4 +- .../RD-18-ticket-refs-frontend.md | 164 ++++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- libs/beheer/src/application/audit.store.ts | 2 +- .../src/application/stamdata.store.spec.ts | 6 +- libs/beheer/src/application/stamdata.store.ts | 2 +- libs/beheer/src/domain/audit-entry.ts | 2 +- .../src/infrastructure/audit.adapter.ts | 4 +- libs/beheer/src/ui/audit.page.ts | 2 +- libs/beheer/src/ui/feature-flags.page.ts | 2 +- .../stamdata-table-editor.component.ts | 4 +- libs/beheer/src/ui/stamdata.page.ts | 4 +- libs/shared/docs/a11y.mdx | 6 +- libs/shared/docs/behaviour-spec.mdx | 28 +-- libs/shared/docs/cibg-gaps.mdx | 4 +- libs/shared/docs/layers.mdx | 2 +- libs/shared/src/application/debounced-save.ts | 2 +- .../src/application/feature-flags.store.ts | 2 +- libs/shared/src/application/history.ts | 4 +- libs/shared/src/application/remote-data.ts | 2 +- libs/shared/src/application/submit.spec.ts | 2 +- libs/shared/src/domain/role.ts | 2 +- .../src/infrastructure/dev-params.spec.ts | 2 +- libs/shared/src/infrastructure/dev-params.ts | 2 +- .../infrastructure/feature-flags.adapter.ts | 2 +- .../src/infrastructure/me.adapter.spec.ts | 6 +- .../infrastructure/role.interceptor.spec.ts | 2 +- .../src/infrastructure/role.interceptor.ts | 6 +- libs/shared/src/infrastructure/role.ts | 2 +- libs/shared/src/infrastructure/scenario.ts | 2 +- .../subject.interceptor.spec.ts | 2 +- .../src/infrastructure/subject.interceptor.ts | 2 +- libs/shared/src/infrastructure/subject.ts | 2 +- .../src/infrastructure/upload.adapter.ts | 2 +- libs/shared/src/kernel/pii.ts | 2 +- libs/shared/src/layout/shell/shell.stories.ts | 2 +- .../src/layout/site-header/nav-config.ts | 2 +- .../site-header/site-header.component.ts | 2 +- .../layout/site-header/site-header.stories.ts | 2 +- libs/shared/src/ui/alert/alert.stories.ts | 2 +- .../src/ui/data-block/data-block.component.ts | 2 +- .../src/ui/form-field/form-field.stories.ts | 4 +- .../ui/masked-value/masked-value.component.ts | 2 +- .../rich-text-editor.component.ts | 2 +- scripts/gen-behaviour-spec.mjs | 2 +- 102 files changed, 361 insertions(+), 197 deletions(-) create mode 100644 docs/project/readable-codebase/RD-18-ticket-refs-frontend.md diff --git a/apps/behandelportal/src/app/app.routes.ts b/apps/behandelportal/src/app/app.routes.ts index 96c85a2..80106c6 100644 --- a/apps/behandelportal/src/app/app.routes.ts +++ b/apps/behandelportal/src/app/app.routes.ts @@ -20,7 +20,7 @@ export const routes: Routes = [ }, { path: 'aanvraag/:id', - // Same capability the werkvoorraad list itself is gated by (WP-64/65) — the + // Same capability the werkvoorraad list itself is gated by — the // detail page is reachable only from a row already filtered to that capability. canActivate: [capabilityGuard('aanvraag:beoordelen')], loadComponent: () => @@ -36,14 +36,14 @@ export const routes: Routes = [ }, { path: 'beheer/audit', - // Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default + // Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default // unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces. canActivate: [capabilityGuard('cases:manage')], loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage), }, { path: 'beheer/functies', - // Admin-only feature-flag toggles (WP-47), gated by `flags:manage`. + // Admin-only feature-flag toggles, gated by `flags:manage`. canActivate: [capabilityGuard('flags:manage')], loadComponent: () => import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage), diff --git a/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts b/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts index 85cc7ee..9b3493a 100644 --- a/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts +++ b/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts @@ -4,7 +4,7 @@ import { MEDEWERKER_ID, currentRollen } from './medewerker'; /** * Infrastructure: resolves the current medewerker identity into a `Principal` - * (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3, + * (ADR-C-004). Stands in for a real employee-SSO redirect flow (ADR-0002 §3, * "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s * BSN check, no format to reject, so `authenticate()` takes no input and returns the * `Principal` directly rather than a `Result` with an error variant that can never diff --git a/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts b/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts index 6d9b5ce..3867ae8 100644 --- a/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts +++ b/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts @@ -2,7 +2,7 @@ import { Component, output } from '@angular/core'; import { ButtonComponent } from '@shared/ui/button/button.component'; /** - * Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and, + * Organism: employee-SSO-style mock login (ADR-C-004). No real auth — and, * unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no * BSN, and this app has no password of its own to check either way. There is * nothing to compose beyond one button, which is itself evidence for the ADR — the diff --git a/apps/behandelportal/src/app/behandeling/application/beoordeling.store.ts b/apps/behandelportal/src/app/behandeling/application/beoordeling.store.ts index da5befc..c908b0b 100644 --- a/apps/behandelportal/src/app/behandeling/application/beoordeling.store.ts +++ b/apps/behandelportal/src/app/behandeling/application/beoordeling.store.ts @@ -8,7 +8,7 @@ import { type Err = Error | undefined; -/** One aanvraag's beoordeling detail (WP-65) — a root singleton like `WerkvoorraadStore`. +/** One aanvraag's beoordeling detail — a root singleton like `WerkvoorraadStore`. Keyed by id: navigating to a different case resets to Loading. */ @Injectable({ providedIn: 'root' }) export class BeoordelingStore { diff --git a/apps/behandelportal/src/app/behandeling/application/werkvoorraad.store.ts b/apps/behandelportal/src/app/behandeling/application/werkvoorraad.store.ts index 19b2509..e5ba8ca 100644 --- a/apps/behandelportal/src/app/behandeling/application/werkvoorraad.store.ts +++ b/apps/behandelportal/src/app/behandeling/application/werkvoorraad.store.ts @@ -8,7 +8,7 @@ import { type Err = Error | undefined; -/** The behandelaar's queue (WP-64) — a root singleton like `AdminCasesStore`'s ssp +/** The behandelaar's queue — a root singleton like `AdminCasesStore`'s ssp counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */ @Injectable({ providedIn: 'root' }) export class WerkvoorraadStore { diff --git a/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts b/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts index 091ee01..1594c86 100644 --- a/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts +++ b/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts @@ -2,8 +2,8 @@ import { formatDatumNl } from '@shared/kernel/datum'; import { AanvraagType } from './werkvoorraad-item'; import { BeoordelingStatus, BeoordelingView } from './beoordeling'; -/** View-model mapping shared by the werkvoorraad list (WP-64) and the beoordeling - detail screen (WP-65): type/status → labels. Pure, no Angular. Lives here (not in +/** View-model mapping shared by the werkvoorraad list and the beoordeling + detail screen: type/status → labels. Pure, no Angular. Lives here (not in `werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */ diff --git a/apps/behandelportal/src/app/behandeling/domain/beoordeling.ts b/apps/behandelportal/src/app/behandeling/domain/beoordeling.ts index 5a1bb7e..5c42963 100644 --- a/apps/behandelportal/src/app/behandeling/domain/beoordeling.ts +++ b/apps/behandelportal/src/app/behandeling/domain/beoordeling.ts @@ -1,8 +1,8 @@ import { AanvraagType } from './werkvoorraad-item'; /** - * A case's full status lifecycle as the beoordeling detail screen sees it (WP-65) — - * wider than `WerkvoorraadStatus` (WP-64), which only ever sees the two "still open" + * A case's full status lifecycle as the beoordeling detail screen sees it — + * wider than `WerkvoorraadStatus`, which only ever sees the two "still open" * tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept` * — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet). */ diff --git a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts index ef9a56c..3462602 100644 --- a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts +++ b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts @@ -1,6 +1,6 @@ import { Result, assertNever } from '@shared/kernel/fp'; -/** The three actions the beoordeling screen offers a behandelaar (WP-65b) — mirrors the +/** The three actions the beoordeling screen offers a behandelaar — mirrors the backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw enum — see `RecordBesluitRequest`). */ const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const; diff --git a/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item.ts b/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item.ts index b5fcb35..1aed5a7 100644 --- a/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item.ts +++ b/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item.ts @@ -1,5 +1,5 @@ /** - * A queue entry as the behandelportal sees it (WP-64) — the parsed, domain-side view + * A queue entry as the behandelportal sees it — the parsed, domain-side view * of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular. * * The status union is narrower than the SSP's full `AanvraagStatus` (ssp's diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts b/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts index fbb0d5d..85a379b 100644 --- a/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts +++ b/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts @@ -13,7 +13,7 @@ import { import { AanvraagType } from '@behandeling/domain/werkvoorraad-item'; /** - * Infrastructure adapter for the beoordeling detail read (WP-65) — the only place its + * Infrastructure adapter for the beoordeling detail read — the only place its * HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated + * mapped to domain by the parse* boundary below. */ diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/besluit.adapter.ts b/apps/behandelportal/src/app/behandeling/infrastructure/besluit.adapter.ts index 0af191f..bee51d2 100644 --- a/apps/behandelportal/src/app/behandeling/infrastructure/besluit.adapter.ts +++ b/apps/behandelportal/src/app/behandeling/infrastructure/besluit.adapter.ts @@ -3,7 +3,7 @@ import { ApiClient } from '@shared/infrastructure/api-client'; import { Valid } from '@behandeling/domain/besluit.machine'; /** - * Infrastructure adapter for recording a behandelaar's decision (WP-65b) — the single + * Infrastructure adapter for recording a behandelaar's decision — the single * place its HTTP lives. No return value: a successful call means the server accepted * the transition; the caller reloads `BeoordelingStore` to see the new status (the * server, not this adapter, re-validates and is the authority). diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts b/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts index c48346b..99a099b 100644 --- a/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts +++ b/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts @@ -8,7 +8,7 @@ import { } from '@behandeling/domain/werkvoorraad-item'; /** - * Infrastructure adapter for the behandelportal's queue read (WP-64) — the only + * Infrastructure adapter for the behandelportal's queue read — the only * place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response * is validated + mapped to the (narrower) queue domain shape by the parse* boundary * below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not diff --git a/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts b/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts index d8c4210..5acfc73 100644 --- a/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts +++ b/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts @@ -1,7 +1,7 @@ import { Component, input } from '@angular/core'; import { BeoordelingDocument } from '@behandeling/domain/beoordeling'; -/** Organism: the documents linked to an aanvraag (WP-65) — plain links to the existing +/** Organism: the documents linked to an aanvraag — plain links to the existing (pre-existing, unauthenticated — same as ssp's own document previews) content endpoint. No new shared atom: a context-local list, not a reusable building block. */ @Component({ diff --git a/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts b/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts index 8922f77..1a39d01 100644 --- a/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts +++ b/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts @@ -14,8 +14,8 @@ import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-docu import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component'; /** - * Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links - * here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b) — + * Page: one aanvraag's beoordeling detail. The werkvoorraad list links + * here. `canBesluiten` (server-computed, ADR-0001) gates the decision form — * the page never recomputes the lifecycle itself. On a recorded decision the form emits * `decided`, and the page just reloads (the server is the authority on the new status). */ diff --git a/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts b/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts index 8087f7a..db12c5d 100644 --- a/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts +++ b/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts @@ -14,7 +14,7 @@ import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/b import { createSubmitBesluit } from '@behandeling/application/submit-besluit'; /** - * Organism: the decision form (WP-65b) — goedkeuren/afwijzen/meer-info-opvragen. Same + * Organism: the decision form — goedkeuren/afwijzen/meer-info-opvragen. Same * idiom as every other form in this house (`change-request-form`): all state in one * signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*` * command returning `Result`. The server re-validates the transition and is the diff --git a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts index c40cf68..95ff32f 100644 --- a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts +++ b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts @@ -4,9 +4,9 @@ import { ApplicationLinkComponent } from '@shared/ui/application-link/applicatio import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item'; import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view'; -/** Organism: the behandelaar's queue as CIBG "aanvragen" rows (WP-64) — composition +/** Organism: the behandelaar's queue as CIBG "aanvragen" rows — composition of the two existing shared/ui molecules, no new atom. Each row links to the - beoordeling detail page (WP-65). */ + beoordeling detail page. */ @Component({ selector: 'app-werkvoorraad-list', imports: [ApplicationListComponent, ApplicationLinkComponent], diff --git a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts index b52e8f6..70b3e91 100644 --- a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts +++ b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts @@ -10,10 +10,10 @@ import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store'; import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component'; /** - * Page: the behandelaar's werkvoorraad (WP-64) — the behandelportal's landing page. + * Page: the behandelaar's werkvoorraad — the behandelportal's landing page. * Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's * AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening - * a case's detail is out of scope here (WP-65). + * a case's detail is out of scope here. */ @Component({ selector: 'app-werkvoorraad-page', @@ -69,7 +69,7 @@ export class WerkvoorraadPage { private loadRequested = false; constructor() { // Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) — - // same guard-against-the-loop idiom as AdminCasesPage (WP-26 lesson). + // same guard-against-the-loop idiom as AdminCasesPage. effect(() => { if (this.canBeoordelen() && !this.loadRequested) { this.loadRequested = true; diff --git a/apps/behandelportal/src/app/shell/nav.config.ts b/apps/behandelportal/src/app/shell/nav.config.ts index 456019b..c8b4e6b 100644 --- a/apps/behandelportal/src/app/shell/nav.config.ts +++ b/apps/behandelportal/src/app/shell/nav.config.ts @@ -8,7 +8,7 @@ export const NAV_ITEMS: readonly HeaderNavItem[] = [ /** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS. No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from - WP-61's bootstrap trim, not revisited by this migration. */ + the bootstrap trim, not revisited by this migration. */ export const ADMIN_LINKS: readonly AdminLink[] = [ { label: $localize`:@@header.nav.stamdata:Stamdata`, diff --git a/apps/ssp/src/app/app.routes.ts b/apps/ssp/src/app/app.routes.ts index 610ca24..0a546fa 100644 --- a/apps/ssp/src/app/app.routes.ts +++ b/apps/ssp/src/app/app.routes.ts @@ -61,7 +61,7 @@ export const routes: Routes = [ }, { path: 'brief/huisstijl', - // Admin-only org-template editor (WP-26): capabilityGuard denies-by-default + // Admin-only org-template editor: capabilityGuard denies-by-default // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces // via the OrgAdmin gate — the guard just avoids loading a page that would 403. canActivate: [capabilityGuard('orgtemplate:edit')], @@ -78,7 +78,7 @@ export const routes: Routes = [ }, { path: 'beheer/zaken', - // Admin-only cases overview + delete (WP-36): capabilityGuard denies-by-default + // Admin-only cases overview + delete: capabilityGuard denies-by-default // unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the // CasesAdmin gate — the guard just avoids loading a page that would 403. The page // lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer. @@ -88,14 +88,14 @@ export const routes: Routes = [ }, { path: 'beheer/audit', - // Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default + // Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default // unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces. canActivate: [capabilityGuard('cases:manage')], loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage), }, { path: 'beheer/functies', - // Admin-only feature-flag toggles (WP-47), gated by `flags:manage`. + // Admin-only feature-flag toggles, gated by `flags:manage`. canActivate: [capabilityGuard('flags:manage')], loadComponent: () => import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage), diff --git a/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts b/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts index 652622c..52d6c60 100644 --- a/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts +++ b/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts @@ -7,7 +7,7 @@ import { Principal } from '../domain/principal'; @Injectable({ providedIn: 'root' }) export class DigidAdapter { // ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity. - // Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity + // Real BSN validation (parseBsn) is the trust boundary; swap the fixed identity // for a real OIDC redirect flow when there's an IdP. async authenticate(bsn: string): Promise> { const r = parseBsn(bsn); diff --git a/apps/ssp/src/app/brief/application/brief.store.spec.ts b/apps/ssp/src/app/brief/application/brief.store.spec.ts index b27dd61..94d7f63 100644 --- a/apps/ssp/src/app/brief/application/brief.store.spec.ts +++ b/apps/ssp/src/app/brief/application/brief.store.spec.ts @@ -54,7 +54,7 @@ const caseContext: CaseContext = { const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext }; -/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of +/** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of touching the DOM, so a spec can assert a command's success path directly. */ function fakeBlobPresenter() { const opened: Blob[] = []; @@ -158,7 +158,7 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => { }); }); -// --- WP-27: undo/redo history + rejection diff --- +// --- Undo/redo history + rejection diff --- function block(id: string, text: string): LetterBlock { return { @@ -308,7 +308,7 @@ describe('BriefStore rejection diff', () => { describe('BriefStore.previewLetter', () => { afterEach(() => vi.restoreAllMocks()); - it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => { + it('opens the composed letter via BLOB_PRESENTER on success', async () => { const { presenter, opened } = fakeBlobPresenter(); const store = setup( { @@ -412,11 +412,11 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => { }); }); -// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the -// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds -// that); this fake adapter is what exercises the branch until then. --- +// --- CQ-007's expand half: a 404 from GET /brief tolerates by calling the +// existing reset() command, exactly once. Today's backend never 404s yet; +// this fake adapter is what exercises the branch until then. --- -describe('BriefStore.load — 404 tolerance (RB-22)', () => { +describe('BriefStore.load — 404 tolerance', () => { const notFound: Result = { ok: false, error: { tag: 'notFound' } }; const resetOk: Result = { ok: true, value: view }; diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts index a556947..7ab3bc1 100644 --- a/apps/ssp/src/app/brief/application/brief.store.ts +++ b/apps/ssp/src/app/brief/application/brief.store.ts @@ -55,8 +55,8 @@ export class BriefStore implements PendingSave { /** Surfaced autosave state for the indicator + aria-live region. */ readonly saveState = signal({ tag: 'Idle' }); - /** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of - `Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded + /** Undo/redo is SHELL state, not machine state: a `createHistory` stack of + `Brief` snapshots (the mechanics live in a shared helper). Only CONTENT edits are recorded (they flow through `edit()`); status transitions never enter history, or undo would replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine changes. */ @@ -64,7 +64,7 @@ export class BriefStore implements PendingSave { readonly canUndo = this.history.canUndo; readonly canRedo = this.history.canRedo; - /** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The + /** The letter as it stood when it was REJECTED, captured shell-side. The approver diffs it against the resubmitted letter. POC limit: in-memory only, so a full page reload loses it — a real system would persist the rejected revision. */ private rejectionSnapshot = signal(null); @@ -81,7 +81,7 @@ export class BriefStore implements PendingSave { ); readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0); - /** The org template the letter renders with (WP-24). Server-owned appearance data, + /** The org template the letter renders with. Server-owned appearance data, not letter state — held beside the machine, never inside it (`brief.machine.ts` stays untouched by design). Set from every server view that carries it. */ readonly orgTemplate = signal(null); @@ -125,7 +125,7 @@ export class BriefStore implements PendingSave { return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics()); }); - /** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand + /** True once a 404-triggered recovery has been attempted (CQ-007's expand half — see `recoverFromMissingBrief`). This is the structural once-only bound: a repeated 404 falls straight to the `error` branch below and can never reach `adapter.reset()` a second time, regardless of how many times `load()` runs. */ @@ -200,7 +200,7 @@ export class BriefStore implements PendingSave { } // 600ms debounced autosave (the server is the store of record). Timer mechanics live in - // the shared helper; `flushSave` below is the store-specific write + save-state (WP-31). + // the shared helper; `flushSave` below is the store-specific write + save-state. private debouncedSave = createDebouncedSave({ canSave: () => this.canEdit(), flush: () => this.flushSave(), @@ -223,7 +223,7 @@ export class BriefStore implements PendingSave { } } - /** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */ + /** Retry a failed autosave — reuses the existing flush path, no new state. */ retrySave() { void this.flushSave(); } @@ -311,7 +311,7 @@ export class BriefStore implements PendingSave { this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions }); break; case 'rejected': - // Capture the letter as-rejected for the resubmission diff (WP-27). This is the + // Capture the letter as-rejected for the resubmission diff. This is the // "before" snapshot the approver later compares against. this.rejectionSnapshot.set(brief); this.store.dispatch({ diff --git a/apps/ssp/src/app/brief/application/org-template.store.spec.ts b/apps/ssp/src/app/brief/application/org-template.store.spec.ts index f5decef..014a2fe 100644 --- a/apps/ssp/src/app/brief/application/org-template.store.spec.ts +++ b/apps/ssp/src/app/brief/application/org-template.store.spec.ts @@ -32,7 +32,7 @@ const subOrgs: SubOrgSummary[] = [ { subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 }, ]; -/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of +/** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of touching the DOM, so a spec can assert a command's success path directly. */ function fakeBlobPresenter() { const opened: Blob[] = []; @@ -70,10 +70,10 @@ function setup( return TestBed.inject(OrgTemplateStore); } -// --- RB-28 (TE-006): proefbrief() ends in BLOB_PRESENTER.open, not a raw +// --- TE-006: proefbrief() ends in BLOB_PRESENTER.open, not a raw // window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. --- -describe('OrgTemplateStore.proefbrief (RB-28)', () => { +describe('OrgTemplateStore.proefbrief', () => { it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => { // Given a loaded sub-org template. const { presenter, opened } = fakeBlobPresenter(); diff --git a/apps/ssp/src/app/brief/application/org-template.store.ts b/apps/ssp/src/app/brief/application/org-template.store.ts index 87ce84a..1ec5f0d 100644 --- a/apps/ssp/src/app/brief/application/org-template.store.ts +++ b/apps/ssp/src/app/brief/application/org-template.store.ts @@ -28,7 +28,7 @@ const LOGO_CATEGORY = 'org-logo'; const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`; /** - * Root singleton for the admin org-template editor (WP-26). The Elm machine owns the + * Root singleton for the admin org-template editor. The Elm machine owns the * editable draft; commands here do the debounced save, publish (impact-confirm), * rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The * logo upload reuses the shared upload transport; its completion mutates the draft @@ -151,7 +151,7 @@ export class OrgTemplateStore implements PendingSave { this.debouncedSave.schedule(); } - // 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the + // 600ms debounced autosave (same idiom as BriefStore). Timer mechanics live in the // shared helper; `flushSave` below is the store-specific write + save-state. private debouncedSave = createDebouncedSave({ canSave: () => this.loaded() !== null, diff --git a/apps/ssp/src/app/brief/domain/brief-diff.ts b/apps/ssp/src/app/brief/domain/brief-diff.ts index d41aa63..b21b947 100644 --- a/apps/ssp/src/app/brief/domain/brief-diff.ts +++ b/apps/ssp/src/app/brief/domain/brief-diff.ts @@ -2,7 +2,7 @@ import { Brief, LetterBlock, allBlocks } from './brief'; /** * The rejection diff as a PURE function over two immutable `Brief` values — the whole - * teaching payload of WP-27: because state is one value, "what changed since the letter + * teaching payload here: because state is one value, "what changed since the letter * was rejected" is just a fold over two snapshots, no change-tracking bookkeeping. * * Blocks are matched by `blockId` (stable `local-N`/seed ids): diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.ts b/apps/ssp/src/app/brief/domain/org-template.machine.ts index 8c63953..96a5ca2 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.ts @@ -3,7 +3,7 @@ import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from ' import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine'; /** - * The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) — + * The admin org-template editor as one Elm-style machine (PRD Brief v2 §5) — * the same idiom as the wizards. The DRAFT org template is form state (edited in * place on the canvas); publish/rollback are effects that come back as `DraftLoaded`. * `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is diff --git a/apps/ssp/src/app/brief/domain/org-template.ts b/apps/ssp/src/app/brief/domain/org-template.ts index 0153f43..b8f05ef 100644 --- a/apps/ssp/src/app/brief/domain/org-template.ts +++ b/apps/ssp/src/app/brief/domain/org-template.ts @@ -1,9 +1,9 @@ /** - * The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis — + * The organization template (Brief v2 PRD §3): the SECOND template axis — * appearance/identity per sub-organization (letterhead, footer, signature, margins). * Orthogonal to the case-type template (sections + placeholders); the two only meet * at render time, on the letter canvas. Server-owned: the FE renders it verbatim, - * never edits it here (the admin editor is WP-26). + * never edits it here (the admin editor does). */ export interface Margins { @@ -30,7 +30,7 @@ export interface OrgTemplate { readonly version: number; } -// --- admin editor (WP-26) --- +// --- admin editor --- /** A published snapshot in the version history: who is faked, `publishedAt` is real. */ export interface OrgTemplateVersion { diff --git a/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts b/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts index 02d3fad..dd0f174 100644 --- a/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts @@ -38,9 +38,9 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric * (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the * returned brief. `load` (the only read) does its own try/catch instead of the * shared `runResult` fold, because it needs one extra bit `runResult` throws away: - * whether the failure was an HTTP 404 (see `BriefLoadFailure` — RB-22, CQ-007's - * expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the - * `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance. + * whether the failure was an HTTP 404 (see `BriefLoadFailure` — CQ-007's + * expand half). Today's backend never 404s `GET /brief`, so the + * `notFound` branch is unreached until it does; this adapter is ready in advance. */ export interface BriefView { @@ -66,7 +66,7 @@ export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is /** True when the thrown value carries an HTTP 404 status — matches both the generic `SwaggerException` (today's shape, since `GET /brief` declares no 404 response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once - RB-23 gives the endpoint a documented 404 response). */ + the endpoint gets a documented 404 response). */ function isHttpNotFound(e: unknown): boolean { return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404; } diff --git a/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts b/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts index d777d7d..3b7f9b9 100644 --- a/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts @@ -14,19 +14,19 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning * to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a * hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s * `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set - * here explicitly (WP-74 — without `X-Subject` this always previewed + * here explicitly (without `X-Subject` this always previewed * `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are * dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under * `isDevMode()`, mirroring how the interceptors themselves are only registered in dev * (`app.config.ts`) — a production build sends neither header from this call (BIO-012). * - * `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven + * `cache: 'no-store'`: the endpoint has no `Cache-Control`, only a CORS-driven * `Vary: Origin`, and its content changes at the SAME URL as the letter moves * draft → sent. Explicitly bypassing the HTTP cache is the correct default for any - * mutable resource served under one unversioned URL — independent of WP-74's - * identity work, and not a complete fix by itself: see the KNOWN GAP note below. + * mutable resource served under one unversioned URL — independent of the + * identity work above, and not a complete fix by itself: see the KNOWN GAP note below. * - * KNOWN GAP (WP-74, not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`, + * KNOWN GAP (not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`, * this repo's own e2e run against a real backend observed this endpoint's SENT * response still carrying the draft watermark, even though (a) the outgoing request * carried the correct `X-Subject`, and (b) `curl` against the same backend at the @@ -34,7 +34,7 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning * did not change the outcome, so it is very unlikely a client-side caching artifact — * it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed * read path, reproducible for MULTIPLE distinct owners and NOT reproducible for - * `DemoOwner`, which needs backend-side investigation (out of WP-74's file scope — + * `DemoOwner`, which needs backend-side investigation (out of this file's scope — * see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared * `zorgverlener` identity until this is root-caused). */ diff --git a/apps/ssp/src/app/brief/ui/brief.page.ts b/apps/ssp/src/app/brief/ui/brief.page.ts index b5f1a48..0892b14 100644 --- a/apps/ssp/src/app/brief/ui/brief.page.ts +++ b/apps/ssp/src/app/brief/ui/brief.page.ts @@ -167,7 +167,7 @@ export class BriefPage { void this.store.resetDemo(); } - /** Typed narrowing for the `` loaded slot — see WP-06: a structural + /** Typed narrowing for the `` loaded slot: a structural directive's context can't inherit a generic from a sibling host input, so the Success value is unwrapped here instead of through `let-`. */ protected readonly loaded = computed(() => { @@ -179,7 +179,7 @@ export class BriefPage { void this.store.load(); } - /** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the + /** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo. Ignored while focus is in the rich-text editor or a form control, so the browser's own text undo keeps working there — our shell-level undo is for structural edits (add/remove/reorder blocks). */ protected onKey(e: KeyboardEvent) { diff --git a/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts b/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts index b5aca9a..bebded2 100644 --- a/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts +++ b/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts @@ -53,9 +53,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; footer around the case-type template's sections. `editableRegions` picks who edits what: `'content'` hosts the editable letter-sections in place (drafter), `'none'` renders everything read-only (approver/locked, absorbs the old letter-preview), - `'template'` reserves the org-identity regions for the admin editor (WP-26). + `'template'` reserves the org-identity regions for the admin editor. Letter typography/geometry come from the shared `public/letter.css` contract — - the same file the backend preview renderer inlines (WP-25). */ + the same file the backend preview renderer inlines. */ @Component({ selector: 'app-letter-canvas', imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent], @@ -82,7 +82,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; color: var(--rhc-color-foreground-subtle); font-variant-numeric: tabular-nums; } - /* Rejection-diff badge (WP-27): a small pill above a changed/added block. */ + /* Rejection-diff badge: a small pill above a changed/added block. */ .diff-block.diff-changed { border-inline-start: 3px solid var(--rhc-color-oranje-500); padding-inline-start: var(--rhc-space-max-sm); @@ -98,7 +98,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; background: var(--rhc-color-oranje-500); } .diff-badge.added { - /* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */ + /* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (axe). */ color: var(--rhc-color-wit); background: var(--rhc-color-groen-700); } @@ -345,12 +345,12 @@ export class LetterCanvasComponent { brief = input.required(); orgTemplate = input.required(); /** Who edits what on the surface: read-only ('none', the drafter preview + approver - view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */ + view) or admin editor ('template'). Authoring moved to letter-editor. */ editableRegions = input<'template' | 'none'>('none'); diagnostics = input([]); - /** Initial zoom; the in-canvas controls take over from here (WP-27). */ + /** Initial zoom; the in-canvas controls take over from here. */ zoom = input(1); - /** Blocks changed/added/removed since the letter was rejected (WP-27); badged when + /** Blocks changed/added/removed since the letter was rejected; badged when `showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer exist in the letter — the composer surfaces them as a count. */ blockDiffs = input>(new Map()); @@ -445,7 +445,7 @@ export class LetterCanvasComponent { constructor() { // ponytail: whole-surface height / A4-interval — ignores that a break never truly - // falls mid-line; the caption says "±" and WP-25's server preview is authoritative. + // falls mid-line; the caption says "±" and the server preview is authoritative. const observer = new ResizeObserver(([entry]) => { // ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark. const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX); diff --git a/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.stories.ts b/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.stories.ts index 8d57118..e137fb9 100644 --- a/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.stories.ts +++ b/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.stories.ts @@ -127,12 +127,12 @@ export const ReadOnlyZonderBevindingen: Story = { args: { editableRegions: 'none', diagnostics: [] }, }; -/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */ +/** Admin editor focus: body read-only, no "not yours" tint. */ export const TemplateMode: Story = { args: { editableRegions: 'template' } }; export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } }; -/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */ +/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged. */ export const WithDiff: Story = { args: { editableRegions: 'none', @@ -150,14 +150,14 @@ export const PageBreak: Story = { args: { editableRegions: 'none', brief: longBrief, diagnostics: [] }, }; -// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload). +// Inline SVG so the story needs no backend/upload round-trip (the logo upload). const sampleLogo = 'data:image/svg+xml;utf8,' + encodeURIComponent( 'CIBG', ); -/** Published org logo (WP-26 AC2): the letterhead shows it above the org name. */ +/** Published org logo: the letterhead shows it above the org name. */ export const MetLogo: Story = { args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo }, }; diff --git a/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.component.ts b/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.component.ts index 6e36532..8366949 100644 --- a/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.component.ts +++ b/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.component.ts @@ -137,7 +137,7 @@ export class LetterComposerComponent { canReject = input(false); canSend = input(false); busy = input(false); - /** Rejection diff (WP-27): the changed/added/removed blocks and their count. The + /** Rejection diff: the changed/added/removed blocks and their count. The "Toon wijzigingen" toggle only appears when there's something to show. */ blockDiffs = input>(new Map()); removedCount = input(0); diff --git a/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.stories.ts b/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.stories.ts index 3dc7314..ef93868 100644 --- a/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.stories.ts +++ b/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.stories.ts @@ -181,7 +181,7 @@ export const Sent: Story = { }), }; -/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed, +/** Approver's "Toon wijzigingen": a resubmitted letter with blocks changed, added and removed since the last rejection. */ export const RejectionDiff: Story = { render: () => diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts index 7cd9b69..59c303c 100644 --- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts +++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts @@ -70,7 +70,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = { }; /** - * Organism (WP-26): the admin org-template editor. The mirror of the drafter's + * Organism: the admin org-template editor. The mirror of the drafter's * composer — the letter canvas runs in `editableRegions='template'` so the * letterhead/signature/footer are edited in place, while the content is a read-only * sample. Margins, logo upload, version history and the publish bar sit around it. diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts index 11fca36..f6bdd7a 100644 --- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts +++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts @@ -87,12 +87,12 @@ const sampleLogo = 'CIBG', ); -/** Published logo (WP-26 AC2): the letterhead canvas shows it above the org name. */ +/** Published logo: the letterhead canvas shows it above the org name. */ export const MetLogo: Story = { args: { logoUrl: sampleLogo }, }; -/** Client-side upload rejection (existing `rejectReason`, WP-26 AC5) — type/size caught +/** Client-side upload rejection (existing `rejectReason`) — type/size caught before the file ever reaches the backend. */ export const LogoUploadFout: Story = { args: { diff --git a/apps/ssp/src/app/brief/ui/org-template.page.ts b/apps/ssp/src/app/brief/ui/org-template.page.ts index 697f19d..82a2865 100644 --- a/apps/ssp/src/app/brief/ui/org-template.page.ts +++ b/apps/ssp/src/app/brief/ui/org-template.page.ts @@ -7,7 +7,7 @@ import { AccessStore } from '@shared/application/access.store'; import { OrgTemplateStore } from '@brief/application/org-template.store'; import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component'; -/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default +/** Page: thin container for the admin org-template editor. Deny-by-default capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor for admins. Loads once the capability resolves; wires store commands to the organism. */ @Component({ diff --git a/apps/ssp/src/app/brief/ui/passage-picker/passage-picker.component.ts b/apps/ssp/src/app/brief/ui/passage-picker/passage-picker.component.ts index 080b004..591b9d7 100644 --- a/apps/ssp/src/app/brief/ui/passage-picker/passage-picker.component.ts +++ b/apps/ssp/src/app/brief/ui/passage-picker/passage-picker.component.ts @@ -10,8 +10,8 @@ import { LibraryPassage } from '@brief/domain/brief'; inserts ALL checked passages at once (a single message upstream) — there is no single-insert path. Presentational: emits the chosen passages in list order. - Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in - `src/app` outside its own story (WP-28 audit). Kept for now rather than deleted + Superseded by `besluit-panel`'s guided drafting: no consumer left in + `src/app` outside its own story. Kept for now rather than deleted in-flight of an unrelated WP; a future cleanup can remove it. */ @Component({ selector: 'app-passage-picker', @@ -87,7 +87,7 @@ export class PassagePickerComponent { protected checked = signal>({}); protected query = signal(''); /** Client-side filter on label + rendered content text — the library is small, so no - server search (WP-27). Placeholder keys are searchable too (see `textOf`). */ + server search. Placeholder keys are searchable too (see `textOf`). */ protected filtered = computed(() => { const q = this.query().trim().toLowerCase(); if (!q) return this.passages(); diff --git a/apps/ssp/src/app/herregistratie/domain/intake.acceptance.spec.ts b/apps/ssp/src/app/herregistratie/domain/intake.acceptance.spec.ts index cfb926b..a08cf36 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.acceptance.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.acceptance.spec.ts @@ -147,7 +147,7 @@ describe('intake acceptance journeys', () => { }); }); - it('raising uren above the threshold after answering scholing drops both fields (WP-69 §6)', () => { + it('raising uren above the threshold after answering scholing drops both fields', () => { // Given a journey that answered the scholing question while uren was low. const atReview = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, @@ -170,7 +170,7 @@ describe('intake acceptance journeys', () => { ); // Then the submission succeeds, and BOTH the stale answer and its punten are gone — - // exactly the crafted-POST-shaped payload WP-69's server rule rejects. + // exactly the crafted-POST-shaped payload the server rule rejects. expect(done.tag).toBe('Submitted'); expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined(); expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined(); diff --git a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts index a4a48d8..ac64e01 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts @@ -171,7 +171,7 @@ describe('submit', () => { expect(withScholing.data.punten).toBe(200); }); - it('does not require punten for a hidden question (WP-69 §6)', () => { + it('does not require punten for a hidden question', () => { // scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above // threshold — the template hides the question, so punten must not be required either. const staleScholingNoPunten = givenIntake( @@ -183,7 +183,7 @@ describe('submit', () => { expect(good.data.aanvullendeScholing).toBeUndefined(); }); - it('drops punten when raising uren hides the question (WP-69 §6)', () => { + it('drops punten when raising uren hides the question', () => { // Same stale answer, but this time punten was also filled in while uren was low. const staleScholingWithPunten = givenIntake( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, diff --git a/apps/ssp/src/app/herregistratie/domain/intake.machine.ts b/apps/ssp/src/app/herregistratie/domain/intake.machine.ts index 883f661..54d9a23 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.machine.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.machine.ts @@ -116,7 +116,7 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu // visible (lageUren) AND scholing was followed — matching the template's // `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then // raising uren above the threshold left an error on a field the template no longer - // renders (WP-69 §6). + // renders. if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') { const p = parseUren(a.punten ?? ''); if (!p.ok) errors.punten = p.error; @@ -149,8 +149,8 @@ function validateAll(a: Answers, scholingThreshold: number): Result(initial, reduce, { Submitting: async (s, store) => { this.profile.beginHerregistratie(); - // WP-69: the scholing answer rides along so the server can re-validate it as the + // The scholing answer rides along so the server can re-validate it as the // authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by // JSON.stringify, so a wizard above the threshold sends neither field. const r = await this.draftSync.submit({ diff --git a/apps/ssp/src/app/registratie/application/aanvragen.store.spec.ts b/apps/ssp/src/app/registratie/application/aanvragen.store.spec.ts index 8147917..e9c5123 100644 --- a/apps/ssp/src/app/registratie/application/aanvragen.store.spec.ts +++ b/apps/ssp/src/app/registratie/application/aanvragen.store.spec.ts @@ -46,8 +46,8 @@ describe('AanvragenStore', () => { expect(store.lastError()).toBeNull(); }); - // RB-20: a failed cancel must not be silent — the row rolls back AND the store - // surfaces the error the page renders. Before RB-20 this only rolled back + // A failed cancel must not be silent — the row rolls back AND the store + // surfaces the error the page renders. Before this fix it only rolled back // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever. it('rolls back the removal and surfaces the error when the cancel fails', async () => { const cancel = vi.fn().mockRejectedValue(new Error('boom')); diff --git a/apps/ssp/src/app/registratie/application/aanvragen.store.ts b/apps/ssp/src/app/registratie/application/aanvragen.store.ts index 0095cc7..8ff931f 100644 --- a/apps/ssp/src/app/registratie/application/aanvragen.store.ts +++ b/apps/ssp/src/app/registratie/application/aanvragen.store.ts @@ -14,7 +14,7 @@ type Err = Error | undefined; * change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches * so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is * computed server-side on read). Cancel goes through `runSubmit` and rolls back plus - * surfaces `lastError` on failure (RB-20). + * surfaces `lastError` on failure. */ @Injectable({ providedIn: 'root' }) export class AanvragenStore { @@ -23,7 +23,7 @@ export class AanvragenStore { private state = signal>({ tag: 'Loading' }); readonly aanvragen = this.state.asReadonly(); - /** Set on a failed cancel (RB-20): the optimistic removal already rolled back by + /** Set on a failed cancel: the optimistic removal already rolled back by then, this is only the message for the alert the page renders above the list. */ private error = signal(null); readonly lastError = this.error.asReadonly(); @@ -55,7 +55,7 @@ export class AanvragenStore { /** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE. No resync — the delete succeeded, so the optimistic removal is authoritative. On - failure, roll back AND surface the error (RB-20) — a silent reappearance leaves the + failure, roll back AND surface the error — a silent reappearance leaves the user guessing why the block came back. */ async cancel(id: string) { const before = this.state(); diff --git a/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts b/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts index 851368d..f238358 100644 --- a/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts +++ b/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts @@ -44,8 +44,8 @@ describe('AdminCasesStore', () => { expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']); }); - // RB-20: a failed delete must not be silent — the row rolls back AND the store - // surfaces the error the page renders. Before RB-20 this only rolled back + // A failed delete must not be silent — the row rolls back AND the store + // surfaces the error the page renders. Before this fix it only rolled back // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever. it('rolls back the removal and surfaces the error when the delete fails', async () => { const deleteAny = vi.fn().mockRejectedValue(new Error('boom')); diff --git a/apps/ssp/src/app/registratie/application/admin-cases.store.ts b/apps/ssp/src/app/registratie/application/admin-cases.store.ts index a3b0ad5..71fd1f5 100644 --- a/apps/ssp/src/app/registratie/application/admin-cases.store.ts +++ b/apps/ssp/src/app/registratie/application/admin-cases.store.ts @@ -7,11 +7,11 @@ import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aa type Err = Error | undefined; /** - * Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office + * Admin view of ALL cases across owners (`cases:manage`) — the back-office * counterpart of the user-facing `AanvragenStore`. Same shape: one root singleton * owns the list as a writable RemoteData signal, delete removes the row synchronously * (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on - * failure (RB-20). Admin delete removes any case (any owner, submitted or not — the + * failure. Admin delete removes any case (any owner, submitted or not — the * server enforces the capability). */ @Injectable({ providedIn: 'root' }) @@ -21,7 +21,7 @@ export class AdminCasesStore { private state = signal>({ tag: 'Loading' }); readonly cases = this.state.asReadonly(); - /** Set on a failed delete (RB-20): the optimistic removal already rolled back by + /** Set on a failed delete: the optimistic removal already rolled back by then, this is only the message for the alert the page renders above the list. */ private error = signal(null); readonly lastError = this.error.asReadonly(); @@ -47,7 +47,7 @@ export class AdminCasesStore { } /** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error - AND surface it (RB-20) — a silent reappearance leaves the admin guessing why. */ + AND surface it — a silent reappearance leaves the admin guessing why. */ async delete(id: string) { const before = this.state(); if (before.tag === 'Success') { diff --git a/apps/ssp/src/app/registratie/application/draft-sync.spec.ts b/apps/ssp/src/app/registratie/application/draft-sync.spec.ts index c69ee82..e862381 100644 --- a/apps/ssp/src/app/registratie/application/draft-sync.spec.ts +++ b/apps/ssp/src/app/registratie/application/draft-sync.spec.ts @@ -96,7 +96,7 @@ describe('createDraftSync', () => { expect(r.ok).toBe(false); }); - it('recovers from a create conflict by adopting the existing Concept (WP-35)', async () => { + it('recovers from a create conflict by adopting the existing Concept', async () => { // Server enforces one Concept per type: a stale/cross-tab create is rejected (409), // and ensureId adopts the existing Concept from the list instead of erroring. const create = vi.fn().mockRejectedValue({ status: 409 }); diff --git a/apps/ssp/src/app/registratie/application/draft-sync.ts b/apps/ssp/src/app/registratie/application/draft-sync.ts index 07b5b18..dcb33a7 100644 --- a/apps/ssp/src/app/registratie/application/draft-sync.ts +++ b/apps/ssp/src/app/registratie/application/draft-sync.ts @@ -63,7 +63,7 @@ export function createDraftSync(deps: DraftSyncDeps) { if (id) return id; ensuring ??= adapter .create(deps.type) - // WP-35: one Concept per type is server-enforced. Within a tab the resumeGate + // One Concept per type is server-enforced. Within a tab the resumeGate // already prevents a second create, but a cross-tab/stale race can still hit the // server's guard (409) — recover by adopting the existing Concept instead of // erroring. Only recover when one actually exists; otherwise surface the failure. diff --git a/apps/ssp/src/app/registratie/application/find-concept.ts b/apps/ssp/src/app/registratie/application/find-concept.ts index dfbe364..35042a4 100644 --- a/apps/ssp/src/app/registratie/application/find-concept.ts +++ b/apps/ssp/src/app/registratie/application/find-concept.ts @@ -3,7 +3,7 @@ import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aa /** * Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs - * before it can start writing (RB-21 / CQ-001). Free functions that take the adapter + * before it can start writing (CQ-001). Free functions that take the adapter * as a parameter, not `inject()`, so they get a direct spec without Angular TestBed. * `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path; * these two functions only read. diff --git a/apps/ssp/src/app/registratie/domain/aanvraag.ts b/apps/ssp/src/app/registratie/domain/aanvraag.ts index 7e906ae..7295204 100644 --- a/apps/ssp/src/app/registratie/domain/aanvraag.ts +++ b/apps/ssp/src/app/registratie/domain/aanvraag.ts @@ -10,8 +10,8 @@ */ export type AanvraagType = 'registratie' | 'herregistratie' | 'intake'; -// Ingediend/MeerInfoGevraagd (ADR-0002/WP-63) are widened into the union so the parse -// boundary + renderers are ready, but no backend path emits them yet — that's WP-65's +// Ingediend/MeerInfoGevraagd (ADR-0002) are widened into the union so the parse +// boundary + renderers are ready, but no backend path emits them yet — that's the // behandelaar-facing transition endpoint. export type AanvraagStatus = | { tag: 'Concept'; stepIndex: number; stepCount: number } @@ -29,7 +29,7 @@ export interface Aanvraag { createdAt: string; updatedAt: string; submittedAt?: string; - /** The case owner (a BSN). Only populated by the admin cross-owner list (WP-36); + /** The case owner (a BSN). Only populated by the admin cross-owner list; the user's own list leaves it undefined. */ owner?: string; } diff --git a/apps/ssp/src/app/registratie/domain/change-request.machine.ts b/apps/ssp/src/app/registratie/domain/change-request.machine.ts index 6fae12a..4590d2d 100644 --- a/apps/ssp/src/app/registratie/domain/change-request.machine.ts +++ b/apps/ssp/src/app/registratie/domain/change-request.machine.ts @@ -5,7 +5,7 @@ import { } from '@registratie/domain/value-objects/telefoonnummer'; /** What the user is typing (raw, possibly invalid). The BRP address is NOT part of - the form — it is authoritative and shown read-only (WP-34); only the phone number + the form — it is authoritative and shown read-only; only the phone number is editable here. */ export interface Draft { telefoon: string; diff --git a/apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.ts b/apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.ts index a0fa92f..6e70c34 100644 --- a/apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.ts +++ b/apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.ts @@ -32,12 +32,12 @@ export class AanvragenAdapter { return this.client.aanvragenAll(); } - /** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */ + /** Admin: every case across all owners (`cases:manage`). Parsed at the boundary. */ listAll(): Promise { return this.client.casesAll(); } - /** Admin: delete ANY case (any owner, submitted or not — WP-36). */ + /** Admin: delete ANY case (any owner, submitted or not). */ deleteAny(id: string): Promise { return this.client.cases(id); } @@ -117,7 +117,7 @@ function parseCommon(dto: AanvraagSummaryDto): Result { createdAt: dto.createdAt, updatedAt: dto.updatedAt, submittedAt: dto.submittedAt, - owner: dto.owner, // only present on the admin cross-owner list (WP-36) + owner: dto.owner, // only present on the admin cross-owner list }); } diff --git a/apps/ssp/src/app/registratie/infrastructure/change-request.adapter.ts b/apps/ssp/src/app/registratie/infrastructure/change-request.adapter.ts index fe66b8b..7d61037 100644 --- a/apps/ssp/src/app/registratie/infrastructure/change-request.adapter.ts +++ b/apps/ssp/src/app/registratie/infrastructure/change-request.adapter.ts @@ -6,7 +6,7 @@ import { Valid } from '@registratie/domain/change-request.machine'; * Infrastructure adapter for the telefoonwijziging POST (`/api/v1/change-requests`) — * the single place the network client lives for contact changes, so the command * and the UI never touch `ApiClient`. The BRP address is authoritative and not - * submitted (WP-34); only the phone number is. Returns the server reference; the + * submitted; only the phone number is. Returns the server reference; the * server re-validates and is the authority. */ @Injectable({ providedIn: 'root' }) diff --git a/apps/ssp/src/app/registratie/ui/admin-cases.page.ts b/apps/ssp/src/app/registratie/ui/admin-cases.page.ts index cc051ac..0b1ac71 100644 --- a/apps/ssp/src/app/registratie/ui/admin-cases.page.ts +++ b/apps/ssp/src/app/registratie/ui/admin-cases.page.ts @@ -13,7 +13,7 @@ import { TYPE_LABELS, statusLabel, referentie } from '@registratie/domain/aanvra import { AdminCasesStore } from '@registratie/application/admin-cases.store'; /** - * Admin page: every case across all owners, with an admin delete (WP-36). Lives in + * Admin page: every case across all owners, with an admin delete. Lives in * `registratie` (which owns the Aanvraag aggregate) — the back-office counterpart of the * user's dashboard, reusing the same view labels + trust-boundary parse. Deny-by-default * capability gate (`cases:manage`): a denial alert for non-admins, the list for admins. @@ -109,7 +109,7 @@ export class AdminCasesPage { private loadRequested = false; constructor() { // Load once the capability resolves to allowed (a 403 GET would be wasted otherwise). - // Depends only on canManage() + a plain flag — never the store model (WP-26 loop lesson). + // Depends only on canManage() + a plain flag — never the store model (the loop lesson). effect(() => { if (this.canManage() && !this.loadRequested) { this.loadRequested = true; diff --git a/apps/ssp/src/app/registratie/ui/change-request-form/change-request-form.component.ts b/apps/ssp/src/app/registratie/ui/change-request-form/change-request-form.component.ts index c7bf467..92ab814 100644 --- a/apps/ssp/src/app/registratie/ui/change-request-form/change-request-form.component.ts +++ b/apps/ssp/src/app/registratie/ui/change-request-form/change-request-form.component.ts @@ -20,7 +20,7 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang /** * Organism: contact-change (telefoonwijziging) form. The BRP address is authoritative - * and shown READ-ONLY (WP-34) — you change your address at the gemeente, not here — so + * and shown READ-ONLY — you change your address at the gemeente, not here — so * only the phone number is editable. Uses the SAME idiom as the wizards: all state in * one signal driven by the pure `reduce` (change-request.machine.ts), submitted via a * `submit-*` command returning `Result`. The server re-validates. diff --git a/apps/ssp/src/app/shell/debug-state/debug-state.component.ts b/apps/ssp/src/app/shell/debug-state/debug-state.component.ts index b3faf88..b258acc 100644 --- a/apps/ssp/src/app/shell/debug-state/debug-state.component.ts +++ b/apps/ssp/src/app/shell/debug-state/debug-state.component.ts @@ -136,7 +136,7 @@ export class DebugStateComponent { pendingHerregistratie: this.profileStore?.pendingHerregistratie(), })); - // Dev switchers (WP-33): flip role/scenario without hand-editing the URL. Both are + // Dev switchers flip role/scenario without hand-editing the URL. Both are // read per-request in interceptors, so a reload re-runs them and re-fetches decisions. protected readonly roles = ROLES; protected readonly scenarios = SCENARIOS; @@ -158,7 +158,7 @@ export class DebugStateComponent { this.applyAndReload(); } - // Strip the dev params from the URL before reloading (WP-37) so a stale ?scenario=/?role= + // Strip the dev params from the URL before reloading so a stale ?scenario=/?role= // in the address bar can't override the value the switcher just stored (currentScenario/ // currentRole read the URL first) — otherwise a switch to "default"/"drafter" gets stuck. private applyAndReload(): void { diff --git a/apps/ssp/src/app/showcase/highlight-ts.ts b/apps/ssp/src/app/showcase/highlight-ts.ts index 77ad118..756b05c 100644 --- a/apps/ssp/src/app/showcase/highlight-ts.ts +++ b/apps/ssp/src/app/showcase/highlight-ts.ts @@ -1,5 +1,5 @@ /** - * Tiny, dependency-free TS highlighter for the teaching showcase (WP-39). Escapes HTML, + * Tiny, dependency-free TS highlighter for the teaching showcase. Escapes HTML, * then wraps line-comments, strings, and a fixed keyword set in `.c`/`.s`/`.k` spans (the * classes `concepts.page` styles). Deliberately naive — good enough for the short, curated * snippets shown here; not a real tokenizer. Input is always our OWN source (extracted by diff --git a/apps/ssp/src/index.html b/apps/ssp/src/index.html index 7dea9cb..4d5a3d3 100644 --- a/apps/ssp/src/index.html +++ b/apps/ssp/src/index.html @@ -10,8 +10,8 @@ url(../fonts|icons|images) refs resolve against the vendored folder at runtime. Licensed Rijksoverheid fonts are not used — styles.scss overrides the stack to system-ui. --> - + -
      - 1 · Discriminated unions -

      Laat elke variant precies de gegevens dragen die kloppen — niets meer.

      -
      -
      -

      Fout — vlakke interface

      -
      
      -            

      - Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand. -

      -
      -
      -

      Goed — sum type

      -
      -
      
      -              
      ↳ {{ src['union'] }}
      -
      - -

      - De variant Doorgehaald kent geen herregistratiedatum, dus de rij bestaat - simpelweg niet. -

      -
      -
      -
      - - -
      - 2 · RemoteData fold -

      - Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans. -

      -
      -
      -

      Vier toestanden, één molecuul

      -

      Loading

      - {{ v }} -

      Empty

      - {{ v }} -

      Failure

      - {{ v }} -

      Success

      -
        - @for (i of successRes.value(); track i) { -
      • {{ i }}
      • - } -
      -
      -
      -

      De exhaustieve fold

      -
      -
      
      -              
      ↳ {{ src['fold'] }}
      -
      -

      - Een nieuwe variant toevoegen breekt de compile via assertNever tot je hem - afhandelt. -

      -
      -
      -
      - - -
      - 3 · Parse, don't validate -

      Na het parsen onthoudt het type dat de waarde geldig is.

      -
      -
      -

      Smart constructor → Result

      -
      -
      
      -              
      ↳ {{ src['parse'] }}
      -
      - -
      - @let r = parsed(); -
      - @if (r.ok) { -
      -

      ok

      -
      Postcode ="{{ r.value }}"
      -

      - Een gevalideerde Postcode is een ander type dan een ruwe string. -

      -
      - } @else { -
      -

      err

      -
      {{ r.error }}
      -
      - } -
      -
      -
      - - -
      - 4 · Form als state machine -

      - Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de - huidige. -

      -
      -
      -

      Fout — losse booleans

      -
      
      -            

      - Niets verhindert"submitting" mét validatiefouten of een successcherm met errors. -

      -
      -
      -

      Goed — één tagged union

      -
      -
      
      -              
      ↳ {{ src['machine'] }}
      -
      -
      - @for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) { - {{ n }} - } -
      - -
      -
      -
      - - -
      - 5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen" -

      - Het aantal stappen ligt vast (STEPS); vervolgvragen verschijnen - binnen een stap op basis van eerdere antwoorden. Antwoord"ja" op buitenland of - vul weinig uren in, en er komt een extra vraag bij in dezelfde stap — de voortgang"van N" - blijft gelijk. -

      -
      -
      -

      Vaste stappen

      -
      -
      
      -              
      ↳ {{ src['steps'] }}
      -
      -
      - @for (s of iw.steps; track s; let last = $last) { - {{ s }} - @if (!last) { - - } - } -
      -

      - De stappen zijn altijd dezelfde; alleen de vragen binnen een stap verschijnen - of verdwijnen. -

      -
      -
      -

      De wizard

      - -
      -
      -
      - - -
      - 6 · PII — maskeren & parsen -

      - Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard - gemaskeerd tonen, alleen tonen na een vastgelegde handeling; en "parse, don't validate" op - het gevoeligste veld — een pure functie die de elfproef afdwingt. -

      -
      -
      -

      Maskeren — atom

      -

      - BSN: - -

      -
      -
      
      -              
      ↳ {{ src['mask'] }}
      -
      -

      - Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het - behandelscherm). De atom bevat de maskeer-detectie — geen los *-gesnuffel - bij elke gebruiker. -

      -
      -
      -

      Parse (elfproef) → Result

      - - @let b = bsnParsed(); - @if (bsnRaw()) { -
      - @if (b.ok) { -

      ok

      -
      Bsn ="{{ b.value }}"
      - } @else { -

      err

      -
      {{ b.error }}
      - } -
      - } -
      -
      
      -              
      ↳ {{ src['parseBsn'] }}
      -
      -
      -
      -
      + + + + + + `, }) -export class ConceptsPage { - isEmpty = (v: string[]) => !v || v.length === 0; - - doorgehaald: Registration = { - bigNummer: '19012345601', - naam: 'Dr. A. (Anna) de Vries', - beroep: 'Arts', - registratiedatum: '2012-09-01', - geboortedatum: '1985-03-14', - status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' }, - }; - - loadingRes = fakeResource('loading'); - emptyRes = fakeResource('resolved', []); - errorRes = fakeResource('error', undefined, new Error('Demo')); - successRes = fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']); - - raw = signal(''); - parsed = computed(() => parsePostcode(this.raw())); - - // 6 · PII demo. Masked-by-default value that reveals locally (the real reveal is - // step-up-gated + audited elsewhere); plus a live elfproef parse mirroring the postcode demo. - demoBsn = '123456782'; - bsnRevealed = signal(false); - bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn))); - bsnRaw = signal(''); - bsnParsed = computed(() => parseBsn(this.bsnRaw())); - - // Deliberately-wrong illustrations (no real source to link — they show the anti-pattern). - private readonly illustrations: Record = { - unionBad: `interface Registration { - status: 'Geregistreerd' | 'Doorgehaald'; - herregistratieDatum: string; // altijd aanwezig 😬 -}`, - machineBad: `submitting = signal(false); -submitted = signal(false); -errors = signal<...>({}); -// submitting === true && errors.size > 0 ? 🤷`, - }; - - /** Highlighted HTML per snippet: the real ones come from SNIPPETS (extracted from source - by gen:snippets — they can't drift), the illustrations are authored above. */ - protected readonly code: Record = Object.fromEntries( - Object.entries({ ...SNIPPETS, ...this.illustrations }).map(([k, v]) => [k, highlightTs(v)]), - ); - - /** The real file each linked snippet is extracted from (shown as a caption). */ - protected readonly src: Record = { - union: 'registratie/domain/registration.ts', - fold: 'shared/application/remote-data.ts', - parse: 'registratie/domain/value-objects/postcode.ts', - machine: 'registratie/domain/change-request.machine.ts', - steps: 'herregistratie/domain/intake.machine.ts', - parseBsn: 'shared/kernel/bsn.ts', - mask: 'shared/kernel/pii.ts', - }; -} +export class ConceptsPage {} diff --git a/apps/ssp/src/app/showcase/form-machine.section.ts b/apps/ssp/src/app/showcase/form-machine.section.ts new file mode 100644 index 0000000..ae534ba --- /dev/null +++ b/apps/ssp/src/app/showcase/form-machine.section.ts @@ -0,0 +1,87 @@ +import { Component } from '@angular/core'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component'; +import { ConceptCardComponent } from './concept-card.component'; +import { SNIPPETS } from './snippets.generated'; +import { highlightTs } from './highlight-ts'; + +/** Section 4: form as a state machine, shown as a live state diagram. One tagged union + drives the UI — the marked state below is the wizard's current one. Composition-only. */ +@Component({ + selector: 'app-concepts-form-machine-section', + imports: [HeadingComponent, HerregistratieWizardComponent, ConceptCardComponent], + styles: [ + ` + .machine { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0 0 1rem; + } + .node { + padding: 0.4rem 0.8rem; + border-radius: 999px; + border: 1px solid var(--rhc-color-grijs-300); + font-size: 0.82rem; + color: var(--rhc-color-grijs-700); + transition: all 0.15s; + } + .node.on { + background: var(--rhc-color-hemelblauw-100); + border-color: var(--rhc-color-hemelblauw-500); + color: var(--rhc-color-hemelblauw-700); + font-weight: 700; + /* teaching motion: the active state pops as the wizard transitions (the .node + transition above animates it; reduced-motion is handled globally). */ + transform: scale(1.06); + } + `, + ], + template: ` +
      + 4 · Form als state machine +

      + Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige. +

      +
      + +

      + Niets verhindert"submitting" mét validatiefouten of een successcherm met errors. +

      +
      + +
      + @for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) { + {{ n }} + } +
      + +
      +
      +
      + `, +}) +export class FormMachineSection { + // Deliberately-wrong illustration (no real source to link — it shows the anti-pattern). + private readonly machineBad = `submitting = signal(false); +submitted = signal(false); +errors = signal<...>({}); +// submitting === true && errors.size > 0 ? 🤷`; + + /** Highlighted HTML per snippet: `machine` comes from SNIPPETS (extracted from source by + gen:snippets — it can't drift), `machineBad` is authored above. */ + protected readonly code: Record = { + machineBad: highlightTs(this.machineBad), + machine: highlightTs(SNIPPETS['machine']), + }; + + /** The real file the linked snippet is extracted from (shown as a caption). */ + protected readonly src: Record = { + machine: 'registratie/domain/change-request.machine.ts', + }; +} diff --git a/apps/ssp/src/app/showcase/parse.section.ts b/apps/ssp/src/app/showcase/parse.section.ts new file mode 100644 index 0000000..ac8aed0 --- /dev/null +++ b/apps/ssp/src/app/showcase/parse.section.ts @@ -0,0 +1,60 @@ +import { Component, computed, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; +import { parsePostcode } from '@registratie/domain/value-objects/postcode'; +import { ConceptCardComponent } from './concept-card.component'; +import { SNIPPETS } from './snippets.generated'; +import { highlightTs } from './highlight-ts'; + +/** Section 3: parse, don't validate. After parsing, the TYPE remembers the value is valid. + Composition-only; owns its own postcode demo. */ +@Component({ + selector: 'app-concepts-parse-section', + imports: [FormsModule, HeadingComponent, TextInputComponent, ConceptCardComponent], + template: ` +
      + 3 · Parse, don't validate +

      Na het parsen onthoudt het type dat de waarde geldig is.

      +
      + + + + @let r = parsed(); + + @if (r.ok) { +
      +
      Postcode ="{{ r.value }}"
      +

      + Een gevalideerde Postcode is een ander type dan een ruwe string. +

      +
      + } @else { +
      +
      {{ r.error }}
      +
      + } +
      +
      +
      + `, +}) +export class ParseSection { + raw = signal(''); + parsed = computed(() => parsePostcode(this.raw())); + + protected readonly code: Record = { parse: highlightTs(SNIPPETS['parse']) }; + protected readonly src: Record = { + parse: 'registratie/domain/value-objects/postcode.ts', + }; +} diff --git a/apps/ssp/src/app/showcase/pii.section.ts b/apps/ssp/src/app/showcase/pii.section.ts new file mode 100644 index 0000000..636fe1d --- /dev/null +++ b/apps/ssp/src/app/showcase/pii.section.ts @@ -0,0 +1,102 @@ +import { Component, computed, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; +import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component'; +import { parseBsn } from '@shared/kernel/bsn'; +import { maskBsn } from '@shared/kernel/pii'; +import { ConceptCardComponent } from './concept-card.component'; +import { SNIPPETS } from './snippets.generated'; +import { highlightTs } from './highlight-ts'; + +/** Section 6: PII — masking and parsing. A BSN (Dutch citizen service number) is + special-category personal data (GDPR art. 9): masked by default, revealed only after + a logged action; "parse, don't validate" on the most sensitive field — a pure function + enforces the checksum. Composition-only. The ok/err result nests its own + `` for its label, for the same reason section 2 does. */ +@Component({ + selector: 'app-concepts-pii-section', + imports: [ + FormsModule, + HeadingComponent, + TextInputComponent, + MaskedValueComponent, + ConceptCardComponent, + ], + template: ` +
      + 6 · PII — maskeren & parsen +

      + Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard gemaskeerd + tonen, alleen tonen na een vastgelegde handeling; en"parse, don't validate" op het + gevoeligste veld — een pure functie die de elfproef afdwingt. +

      +
      + +

      + BSN: + +

      +

      + Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het + behandelscherm). De atom bevat de maskeer-detectie — geen los *-gesnuffel + bij elke gebruiker. +

      +
      + + + @let b = bsnParsed(); + @if (bsnRaw()) { +
      + + @if (b.ok) { +
      Bsn ="{{ b.value }}"
      + } @else { +
      {{ b.error }}
      + } +
      +
      + } +
      +
      +
      + `, +}) +export class PiiSection { + // Masked-by-default value that reveals locally (the real reveal is step-up-gated + + // audited elsewhere); plus a live elfproef parse mirroring the postcode demo. + demoBsn = '123456782'; + bsnRevealed = signal(false); + bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn))); + bsnRaw = signal(''); + bsnParsed = computed(() => parseBsn(this.bsnRaw())); + + protected readonly code: Record = { + mask: highlightTs(SNIPPETS['mask']), + parseBsn: highlightTs(SNIPPETS['parseBsn']), + }; + protected readonly src: Record = { + mask: 'shared/kernel/pii.ts', + parseBsn: 'shared/kernel/bsn.ts', + }; +} diff --git a/apps/ssp/src/app/showcase/remote-data.section.ts b/apps/ssp/src/app/showcase/remote-data.section.ts new file mode 100644 index 0000000..5b9e942 --- /dev/null +++ b/apps/ssp/src/app/showcase/remote-data.section.ts @@ -0,0 +1,92 @@ +import { Component } from '@angular/core'; +import type { Resource } from '@angular/core'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { ASYNC } from '@shared/ui/async/async.component'; +import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; +import { ConceptCardComponent } from './concept-card.component'; +import { SNIPPETS } from './snippets.generated'; +import { highlightTs } from './highlight-ts'; + +/** Minimal fake Resource so can be driven through every state without HTTP. */ +function fakeResource(status: string, value?: T, error?: Error): Resource { + return { + value: () => value as T, + status: () => status, + error: () => error, + hasValue: () => value !== undefined, + reload: () => {}, + } as unknown as Resource; +} + +/** Section 2: RemoteData fold. One value with four mutually exclusive states, instead of + three loose booleans. Composition-only; owns its own fake resources. Each of the four + demo states nests its own `` for its label — a plain `.tag` element + written here would carry this section's scope, not the card's, and stay unstyled. */ +@Component({ + selector: 'app-concepts-remote-data-section', + imports: [HeadingComponent, ...ASYNC, SkeletonComponent, ConceptCardComponent], + template: ` +
      + 2 · RemoteData fold +

      + Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans. +

      +
      + +
      + + {{ v }} + + + {{ v }} + + + {{ v }} + + +
        + @for (i of successRes.value(); track i) { +
      • {{ i }}
      • + } +
      +
      +
      +
      + +

      + Een nieuwe variant toevoegen breekt de compile via assertNever tot je hem + afhandelt. +

      +
      +
      +
      + `, +}) +export class RemoteDataSection { + isEmpty = (v: string[]) => !v || v.length === 0; + + loadingRes = fakeResource('loading'); + emptyRes = fakeResource('resolved', []); + errorRes = fakeResource('error', undefined, new Error('Demo')); + successRes = fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']); + + protected readonly code: Record = { fold: highlightTs(SNIPPETS['fold']) }; + protected readonly src: Record = { fold: 'shared/application/remote-data.ts' }; +} diff --git a/apps/ssp/src/app/showcase/unions.section.ts b/apps/ssp/src/app/showcase/unions.section.ts new file mode 100644 index 0000000..0f2428c --- /dev/null +++ b/apps/ssp/src/app/showcase/unions.section.ts @@ -0,0 +1,67 @@ +import { Component } from '@angular/core'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; +import { Registration } from '@registratie/domain/registration'; +import { ConceptCardComponent } from './concept-card.component'; +import { SNIPPETS } from './snippets.generated'; +import { highlightTs } from './highlight-ts'; + +/** Section 1: discriminated unions. Each variant carries exactly the data that fits it — + nothing more. Composition-only; owns its own demo data. */ +@Component({ + selector: 'app-concepts-unions-section', + imports: [HeadingComponent, RegistrationSummaryComponent, ConceptCardComponent], + template: ` +
      + 1 · Discriminated unions +

      Laat elke variant precies de gegevens dragen die kloppen — niets meer.

      +
      + +

      + Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand. +

      +
      + + +

      + De variant Doorgehaald kent geen herregistratiedatum, dus de rij bestaat + simpelweg niet. +

      +
      +
      +
      + `, +}) +export class UnionsSection { + doorgehaald: Registration = { + bigNummer: '19012345601', + naam: 'Dr. A. (Anna) de Vries', + beroep: 'Arts', + registratiedatum: '2012-09-01', + geboortedatum: '1985-03-14', + status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' }, + }; + + // Deliberately-wrong illustration (no real source to link — it shows the anti-pattern). + private readonly unionBad = `interface Registration { + status: 'Geregistreerd' | 'Doorgehaald'; + herregistratieDatum: string; // altijd aanwezig 😬 +}`; + + /** Highlighted HTML per snippet: `union` comes from SNIPPETS (extracted from source by + gen:snippets — it can't drift), `unionBad` is authored above. */ + protected readonly code: Record = { + unionBad: highlightTs(this.unionBad), + union: highlightTs(SNIPPETS['union']), + }; + + /** The real file the linked snippet is extracted from (shown as a caption). */ + protected readonly src: Record = { + union: 'registratie/domain/registration.ts', + }; +} diff --git a/apps/ssp/src/app/showcase/vragenlijst.section.ts b/apps/ssp/src/app/showcase/vragenlijst.section.ts new file mode 100644 index 0000000..49275d7 --- /dev/null +++ b/apps/ssp/src/app/showcase/vragenlijst.section.ts @@ -0,0 +1,81 @@ +import { Component } from '@angular/core'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component'; +import { ConceptCardComponent } from './concept-card.component'; +import { SNIPPETS } from './snippets.generated'; +import { highlightTs } from './highlight-ts'; + +/** Section 5: a questionnaire with a fixed step count — "show questions, don't add + steps". The step count never changes; follow-up questions reveal inline based on + earlier answers. Composition-only. */ +@Component({ + selector: 'app-concepts-vragenlijst-section', + imports: [HeadingComponent, IntakeWizardComponent, ConceptCardComponent], + styles: [ + ` + .steplist { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + margin: 0 0 1rem; + } + .pill { + padding: 0.3rem 0.7rem; + border-radius: 8px; + background: var(--rhc-color-grijs-100); + font-size: 0.8rem; + } + .pill.extra { + background: var(--rhc-color-geel-100); + border: 1px dashed var(--rhc-color-geel-600); + } + .arrow { + color: var(--rhc-color-grijs-400); + } + `, + ], + template: ` +
      + 5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen" +

      + Het aantal stappen ligt vast (STEPS); vervolgvragen verschijnen + binnen een stap op basis van eerdere antwoorden. Antwoord"ja" op buitenland of vul + weinig uren in, en er komt een extra vraag bij in dezelfde stap — de voortgang"van N" blijft + gelijk. +

      +
      + +
      + @for (s of iw.steps; track s; let last = $last) { + {{ s }} + @if (!last) { + + } + } +
      +

      + De stappen zijn altijd dezelfde; alleen de vragen binnen een stap verschijnen + of verdwijnen. +

      +
      + + + +
      +
      + `, +}) +export class VragenlijstSection { + protected readonly code: Record = { steps: highlightTs(SNIPPETS['steps']) }; + protected readonly src: Record = { + steps: 'herregistratie/domain/intake.machine.ts', + }; +} diff --git a/docs/project/readable-codebase/RD-24-concepts-sections.md b/docs/project/readable-codebase/RD-24-concepts-sections.md new file mode 100644 index 0000000..53a671f --- /dev/null +++ b/docs/project/readable-codebase/RD-24-concepts-sections.md @@ -0,0 +1,230 @@ +# RD-24 — Split `concepts.page.ts`, and fix the highlighting it has never rendered + +Status: done +Source: PLAN.md 3g, order step 6 + +## Why + +`concepts.page.ts` measures ~471 effective lines against a limit of 250, and carries +`/* eslint-disable max-lines */`. It is one template with six teaching sections and a 142-line +`styles:` block. + +A per-section split alone does not fix the styles, because Angular scopes a component's CSS to +its own template. Splitting without moving the CSS by owner would leave every section unstyled. + +**And measuring that constraint turned up a live bug** (decision 1). + +## Read first + +- `apps/ssp/src/app/showcase/concepts.page.ts` — the whole file: styles at 48-189, template at + 190-439. +- `libs/shared/styles.scss:105-130` — the `--app-devpanel-*` tokens and the existing + `.app-stack` / `.app-section` / `.app-text-subtle` globals. The new globals go beside them, + and the file's own comment says it exists to centralise exactly these idioms. +- `apps/ssp/src/app/showcase/highlight-ts.ts:42-45` — the `` markup whose + colours decision 1 restores. +- `scripts/check-tokens.sh:14` — the guard, and its `--include` glob. + +## Decisions (pre-made, don't relitigate) + +1. **The syntax highlighting is dead today. Fix it by making those rules global.** Verified + against the built output, not inferred: + + ``` + pre[_ngcontent-%COMP%] .k[_ngcontent-%COMP%]{color:#c792ea} + ``` + + The `.k`/`.s`/`.c` spans arrive through `[innerHTML]`, so they never carry an `_ngcontent` + attribute, and the rule cannot match. `highlight-ts.ts` computes the spans, its spec passes, + and every keyword, string and comment renders in the plain foreground colour. No component in + this repository uses `ViewEncapsulation.None`, and there is no global rule for `.k`, `.s` or + `.c`. + + So `.app-code .k|.s|.c` becomes **global**, in `libs/shared/styles.scss`. A component cannot + own a rule that targets markup it did not render. + +2. **Five new tokens, beside `--app-devpanel-*`**, which exist for this same reason: + + ```scss + --app-code-bg: #1e2430; + --app-code-fg: #e6e9ef; + --app-code-keyword: #c792ea; + --app-code-string: #c3e88d; + --app-code-comment: #7e8aa0; + ``` + + `styles.scss` is the token bridge and the guard's one exempt file, so these literals belong + here and nowhere else. + +3. **Four new globals in `libs/shared/styles.scss`**, named with the existing `.app-` prefix: + + | Global | Replaces | Why not a component | + | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | + | `.app-code` | `pre` | must style `[innerHTML]` children (decision 1) | + | `.app-lead` | `.lead` | a page-level typography idiom, used by all six sections | + | `.app-cols` | `.cols` | same | + | `.app-note` | `.note` | its content includes markup (``), so it must be projected, and projected content keeps the _declaring_ component's scope | + + **Delete `.section` entirely** — the global `.app-section` already exists and does the job. + +4. **`concept-card.component.ts` owns the card vocabulary and renders it.** New component in + `apps/ssp/src/app/showcase/`. It owns `.card`, `.card--good`, `.card--bad`, `.tag`, its three + modifiers and both `::before` rules, plus `.linked` and `.linked .src`. + + Its API, driven by what the 12 current usages need: + + ```ts + variant = input<'good' | 'bad' | 'plain'>('plain'); // card--good / card--bad / tag colour + tag = input.required(); // the uppercase label + code = input(); // pre [innerHTML], optional + src = input(); // figcaption; wraps code in figure.linked + ``` + + Everything else is projected through ``. The card **renders the `
      ` itself**
      +   when `code` is set — that is what keeps `.app-code`'s box styling working without relying on
      +   projection.
      +
      +5. **Six section components, one per `
      `**, in `apps/ssp/src/app/showcase/`: + + | File | Class | Heading | Own CSS | + | ------------------------- | -------------------- | --------------------------- | --------------------------------------------- | + | `unions.section.ts` | `UnionsSection` | 1 · Discriminated unions | none | + | `remote-data.section.ts` | `RemoteDataSection` | 2 · RemoteData fold | none | + | `parse.section.ts` | `ParseSection` | 3 · Parse, don't validate | none | + | `form-machine.section.ts` | `FormMachineSection` | 4 · Form als state machine | `.machine`, `.node`, `.node.on` | + | `vragenlijst.section.ts` | `VragenlijstSection` | 5 · Vragenlijst | `.steplist`, `.pill`, `.pill.extra`, `.arrow` | + | `pii.section.ts` | `PiiSection` | 6 · PII — maskeren & parsen | none | + + The "Own CSS" column is measured: those selectors appear in exactly one section each. Every + other selector is now a global or lives in the card. + +6. **The page keeps only what composes.** After the split `concepts.page.ts` holds its heading, + its intro, and six elements. It keeps no `styles:` block. `code` and `src` (the generated + snippets) move to whichever sections use them — each section imports + `snippets.generated.ts` directly. + +7. **Widen the colour guard, and fix the one file that widening catches.** + `scripts/check-tokens.sh:14` greps `--include='*.component.ts'`, so **every `*.page.ts`, + `*.section.ts` and `*.step.ts` in the repository is invisible to it** — including the six + sections this ticket creates and the six `*.step.ts` files RD-22 and RD-23 just added. That + is why this page accumulated 21 hardcoded colours unnoticed. + + Change the include to `--include='*.ts'` and exclude specs and stories, which legitimately + show colour swatches: + + ```bash + hits=$(grep -rnE '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' apps libs --include='*.ts' \ + | grep -vE '\.(spec|stories)\.ts:' | grep -v 'token-ok' || true) + ``` + + Measured: this newly catches exactly one other line, `libs/beheer/src/ui/audit.page.ts:39` + (`var(--rhc-color-rood-600, #a30000)`). Fix it by dropping the fallback, as decision 8 does + for this page. **Leave the CIBG-GAP marker check at `*.component.ts`** — a gap extension is a + component concept (ADR-0003). + +8. **Drop every `var(--rhc-…, #hex)` fallback.** All the referenced tokens are defined in the + bridge, so the fallback is dead weight that also trips the widened guard. `.card`'s + `background: #fff` becomes `var(--rhc-color-wit)` — verified: that token is defined in + `styles.scss`. + +9. **No stories.** `showcase` is a teaching page, not a feature, and it has no story today. + Adding six is not this ticket's job. + +10. **Delete `/* eslint-disable max-lines */` from the page.** Mandatory — the rules pin each + other in both directions. + +## Files + +- `libs/shared/styles.scss` — 5 tokens, 4 globals +- `scripts/check-tokens.sh` — one line (decision 7) +- `libs/beheer/src/ui/audit.page.ts` — one fallback (decision 7) +- `apps/ssp/src/app/showcase/concept-card.component.ts` (new) +- `apps/ssp/src/app/showcase/{unions,remote-data,parse,form-machine,vragenlijst,pii}.section.ts` (new) +- `apps/ssp/src/app/showcase/concepts.page.ts` + +## Steps + +1. Add the tokens and the four globals to `libs/shared/styles.scss` (decisions 2 and 3). +2. Write `concept-card.component.ts` (decision 4). +3. Move each `
      ` into its own file, replacing every `
      ` with + ``, `class="lead|cols|note"` with the `.app-*` names, and `
      ` with
      +   either the card's `code` input or `
      ` for the four dynamic result blocks.
      +4. Reduce the page to composition, with no `styles:` block.
      +5. Widen the guard and fix `audit.page.ts` (decision 7).
      +6. Delete the disable (decision 10).
      +7. `git add -A`, then run the acceptance commands.
      +8. Update this ticket's `Status:` to `done` and the README's RD-24 row to `done`.
      +9. Commit all of it together.
      +
      +## Acceptance criteria
      +
      +Measured against the tree before handover. Run after `git add -A`.
      +
      +```bash
      +D=apps/ssp/src/app/showcase
      +git ls-files "$D/*.section.ts" | wc -l                       # is 0 -> MUST be 6
      +git ls-files "$D/concept-card.component.ts" | wc -l          # is 0 -> MUST be 1
      +git grep -c "eslint-disable max-lines" -- $D/concepts.page.ts   # is 1 -> MUST be 0
      +git grep -c "styles:" -- $D/concepts.page.ts                 # is 1 -> MUST be 0
      +```
      +
      +The colours left the page, and the guard now covers it:
      +
      +```bash
      +git grep -cE "#[0-9a-fA-F]{3,6}" -- $D/concepts.page.ts      # is 21 -> MUST be 0
      +git grep -c "include='\*\.component\.ts'" -- scripts/check-tokens.sh   # is 2 -> MUST be 1 (the CIBG-GAP check keeps it)
      +npm run check:tokens                                          # exits 0
      +```
      +
      +The highlighting rules are global, where innerHTML children can reach them (decision 1):
      +
      +```bash
      +git grep -c "app-code" -- libs/shared/styles.scss             # MUST be >= 4
      +git grep -c "app-code" -- $D/concepts.page.ts                 # MUST be 0
      +```
      +
      +The teaching content did not change while being moved:
      +
      +```bash
      +git grep -ho "code\['[a-zA-Z]*'\]" -- $D/ | sort -u | wc -l   # is 9 -> MUST still be 9
      +```
      +
      +```bash
      +npm run ci --full   # exits 0
      +```
      +
      +## Verification
      +
      +**`--full` is required** — this edits `libs/shared/styles.scss`, which every story renders
      +against.
      +
      +**Look at the page.** This is the one ticket in the arc whose main fix is invisible to every
      +automated check: no test asserts a computed colour. Run `npm start`, open `/concepts`, and
      +confirm that keywords, strings and comments in the code blocks are now coloured — purple, green
      +and grey-italic against the dark background. If they are still monochrome, the rules are still
      +scoped to a component.
      +
      +**Do not add a line-count command.** `npm run lint` is the exact check.
      +
      +## Out of scope
      +
      +- Changing any teaching copy, snippet or demo. This is a move, not a rewrite.
      +- `highlight-ts.ts` itself. Its output is correct; only the CSS was unreachable.
      +- Adding stories (decision 9).
      +- The `--app-devpanel-*` tokens, and any other page's colours.
      +
      +## Risks
      +
      +- **Angular does not style projected or `[innerHTML]` content from the receiving component.**
      +  This is the constraint that shapes decisions 1, 3 and 4. If you find yourself moving a rule
      +  into a component and its markup comes from somewhere else, the rule belongs in the global
      +  sheet.
      +- **The `.app-note` case is subtle**: a note's text contains `` markup, so it must be
      +  projected — which is exactly why it cannot be styled by the card. Global it is.
      +- **The four dynamic `
      ` blocks** (the ok/err demo output in sections 3 and 6) are not code
      +  snippets and have no `src`. Give them `class="app-code"` directly rather than forcing them
      +  through the card's `code` input.
      +- **Widening the guard is a two-line change with a measured blast radius of one other file**
      +  (decision 7). If it catches more than `audit.page.ts:39`, stop and report — something landed
      +  since this ticket was written.
      +- **Deleting the disable is mandatory** (decision 10).
      diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md
      index 6603dc7..ccdc2c5 100644
      --- a/docs/project/readable-codebase/README.md
      +++ b/docs/project/readable-codebase/README.md
      @@ -118,7 +118,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
       | RD-21 | `rich-text-dom.ts` helpers + spec cases                                      | 02         | yes       | done   |
       | RD-22 | `intake-wizard` to 3 step components                                         | 08, 20     | yes       | done   |
       | RD-23 | `registratie-wizard` to 3 steps + the upload-controller move                 | 08, 20     | yes       | done   |
      -| RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens       | 02         | yes       | todo   |
      +| RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens       | 02         | yes       | done   |
       | RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children            | 02         | yes       | todo   |
       | RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable         | 02         | yes       | todo   |
       | RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports              | 21         | yes       | todo   |
      diff --git a/libs/beheer/src/ui/audit.page.ts b/libs/beheer/src/ui/audit.page.ts
      index 3054f22..6df5795 100644
      --- a/libs/beheer/src/ui/audit.page.ts
      +++ b/libs/beheer/src/ui/audit.page.ts
      @@ -36,7 +36,7 @@ import { AuditStore } from '@beheer/application/audit.store';
               font-weight: var(--rhc-text-font-weight-semi-bold);
             }
             .deny {
      -        color: var(--rhc-color-rood-600, #a30000);
      +        color: var(--rhc-color-rood-600);
               font-weight: var(--rhc-text-font-weight-semi-bold);
             }
           `,
      diff --git a/libs/shared/styles.scss b/libs/shared/styles.scss
      index 84af730..d357b32 100644
      --- a/libs/shared/styles.scss
      +++ b/libs/shared/styles.scss
      @@ -113,6 +113,15 @@ body {
         --app-devpanel-accent: #9cdcfe;
         --app-devpanel-border: #444;
         --app-devpanel-shadow: rgb(0 0 0 / 0.4);
      +
      +  /* Showcase code-block palette (concepts.page.ts, RD-24): the same "exempt file"
      +     reasoning as --app-devpanel-* above. A dark code-editor palette, kept off the CIBG
      +     design system, for the teaching page's highlighted TS snippets. */
      +  --app-code-bg: #1e2430;
      +  --app-code-fg: #e6e9ef;
      +  --app-code-keyword: #c792ea;
      +  --app-code-string: #c3e88d;
      +  --app-code-comment: #7e8aa0;
       }
       
       /* App utility classes: centralise the repeated inline layout idioms so components stay
      @@ -127,6 +136,49 @@ body {
       .app-text-subtle {
         color: var(--rhc-color-foreground-subtle);
       }
      +/* Highlighted code block (concepts.page.ts, RD-24). Global because the `.k`/`.s`/`.c`
      +   keyword/string/comment spans arrive through `[innerHTML]` — they never carry the
      +   rendering component's `_ngcontent` attribute, so a component-scoped rule can never
      +   match them. This is why the highlighting never rendered before RD-24. */
      +.app-code {
      +  background: var(--app-code-bg);
      +  color: var(--app-code-fg);
      +  padding: 1rem;
      +  border-radius: 8px;
      +  overflow: auto;
      +  font-size: 0.82rem;
      +  line-height: 1.55;
      +  margin: 0;
      +}
      +.app-code .k {
      +  color: var(--app-code-keyword);
      +}
      +.app-code .s {
      +  color: var(--app-code-string);
      +}
      +.app-code .c {
      +  color: var(--app-code-comment);
      +  font-style: italic;
      +}
      +/* Page-level typography idioms shared by every showcase section (concepts.page.ts). */
      +.app-lead {
      +  color: var(--rhc-color-grijs-700);
      +  max-width: 46rem;
      +  margin: 0.25rem 0 1.25rem;
      +}
      +.app-cols {
      +  display: grid;
      +  grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
      +  gap: 1.5rem;
      +  align-items: start;
      +}
      +/* A note whose content includes markup (e.g. ``) must be projected into its
      +   card, so it keeps the DECLARING component's scope, not the card's — global it is. */
      +.app-note {
      +  font-size: 0.9rem;
      +  color: var(--rhc-color-grijs-700);
      +  margin: 0.75rem 0 0;
      +}
       
       /* Route transitions (withViewTransitions): cross-fade the routed CONTENT only.
          The chrome gets its own stable view-transition-name so it's lifted out of the
      diff --git a/scripts/check-tokens.sh b/scripts/check-tokens.sh
      index 3e86c97..80afa43 100755
      --- a/scripts/check-tokens.sh
      +++ b/scripts/check-tokens.sh
      @@ -1,7 +1,9 @@
       #!/usr/bin/env bash
      -# WP-02 token guard: fail if any *.component.ts hardcodes a colour (hex/rgb/hsl)
      -# instead of a --rhc-*/--app-* design token. Palette values live ONLY in the
      -# styles.scss token bridge (the one exempt file — it IS the bridge).
      +# WP-02 token guard: fail if any *.ts file hardcodes a colour (hex/rgb/hsl) instead of
      +# a --rhc-*/--app-* design token. Specs and stories are exempt — they legitimately show
      +# colour swatches. Palette values live ONLY in the styles.scss token bridge (the one
      +# exempt file — it IS the bridge). Widened from *.component.ts to *.ts in RD-24: a
      +# *.page.ts, *.section.ts or *.step.ts hardcoding a colour was invisible before that.
       #
       # px/rem are deliberately NOT grepped: too many false positives (font sizes,
       # transforms, media queries). Raw border widths are fixed by hand and mapped to
      @@ -11,7 +13,8 @@
       # false positive (a colour word inside a comment or a data-URI). Keep the bar high.
       set -uo pipefail
       
      -hits=$(grep -rnE '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' apps libs --include='*.component.ts' | grep -v 'token-ok' || true)
      +hits=$(grep -rnE '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' apps libs --include='*.ts' \
      +  | grep -vE '\.(spec|stories)\.ts:' | grep -v 'token-ok' || true)
       if [ -n "$hits" ]; then
         echo "$hits"
         echo 'FAIL: hardcoded colours in components (use --rhc-*/--app-* tokens, or add a `token-ok` marker + reason)'
      
      From 5aed15bb98c86d39377babbcc334e441c3311197 Mon Sep 17 00:00:00 2001
      From: Edwin van den Houdt 
      Date: Fri, 4 Sep 2026 23:44:17 +0200
      Subject: [PATCH 37/46] docs: record the supervisor-as-second-writer mistake
       from RD-24
      
      A task notification fires every time an agent stops with no live children,
      including a pause mid-task. RD-24's agent paused and notified; the supervisor
      read that as abandonment, took over the tree, ran the gate and edited the
      ticket Status while the agent was still working. The agent resumed, correctly
      detected a second writer, and refused to commit.
      
      This is the mirror image of the RD-17 race the section above records: there a
      fresh supervisor met a running agent, here a running agent met its own
      supervisor.
      
      Co-Authored-By: Claude Opus 5 
      ---
       docs/project/readable-codebase/README.md | 15 +++++++++++++++
       1 file changed, 15 insertions(+)
      
      diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md
      index ccdc2c5..5de8e1f 100644
      --- a/docs/project/readable-codebase/README.md
      +++ b/docs/project/readable-codebase/README.md
      @@ -294,3 +294,18 @@ git log --oneline -1        # did an agent already commit the ticket?
       
       If the tree is dirty and this session did not dirty it, find the agent before you write a
       ticket around the evidence it leaves.
      +
      +**The mirror-image mistake, made during RD-24: the supervisor became the second writer.** A
      +task notification fires every time an agent stops with **no live children — including a pause
      +mid-task**. RD-24's agent paused, said it was waiting on a check, and notified. The supervisor
      +read that as an abandoned task, took over the working tree, ran the gate and edited the ticket
      +`Status:` — while the agent was still running. The agent then resumed, correctly detected a
      +second process writing its tree, and refused to commit.
      +
      +Nothing was lost, because the agent stopped instead of committing. Two rules follow:
      +
      +- **A notification is not proof the work is finished.** A finished agent hands back a report
      +  with acceptance numbers. "I am waiting for X" is a pause.
      +- **Before taking over an agent's tree, confirm the agent is gone**: `ps aux | grep "npm run"`,
      +  and check whether its own result has arrived. Taking over is a real option — RD-24 was
      +  finished that way — but it must be a decision, not an assumption.
      
      From cf1f6415346068600575949eea9b8dba59d41ffe Mon Sep 17 00:00:00 2001
      From: Edwin van den Houdt 
      Date: Sat, 5 Sep 2026 00:02:30 +0200
      Subject: [PATCH 38/46] refactor: split org-template-editor by output cluster
       (RD-25)
      MIME-Version: 1.0
      Content-Type: text/plain; charset=UTF-8
      Content-Transfer-Encoding: 8bit
      
      org-template-editor.component.ts carried an eslint-disable for max-lines,
      padded by a dead sample-letter constant, 13 label inputs that were never
      bindable, and two self-contained mutation clusters. Split all three out:
      
      - SAMPLE_LETTER_BRIEF moves to brief/domain/sample-letter.ts. It is
        production content (the letter the admin previews), not a test fixture,
        so it stays out of brief.testing.ts (no-testing-in-production forbids
        production code from reaching a *.testing.ts file).
      - 11 of the 13 label inputs become inline i18n template text. The two
        that interpolate MARGIN_MIN_MM/MARGIN_MAX_MM (marginsLegend,
        invalidHint) stay in TS, because moving an interpolated $localize call
        into a template renames the xlf placeholder and breaks the translation
        merge. Every id is preserved; messages.en.xlf is unchanged.
      - logo-upload.component.ts and version-history.component.ts each take
        one output cluster. The parent still declares and re-emits all 11
        outputs — org-template.page.ts binds them directly on
         and is out of this ticket's file scope, so
        the parent's public surface cannot shrink.
      
      Correction to the ticket while executing it: its acceptance check for
      "= output" on the parent read "MUST be 7", copying decision 4's cluster
      count instead of decision 5's (and the ticket's own Risks section's)
      explicit requirement that the parent keep all 11 declarations. Fixed the
      ticket's acceptance section to the correct number.
      
      npm run ci --full is green.
      
      Co-Authored-By: Claude Sonnet 5 
      ---
       .../ssp/src/app/brief/domain/sample-letter.ts |  51 +++++
       .../logo-upload.component.ts                  |  77 +++++++
       .../org-template-editor.component.ts          | 206 +++++-------------
       .../version-history.component.ts              |  78 +++++++
       .../RD-25-org-template-editor.md              | 176 +++++++++++++++
       docs/project/readable-codebase/README.md      |   2 +-
       6 files changed, 435 insertions(+), 155 deletions(-)
       create mode 100644 apps/ssp/src/app/brief/domain/sample-letter.ts
       create mode 100644 apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts
       create mode 100644 apps/ssp/src/app/brief/ui/org-template-editor/version-history.component.ts
       create mode 100644 docs/project/readable-codebase/RD-25-org-template-editor.md
      
      diff --git a/apps/ssp/src/app/brief/domain/sample-letter.ts b/apps/ssp/src/app/brief/domain/sample-letter.ts
      new file mode 100644
      index 0000000..0b2c1c7
      --- /dev/null
      +++ b/apps/ssp/src/app/brief/domain/sample-letter.ts
      @@ -0,0 +1,51 @@
      +import { Brief } from './brief';
      +
      +/** A minimal read-only sample letter, so the admin sees the org identity in context
      +    while editing (content itself is not the admin's to change). Production content —
      +    the letter the org-template editor previews — not a test fixture, so it lives here
      +    rather than in `brief.testing.ts` (dependency-cruiser's no-testing-in-production
      +    rule forbids production code from reaching any `*.testing.ts`). */
      +export const SAMPLE_LETTER_BRIEF: Brief = {
      +  briefId: 'VOORBEELD-0001',
      +  beroep: 'arts',
      +  templateId: 'sample',
      +  drafterId: 'sample',
      +  status: { tag: 'draft' },
      +  placeholders: [
      +    { key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
      +    { key: 'datum', label: 'Datum', autoResolvable: true },
      +  ],
      +  sections: [
      +    {
      +      sectionKey: 'body',
      +      title: 'Voorbeeldinhoud',
      +      required: true,
      +      locked: true,
      +      blocks: [
      +        {
      +          type: 'freeText',
      +          blockId: 'sample-1',
      +          content: {
      +            paragraphs: [
      +              {
      +                nodes: [
      +                  { type: 'text', text: 'Geachte ' },
      +                  { type: 'placeholder', key: 'naam_zorgverlener' },
      +                  { type: 'text', text: ',' },
      +                ],
      +              },
      +              {
      +                nodes: [
      +                  {
      +                    type: 'text',
      +                    text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
      +                  },
      +                ],
      +              },
      +            ],
      +          },
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts b/apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts
      new file mode 100644
      index 0000000..2914eee
      --- /dev/null
      +++ b/apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts
      @@ -0,0 +1,77 @@
      +import { Component, computed, input, output } from '@angular/core';
      +import { HeadingComponent } from '@shared/ui/heading/heading.component';
      +import { AlertComponent } from '@shared/ui/alert/alert.component';
      +import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
      +import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
      +import { UploadState } from '@shared/domain/upload.machine';
      +
      +const LOGO_CATEGORY = 'org-logo';
      +
      +/**
      + * Organism: the org-template editor's logo-upload block, split out of
      + * `org-template-editor.component.ts` (RD-25) — one of its two self-contained
      + * mutation clusters. Presentational: every mutation is an output the parent
      + * re-emits unchanged.
      + */
      +@Component({
      +  selector: 'app-logo-upload',
      +  imports: [HeadingComponent, AlertComponent, FileInputComponent, SingleUploadComponent],
      +  styles: [
      +    `
      +      :host {
      +        display: block;
      +      }
      +      .section {
      +        margin-block-start: var(--rhc-space-max-xl);
      +      }
      +    `,
      +  ],
      +  template: `
      +    
      + Logo + @if (logoCategory()) { + + } + @if (logoRejection()) { + {{ logoRejection() }} + } + @if (logoUploads().length) { +
        + @for (u of logoUploads(); track u.localId) { +
      • + } +
      + } +
      + `, +}) +export class LogoUploadComponent { + logoUrl = input(null); + uploadState = input.required(); + previewUrlFor = input<(documentId: string) => string | undefined>(); + + logoSelected = output(); + logoRemoved = output(); + logoRetry = output(); + + protected logoCategory = computed(() => + this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY), + ); + protected logoUploads = computed(() => + this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY), + ); + protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]); +} diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts index 59c303c..2239ad6 100644 --- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts +++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts @@ -1,13 +1,9 @@ -/* eslint-disable max-lines */ // sample letter + labels + editor in one file — removed by RD-25 import { Component, computed, input, output } from '@angular/core'; -import { DatePipe } from '@angular/common'; -import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; -import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component'; -import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component'; import { UploadState } from '@shared/domain/upload.machine'; import { Brief } from '@brief/domain/brief'; +import { SAMPLE_LETTER_BRIEF } from '@brief/domain/sample-letter'; import { MARGIN_MAX_MM, MARGIN_MIN_MM, @@ -18,74 +14,29 @@ import { } from '@brief/domain/org-template'; import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component'; +import { LogoUploadComponent } from './logo-upload.component'; +import { VersionHistoryComponent } from './version-history.component'; -const LOGO_CATEGORY = 'org-logo'; const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm']; -/** A minimal read-only sample letter, so the admin sees the org identity in context - while editing (content itself is not the admin's to change). */ -export const SAMPLE_LETTER_BRIEF: Brief = { - briefId: 'VOORBEELD-0001', - beroep: 'arts', - templateId: 'sample', - drafterId: 'sample', - status: { tag: 'draft' }, - placeholders: [ - { key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true }, - { key: 'datum', label: 'Datum', autoResolvable: true }, - ], - sections: [ - { - sectionKey: 'body', - title: 'Voorbeeldinhoud', - required: true, - locked: true, - blocks: [ - { - type: 'freeText', - blockId: 'sample-1', - content: { - paragraphs: [ - { - nodes: [ - { type: 'text', text: 'Geachte ' }, - { type: 'placeholder', key: 'naam_zorgverlener' }, - { type: 'text', text: ',' }, - ], - }, - { - nodes: [ - { - type: 'text', - text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.', - }, - ], - }, - ], - }, - }, - ], - }, - ], -}; - /** * Organism: the admin org-template editor. The mirror of the drafter's * composer — the letter canvas runs in `editableRegions='template'` so the * letterhead/signature/footer are edited in place, while the content is a read-only - * sample. Margins, logo upload, version history and the publish bar sit around it. - * Presentational: every mutation is an output the store turns into a command. + * sample. Margins and the publish bar sit around it; the logo uploader and version + * history are their own children (`app-logo-upload`, `app-version-history`, RD-25) — + * each a self-contained mutation cluster. Presentational: every mutation is an + * output the store turns into a command, whether sourced here or re-emitted from + * a child. */ @Component({ selector: 'app-org-template-editor', imports: [ - DatePipe, - HeadingComponent, ButtonComponent, AlertComponent, - FileInputComponent, - SingleUploadComponent, LetterCanvasComponent, + LogoUploadComponent, + VersionHistoryComponent, ], styles: [ ` @@ -123,22 +74,6 @@ export const SAMPLE_LETTER_BRIEF: Brief = { .margins input { width: 6rem; } - .history-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--rhc-space-max-sm); - } - .history-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--rhc-space-max-md); - border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default); - padding-block-end: var(--rhc-space-max-sm); - } .bar { display: flex; flex-wrap: wrap; @@ -154,7 +89,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = { template: `