feat(portal-admin): audit dashboard — bar / donut / stacked-bar above the table #172

Merged
julien merged 1 commits from feat/portal-admin-audit-charts into main 2026-05-16 22:18:23 +02:00
Owner

Summary

PR 3 (final) of the charts chantier — closes the loop on ADR-0023 by wiring the three starter components shipped in #171 onto the /audit page.

PR 1 ✅ — ADR-0023 (decision + a11y contract + bundle plan).
PR 2 ✅ — libs/shared/charts/ foundations + bar / donut / stacked-bar.
PR 3 (this one) — /audit page integration: three charts above the existing table.

What lands

Three computed aggregations on AuditPage

dailyVolume()       // (day: 'YYYY-MM-DD', count: number)[]
outcomeBreakdown()  // (outcome: string, count: number)[]
dailyByEventType()  // (day, eventType, count)[]   — flat, the chart pre-pivots
totalOnPage()       // number for the donut centre label
hasChartData()      // gate the section out when the page is empty

All four derive from the current page only (page()?.items). No new BFF endpoint — server-side aggregations across the full filter set would need a /api/admin/audit/stats resource that's worth its own ADR + chantier when the use case appears.

Section markup — audit.html

A new <section class="charts"> between the status bar and the existing table, gated on hasChartData():

[At a glance — current page]
[charts-note: "Aggregations are computed from the events currently loaded on this page only…"]

┌─ Events per day (bar) ─┬─ Outcome breakdown (donut) ─┐
├─ Events per day, by event type (stacked bar) — wide ┤
└──────────────────────────────────────────────────────┘

The stacked-bar tile gets .chart-tile--wide (spans both grid columns) since its legend benefits from the horizontal space. The two-column grid collapses to one column under 800 px viewports.

SCSS — audit.scss

.charts shares the same surface tokens as .filters + .table-wrap (white / gray-800 background, 1 px border, 0.5 rem radius) so the page reads as one stack of related blocks. .charts-grid is display: grid; grid-template-columns: repeat(2, 1fr) with a @media (max-width: 800px) fallback to single-column.

Project budget — apps/portal-admin/project.json

anyComponentStyle budget bumped from 5/6 KB warn/error to 6/8 KB. audit.scss lands at ~6.5 KB after the charts grid additions, comfortably under the new ceiling. The old 5/6 KB threshold predated the charts row; this is a one-time accommodation, not a global relaxation.

Tsconfig wiring — apps/portal-admin/tsconfig.app.json

nx sync added the new project reference to libs/shared/charts/tsconfig.lib.json after the import { BarChart, … } from 'shared-charts' in audit.ts. Standard plumbing.

Spec — audit.spec.ts

Three new assertions under a charts describe block:

  1. The three <lib-*-chart> elements render when the page has data.
  2. <section class="charts"> is absent when the page is empty (no half-rendered donut on { total: 0, items: [] }).
  3. The donut's .donut-center-label reads the page's item count — verifies the [centerLabel] binding wired correctly.

Notes for the reviewer

  • Why charts only on the current page, not on the full result set? Per the "Don't add features beyond what the task requires" rule from CLAUDE.md. The user's brief was "tester les composants sur la page Audit log", not "design a full analytics dashboard". The .charts-note paragraph explicitly says "computed from the events currently loaded on this page only" so the limitation is disclosed, not hidden. A future server-side /api/admin/audit/stats (with audit_reader-scoped queries + caching) is the natural follow-up if real investigative use exposes the per-page scope as a friction point.
  • Why <section> with its own <h2> rather than just inline tiles? A11y. Per ADR-0016, document structure is a first-class concern. The charts are a meaningful sub-region of the page; landmark + heading help screen-reader users navigate.
  • Why no i18n marks on the captions? Portal-admin chrome stays in source locale per ADR-0020. The audit page has zero i18n markers today (filter labels, table headers, error messages are all plain English); the chart captions land in the same posture for consistency. If portal-admin grows i18n later, the captions get marked in the same sweep as the rest of the page.
  • Bundle impact reality-check: the chart-bearing lazy audit chunk grew from ~4.4 KB gzip to 84 KB gzip with the chart deps loaded. ADR-0023 estimated ~65 KB; the actual is higher because Plot pulls in more d3-scale-chromatic interpolators than I projected. Still well under the 100 KB cap from ADR-0017, but worth noting if a fourth chart type with its own d3 submodule lands (line / scatter / heatmap will push it further).
  • No anonymous-state regression on the table: the existing trace-link + actor-pivot behaviours from #163 are unaffected — the charts section sits between the status bar and the table, doesn't share any markup.

