feat(reports): admin Reports dashboard — ledger-first charts

Adds an admin Reports screen (/setup/reports, gated report:read) — an
on-demand dashboard over the signed event log.

Server (ledger-first): GET /api/reports/summary?from&to&bucket aggregates
in one call — entry/exit counts + all money summed straight from
ledger_events (same source the shift Z-report reconciles, so totals tie
out to the drawer); revenue split into ticket / subscription-sale /
out-of-window mirrors the Z-report. Duration stats come from the sessions
cache (flagged). All bucketing is in the SITE timezone (siteTz). A .csv
export of the per-bucket series. reports.ts + routes/reports.ts.

Web: Reports.tsx — date-range presets (today/7d/30d/90d), hour/day/month
grain, KPI cards, entry/exit line, revenue bar + cash/card split,
revenue-mix pie, peak-hours histogram, numeric breakdown, subscription
stats. Charts via Recharts (MIT), lazy-loaded into its own chunk
(~111KB gz) so the booth bundle is untouched. New Setup tab + nav + i18n
(sq + en parity). asc() exported from @parking/db; formatMinutes helper.

Tests: reports.test.ts (10) pin the sums, tz bucketing, money split,
duration stats, subscription counts. server 90/90; build+lint 14/14.

Wiki: reporting-analytics.md "Built v1" section + log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-22 00:16:07 +02:00
parent 742653aefb
commit 5a5f5c554b
16 changed files with 1358 additions and 5 deletions
+31 -1
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, domain, business, reporting]
sources: []
updated: 2026-06-15
updated: 2026-06-22
status: open
---
@@ -12,6 +12,36 @@ Turning the signed event log into the numbers an owner runs the business on. All
**projections over the [[append-only-event-chain]]** — the chain is the single source, reports are
derived and rebuildable, never a separate ledger.
## Built — admin Reports dashboard v1 (2026-06-22)
A first cut shipped: an admin **Reports** screen (`/setup/reports`, gated on `report:read`), an
on-demand **dashboard** (not a live feed). Server aggregates everything in **one call**
(`GET /api/reports/summary?from&to&bucket`) so the SPA only renders; `…/summary.csv` exports the
per-bucket series. Code: `apps/server/src/reports.ts` (+ `routes/reports.ts`), `apps/web/src/Reports.tsx`.
- **Ledger-first** (decision 2026-06-22). Entry/exit **counts** and all **money** are summed
straight from the signed `ledger_events` — the SAME source the `shift_z_report` reconciles, so a
chart total always ties out to the drawer. The revenue **split** (transient ticket /
subscription sale / out-of-window window-charge) mirrors the Z-report's split exactly
(`subscriptionSale` / `subscriptionWindowCharge` payload flags). Duration/occupancy stats are the
one exception: read from the derived `sessions` cache (pairing each entry with its exit on the
chain by hand is awkward) — flagged as a cache, not the financial truth.
- **Site-timezone bucketing.** A "day"/"hour" bucket is **local wall-clock** in `siteConfig.timezone`
(reuses `siteTz()`), so a 23:30Z entry lands on the right local date and the peak-hour histogram
reads in wall-clock. Bucket grain: hour / day / month, with date-range presets (today / 7d / 30d / 90d).
- **Views:** KPI cards (entries, exits, revenue, payments, avg stay, current subscribers); entry/exit
line; revenue bar (per bucket) + cash/card split; revenue-mix pie; **peak-hours** histogram
(entries by local hour-of-day); a numeric breakdown (cash/card, the 3-way revenue split, closed
sessions, avg/median stay, active subs + cars covered); subscription status counts + currently-valid
coverage as of the range end. Charts via **Recharts** (MIT), **lazy-loaded** into its own bundle
chunk so the booth never downloads it. Tested: `reports.test.ts` (10) pin the sums, the tz bucketing,
the money split, duration stats, and subscription counts.
**Not yet** (deferred from the list below): anomalies/voids reporting, per-operator takings, the
plate/entry search (next section), PDF export, and a live dashboard. The `report:read` permission
already existed for "events feed, occupancy, future reports" — this is its first real consumer
beyond the feed.
## Reports (driven by the events already designed)
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
+14
View File
@@ -1340,3 +1340,17 @@ deploy leaves it unset. Added a "Deploy-time server configuration (runbook)" sec
[[disk-os-hardening]] documenting COOKIE_SECURE=0 (+ JWT_SECRET / EVENT_SIGNING_KEY) and corrected
the stale "Secure when NODE_ENV=production" line on [[local-jwt-auth]]. auth.test.ts (5) pins the
matrix; server 80/80.
## [2026-06-22] feat | Admin Reports dashboard v1 (ledger-first charts) + camera "Test ANPR"
Built the admin Reports screen (`/setup/reports`, gated `report:read`): one server call
(`GET /api/reports/summary?from&to&bucket`, + `.csv` export) aggregates entry/exit counts and all
money straight from the signed `ledger_events` (LEDGER-FIRST decision) — the same source the
`shift_z_report` reconciles, so totals tie out to the drawer; the 3-way revenue split (ticket /
subscription sale / out-of-window) mirrors the Z-report. Duration/occupancy stats come from the
`sessions` cache (flagged). All bucketing is in the SITE timezone (`siteTz()`). Views: KPI cards,
entry/exit line, revenue bar + cash/card split, revenue-mix pie, peak-hours histogram, numeric
breakdown, subscription stats. Charts via Recharts (MIT), lazy-loaded into its own chunk (111KB gz)
so the booth bundle is untouched. reports.test.ts (10) pins the sums/tz/split/duration/subs; server
90/90, build+lint 14/14. Also (earlier same session): a camera "Test ANPR" probe in first-run setup
(`POST /api/setup/test-anpr`) — snapshot→vision analyze, fail-soft, shown only when a camera's ANPR
opt-in is checked. See [[reporting-analytics]], [[opencv-anpr-service]].