feat(reports): admin Reports dashboard — ledger-first charts

Adds an admin Reports screen (/setup/reports, gated report:read) — an
on-demand dashboard over the signed event log.

Server (ledger-first): GET /api/reports/summary?from&to&bucket aggregates
in one call — entry/exit counts + all money summed straight from
ledger_events (same source the shift Z-report reconciles, so totals tie
out to the drawer); revenue split into ticket / subscription-sale /
out-of-window mirrors the Z-report. Duration stats come from the sessions
cache (flagged). All bucketing is in the SITE timezone (siteTz). A .csv
export of the per-bucket series. reports.ts + routes/reports.ts.

Web: Reports.tsx — date-range presets (today/7d/30d/90d), hour/day/month
grain, KPI cards, entry/exit line, revenue bar + cash/card split,
revenue-mix pie, peak-hours histogram, numeric breakdown, subscription
stats. Charts via Recharts (MIT), lazy-loaded into its own chunk
(~111KB gz) so the booth bundle is untouched. New Setup tab + nav + i18n
(sq + en parity). asc() exported from @parking/db; formatMinutes helper.

Tests: reports.test.ts (10) pin the sums, tz bucketing, money split,
duration stats, subscription counts. server 90/90; build+lint 14/14.

Wiki: reporting-analytics.md "Built v1" section + log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-22 00:16:07 +02:00
parent 742653aefb
commit 5a5f5c554b
16 changed files with 1358 additions and 5 deletions
+183
View File
@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it } from "vitest";
import { sessions, siteConfig, subscriptions, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { randomUUID } from "node:crypto";
import { makeLog } from "./test-helpers.js";
import { reportSummary } from "./reports.js";
import type { EventLog } from "./event-log.js";
// Reports aggregation — LEDGER-FIRST. These pin that the numbers an admin sees are
// summed straight from the signed ledger (entry/exit counts + payment money, split the
// same way the shift Z-report splits it), bucketed in the SITE TIMEZONE, with duration
// stats from the closed-sessions cache and subscription counts as of the range end.
let db: Db;
let log: EventLog;
beforeEach(() => {
({ db } = createTestDb());
log = makeLog(db);
// Fix the site timezone so bucket labels are deterministic regardless of the test host.
db.insert(siteConfig).values({ id: 1, timezone: "Europe/Tirane" }).run();
});
/** ISO at a UTC instant, for deterministic bucket assertions. */
function at(iso: string): string {
return new Date(iso).toISOString();
}
async function entry(occurredAt: string): Promise<void> {
await log.append({ type: "vehicle_entry", direction: "entry", identity: randomUUID(), occurredAt });
}
async function exit(occurredAt: string): Promise<void> {
await log.append({ type: "vehicle_exit", direction: "exit", identity: randomUUID(), occurredAt });
}
async function payment(
occurredAt: string,
amountMinor: number,
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
): Promise<void> {
await log.append({
type: "payment",
occurredAt,
payload: {
amountMinor,
currency: "ALL",
tender: opts.tender ?? "cash",
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
},
});
}
const RANGE = { from: at("2026-06-01T00:00:00Z"), to: at("2026-06-30T23:59:59Z") };
describe("reportSummary — ledger-first totals", () => {
it("counts entries and exits from the signed ledger", async () => {
await entry(at("2026-06-10T08:00:00Z"));
await entry(at("2026-06-10T09:00:00Z"));
await exit(at("2026-06-10T18:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.entries).toBe(2);
expect(r.totals.exits).toBe(1);
});
it("excludes events outside [from, to)", async () => {
await entry(at("2026-05-31T23:00:00Z")); // before
await entry(at("2026-06-15T10:00:00Z")); // inside
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.entries).toBe(1);
});
it("sums payment money and splits cash vs card", async () => {
await payment(at("2026-06-12T10:00:00Z"), 20000, { tender: "cash" });
await payment(at("2026-06-12T11:00:00Z"), 5000, { tender: "card" });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.payments).toBe(2);
expect(r.totals.revenueMinor).toBe(25000);
expect(r.totals.cashMinor).toBe(20000);
expect(r.totals.cardMinor).toBe(5000);
});
it("splits revenue into ticket / subscription-sale / out-of-window, mirroring the Z-report", async () => {
await payment(at("2026-06-12T10:00:00Z"), 10000); // transient ticket
await payment(at("2026-06-12T10:05:00Z"), 30000, { subscriptionSale: true });
await payment(at("2026-06-12T10:06:00Z"), 1500, { subscriptionWindowCharge: true });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.ticketMinor).toBe(10000);
expect(r.totals.subscriptionSalesMinor).toBe(30000);
expect(r.totals.subscriptionWindowMinor).toBe(1500);
// The three add up to the gross revenue.
expect(r.totals.revenueMinor).toBe(41500);
});
it("picks up the currency from a payment in range", async () => {
await payment(at("2026-06-12T10:00:00Z"), 10000);
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.currency).toBe("ALL");
});
});
describe("reportSummary — time bucketing (site timezone)", () => {
it("buckets by local day; a 23:30 UTC event lands on the NEXT local day in Tirane (UTC+2/3)", async () => {
// 2026-06-15T23:30Z is 2026-06-16 01:30 local (summer, UTC+2) → the 16th bucket.
await entry(at("2026-06-15T23:30:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
const point = r.series.find((p) => p.entries > 0);
expect(point?.bucket).toBe("2026-06-16");
});
it("series points are sorted and carry per-bucket entries/exits/revenue", async () => {
await entry(at("2026-06-10T08:00:00Z"));
await payment(at("2026-06-10T09:00:00Z"), 7000);
await entry(at("2026-06-12T08:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
const labels = r.series.map((p) => p.bucket);
expect(labels).toEqual([...labels].sort());
const d10 = r.series.find((p) => p.bucket === "2026-06-10");
expect(d10?.entries).toBe(1);
expect(d10?.revenueMinor).toBe(7000);
});
it("entriesByHour is a 24-slot local-hour histogram", async () => {
// 06:00Z = 08:00 local (summer) → hour slot 8.
await entry(at("2026-06-10T06:00:00Z"));
await entry(at("2026-06-11T06:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.entriesByHour).toHaveLength(24);
expect(r.entriesByHour[8]).toBe(2);
expect(r.entriesByHour.reduce((a, b) => a + b, 0)).toBe(2);
});
});
describe("reportSummary — duration (sessions cache) + subscriptions", () => {
it("computes parked-minute stats from closed sessions whose exit fell in range", async () => {
// 60-min and 120-min stays → avg 90, median 90.
db.insert(sessions).values({
id: "s1",
identity: "t1",
enteredAt: at("2026-06-10T08:00:00Z"),
exitedAt: at("2026-06-10T09:00:00Z"),
state: "closed",
}).run();
db.insert(sessions).values({
id: "s2",
identity: "t2",
enteredAt: at("2026-06-10T08:00:00Z"),
exitedAt: at("2026-06-10T10:00:00Z"),
state: "closed",
}).run();
// An OPEN session (no exit) must not count.
db.insert(sessions).values({ id: "s3", identity: "t3", enteredAt: at("2026-06-10T08:00:00Z"), state: "open" }).run();
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.closedSessions).toBe(2);
expect(r.totals.totalParkedMinutes).toBe(180);
expect(r.totals.avgParkedMinutes).toBe(90);
expect(r.totals.medianParkedMinutes).toBe(90);
});
it("counts subscriptions by status and currently-valid coverage as of `to`", async () => {
const base = { holderName: "x", period: "month" as const, createdAt: at("2026-06-01T00:00:00Z") };
// active + valid window covering `to`, quantity 2.
db.insert(subscriptions).values({
id: "a", status: "active", quantity: 2,
validFrom: at("2026-06-01T00:00:00Z"), validTo: at("2026-07-01T00:00:00Z"), ...base,
}).run();
// active but EXPIRED before `to` → not currently valid.
db.insert(subscriptions).values({
id: "b", status: "active", quantity: 1,
validFrom: at("2026-05-01T00:00:00Z"), validTo: at("2026-06-05T00:00:00Z"), ...base,
}).run();
// suspended.
db.insert(subscriptions).values({ id: "c", status: "suspended", quantity: 1, ...base }).run();
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.subscriptions.active).toBe(2);
expect(r.subscriptions.suspended).toBe(1);
expect(r.subscriptions.revoked).toBe(0);
expect(r.subscriptions.currentlyValid).toBe(1);
expect(r.subscriptions.coveredCars).toBe(2);
});
});