7ef332999e
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
104 lines
6.4 KiB
Markdown
104 lines
6.4 KiB
Markdown
---
|
||
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.
|