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); 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
});
});
+98 -7
View File
@@ -4,9 +4,11 @@ import {
desc, desc,
eq, eq,
gte, gte,
lt,
lte, lte,
ledgerEvents, ledgerEvents,
sessions, sessions,
siteConfig,
subscriptions, subscriptions,
tariffVersions, tariffVersions,
tariffs, tariffs,
@@ -46,8 +48,13 @@ export interface SeriesPoint {
readonly exits: number; readonly exits: number;
/** Net transient revenue collected in the bucket (minor units), all tenders. */ /** Net transient revenue collected in the bucket (minor units), all tenders. */
readonly revenueMinor: number; 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). */ /** Payment COUNT in the bucket (transactions, not amount). */
readonly payments: number; readonly payments: number;
/** Cars inside at the END of the bucket (occupancyStart + running entries−exits). */
readonly occupancyEnd: number;
} }
export interface ReportTotals { export interface ReportTotals {
@@ -67,6 +74,10 @@ export interface ReportTotals {
readonly totalParkedMinutes: number; readonly totalParkedMinutes: number;
readonly avgParkedMinutes: number; readonly avgParkedMinutes: number;
readonly medianParkedMinutes: 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 { export interface SubscriptionStats {
@@ -79,6 +90,13 @@ export interface SubscriptionStats {
readonly coveredCars: number; 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 { export interface ReportSummary {
readonly from: string; readonly from: string;
readonly to: string; readonly to: string;
@@ -89,25 +107,43 @@ export interface ReportSummary {
readonly series: SeriesPoint[]; readonly series: SeriesPoint[];
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */ /** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
readonly entriesByHour: number[]; 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; readonly subscriptions: SubscriptionStats;
} }
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */ /** 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 fmtCache = new Map<string, Intl.DateTimeFormat>();
const fmt = new Intl.DateTimeFormat("en-CA", { 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, timeZone: tz,
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
hour: "2-digit", hour: "2-digit",
hourCycle: "h23", hourCycle: "h23",
weekday: "short",
}); });
fmtCache.set(tz, fmt);
}
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value])); const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
return { return {
y: Number(parts.year), y: Number(parts.year),
mo: Number(parts.month), mo: Number(parts.month),
d: Number(parts.day), d: Number(parts.day),
h: Number(parts.hour), 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 seriesMap = new Map<string, SeriesPoint>();
const entriesByHour = new Array<number>(24).fill(0); const entriesByHour = new Array<number>(24).fill(0);
const entriesByDowHour = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
const totals = { const totals = {
entries: 0, entries: 0,
exits: 0, exits: 0,
@@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
ticketMinor: 0, ticketMinor: 0,
subscriptionSalesMinor: 0, subscriptionSalesMinor: 0,
subscriptionWindowMinor: 0, subscriptionWindowMinor: 0,
voids: 0,
anomalies: 0,
}; };
function point(label: string): SeriesPoint { function point(label: string): SeriesPoint {
let p = seriesMap.get(label); let p = seriesMap.get(label);
if (!p) { 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); seriesMap.set(label, p);
} }
return 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 if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
totals.entries++; totals.entries++;
p.entries++; p.entries++;
const h = localParts(row.occurredAt, tz).h; const lp = localParts(row.occurredAt, tz);
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1; 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") { } else if (row.type === "vehicle_exit") {
totals.exits++; totals.exits++;
p.exits++; p.exits++;
} else if (row.type === "void") {
totals.voids++;
} else if (row.type === "anomaly") {
totals.anomalies++;
} else if (row.type === "payment") { } else if (row.type === "payment") {
const pl = (row.payload ?? {}) as PaymentPayload; const pl = (row.payload ?? {}) as PaymentPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
@@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
totals.revenueMinor += amt; totals.revenueMinor += amt;
p.payments++; p.payments++;
p.revenueMinor += amt; p.revenueMinor += amt;
if (pl.tender === "card") totals.cardMinor += amt; if (pl.tender === "card") {
else totals.cashMinor += amt; totals.cardMinor += amt;
p.cardMinor += amt;
} else {
totals.cashMinor += amt;
p.cashMinor += amt;
}
// Revenue split mirrors the shift Z-report: subscription sale / window charge / // Revenue split mirrors the shift Z-report: subscription sale / window charge /
// (the rest is) transient ticket revenue. // (the rest is) transient ticket revenue.
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt; 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)); 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 // No payment in range? Fall back to the site tariff's latest version currency, so a
// zero-revenue range still labels its money column. // zero-revenue range still labels its money column.
if (!currency) { if (!currency) {
@@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
durations.sort((a, b) => a - b); durations.sort((a, b) => a - b);
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0); 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`). // --- Subscriptions: status counts + currently-valid (window covers `to`).
const subs = db.select().from(subscriptions).all(); const subs = db.select().from(subscriptions).all();
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 }; 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, series,
entriesByHour, entriesByHour,
entriesByDowHour,
stayHistogram,
occupancyStart,
capacity,
subscriptions: subStats, subscriptions: subStats,
}; };
} }
+11 -2
View File
@@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void>
async (req, reply) => { async (req, reply) => {
const summary = reportSummary(db, parseQuery(req.query)); const summary = reportSummary(db, parseQuery(req.query));
const lines = [ const lines = [
"bucket,entries,exits,payments,revenue", "bucket,entries,exits,payments,revenue,cash,card,occupancy_end",
...summary.series.map((p) => ...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 reply
+95 -12
View File
@@ -1,8 +1,10 @@
import { useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Area,
AreaChart,
Bar, Bar,
BarChart, BarChart,
CartesianGrid, CartesianGrid,
@@ -12,6 +14,7 @@ import {
LineChart, LineChart,
Pie, Pie,
PieChart, PieChart,
ReferenceLine,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
@@ -37,6 +40,7 @@ const C = {
border: "#2a2f38", border: "#2a2f38",
text: "#f2f2ee", text: "#f2f2ee",
panel: "#14171c", panel: "#14171c",
panel2: "#1e222a",
}; };
type PresetKey = "today" | "7d" | "30d" | "90d"; type PresetKey = "today" | "7d" | "30d" | "90d";
@@ -140,27 +144,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
...p, ...p,
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket, label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
revenue: p.revenueMinor / 100, revenue: p.revenueMinor / 100,
cash: p.cashMinor / 100,
card: p.cardMinor / 100,
})); }));
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
const mix = [ const mix = [
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber }, { name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan }, { name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green }, { name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
].filter((s) => s.value > 0); ].filter((s) => s.value > 0);
const peakOcc = Math.max(data.occupancyStart, ...data.series.map((p) => p.occupancyEnd));
// Stay-duration bars: "≤30m … ≤24h" + the open-ended tail.
const stay = data.stayHistogram.map((b) => ({
label: b.uptoMin == null ? `>24${t("reports.stay.h")}` : b.uptoMin < 60 ? `≤${b.uptoMin}${t("reports.stay.m")}` : `≤${b.uptoMin / 60}${t("reports.stay.h")}`,
count: b.count,
}));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* KPI cards. */} {/* KPI cards. */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-8">
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" /> <Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" /> <Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" /> <Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" /> <Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} /> <Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
<Kpi <Kpi
label={t("reports.kpi.subscribers")} label={t("reports.kpi.peakOcc")}
value={String(data.subscriptions.currentlyValid)} value={data.capacity ? `${peakOcc} / ${data.capacity}` : String(peakOcc)}
/> />
{/* The "look closer" counters — a spike here is what the signed chain is FOR. */}
<Kpi label={t("reports.kpi.voids")} value={String(tot.voids)} accent={tot.voids > 0 ? "amber" : undefined} />
<Kpi label={t("reports.kpi.anomalies")} value={String(tot.anomalies)} accent={tot.anomalies > 0 ? "red" : undefined} />
</div> </div>
{/* Entry / exit over time. */} {/* Entry / exit over time. */}
@@ -192,8 +206,38 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
</ResponsiveContainer> </ResponsiveContainer>
</Panel> </Panel>
{/* Occupancy over time — THE parking curve: cars inside vs capacity. Step-shaped
(occupancy only moves at entries/exits); the red line is the configured cap. */}
<Panel title={t("reports.chart.occupancy")}>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
<Tooltip contentStyle={tooltipStyle} />
{data.capacity != null && (
<ReferenceLine
y={data.capacity}
stroke={C.red}
strokeDasharray="4 4"
label={{ value: t("reports.capacityLine"), fill: C.red, fontSize: 11, position: "insideTopRight" }}
/>
)}
<Area
type="stepAfter"
dataKey="occupancyEnd"
name={t("reports.chart.occupancySeries")}
stroke={C.cyan}
fill={C.cyan}
fillOpacity={0.15}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</Panel>
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
{/* Revenue per bucket. */} {/* Revenue per bucket, stacked by tender — the drawer's cash vs the bank's card. */}
<Panel title={t("reports.chart.revenue", { currency: cur })}> <Panel title={t("reports.chart.revenue", { currency: cur })}>
<ResponsiveContainer width="100%" height={240}> <ResponsiveContainer width="100%" height={240}>
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}> <BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
@@ -201,7 +245,9 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
<XAxis dataKey="label" stroke={C.muted} fontSize={11} /> <XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} /> <YAxis stroke={C.muted} fontSize={11} />
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} /> <Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} /> <Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="cash" stackId="tender" name={t("reports.row.cash")} fill={C.amber} />
<Bar dataKey="card" stackId="tender" name={t("reports.row.card")} fill={C.cyan} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</Panel> </Panel>
@@ -232,15 +278,15 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
)} )}
</Panel> </Panel>
{/* Peak hours (entries by hour-of-day). */} {/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */}
<Panel title={t("reports.chart.peakHours")}> <Panel title={t("reports.chart.stay")}>
<ResponsiveContainer width="100%" height={240}> <ResponsiveContainer width="100%" height={240}>
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}> <BarChart data={stay} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" /> <CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} /> <XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} /> <YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
<Tooltip contentStyle={tooltipStyle} /> <Tooltip contentStyle={tooltipStyle} />
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} /> <Bar dataKey="count" name={t("reports.row.closed")} fill={C.green} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</Panel> </Panel>
@@ -262,6 +308,12 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
</Panel> </Panel>
</div> </div>
{/* Entries heatmap: hour × day-of-week. Weekday-vs-weekend patterns at a glance —
the direct input for tariff windows (night rates, weekend cards, early bird). */}
<Panel title={t("reports.chart.heatmap")}>
<Heatmap matrix={data.entriesByDowHour} dows={t("reports.dowShort", { returnObjects: true }) as string[]} />
</Panel>
<p className="text-[0.6875rem] text-term-muted"> <p className="text-[0.6875rem] text-term-muted">
{t("reports.footnote", { tz: data.tz })} {t("reports.footnote", { tz: data.tz })}
</p> </p>
@@ -269,6 +321,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
); );
} }
/** Hour-of-day × day-of-week entries heatmap: pure CSS grid, amber intensity scaled to
* the busiest cell. Row 0 = Monday (server contract). Cell tooltip = exact count. */
function Heatmap({ matrix, dows }: { matrix: number[][]; dows: string[] }) {
const max = Math.max(1, ...matrix.flat());
return (
<div className="overflow-x-auto">
<div className="grid min-w-[560px] grid-cols-[max-content_repeat(24,1fr)] gap-px text-[0.625rem]">
<span />
{Array.from({ length: 24 }, (_, h) => (
<span key={h} className="pb-0.5 text-center text-term-muted">
{h % 3 === 0 ? h : ""}
</span>
))}
{matrix.map((row, d) => (
<Fragment key={d}>
<span className="pr-1.5 leading-4 text-term-muted">{dows[d]}</span>
{row.map((v, h) => (
<span
key={h}
title={`${dows[d]} ${String(h).padStart(2, "0")}:00 — ${v}`}
className="h-4 rounded-[1px]"
style={{ background: v === 0 ? C.panel2 : C.amber, opacity: v === 0 ? 1 : 0.25 + 0.75 * (v / max) }}
/>
))}
</Fragment>
))}
</div>
</div>
);
}
const tooltipStyle = { const tooltipStyle = {
background: C.panel, background: C.panel,
border: `1px solid ${C.border}`, border: `1px solid ${C.border}`,
+12
View File
@@ -483,7 +483,11 @@ export interface ReportSeriesPoint {
entries: number; entries: number;
exits: number; exits: number;
revenueMinor: number; revenueMinor: number;
cashMinor: number;
cardMinor: number;
payments: number; payments: number;
/** Cars inside at the END of the bucket. */
occupancyEnd: number;
} }
export interface ReportTotals { export interface ReportTotals {
@@ -500,6 +504,8 @@ export interface ReportTotals {
totalParkedMinutes: number; totalParkedMinutes: number;
avgParkedMinutes: number; avgParkedMinutes: number;
medianParkedMinutes: number; medianParkedMinutes: number;
voids: number;
anomalies: number;
} }
export interface ReportSubscriptionStats { export interface ReportSubscriptionStats {
@@ -519,6 +525,12 @@ export interface ReportSummary {
totals: ReportTotals; totals: ReportTotals;
series: ReportSeriesPoint[]; series: ReportSeriesPoint[];
entriesByHour: number[]; entriesByHour: number[];
/** 7×24, row 0 = Monday — entries heatmap (weekday-vs-weekend patterns). */
entriesByDowHour: number[][];
/** Stay-duration histogram; last bucket has uptoMin null (>24h tail). */
stayHistogram: { uptoMin: number | null; count: number }[];
occupancyStart: number;
capacity: number | null;
subscriptions: ReportSubscriptionStats; subscriptions: ReportSubscriptionStats;
} }
+10 -1
View File
@@ -866,14 +866,23 @@ export const en: Catalog = {
payments: "Payments", payments: "Payments",
avgStay: "Avg stay", avgStay: "Avg stay",
subscribers: "Subscribers", subscribers: "Subscribers",
peakOcc: "Peak occupancy",
voids: "Voided tickets",
anomalies: "Anomalies",
}, },
chart: { chart: {
flow: "Entries & exits over time", flow: "Entries & exits over time",
occupancy: "Occupancy — cars inside",
occupancySeries: "Cars inside",
revenue: "Revenue ({{currency}})", revenue: "Revenue ({{currency}})",
mix: "Revenue mix", mix: "Revenue mix",
peakHours: "Entries by hour of day", stay: "Stay duration (closed sessions)",
heatmap: "Entries heatmap — hour × day",
breakdown: "Breakdown", breakdown: "Breakdown",
}, },
capacityLine: "capacity",
stay: { m: "m", h: "h" },
dowShort: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" }, mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
row: { row: {
cash: "Cash", cash: "Cash",
+10 -1
View File
@@ -881,14 +881,23 @@ export const sq = {
payments: "Pagesa", payments: "Pagesa",
avgStay: "Qëndrim mes.", avgStay: "Qëndrim mes.",
subscribers: "Abonentë", subscribers: "Abonentë",
peakOcc: "Zënia maksimale",
voids: "Bileta të anuluara",
anomalies: "Anomali",
}, },
chart: { chart: {
flow: "Hyrjet & daljet me kalimin e kohës", flow: "Hyrjet & daljet me kalimin e kohës",
occupancy: "Zënia — makina brenda",
occupancySeries: "Makina brenda",
revenue: "Të ardhurat ({{currency}})", revenue: "Të ardhurat ({{currency}})",
mix: "Përbërja e të ardhurave", mix: "Përbërja e të ardhurave",
peakHours: "Hyrjet sipas orës së ditës", stay: "Kohëzgjatja e qëndrimit (sesione të mbyllura)",
heatmap: "Harta e hyrjeve — orë × ditë",
breakdown: "Ndarja", breakdown: "Ndarja",
}, },
capacityLine: "kapaciteti",
stay: { m: "m", h: "o" },
dowShort: ["Hën", "Mar", "Mër", "Enj", "Pre", "Sht", "Die"],
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" }, mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
row: { row: {
cash: "Para në dorë", cash: "Para në dorë",
+1 -1
View File
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
export * from "./schema.js"; export * from "./schema.js";
// Re-export the query helpers consumers need, so they don't depend on // Re-export the query helpers consumers need, so they don't depend on
// drizzle-orm directly (it's an implementation detail of this package). // drizzle-orm directly (it's an implementation detail of this package).
export { eq, ne, and, or, asc, desc, gte, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm"; export { eq, ne, and, or, asc, desc, gt, gte, lt, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
/** /**
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers * Open the local SQLite database in WAL mode. WAL allows many concurrent readers
+25 -1
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, domain, business, reporting] tags: [parking, domain, business, reporting]
sources: [] sources: []
updated: 2026-06-22 updated: 2026-07-05
status: open status: open
--- ---
@@ -73,6 +73,30 @@ disputes ("I was charged for a car that left earlier"), lost-ticket lookup, and
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path - **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
([[open-questions]] #4). ([[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 ## Open
- Which reports matter at launch vs. later; the export format/cadence. - Which reports matter at launch vs. later; the export format/cadence.