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
This commit is contained in:
2026-07-06 12:35:54 +02:00
parent a2e102f3dd
commit 7ef332999e
9 changed files with 334 additions and 32 deletions
+65
View File
@@ -181,3 +181,68 @@ describe("reportSummary — duration (sessions cache) + subscriptions", () => {
expect(r.subscriptions.coveredCars).toBe(2);
});
});
describe("reportSummary — occupancy, heatmap, stay histogram, look-closer counters (2026-07-05)", () => {
it("folds prior ledger into occupancyStart and walks occupancyEnd through the series", async () => {
// Before the range: 3 entries, 1 exit → 2 cars inside when June opens.
await entry(at("2026-05-20T08:00:00Z"));
await entry(at("2026-05-20T09:00:00Z"));
await entry(at("2026-05-21T10:00:00Z"));
await exit(at("2026-05-21T12:00:00Z"));
// In range: +2 on the 10th, −1 on the 11th.
await entry(at("2026-06-10T08:00:00Z"));
await entry(at("2026-06-10T09:00:00Z"));
await exit(at("2026-06-11T09:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.occupancyStart).toBe(2);
expect(r.series.map((p) => [p.bucket, p.occupancyEnd])).toEqual([
["2026-06-10", 4],
["2026-06-11", 3],
]);
});
it("a voided pre-range entry does not inflate occupancyStart", async () => {
const id = randomUUID();
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-05-20T08:00:00Z") });
await log.append({ type: "void", identity: id, occurredAt: at("2026-05-20T08:05:00Z"), payload: { reason: "misprint" } });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.occupancyStart).toBe(0);
});
it("entriesByDowHour lands on the local weekday/hour (row 0 = Monday)", async () => {
// 2026-06-10 is a WEDNESDAY; 08:00Z = 10:00 in Tirane (UTC+2 in June).
await entry(at("2026-06-10T08:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.entriesByDowHour[2]![10]).toBe(1); // Wed row, 10h column
expect(r.entriesByDowHour.flat().reduce((a, b) => a + b, 0)).toBe(1);
});
it("stay histogram buckets closed sessions; series carries the cash/card split", async () => {
db.insert(sessions).values({ id: "h1", identity: "h1", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T08:20:00Z"), state: "closed" }).run(); // 20m → ≤30
db.insert(sessions).values({ id: "h2", identity: "h2", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T09:30:00Z"), state: "closed" }).run(); // 90m → ≤120
db.insert(sessions).values({ id: "h3", identity: "h3", enteredAt: at("2026-06-08T08:00:00Z"), exitedAt: at("2026-06-10T09:00:00Z"), state: "closed" }).run(); // 2 days → >24h tail
await payment(at("2026-06-10T09:00:00Z"), 500, { tender: "cash" });
await payment(at("2026-06-10T09:30:00Z"), 700, { tender: "card" });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
const counts = Object.fromEntries(r.stayHistogram.map((b) => [String(b.uptoMin), b.count]));
expect(counts["30"]).toBe(1);
expect(counts["120"]).toBe(1);
expect(counts["null"]).toBe(1);
const day = r.series.find((p) => p.bucket === "2026-06-10")!;
expect(day.cashMinor).toBe(500);
expect(day.cardMinor).toBe(700);
});
it("counts voids and anomalies in range (the look-closer counters)", async () => {
const id = randomUUID();
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-06-10T08:00:00Z") });
await log.append({ type: "void", identity: id, occurredAt: at("2026-06-10T08:05:00Z"), payload: { reason: "misprint" } });
await log.append({ type: "anomaly", identity: "X", occurredAt: at("2026-06-10T09:00:00Z"), payload: { reason: "test" } });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.voids).toBe(1);
expect(r.totals.anomalies).toBe(1);
expect(r.totals.entries).toBe(0); // the voided entry stays excluded
});
});
+105 -14
View File
@@ -4,9 +4,11 @@ import {
desc,
eq,
gte,
lt,
lte,
ledgerEvents,
sessions,
siteConfig,
subscriptions,
tariffVersions,
tariffs,
@@ -46,8 +48,13 @@ export interface SeriesPoint {
readonly exits: number;
/** Net transient revenue collected in the bucket (minor units), all tenders. */
readonly revenueMinor: number;
/** Tender split of the bucket's revenue (cash = everything not card). */
readonly cashMinor: number;
readonly cardMinor: number;
/** Payment COUNT in the bucket (transactions, not amount). */
readonly payments: number;
/** Cars inside at the END of the bucket (occupancyStart + running entries−exits). */
readonly occupancyEnd: number;
}
export interface ReportTotals {
@@ -67,6 +74,10 @@ export interface ReportTotals {
readonly totalParkedMinutes: number;
readonly avgParkedMinutes: number;
readonly medianParkedMinutes: number;
/** Cancelled tickets + signed anomalies in range — the "look closer" counters
* (the operator at the booth is the threat model's primary adversary). */
readonly voids: number;
readonly anomalies: number;
}
export interface SubscriptionStats {
@@ -79,6 +90,13 @@ export interface SubscriptionStats {
readonly coveredCars: number;
}
/** One bar of the stay-duration histogram: stays up to `uptoMin` minutes (null = the
* open-ended tail). Edges chosen to mirror how tariffs are designed (see tariff.md). */
export interface StayBucket {
readonly uptoMin: number | null;
readonly count: number;
}
export interface ReportSummary {
readonly from: string;
readonly to: string;
@@ -89,25 +107,43 @@ export interface ReportSummary {
readonly series: SeriesPoint[];
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
readonly entriesByHour: number[];
/** Entries by [day-of-week][hour-of-day] — 7×24, row 0 = Monday. The heatmap that
* shows weekday-vs-weekend patterns (feeds tariff-window design). */
readonly entriesByDowHour: number[][];
/** Stay-duration histogram over closed sessions in range. */
readonly stayHistogram: StayBucket[];
/** Cars inside when the range OPENS (folded from the whole prior ledger). */
readonly occupancyStart: number;
/** Nominal capacity from site config (null = uncapped) — the reference line. */
readonly capacity: number | null;
readonly subscriptions: SubscriptionStats;
}
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
});
const fmtCache = new Map<string, Intl.DateTimeFormat>();
const DOW_INDEX: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number; dow: number } {
// Cached per tz — this runs once per ledger row in a report.
let fmt = fmtCache.get(tz);
if (!fmt) {
fmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
weekday: "short",
});
fmtCache.set(tz, fmt);
}
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
return {
y: Number(parts.year),
mo: Number(parts.month),
d: Number(parts.day),
h: Number(parts.hour),
dow: DOW_INDEX[parts.weekday ?? ""] ?? 0, // row 0 = Monday
};
}
@@ -162,6 +198,7 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
const seriesMap = new Map<string, SeriesPoint>();
const entriesByHour = new Array<number>(24).fill(0);
const entriesByDowHour = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
const totals = {
entries: 0,
exits: 0,
@@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
ticketMinor: 0,
subscriptionSalesMinor: 0,
subscriptionWindowMinor: 0,
voids: 0,
anomalies: 0,
};
function point(label: string): SeriesPoint {
let p = seriesMap.get(label);
if (!p) {
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 };
seriesMap.set(label, p);
}
return p;
@@ -196,11 +235,16 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
totals.entries++;
p.entries++;
const h = localParts(row.occurredAt, tz).h;
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
const lp = localParts(row.occurredAt, tz);
entriesByHour[lp.h] = (entriesByHour[lp.h] ?? 0) + 1;
entriesByDowHour[lp.dow]![lp.h] = (entriesByDowHour[lp.dow]![lp.h] ?? 0) + 1;
} else if (row.type === "vehicle_exit") {
totals.exits++;
p.exits++;
} else if (row.type === "void") {
totals.voids++;
} else if (row.type === "anomaly") {
totals.anomalies++;
} else if (row.type === "payment") {
const pl = (row.payload ?? {}) as PaymentPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
@@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
totals.revenueMinor += amt;
p.payments++;
p.revenueMinor += amt;
if (pl.tender === "card") totals.cardMinor += amt;
else totals.cashMinor += amt;
if (pl.tender === "card") {
totals.cardMinor += amt;
p.cardMinor += amt;
} else {
totals.cashMinor += amt;
p.cashMinor += amt;
}
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
// (the rest is) transient ticket revenue.
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
@@ -221,6 +270,31 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
// --- Occupancy: fold the PRIOR ledger for cars-inside at range start, then walk the
// series. Voided pre-range entries cancel out the same way the in-range pass does.
// Sparse buckets (no events) simply carry the previous level — the step line is exact
// at every plotted point.
const prior = db
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
.from(ledgerEvents)
.where(lt(ledgerEvents.occurredAt, q.from))
.all();
const priorVoided = new Set<string>();
for (const r of prior) if (r.type === "void" && r.identity) priorVoided.add(r.identity);
let occupancyStart = 0;
for (const r of prior) {
if (r.type === "vehicle_entry" && !(r.identity && priorVoided.has(r.identity))) occupancyStart++;
else if (r.type === "vehicle_exit") occupancyStart--;
}
occupancyStart = Math.max(0, occupancyStart);
let running = occupancyStart;
for (const p of series) {
running = Math.max(0, running + p.entries - p.exits);
(p as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] }).occupancyEnd = running;
}
const capacity = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get()?.capacity ?? null;
// No payment in range? Fall back to the site tariff's latest version currency, so a
// zero-revenue range still labels its money column.
if (!currency) {
@@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
durations.sort((a, b) => a - b);
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
// Stay-duration histogram. Edges mirror how rate cards are designed (30m/1h bands,
// the 8h working day, the 24h rolling day) so the chart answers "where should the
// ladder/up-to breakpoints sit". Last bucket is the open-ended >24h tail.
const STAY_EDGES_MIN = [30, 60, 120, 240, 480, 1440];
const stayHistogram: { uptoMin: number | null; count: number }[] = [
...STAY_EDGES_MIN.map((uptoMin) => ({ uptoMin, count: 0 })),
{ uptoMin: null, count: 0 },
];
for (const mins of durations) {
const i = STAY_EDGES_MIN.findIndex((edge) => mins <= edge);
stayHistogram[i === -1 ? STAY_EDGES_MIN.length : i]!.count++;
}
// --- Subscriptions: status counts + currently-valid (window covers `to`).
const subs = db.select().from(subscriptions).all();
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
@@ -283,6 +370,10 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
},
series,
entriesByHour,
entriesByDowHour,
stayHistogram,
occupancyStart,
capacity,
subscriptions: subStats,
};
}
+11 -2
View File
@@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void>
async (req, reply) => {
const summary = reportSummary(db, parseQuery(req.query));
const lines = [
"bucket,entries,exits,payments,revenue",
"bucket,entries,exits,payments,revenue,cash,card,occupancy_end",
...summary.series.map((p) =>
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
[
p.bucket,
p.entries,
p.exits,
p.payments,
(p.revenueMinor / 100).toFixed(2),
(p.cashMinor / 100).toFixed(2),
(p.cardMinor / 100).toFixed(2),
p.occupancyEnd,
].join(","),
),
];
reply