Files
julian 7ef332999e feat(reports): occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
The dashboard had generic BI views but nothing parking-shaped. Added:

- Occupancy step-area over the range with the configured capacity as a
  red reference line. occupancyStart folds the ENTIRE prior ledger
  (voided entries excluded, clamped ≥0); each series point carries
  occupancyEnd. Answers "when are we near full".
- Entries heatmap hour × day-of-week (7×24, row 0 = Monday, site tz) as
  a pure CSS-grid intensity map — weekday-vs-weekend at a glance, the
  direct evidence for tariff windows. Replaces the flat hour histogram
  (strictly contains it).
- Stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h/
  tail): where ladder/up-to breakpoints should sit.
- Voids + anomalies KPIs (accented when >0) — the look-closer counters
  the signed chain exists for; peak-occupancy KPI (peak / capacity).
- Revenue bars stacked cash vs card (the drawer's money vs the bank's);
  CSV export gains cash, card, occupancy_end columns.

Internals: localParts caches its Intl formatter per tz (was one new
formatter per ledger row); @parking/db re-exports lt/gt. 5 new tests.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:35:54 +02:00

104 lines
6.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: concept
tags: [parking, domain, business, reporting]
sources: []
updated: 2026-07-05
status: open
---
# Reporting & Analytics
Turning the signed event log into the numbers an owner runs the business on. All reports are
**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 — a **top-level section** at **`/reports`** with its
own header nav entry (not nested under Setup), 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:
`payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]).
- **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover.
- **Stay analytics** — average/median duration, distribution; transient vs. [[subscription]] split.
- **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`.
- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation —
the `anomaly` events + reconciliation findings ([[reconciliation]]).
## Plate / entry search (admin lookup) — user-requested 2026-06-15
The admin can **search for an entry/session by licence plate** — *if the plate was captured* (by
the [[opencv-anpr-service|vision service]] or an LPR read; a pure-ticket transient has no plate).
Returns the matching session(s): entry/exit times, fee, payment, snapshot image. Useful for
disputes ("I was charged for a car that left earlier"), lost-ticket lookup, and incident review.
- Search keys: plate (when captured), ticket id, session id, time range.
- Read-only over the chain; surfaces the linked snapshot ([[lpr-camera]] `imageRef`) as evidence.
- Honest limit: **no plate → no plate-search hit.** The UI must say "not captured", not "no such
car", so the absence isn't mistaken for a missing record.
## Properties
- **Offline** ([[offline-first]]): all computed locally from the local DB; no cloud BI dependency.
- **Reproducible**: a report run twice over the same chain gives the same answer; figures trace to
signed events.
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
([[open-questions]] #4).
## As-built additions (2026-07-05) — the parking-shaped graphics
Operator ask: "check /reports for improvements and meaningful graphics." The dashboard (Recharts,
ledger-first aggregation in `apps/server/src/reports.ts`) gained the three views that are
parking-specific rather than generic BI, plus fraud counters:
- **Occupancy curve** — cars-inside step-area over the range with the configured capacity as a
red reference line. `occupancyStart` folds the ENTIRE prior ledger (voided entries excluded,
clamped ≥0); each series point carries `occupancyEnd`. The at-a-glance answer to "when are we
near full" — the input for capacity and dynamic-window decisions.
- **Entries heatmap (hour × day-of-week)** — 7×24 matrix (`entriesByDowHour`, row 0 = Monday,
site-tz), rendered as a pure CSS-grid amber-intensity map. Shows weekday-vs-weekend and
morning/evening patterns — the direct evidence for tariff windows (night rates, weekend cards,
early-bird — see [[tariff-industry-survey]]). Replaces the flat entries-by-hour bar (strictly
contains it).
- **Stay-duration histogram** — closed sessions bucketed at 30m/1h/2h/4h/8h/24h/tail
(`stayHistogram`): where the ladder/up-to breakpoints should sit.
- **Look-closer counters** — voids + anomalies in range as KPI cards (accented when >0): a spike
is exactly what the signed chain exists to surface (the operator is the threat model).
- Revenue bars now **stack cash vs card** per bucket (`cashMinor`/`cardMinor` on each point —
the drawer's money vs the bank's); peak-occupancy KPI (`peak / capacity`); CSV export gained
cash, card, occupancy_end columns. `localParts` now caches its Intl formatter per tz (was one
`new Intl.DateTimeFormat` per ledger row).
## Open
- Which reports matter at launch vs. later; the export format/cadence.
- Dashboard (live) vs. on-demand reports.