Test plan

  • pnpm nx test portal-admin57 specs pass (was 54; +3 for the new charts assertions).
  • pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-charts — 9/9 tasks green.
  • Audit lazy chunk: 84 KB gzip (was 4.4 KB before the chart deps loaded); under the 100 KB lazy-chunk budget.
  • Manual visual smoke — sign in to portal-admin with Portal.Admin → navigate /audit:
    • The three charts render above the filter form, in a 2-column grid (stacked-bar spanning both).
    • The donut centre label reads the current page's event count.
    • Apply a filter that narrows the page to ~3 events → charts re-render with the narrowed dataset.
    • Click the <details> "Data table" disclosure under each chart → the tabular fallback expands with the exact rows.
    • Toggle dark mode in the footer → axis text + chart envelope swap; the donut's Cividis-equivalent palette kicks in on the categorical chart too (palette already swapped by resolveTheme() in the lib).
    • Filter to an empty result set → the .charts section disappears, the "No audit events match the current filters" empty-state stays.

What's next

Chantier closed. Three light follow-ups stay open but optional:

  • Server-side aggregations if the per-page scope becomes a real friction point. New BFF endpoint + likely a fourth time-range selector on the SPA.
  • More chart types (line / scatter / heatmap) when business modules ask for them. The lib's _internal/ carries the a11y plumbing already; each new component is the ~50 LOC Plot wrapper + spec.
  • Promote shared chart styling to a fourth shared concern in libs/shared/ui/ if a third app ever joins. Not urgent.
## Summary PR 3 (final) of the charts chantier — closes the loop on [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md) by wiring the three starter components shipped in #171 onto the `/audit` page. ``` PR 1 ✅ — ADR-0023 (decision + a11y contract + bundle plan). PR 2 ✅ — libs/shared/charts/ foundations + bar / donut / stacked-bar. PR 3 (this one) — /audit page integration: three charts above the existing table. ``` ## What lands ### Three computed aggregations on `AuditPage` ```ts dailyVolume() // (day: 'YYYY-MM-DD', count: number)[] outcomeBreakdown() // (outcome: string, count: number)[] dailyByEventType() // (day, eventType, count)[] — flat, the chart pre-pivots totalOnPage() // number for the donut centre label hasChartData() // gate the section out when the page is empty ``` All four derive from the **current page only** (`page()?.items`). No new BFF endpoint — server-side aggregations across the full filter set would need a `/api/admin/audit/stats` resource that's worth its own ADR + chantier when the use case appears. ### Section markup — [`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html) A new `<section class="charts">` between the status bar and the existing table, gated on `hasChartData()`: ``` [At a glance — current page] [charts-note: "Aggregations are computed from the events currently loaded on this page only…"] ┌─ Events per day (bar) ─┬─ Outcome breakdown (donut) ─┐ ├─ Events per day, by event type (stacked bar) — wide ┤ └──────────────────────────────────────────────────────┘ ``` The stacked-bar tile gets `.chart-tile--wide` (spans both grid columns) since its legend benefits from the horizontal space. The two-column grid collapses to one column under 800 px viewports. ### SCSS — [`audit.scss`](apps/portal-admin/src/app/pages/audit/audit.scss) `.charts` shares the same surface tokens as `.filters` + `.table-wrap` (white / gray-800 background, 1 px border, 0.5 rem radius) so the page reads as one stack of related blocks. `.charts-grid` is `display: grid; grid-template-columns: repeat(2, 1fr)` with a `@media (max-width: 800px)` fallback to single-column. ### Project budget — [`apps/portal-admin/project.json`](apps/portal-admin/project.json) `anyComponentStyle` budget bumped from 5/6 KB warn/error to **6/8 KB**. `audit.scss` lands at ~6.5 KB after the charts grid additions, comfortably under the new ceiling. The old 5/6 KB threshold predated the charts row; this is a one-time accommodation, not a global relaxation. ### Tsconfig wiring — [`apps/portal-admin/tsconfig.app.json`](apps/portal-admin/tsconfig.app.json) `nx sync` added the new project reference to `libs/shared/charts/tsconfig.lib.json` after the `import { BarChart, … } from 'shared-charts'` in `audit.ts`. Standard plumbing. ### Spec — [`audit.spec.ts`](apps/portal-admin/src/app/pages/audit/audit.spec.ts) Three new assertions under a `charts` describe block: 1. The three `<lib-*-chart>` elements render when the page has data. 2. `<section class="charts">` is **absent** when the page is empty (no half-rendered donut on `{ total: 0, items: [] }`). 3. The donut's `.donut-center-label` reads the page's item count — verifies the `[centerLabel]` binding wired correctly. ## Notes for the reviewer - **Why charts only on the current page, not on the full result set?** Per the "Don't add features beyond what the task requires" rule from CLAUDE.md. The user's brief was "tester les composants sur la page Audit log", not "design a full analytics dashboard". The `.charts-note` paragraph explicitly says "computed from the events currently loaded on this page only" so the limitation is **disclosed**, not hidden. A future server-side `/api/admin/audit/stats` (with `audit_reader`-scoped queries + caching) is the natural follow-up if real investigative use exposes the per-page scope as a friction point. - **Why `<section>` with its own `<h2>` rather than just inline tiles?** A11y. Per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md), document structure is a first-class concern. The charts are a meaningful sub-region of the page; landmark + heading help screen-reader users navigate. - **Why no i18n marks on the captions?** Portal-admin chrome stays in source locale per ADR-0020. The audit page has zero i18n markers today (filter labels, table headers, error messages are all plain English); the chart captions land in the same posture for consistency. If portal-admin grows i18n later, the captions get marked in the same sweep as the rest of the page. - **Bundle impact reality-check**: the chart-bearing lazy `audit` chunk grew from ~4.4 KB gzip to **84 KB gzip** with the chart deps loaded. ADR-0023 estimated ~65 KB; the actual is higher because Plot pulls in more `d3-scale-chromatic` interpolators than I projected. Still well under the 100 KB cap from [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md), but worth noting if a fourth chart type with its own d3 submodule lands (line / scatter / heatmap will push it further). - **No anonymous-state regression on the table**: the existing trace-link + actor-pivot behaviours from #163 are unaffected — the charts section sits between the status bar and the table, doesn't share any markup. ## Test plan - [x] `pnpm nx test portal-admin` — **57 specs pass** (was 54; +3 for the new charts assertions). - [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-charts` — 9/9 tasks green. - [x] Audit lazy chunk: **84 KB gzip** (was 4.4 KB before the chart deps loaded); under the 100 KB lazy-chunk budget. - [ ] **Manual visual smoke** — sign in to portal-admin with `Portal.Admin` → navigate `/audit`: - The three charts render above the filter form, in a 2-column grid (stacked-bar spanning both). - The donut centre label reads the current page's event count. - Apply a filter that narrows the page to ~3 events → charts re-render with the narrowed dataset. - Click the `<details>` "Data table" disclosure under each chart → the tabular fallback expands with the exact rows. - Toggle dark mode in the footer → axis text + chart envelope swap; the donut's Cividis-equivalent palette kicks in on the categorical chart too (palette already swapped by `resolveTheme()` in the lib). - Filter to an empty result set → the `.charts` section disappears, the "No audit events match the current filters" empty-state stays. ## What's next Chantier closed. Three light follow-ups stay open but optional: - **Server-side aggregations** if the per-page scope becomes a real friction point. New BFF endpoint + likely a fourth `time-range` selector on the SPA. - **More chart types** (line / scatter / heatmap) when business modules ask for them. The lib's `_internal/` carries the a11y plumbing already; each new component is the ~50 LOC Plot wrapper + spec. - **Promote shared chart styling** to a fourth shared concern in `libs/shared/ui/` if a third app ever joins. Not urgent.
julien added 1 commit 2026-05-16 22:16:51 +02:00
feat(portal-admin): audit dashboard — bar / donut / stacked-bar above the table
CI / commits (pull_request) Successful in 2m51s
CI / scan (pull_request) Successful in 3m6s
CI / check (pull_request) Successful in 3m16s
CI / a11y (pull_request) Successful in 2m7s
CI / perf (pull_request) Successful in 6m34s
7d56802e1d
PR 3 of the charts chantier — closes the loop on ADR-0023 by
wiring the three starter components from #171 onto the /audit
page.

