From 4e9af05cc1deb08575f8bb2943de1c1f35cb2027 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 26 Jun 2026 17:56:12 +0200 Subject: [PATCH] Add dev-only state debug view (Elm-style "show the Model") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A floating, read-only panel that renders the current root-store state (SessionStore + BigProfileStore) live via the json pipe. Whole thing is gated by isDevMode() so it never renders or ships in production. - Observer only — no new store/library/state pattern (PRD prime directive). - BigProfileStore resolved lazily on first open; its httpResources fetch eagerly on construction, so we avoid a personal-data fetch on pages that don't use it. - bsn masked before render; no persistence/logging/network in the feature. - maskBsn has a unit spec; UI exercised via a Devtools Storybook story, per repo convention (no TestBed component tests). Co-Authored-By: Claude Opus 4.8 --- .../shared/layout/shell/shell.component.ts | 4 +- .../ui/debug-state/debug-state.component.ts | 67 +++++++++++++++++++ .../ui/debug-state/debug-state.stories.ts | 14 ++++ src/app/shared/ui/debug-state/mask.spec.ts | 13 ++++ src/app/shared/ui/debug-state/mask.ts | 5 ++ 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 src/app/shared/ui/debug-state/debug-state.component.ts create mode 100644 src/app/shared/ui/debug-state/debug-state.stories.ts create mode 100644 src/app/shared/ui/debug-state/mask.spec.ts create mode 100644 src/app/shared/ui/debug-state/mask.ts diff --git a/src/app/shared/layout/shell/shell.component.ts b/src/app/shared/layout/shell/shell.component.ts index 1bfb91a..d881d5c 100644 --- a/src/app/shared/layout/shell/shell.component.ts +++ b/src/app/shared/layout/shell/shell.component.ts @@ -2,12 +2,13 @@ import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component'; import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component'; +import { DebugStateComponent } from '@shared/ui/debug-state/debug-state.component'; /** Template: persistent app chrome. Header + footer mount once; only the routed content inside changes (and cross-fades — see styles.scss). */ @Component({ selector: 'app-shell', - imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent], + imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent, DebugStateComponent], styles: [` :host{display:block} .skip{position:absolute;left:var(--app-skip-link-offset)} @@ -26,6 +27,7 @@ import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.comp + `, }) export class ShellComponent {} diff --git a/src/app/shared/ui/debug-state/debug-state.component.ts b/src/app/shared/ui/debug-state/debug-state.component.ts new file mode 100644 index 0000000..2747a72 --- /dev/null +++ b/src/app/shared/ui/debug-state/debug-state.component.ts @@ -0,0 +1,67 @@ +import { Component, Injector, computed, inject, isDevMode, signal } from '@angular/core'; +import { JsonPipe } from '@angular/common'; +import { SessionStore } from '@auth/application/session.store'; +import { Session } from '@auth/domain/session'; +import { BigProfileStore } from '@registratie/application/big-profile.store'; +import { maskBsn } from './mask'; + +/** + * Dev-only "show the current Model" panel (Elm-debugger style, read-only). + * Observes the root singletons and renders them via the json pipe. Never a + * product feature: the whole template is gated by isDevMode(), so it does not + * render and is unreachable in a production build. + * + * ponytail: dev tool — intentionally off-theme (not Rijkshuisstijl) and exempt + * from the design-token convention; plain values keep it visually distinct. + */ +@Component({ + selector: 'app-debug-state', + imports: [JsonPipe], + styles: [` + .fab{position:fixed;bottom:1rem;right:1rem;z-index:9999;font:600 12px/1 monospace; + background:#1e1e1e;color:#9cdcfe;border:1px solid #444;border-radius:4px; + padding:.5rem .75rem;cursor:pointer} + .panel{position:fixed;bottom:3.25rem;right:1rem;z-index:9999;width:min(90vw,28rem); + max-height:70vh;overflow:auto;background:#1e1e1e;color:#d4d4d4;border:1px solid #444; + border-radius:6px;box-shadow:0 6px 24px rgba(0,0,0,.4)} + .panel pre{margin:0;padding:.75rem;font:12px/1.5 monospace;white-space:pre-wrap; + word-break:break-word} + `], + template: ` + @if (isDev) { + + @if (visible()) { +
{{ snapshot() | json }}
+ } + } + `, +}) +export class DebugStateComponent { + protected readonly isDev = isDevMode(); + protected readonly visible = signal(false); + + private session = inject(SessionStore); + private injector = inject(Injector); + // Resolved on first open only — BigProfileStore's httpResources fetch eagerly + // on construction, so we must not instantiate it until the dev asks to look. + private profileStore?: BigProfileStore; + + protected readonly snapshot = computed(() => ({ + session: maskSession(this.session.session()), + profile: this.profileStore?.profile(), + decisions: this.profileStore?.decisions(), + aantekeningen: this.profileStore?.aantekeningen(), + pendingHerregistratie: this.profileStore?.pendingHerregistratie(), + })); + + toggle(): void { + if (!this.visible()) this.profileStore ??= this.injector.get(BigProfileStore); + this.visible.update((v) => !v); + } +} + +function maskSession(s: Session | null): Session | null { + return s ? { ...s, bsn: maskBsn(s.bsn) } : null; +} diff --git a/src/app/shared/ui/debug-state/debug-state.stories.ts b/src/app/shared/ui/debug-state/debug-state.stories.ts new file mode 100644 index 0000000..3d59166 --- /dev/null +++ b/src/app/shared/ui/debug-state/debug-state.stories.ts @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { DebugStateComponent } from './debug-state.component'; + +// Devtools, not a design-system atom — titled accordingly. +const meta: Meta = { + title: 'Devtools/State debug', + component: DebugStateComponent, +}; +export default meta; +type Story = StoryObj; + +// The floating button; click it to open the read-only state panel. Stores +// resolve to their default (empty/loading) state here. +export const Default: Story = {}; diff --git a/src/app/shared/ui/debug-state/mask.spec.ts b/src/app/shared/ui/debug-state/mask.spec.ts new file mode 100644 index 0000000..06bedbe --- /dev/null +++ b/src/app/shared/ui/debug-state/mask.spec.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest'; +import { maskBsn } from './mask'; + +describe('maskBsn', () => { + it('keeps the last 3 digits and masks the rest', () => { + expect(maskBsn('123456789')).toBe('******789'); + }); + + it('handles short and empty input without throwing', () => { + expect(maskBsn('12')).toBe('**'); + expect(maskBsn('')).toBe(''); + }); +}); diff --git a/src/app/shared/ui/debug-state/mask.ts b/src/app/shared/ui/debug-state/mask.ts new file mode 100644 index 0000000..d66a518 --- /dev/null +++ b/src/app/shared/ui/debug-state/mask.ts @@ -0,0 +1,5 @@ +/** Redact a BSN for the dev state view: keep the last 3 digits, mask the rest. */ +export function maskBsn(bsn: string): string { + if (bsn.length <= 3) return '*'.repeat(bsn.length); + return '*'.repeat(bsn.length - 3) + bsn.slice(-3); +}