Compare commits

..
10 Commits
Author SHA1 Message Date
ehoandClaude Opus 5 c36d9e3ff0 docs: drop RD-16 — the instruction would have discarded server decisions
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 <noreply@anthropic.com>
2026-09-04 18:50:38 +02:00
ehoandClaude Sonnet 5 fb7b531fdf 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 <noreply@anthropic.com>
2026-09-04 18:48:52 +02:00
ehoandClaude Opus 5 c45d1bc0ff 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 <noreply@anthropic.com>
2026-09-04 18:46:00 +02:00
ehoandClaude Sonnet 5 a8c7a573fc 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 <noreply@anthropic.com>
2026-09-04 18:45:04 +02:00
ehoandClaude Sonnet 5 c599fee8e2 refactor: fold org-template's action lifecycle + pendingPublish into one union (RD-13)
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 <noreply@anthropic.com>
2026-09-04 18:38:23 +02:00
ehoandClaude Opus 5 8e5f48c5d2 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 <noreply@anthropic.com>
2026-09-04 18:28:13 +02:00
ehoandClaude Sonnet 5 02d41536df refactor(brief): move the action lifecycle into the machine (RD-12)
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 <noreply@anthropic.com>
2026-09-04 18:27:09 +02:00
ehoandClaude Opus 5 43f62ddfee 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 <noreply@anthropic.com>
2026-09-04 18:08:21 +02:00
ehoandClaude Sonnet 5 827c655c1b refactor: fold machine-remote-data into remote-data.ts, PascalCase load lifecycle (RD-11)
`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 <noreply@anthropic.com>
2026-09-04 18:07:00 +02:00
ehoandClaude Sonnet 5 11664d2efa 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 <noreply@anthropic.com>
2026-09-04 17:45:05 +02:00
32 changed files with 1452 additions and 269 deletions
@@ -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 });
});
});
@@ -1,10 +1,9 @@
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 { createHistory } from '@shared/application/history';
import { createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
import { fromLoadLifecycle } from '@shared/application/remote-data';
import {
Brief,
CaseContext,
@@ -29,7 +28,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 {
@@ -41,11 +40,16 @@ export class BriefStore implements PendingSave {
readonly model = this.store.model;
private actionState = signal<ActionState>({ 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. */
@@ -95,11 +99,11 @@ export class BriefStore implements PendingSave {
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
purely a projection of its loading/failed tags onto the shared async seam. */
readonly remoteData = computed(() => machineRemoteData(this.model()));
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
private brief = computed<Brief | null>(() => {
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 +115,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 +186,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 } });
@@ -212,7 +216,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 +230,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 +255,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 +275,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 +285,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<Result<string, BriefView>>) {
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);
}
@@ -1,8 +1,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 { 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';
import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
@@ -13,6 +12,7 @@ import {
SubOrgSummary,
} from '@brief/domain/org-template';
import {
OrgTemplateActionState,
OrgTemplateMsg,
OrgTemplateState,
initial,
@@ -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<OrgTemplateState, { tag: 'loaded' }>;
type LoadedState = Extract<OrgTemplateState, { tag: 'Loaded' }>;
const LOGO_CATEGORY = 'org-logo';
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
@@ -47,22 +47,28 @@ export class OrgTemplateStore implements PendingSave {
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
readonly selectedSubOrgId = signal<string | null>(null);
private actionState = signal<ActionState>({ 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<OrgTemplateActionState>(() => 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<SaveState>({ tag: 'Idle' });
/** 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<LoadedState | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s : null;
return s.tag === 'Loaded' ? s : null;
});
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
@@ -101,7 +107,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({
@@ -165,60 +171,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);
}
@@ -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,40 @@ 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' });
});
// --- 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', () => {
@@ -280,10 +313,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' };
}
+30 -10
View File
@@ -36,17 +36,22 @@ 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' }
| { tag: 'Loading' }
| {
tag: 'loaded';
tag: 'Loaded';
brief: Brief;
availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions;
action: BriefActionState;
}
| { tag: 'failed'; reason: string };
| { tag: 'Failed'; reason: string };
export const initial: BriefState = { tag: 'loading' };
export const initial: BriefState = { tag: 'Loading' };
export type BriefMsg =
| {
@@ -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 {
@@ -110,7 +118,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 +197,16 @@ 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,
// 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 };
return { tag: 'Failed', reason: m.reason };
case 'Seed':
return m.state;
@@ -203,7 +214,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,
);
@@ -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);
}
@@ -275,6 +295,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 };
}
@@ -26,7 +26,7 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): 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,10 +154,50 @@ 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);
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',
});
});
});
@@ -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,11 +28,22 @@ 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 }
| { tag: 'Loading' }
| { tag: 'Failed'; reason: string }
| {
tag: 'loaded';
tag: 'Loaded';
subOrgId: string;
draft: OrgTemplate;
publishedVersion: number;
@@ -34,9 +52,10 @@ export type OrgTemplateState =
dirty: boolean;
/** Logo upload sub-state (single file, `org-logo` category). */
upload: UploadState;
action: OrgTemplateActionState;
};
export const initial: OrgTemplateState = { tag: 'loading' };
export const initial: OrgTemplateState = { tag: 'Loading' };
export type OrgTemplateMsg =
| { tag: 'Loading' }
@@ -47,22 +66,27 @@ 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 {
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 +95,19 @@ 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,
// 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 }));
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')
@@ -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);
}
+1 -1
View File
@@ -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() {
@@ -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<WizardStatus>(() => {
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<WizardPhase>(() => {
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. */
@@ -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<WizardStatus>(() => {
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<WizardPhase>(() => {
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
@@ -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<WizardStatus>(() => {
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<WizardPhase>(() => {
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. */
+38 -9
View File
@@ -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<Err, DashboardView>`,
line 45 re-maps to `RemoteData<Err, BigProfile>`. 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.
@@ -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<WizardPhase>()`. 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.
@@ -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
`<app-async>` 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<S, { tag: 'Loaded' }>` 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<OrgTemplateState, { tag: 'loaded' }>` |
| 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.
@@ -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<ActionState>({ 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 `<app-async>` 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 `<ng-template appAsyncLoaded>`, 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
`<ng-template appAsyncLoaded>`. 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.
@@ -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<ActionState>({ 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.
@@ -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.
@@ -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-<hex>` 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/<name>`.
3. For each branch: `git branch -d worktree-agent-<hex>` (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.
+43 -11
View File
@@ -104,13 +104,13 @@ 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-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-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 | | 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`~~ — 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 |
@@ -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
@@ -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
@@ -201,5 +206,32 @@ 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.
- **`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
`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
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: <X>'`.** 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.
@@ -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<StamdataEditorState, { tag: 'loaded' }>;
type LoadedState = Extract<StamdataEditorState, { tag: 'Loaded' }>;
/**
* 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<string>('');
readonly remoteData = computed(() => machineRemoteData(this.model()));
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
private loaded = computed<LoadedState | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s : null;
return s.tag === 'Loaded' ? s : null;
});
readonly table = computed<StamTable | null>(() => this.loaded()?.table ?? null);
readonly rows = computed<readonly StamRow[]>(() => this.loaded()?.rows ?? []);
@@ -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' });
});
});
@@ -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:
+18 -7
View File
@@ -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. 530 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
@@ -303,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
@@ -819,6 +830,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 +853,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)
+4 -4
View File
@@ -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 `<app-async>` 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 `<app-async>` to render, the way `BriefStore.remoteData` does — the machine
keeps deciding what the _letter_ is doing, `RemoteData` keeps deciding what the _fetch_ is
doing.
@@ -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' };
@@ -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.
@@ -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));
});
});
@@ -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 `<app-async>` 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 extends LoadLifecycle>(
s: S,
): RemoteData<Error, Extract<S, { tag: 'loaded' }>> {
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<S, { tag: 'loaded' }> };
}
}
@@ -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<string, number> = 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));
});
});
@@ -27,6 +27,26 @@ export function fromResource<T>(
return { tag: 'Loading' };
}
/**
* Project an Elm-machine's load lifecycle onto `RemoteData`, for the `<app-async>` 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<Error, Extract<S, { tag: 'Loaded' }>> {
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<S, { tag: 'Loaded' }> };
}
}
// #region showcase:fold
/** Exhaustive fold: you must handle every case, checked at compile time. */
export function foldRemote<E, T, R>(
@@ -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') {
<app-stepper
class="app-section"
[steps]="steps()"
@@ -122,14 +138,14 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
</div>
</form>
}
@case ('submitting') {
@case ('Submitting') {
<app-spinner /> <span>{{ submittingLabel() }}</span>
}
@case ('submitted') {
@case ('Submitted') {
<ng-content select="[wizardSuccess]" />
}
@case ('failed') {
<app-alert type="error">{{ errorMessage() }}</app-alert>
@case ('Failed') {
<app-alert type="error">{{ failedMessage() }}</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen"
>Opnieuw proberen</app-button
@@ -145,13 +161,16 @@ export class WizardShellComponent {
stepTitle = input.required<string>();
/** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */
processName = input('');
status = input.required<WizardStatus>();
phase = input.required<WizardPhase>();
primaryLabel = input.required<string>();
canGoBack = input(false);
errors = input<readonly WizardError[]>([]);
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<void>();
back = output<void>();
cancel = output<void>();
@@ -8,8 +8,8 @@ const meta: Meta<WizardShellComponent> = {
props: args,
template: `
<app-wizard-shell
[steps]="steps" [current]="current" [stepTitle]="stepTitle" [processName]="processName" [status]="status"
[primaryLabel]="primaryLabel" [canGoBack]="canGoBack" [errors]="errors" [errorMessage]="errorMessage"
[steps]="steps" [current]="current" [stepTitle]="stepTitle" [processName]="processName" [phase]="phase"
[primaryLabel]="primaryLabel" [canGoBack]="canGoBack" [errors]="errors"
(goToStep)="goToStep($event)">
<p class="rhc-paragraph">Voorbeeld-stapinhoud (de stapvelden worden hier geprojecteerd).</p>
<div wizardSuccess><p class="rhc-paragraph">Uw aanvraag is ontvangen.</p></div>
@@ -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.' },
},
};