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

6.4 KiB
Raw Permalink Blame History

type, tags, sources, updated, status
type tags sources updated status
concept
parking
domain
business
reporting
2026-07-05 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 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 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.