Add dev-only state debug view (Elm-style "show the Model")

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:56:12 +02:00
parent e5a3030dca
commit 4e9af05cc1
5 changed files with 102 additions and 1 deletions

View File

@@ -2,12 +2,13 @@ import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component'; import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component';
import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.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 /** Template: persistent app chrome. Header + footer mount once; only the routed
content inside <router-outlet> changes (and cross-fades — see styles.scss). */ content inside <router-outlet> changes (and cross-fades — see styles.scss). */
@Component({ @Component({
selector: 'app-shell', selector: 'app-shell',
imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent], imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent, DebugStateComponent],
styles: [` styles: [`
:host{display:block} :host{display:block}
.skip{position:absolute;left:var(--app-skip-link-offset)} .skip{position:absolute;left:var(--app-skip-link-offset)}
@@ -26,6 +27,7 @@ import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.comp
</main> </main>
<app-site-footer /> <app-site-footer />
</div> </div>
<app-debug-state />
`, `,
}) })
export class ShellComponent {} export class ShellComponent {}

View File

@@ -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) {
<button type="button" class="fab" (click)="toggle()">
{{ visible() ? '× state' : '⚙ state' }}
</button>
@if (visible()) {
<div class="panel"><pre>{{ snapshot() | json }}</pre></div>
}
}
`,
})
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;
}

View File

@@ -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<DebugStateComponent> = {
title: 'Devtools/State debug',
component: DebugStateComponent,
};
export default meta;
type Story = StoryObj<DebugStateComponent>;
// 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 = {};

View File

@@ -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('');
});
});

View File

@@ -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);
}