feat(fp): WP-09 — pure-logic closure: dates + missing command specs
Consolidate four hand-rolled nl-NL date formatters (tasks.ts, aanvraag- block, letter-preview, aanvraag-view -- one more than the WP found) into one shared/kernel/datum.ts::formatDatumNl, spec-pinned and empty-safe. Add the two missing command specs CLAUDE.md's testing rule calls for: draft-sync.spec.ts (debounce coalescing + trailing-call + submit Result shape, via fake timers) and submit-change-request.spec.ts. Remove the unused RemoteData.map3 (updating the three docs that mentioned it); the variant input on confirmation.component.ts was already gone. Documents both stale-WP-text corrections in the backlog file. This closes out backlog Phase 1 (FP/DDD core, WP-05..09).
This commit is contained in:
@@ -68,7 +68,7 @@ Default reflex — **if you're about to add a second/third boolean to track stat
|
|||||||
model a discriminated union instead.** Three tools, all in `shared/application`:
|
model a discriminated union instead.** Three tools, all in `shared/application`:
|
||||||
|
|
||||||
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
|
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
|
||||||
Combine sources with `map`/`map2`/`map3`/`andThen` (Failure > Loading > Success).
|
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
|
||||||
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
|
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
|
||||||
templates, mutually exclusive by construction. Default loading spinner/skeleton
|
templates, mutually exclusive by construction. Default loading spinner/skeleton
|
||||||
is delay-gated (~250ms) so fast connections don't flash.
|
is delay-gated (~250ms) so fast connections don't flash.
|
||||||
@@ -145,6 +145,11 @@ not heavy component tests.
|
|||||||
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
|
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
|
||||||
same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
|
same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
|
||||||
fields + ad-hoc error signals.
|
fields + ad-hoc error signals.
|
||||||
|
- **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a
|
||||||
|
date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe
|
||||||
|
(a domain function, a `$localize` string) uses the one hand-written
|
||||||
|
`formatDatumNl` (`shared/kernel/datum.ts`). Never a third hand-rolled
|
||||||
|
`toLocaleDateString` call.
|
||||||
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
|
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
|
||||||
[authGuard]` on protected routes (`app.routes.ts`).
|
[authGuard]` on protected routes (`app.routes.ts`).
|
||||||
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
|
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ profile = computed(() =>
|
|||||||
The rule baked into `map2`: the combined result is a **Failure if either
|
The rule baked into `map2`: the combined result is a **Failure if either
|
||||||
failed**, **Loading if either is still loading**, and only **Success when both
|
failed**, **Loading if either is still loading**, and only **Success when both
|
||||||
succeeded**. So the page renders one state and the combiner callback only runs
|
succeeded**. So the page renders one state and the combiner callback only runs
|
||||||
when it's safe. (`map`, `map3`, `andThen` are variations on the same idea.)
|
when it's safe. (`map`, `andThen` are variations on the same idea.)
|
||||||
|
|
||||||
### 2c. The store — "all state changes go through one pure function"
|
### 2c. The store — "all state changes go through one pure function"
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ for its existing violations, so every WP ends green.
|
|||||||
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
|
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
|
||||||
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
|
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
|
||||||
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
|
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
|
||||||
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo |
|
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
|
||||||
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo |
|
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo |
|
||||||
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
|
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
|
||||||
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
|
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-09 — Pure-logic closure: dates + missing command specs
|
# WP-09 — Pure-logic closure: dates + missing command specs
|
||||||
|
|
||||||
Status: todo
|
Status: done
|
||||||
Phase: 1 — FP/DDD core
|
Phase: 1 — FP/DDD core
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -51,15 +51,28 @@ no spec despite "domain and pure logic must have a spec" (CLAUDE.md §5).
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Exactly one hand-written date formatter in the repo; `grep -rn "toLocaleDateString" src/app`
|
- [x] Exactly one hand-written date formatter in the repo. `formatDatumNl` uses
|
||||||
hits only `datum.ts`.
|
`Intl.DateTimeFormat(...).format()` rather than `.toLocaleDateString()`, so
|
||||||
- [ ] Both command specs exist; debounce coalescing + error path covered.
|
`grep -rn "toLocaleDateString" src/app` now hits **nothing** (stronger than the
|
||||||
- [ ] `map3` / unused `variant` input removed (or a note here why kept).
|
literal criterion, same intent — no file anywhere hand-rolls date formatting).
|
||||||
- [ ] CLAUDE.md rule added.
|
- [x] Both command specs exist; debounce coalescing + error path covered
|
||||||
|
(`draft-sync.spec.ts`, `submit-change-request.spec.ts`).
|
||||||
|
- [x] `map3` removed (found in `shared/application/remote-data.ts`, not
|
||||||
|
`shared/kernel/fp.ts` as the WP text guessed — updated the three docs that
|
||||||
|
mentioned it: CLAUDE.md, `docs/ARCHITECTURE.md`, `remote-data.mdx`). The
|
||||||
|
`variant` input on `confirmation.component.ts` no longer exists — already
|
||||||
|
cleaned up before this WP ran; nothing to do.
|
||||||
|
- [x] CLAUDE.md rule added (`Conventions` — DatePipe in templates, `formatDatumNl` in
|
||||||
|
pure TS).
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
GREEN + `npm run test-storybook:ci`.
|
GREEN + `npm run test-storybook:ci` (208 unit tests, up from WP-08's 201 by the 7 new
|
||||||
|
specs; 137 Storybook/a11y unchanged). Manual smoke via a running `docker compose` stack +
|
||||||
|
Playwright: dashboard's herregistratie-deadline task text ("Verleng uw registratie vóór 1
|
||||||
|
maart 2027"), the Concept aanvraag-block's complete-before text ("Rond de aanvraag af
|
||||||
|
vóór 2 augustus 2026"), and `formatDatumNl` unit specs for the letter-preview's `today` —
|
||||||
|
all render the expected long-form Dutch date, no console errors.
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
3348
documentation.json
3348
documentation.json
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ import { NgTemplateOutlet } from '@angular/common';
|
|||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||||
|
import { formatDatumNl } from '@shared/kernel/datum';
|
||||||
import { Paragraph } from '@shared/kernel/rich-text';
|
import { Paragraph } from '@shared/kernel/rich-text';
|
||||||
import { Brief, LetterBlock } from '@brief/domain/brief';
|
import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
@@ -152,11 +153,7 @@ export class LetterPreviewComponent {
|
|||||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||||
|
|
||||||
protected showSample = signal(false);
|
protected showSample = signal(false);
|
||||||
private today = new Date().toLocaleDateString('nl-NL', {
|
private today = formatDatumNl(new Date());
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
|
|
||||||
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
||||||
private worst = computed(() => {
|
private worst = computed(() => {
|
||||||
|
|||||||
99
src/app/registratie/application/draft-sync.spec.ts
Normal file
99
src/app/registratie/application/draft-sync.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { ApplicationRef, signal } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
||||||
|
import { createDraftSync, DraftSnapshot } from './draft-sync';
|
||||||
|
|
||||||
|
function setup(adapter: Partial<ApplicationsAdapter>) {
|
||||||
|
const navigate = vi.fn().mockResolvedValue(true);
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{ provide: ApplicationsAdapter, useValue: adapter },
|
||||||
|
{ provide: Router, useValue: { navigate } },
|
||||||
|
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const snap = signal<DraftSnapshot | null>(null);
|
||||||
|
const onResume = vi.fn();
|
||||||
|
const draftSync = TestBed.runInInjectionContext(() =>
|
||||||
|
createDraftSync({
|
||||||
|
type: 'registratie',
|
||||||
|
snapshot: () => snap(),
|
||||||
|
onResume,
|
||||||
|
enabled: () => true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
TestBed.inject(ApplicationRef).tick(); // flush the effect's initial run
|
||||||
|
return { draftSync, snap, navigate, onResume };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => TestBed.inject(ApplicationRef).tick();
|
||||||
|
|
||||||
|
describe('createDraftSync', () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('coalesces rapid snapshot changes into ONE debounced sync of the latest value', async () => {
|
||||||
|
const create = vi.fn().mockResolvedValue('a1');
|
||||||
|
const syncDraft = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const { snap } = setup({ create, syncDraft });
|
||||||
|
|
||||||
|
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||||
|
tick();
|
||||||
|
snap.set({ draft: { step: 1, x: 'a' }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||||
|
tick();
|
||||||
|
snap.set({ draft: { step: 1, x: 'ab' }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||||
|
tick();
|
||||||
|
|
||||||
|
// still inside the 600ms debounce window — nothing has synced yet
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
expect(syncDraft).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
expect(create).toHaveBeenCalledTimes(1); // one Concept created, not three
|
||||||
|
expect(syncDraft).toHaveBeenCalledTimes(1); // one sync, not three
|
||||||
|
expect(syncDraft).toHaveBeenCalledWith(
|
||||||
|
'a1',
|
||||||
|
expect.objectContaining({ draft: { step: 1, x: 'ab' } }), // the LAST snapshot wins
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a trailing change after the debounce fires schedules its own sync', async () => {
|
||||||
|
const create = vi.fn().mockResolvedValue('a1');
|
||||||
|
const syncDraft = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const { snap } = setup({ create, syncDraft });
|
||||||
|
|
||||||
|
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||||
|
tick();
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
expect(syncDraft).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
snap.set({ draft: { step: 2 }, stepIndex: 1, stepCount: 3, documentIds: [] });
|
||||||
|
tick();
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
expect(syncDraft).toHaveBeenCalledTimes(2);
|
||||||
|
expect(syncDraft).toHaveBeenLastCalledWith('a1', expect.objectContaining({ stepIndex: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('submit', () => {
|
||||||
|
it('resolves ok with the server response on success', async () => {
|
||||||
|
const create = vi.fn().mockResolvedValue('a1');
|
||||||
|
const submit = vi.fn().mockResolvedValue({ id: 'a1', autoApprovable: true });
|
||||||
|
const { draftSync } = setup({ create, submit });
|
||||||
|
|
||||||
|
const r = await draftSync.submit({});
|
||||||
|
expect(r).toEqual({ ok: true, value: { id: 'a1', autoApprovable: true } });
|
||||||
|
expect(submit).toHaveBeenCalledWith('a1', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds a rejected submit into a Result error, never throwing', async () => {
|
||||||
|
const create = vi.fn().mockResolvedValue('a1');
|
||||||
|
const submit = vi.fn().mockRejectedValue(new Error('boom'));
|
||||||
|
const { draftSync } = setup({ create, submit });
|
||||||
|
|
||||||
|
const r = await draftSync.submit({});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Valid } from '@registratie/domain/change-request.machine';
|
||||||
|
import { ChangeRequestAdapter } from '@registratie/infrastructure/change-request.adapter';
|
||||||
|
import { createSubmitChangeRequest } from './submit-change-request';
|
||||||
|
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||||
|
|
||||||
|
const postcode = parsePostcode('2514 EA');
|
||||||
|
if (!postcode.ok) throw new Error('fixture postcode should parse');
|
||||||
|
|
||||||
|
const data: Valid = { straat: 'Lange Voorhout 9', postcode: postcode.value, woonplaats: 'Den Haag' };
|
||||||
|
|
||||||
|
function setup(adapter: Partial<ChangeRequestAdapter>) {
|
||||||
|
TestBed.configureTestingModule({ providers: [{ provide: ChangeRequestAdapter, useValue: adapter }] });
|
||||||
|
return TestBed.runInInjectionContext(() => createSubmitChangeRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createSubmitChangeRequest', () => {
|
||||||
|
it('resolves ok with the referentie on success', async () => {
|
||||||
|
const submit = setup({ changeRequest: () => Promise.resolve('BIG-2026-000123') });
|
||||||
|
const r = await submit(data);
|
||||||
|
expect(r).toEqual({ ok: true, value: 'BIG-2026-000123' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds a rejected call into a Result error, never throwing', async () => {
|
||||||
|
const submit = setup({
|
||||||
|
changeRequest: () => Promise.reject(new Error('network kaput')),
|
||||||
|
});
|
||||||
|
const r = await submit(data);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a ProblemDetails detail message when the server rejects with one', async () => {
|
||||||
|
const submit = setup({
|
||||||
|
changeRequest: () => Promise.reject({ detail: 'Postcode komt niet overeen met de straat.' }),
|
||||||
|
});
|
||||||
|
const r = await submit(data);
|
||||||
|
expect(r).toEqual({ ok: false, error: 'Postcode komt niet overeen met de straat.' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatDatumNl } from '@shared/kernel/datum';
|
||||||
import { Aanvraag, AanvraagStatus, AanvraagType } from './aanvraag';
|
import { Aanvraag, AanvraagStatus, AanvraagType } from './aanvraag';
|
||||||
|
|
||||||
/** View-model mapping for an aanvraag: type → labels, status → label, and the fields
|
/** View-model mapping for an aanvraag: type → labels, status → label, and the fields
|
||||||
@@ -49,12 +50,6 @@ export interface AanvraagRow {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNL(iso?: string): string {
|
|
||||||
return iso
|
|
||||||
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept
|
/** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept
|
||||||
has no row — it renders as a resumable melding, see aanvraag-block). */
|
has no row — it renders as a resumable melding, see aanvraag-block). */
|
||||||
export function submittedRow(a: Aanvraag): AanvraagRow {
|
export function submittedRow(a: Aanvraag): AanvraagRow {
|
||||||
@@ -63,7 +58,9 @@ export function submittedRow(a: Aanvraag): AanvraagRow {
|
|||||||
const ref = referentie(s);
|
const ref = referentie(s);
|
||||||
if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`);
|
if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`);
|
||||||
if (a.submittedAt)
|
if (a.submittedAt)
|
||||||
parts.push($localize`:@@aanvraag.row.ingediend:ingediend op ${formatNL(a.submittedAt)}:datum:`);
|
parts.push(
|
||||||
|
$localize`:@@aanvraag.row.ingediend:ingediend op ${formatDatumNl(a.submittedAt)}:datum:`,
|
||||||
|
);
|
||||||
if (s.tag === 'InBehandeling' && s.manual)
|
if (s.tag === 'InBehandeling' && s.manual)
|
||||||
parts.push(
|
parts.push(
|
||||||
$localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`,
|
$localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`,
|
||||||
@@ -88,7 +85,7 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`,
|
key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`,
|
||||||
value: a.submittedAt ? formatNL(a.submittedAt) : '—',
|
value: a.submittedAt ? formatDatumNl(a.submittedAt) : '—',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (a.status.tag === 'Afgewezen') {
|
if (a.status.tag === 'Afgewezen') {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatDatumNl } from '@shared/kernel/datum';
|
||||||
import { Registration } from './registration';
|
import { Registration } from './registration';
|
||||||
import { herregistratieDeadline } from './registration.policy';
|
import { herregistratieDeadline } from './registration.policy';
|
||||||
|
|
||||||
@@ -12,10 +13,6 @@ export interface PortalTask {
|
|||||||
actionLabel: string;
|
actionLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNL(d: Date): string {
|
|
||||||
return d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive the open tasks for a professional (pure). Eligibility is the server's
|
* Derive the open tasks for a professional (pure). Eligibility is the server's
|
||||||
* decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it,
|
* decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it,
|
||||||
@@ -33,7 +30,7 @@ export function tasksFromProfile(
|
|||||||
tasks.push({
|
tasks.push({
|
||||||
title: $localize`:@@task.herregistratie.title:Vraag uw herregistratie aan`,
|
title: $localize`:@@task.herregistratie.title:Vraag uw herregistratie aan`,
|
||||||
description: deadline
|
description: deadline
|
||||||
? $localize`:@@task.herregistratie.deadline:Verleng uw registratie vóór ${formatNL(deadline)}:deadline:.`
|
? $localize`:@@task.herregistratie.deadline:Verleng uw registratie vóór ${formatDatumNl(deadline)}:deadline:.`
|
||||||
: $localize`:@@task.herregistratie.nodeadline:U kunt nu uw herregistratie aanvragen.`,
|
: $localize`:@@task.herregistratie.nodeadline:U kunt nu uw herregistratie aanvragen.`,
|
||||||
to: '/herregistratie',
|
to: '/herregistratie',
|
||||||
actionLabel: $localize`:@@task.herregistratie.action:Herregistratie aanvragen`,
|
actionLabel: $localize`:@@task.herregistratie.action:Herregistratie aanvragen`,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, computed, input, output } from '@angular/core';
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { formatDatumNl } from '@shared/kernel/datum';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||||
@@ -65,12 +66,6 @@ export class AanvraagBlockComponent {
|
|||||||
protected conceptText = computed(() => {
|
protected conceptText = computed(() => {
|
||||||
const s = this.aanvraag().status;
|
const s = this.aanvraag().status;
|
||||||
if (s.tag !== 'Concept') return '';
|
if (s.tag !== 'Concept') return '';
|
||||||
return $localize`:@@aanvraagBlock.conceptMelding:Deze aanvraag is nog niet volledig afgerond — u bent gebleven bij stap ${s.stepIndex + 1}:stap: van ${s.stepCount}:totaal:. Rond de aanvraag af vóór ${formatNL(this.deadline())}:datum:.`;
|
return $localize`:@@aanvraagBlock.conceptMelding:Deze aanvraag is nog niet volledig afgerond — u bent gebleven bij stap ${s.stepIndex + 1}:stap: van ${s.stepCount}:totaal:. Rond de aanvraag af vóór ${formatDatumNl(this.deadline())}:datum:.`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNL(iso?: string): string {
|
|
||||||
return iso
|
|
||||||
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,20 +71,6 @@ export function map2<E, A, B, R>(
|
|||||||
return { tag: 'Success', value: f(a.value, b.value) };
|
return { tag: 'Success', value: f(a.value, b.value) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Combine three sources (built on map2). */
|
|
||||||
export function map3<E, A, B, C, R>(
|
|
||||||
a: RemoteData<E, A>,
|
|
||||||
b: RemoteData<E, B>,
|
|
||||||
c: RemoteData<E, C>,
|
|
||||||
f: (a: A, b: B, c: C) => R,
|
|
||||||
): RemoteData<E, R> {
|
|
||||||
return map2(
|
|
||||||
map2(a, b, (x, y) => [x, y] as const),
|
|
||||||
c,
|
|
||||||
([x, y], z) => f(x, y, z),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Chain a second source that depends on the first one's value. */
|
/** Chain a second source that depends on the first one's value. */
|
||||||
export function andThen<E, A, B>(
|
export function andThen<E, A, B>(
|
||||||
rd: RemoteData<E, A>,
|
rd: RemoteData<E, A>,
|
||||||
|
|||||||
22
src/app/shared/kernel/datum.spec.ts
Normal file
22
src/app/shared/kernel/datum.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatDatumNl } from './datum';
|
||||||
|
|
||||||
|
describe('formatDatumNl', () => {
|
||||||
|
it('formats a Date in long Dutch form', () => {
|
||||||
|
expect(formatDatumNl(new Date(2026, 6, 2))).toBe('2 juli 2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats an ISO string the same way', () => {
|
||||||
|
expect(formatDatumNl('2026-07-02')).toBe('2 juli 2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty-safe: undefined, null, and empty string all yield the empty string', () => {
|
||||||
|
expect(formatDatumNl(undefined)).toBe('');
|
||||||
|
expect(formatDatumNl(null)).toBe('');
|
||||||
|
expect(formatDatumNl('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for an unparseable string rather than "Invalid Date"', () => {
|
||||||
|
expect(formatDatumNl('not-a-date')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
16
src/app/shared/kernel/datum.ts
Normal file
16
src/app/shared/kernel/datum.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* The one hand-written date formatter for pure TS (non-template) code — a domain
|
||||||
|
* rule or a `$localize` string can't reach for Angular's `DatePipe`, so this covers
|
||||||
|
* that gap. Templates use `DatePipe` (`| date: 'longDate'`) instead; don't add a
|
||||||
|
* second hand-rolled formatter for either case.
|
||||||
|
*/
|
||||||
|
export function formatDatumNl(d: Date | string | undefined | null): string {
|
||||||
|
if (!d) return '';
|
||||||
|
const date = typeof d === 'string' ? new Date(d) : d;
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
return new Intl.DateTimeFormat('nl-NL', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
@@ -18,10 +18,10 @@ type RemoteData<E, T> = { tag: 'Loading' } | { tag: 'Empty' } | { tag: 'Failure'
|
|||||||
## Combining sources
|
## Combining sources
|
||||||
|
|
||||||
Two or more independent fetches often need to render as ONE state (e.g. a registration
|
Two or more independent fetches often need to render as ONE state (e.g. a registration
|
||||||
call and a BRP call feeding the same page). `map`/`map2`/`map3`/`andThen` combine them
|
call and a BRP call feeding the same page). `map`/`map2`/`andThen` combine them with one
|
||||||
with one precedence rule: **Failure beats Loading beats Empty beats Success** — if either
|
precedence rule: **Failure beats Loading beats Empty beats Success** — if either source
|
||||||
source failed, the combined result is a failure; only when every source succeeded do you
|
failed, the combined result is a failure; only when every source succeeded do you get a
|
||||||
get a combined value.
|
combined value.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
map2(registration, person, (reg, p) => ({ registration: reg, person: p }));
|
map2(registration, person, (reg, p) => ({ registration: reg, person: p }));
|
||||||
|
|||||||
Reference in New Issue
Block a user