Three computed signals derive aggregations from the *current page's*
items (no new BFF endpoint — server-side stats land separately if
ever needed):

  * `dailyVolume` — events grouped by ISO date (YYYY-MM-DD).
  * `outcomeBreakdown` — counts per outcome.
  * `dailyByEventType` — flat (day, eventType, count) triples.

A new `<section class="charts">` renders above the existing filter
form, behind a `hasChartData()` guard so the section disappears
entirely on empty pages (no "0 events" donut, no half-rendered
bars). Each chart sits in a `.chart-tile`; the stacked-bar gets
`.chart-tile--wide` to span both grid columns since its legend
benefits from the extra horizontal space.

A `.charts-note` paragraph above the grid says explicitly that
the aggregations are "computed from the events currently loaded
on this page only" — honest disclosure of the per-page scope, no
misleading "all-time" framing.

Captions / descriptions / aria-labels live in plain English in the
template per ADR-0020's source-locale-only chrome posture. No new
i18n strings.

Bundle impact: the lazy `audit` chunk grows from ~4.4 KB to ~84 KB
gzip with the chart deps loaded — comfortable under [ADR-0017]'s
100 KB cap. The estimate in ADR-0023 was ~65 KB; Plot's full mark
set + d3-scale-chromatic interpolators add a bit more than
projected but still fit the budget.

Side tweaks:
  * `apps/portal-admin/project.json` bumps the `anyComponentStyle`
    budget from 5/6 KB to 6/8 KB to accommodate the charts grid
    SCSS (audit.scss lands at ~6.5 KB).
  * `apps/portal-admin/tsconfig.app.json` references the new
    `libs/shared/charts/tsconfig.lib.json` (auto-added by `nx
    sync` after the import in audit.ts).

Verification:
  * 57 portal-admin specs pass (was 54; +3 for the charts section
    rendering, the empty-page hide guard, and the donut center
    label binding).
  * `pnpm nx run-many -t lint test build --projects=portal-shell,
    portal-admin,shared-charts` — green.
  * Audit chunk gzip = 84 KB, under the budget.
julien merged commit 209f44d667 into main 2026-05-16 22:18:23 +02:00
julien deleted branch feat/portal-admin-audit-charts 2026-05-16 22:18:26 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#172