Files
atomic-design-poc/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts
T
ehoandClaude Opus 5 fc2a3c348b refactor: one member order for the 3 wizard containers (RD-38)
RD-22 and RD-23 brought the wizard containers under the 250-line budget, so
`max-lines` reports nothing. The files still read badly. Line count was never
the problem.

Fix three things in all three containers:

1. The member order was scrambled, and it differed per file. `registratie`
   declared `draftSync` in the middle of a run of `computed`s; `herregistratie`
   read `this.stepLabels.length` seven lines before `stepLabels` existed; the
   three files put the copy arrays in three different places. All three now use
   one nine-section order, so they compare side by side.
2. Pure logic sat in the container. Extract `digitalDocumentIds` into
   `upload.machine.ts` — the "digital and finished uploading" filter was
   written out four times, and it removes a `documentId!` assertion from both
   containers. Extract `diplomaMsg` into a sibling of the step files.
3. Comments carried archaeology. Drop the three RD-05 references and keep the
   rule. Drop "replaces sessionStorage" and the note about focus management that
   moved to the shell. Fix `intake`'s class comment, which claimed answers
   persist to sessionStorage and was contradicted 30 lines below.

`phase` deliberately stays in all three: it cannot live in `domain/`, and three
siblings plus three specs is a worse trade than 17 readable lines. The store ⇄
`draftSync` cycle also stays — both callbacks are deferred, so it is safe, and
one comment now names it.

No behaviour change. Member lists and every `private`/`protected`/`readonly`
modifier are unchanged, which the showcase depends on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:16:23 +02:00

198 lines
6.7 KiB
TypeScript

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