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
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
|
||
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
|
||
});
|
||
});
|