Mijn aanvragen (D): backend draft-sync + resume-by-link (registratie slice)

Replaces the registratie wizard's sessionStorage draft with a backend-owned
Concept aanvraag (PRD 0001, phase D — the registratie vertical slice).

- createDraftSync (registratie/application): reusable controller (field-initializer
  idiom, like createUploadController). Creates the Concept lazily on first progress,
  stamps `?aanvraag=<id>` into the URL, debounced-syncs the machine snapshot per
  change, and resumes from `?aanvraag` on load. Inert without a Router or when an
  explicit seed is present (Storybook/tests) — no network there.
- hasProgress (machine, pure + spec): "worth persisting?" — excludes the automatic
  BRP address prefill so a bare page visit creates nothing. Accepted regression:
  a step-0-only address edit isn't persisted until the user advances/chooses.
- Wizard: dropped STORAGE_KEY/restore + the sessionStorage effect; restart() detaches
  the Concept and drops the link.

Deferred (noted): ApplicationsStore -> phase F (dashboard is its only consumer);
intake-v3 + herregistratie persistence -> phase E (copy this pattern).
Gates green: vitest 125, lint, build; backend unchanged (dotnet 56).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-01 12:23:30 +02:00
co-authored by Claude Opus 4.8
parent 6f250cd987
commit 6db7f1e673
4 changed files with 166 additions and 24 deletions
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { hasProgress, initial, RegistratieState } from './registratie-wizard.machine';
const invullen = (over: Partial<Extract<RegistratieState, { tag: 'Invullen' }>>) =>
({ ...(initial as Extract<RegistratieState, { tag: 'Invullen' }>), ...over });
describe('hasProgress', () => {
it('is false for a fresh wizard', () => {
expect(hasProgress(initial as Extract<RegistratieState, { tag: 'Invullen' }>)).toBe(false);
});
it('ignores an auto-prefilled BRP address at step 0', () => {
const s = invullen({ draft: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', antwoorden: {} } });
expect(hasProgress(s)).toBe(false);
});
it('is true once the user advances, picks correspondence/diploma, or is past step 0', () => {
expect(hasProgress(invullen({ cursor: 1 }))).toBe(true);
expect(hasProgress(invullen({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe(true);
expect(hasProgress(invullen({ draft: { diplomaId: 'd1', antwoorden: {} } }))).toBe(true);
});
});
@@ -91,6 +91,22 @@ export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>):
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
}
/** Has the user meaningfully started, so it's worth persisting as a Concept? Excludes
the automatic BRP address prefill on step 0 — a bare page visit creates nothing.
ponytail: an address typed at step 0 without any of these signals is not yet
persisted (created once they advance/choose); accepted regression vs. sessionStorage. */
export function hasProgress(s: Extract<RegistratieState, { tag: 'Invullen' }>): boolean {
const d = s.draft;
return (
s.cursor > 0 ||
!!d.correspondentie ||
!!d.email ||
!!d.diplomaId ||
!!d.beroep ||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
);
}
/** Validate every question currently visible in ONE step. Errors keyed per field. */
function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Errors, void> {
const errors: Errors = {};