Files
parking_solution/apps/server/src/reports.ts
T
julian 5a5f5c554b 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
2026-06-22 00:16:07 +02:00

282 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
and,
asc,
desc,
eq,
gte,
lte,
ledgerEvents,
sessions,
subscriptions,
tariffVersions,
tariffs,
type Db,
} from "@parking/db";
import { siteTz } from "./subscription-window.js";
// Admin reporting — LEDGER-FIRST aggregation (decision 2026-06-22). The numbers an
// admin sees on the Reports page are summed from the SIGNED, hash-chained
// ledger_events (vehicle_entry/exit + payment), the same source the shift Z-report
// reconciles against — so a chart total always ties out to the drawer. Only the
// duration/occupancy view leans on the derived `sessions` cache, where the ledger is
// awkward (you'd have to pair every entry with its exit by hand); that's flagged as a
// cache, not the financial truth. See wiki/concepts/reports.md, event-streams-split.md.
//
// All bucketing is in the SITE TIMEZONE (siteConfig.timezone) — a "day" is a local
// calendar day, not a UTC one, so a 01:00-local payment lands on the right date and the
// peak-hour chart reads in wall-clock. Pure date math on the stored ISO strings; no
// floats (money is integer minor units throughout).
export type Bucket = "hour" | "day" | "month";
export interface ReportQuery {
/** Inclusive lower bound (ISO instant). */
readonly from: string;
/** Exclusive upper bound (ISO instant). */
readonly to: string;
/** Time grain for the series. Default "day". */
readonly bucket: Bucket;
}
/** One point in a time series, keyed by its local-time bucket label (e.g. "2026-06-22"
* for a day, "2026-06-22 14" for an hour). */
export interface SeriesPoint {
readonly bucket: string;
readonly entries: number;
readonly exits: number;
/** Net transient revenue collected in the bucket (minor units), all tenders. */
readonly revenueMinor: number;
/** Payment COUNT in the bucket (transactions, not amount). */
readonly payments: number;
}
export interface ReportTotals {
readonly entries: number;
readonly exits: number;
readonly payments: number;
readonly revenueMinor: number;
readonly cashMinor: number;
readonly cardMinor: number;
/** Revenue split by what was sold. ticket = transient parking; subscriptionSales =
* new/renewed subscriptions; subscriptionWindow = out-of-window tariff-bridge charges. */
readonly ticketMinor: number;
readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number;
/** Closed transient sessions in range + their parked-minutes stats (from the cache). */
readonly closedSessions: number;
readonly totalParkedMinutes: number;
readonly avgParkedMinutes: number;
readonly medianParkedMinutes: number;
}
export interface SubscriptionStats {
readonly active: number;
readonly suspended: number;
readonly revoked: number;
/** Active subscriptions whose window covers `to` (the report's "now"). */
readonly currentlyValid: number;
/** Cars covered by currently-valid subscriptions (Σ quantity). */
readonly coveredCars: number;
}
export interface ReportSummary {
readonly from: string;
readonly to: string;
readonly bucket: Bucket;
readonly tz: string;
readonly currency: string | null;
readonly totals: ReportTotals;
readonly series: SeriesPoint[];
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
readonly entriesByHour: number[];
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 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),
};
}
/** Bucket label for an instant at the chosen grain, in local time. Sorts lexically. */
function bucketLabel(iso: string, tz: string, bucket: Bucket): string {
const p = localParts(iso, tz);
const mo = String(p.mo).padStart(2, "0");
const d = String(p.d).padStart(2, "0");
const h = String(p.h).padStart(2, "0");
if (bucket === "month") return `${p.y}-${mo}`;
if (bucket === "hour") return `${p.y}-${mo}-${d} ${h}`;
return `${p.y}-${mo}-${d}`;
}
interface PaymentPayload {
amountMinor?: number;
currency?: string;
tender?: "cash" | "card";
subscriptionSale?: boolean;
subscriptionWindowCharge?: boolean;
}
function median(sorted: number[]): number {
if (sorted.length === 0) return 0;
const mid = Math.floor(sorted.length / 2);
const hi = sorted[mid] ?? 0;
if (sorted.length % 2) return hi;
const lo = sorted[mid - 1] ?? 0;
return Math.round((lo + hi) / 2);
}
/**
* Build the admin report summary for [from, to) at the chosen grain. Entry/exit counts
* and money are summed from the signed ledger; duration stats from the closed sessions
* in range; subscription counts from the subscriptions table as of `to`.
*/
export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
const tz = siteTz(db);
// --- Ledger: entry/exit/payment in range, oldest-first so the series builds in order.
const rows = db
.select()
.from(ledgerEvents)
.where(and(gte(ledgerEvents.occurredAt, q.from), lte(ledgerEvents.occurredAt, q.to)))
.orderBy(asc(ledgerEvents.index))
.all();
// Currency for display: money everywhere is { minorUnits, currency }; payments carry
// the currency they were taken in, so take it from a payment in range (then fall back
// to the active tariff version). Reports never mix currencies (single-currency site).
let currency: string | null = null;
const seriesMap = new Map<string, SeriesPoint>();
const entriesByHour = new Array<number>(24).fill(0);
const totals = {
entries: 0,
exits: 0,
payments: 0,
revenueMinor: 0,
cashMinor: 0,
cardMinor: 0,
ticketMinor: 0,
subscriptionSalesMinor: 0,
subscriptionWindowMinor: 0,
};
function point(label: string): SeriesPoint {
let p = seriesMap.get(label);
if (!p) {
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
seriesMap.set(label, p);
}
return p;
}
for (const row of rows) {
const label = bucketLabel(row.occurredAt, tz, q.bucket);
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
if (row.type === "vehicle_entry") {
totals.entries++;
p.entries++;
const h = localParts(row.occurredAt, tz).h;
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
} else if (row.type === "vehicle_exit") {
totals.exits++;
p.exits++;
} else if (row.type === "payment") {
const pl = (row.payload ?? {}) as PaymentPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (!currency && typeof pl.currency === "string") currency = pl.currency;
totals.payments++;
totals.revenueMinor += amt;
p.payments++;
p.revenueMinor += amt;
if (pl.tender === "card") totals.cardMinor += amt;
else totals.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;
else if (pl.subscriptionWindowCharge === true) totals.subscriptionWindowMinor += amt;
else totals.ticketMinor += amt;
}
}
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
// 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) {
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (tariff) {
const tv = db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.get();
currency = tv?.currency ?? null;
}
}
// --- Duration: closed transient sessions whose EXIT fell in range (the cache; flagged).
const closed = db
.select()
.from(sessions)
.where(and(gte(sessions.exitedAt, q.from), lte(sessions.exitedAt, q.to)))
.all();
const durations: number[] = [];
for (const s of closed) {
if (!s.enteredAt || !s.exitedAt) continue;
const mins = Math.max(0, Math.round((Date.parse(s.exitedAt) - Date.parse(s.enteredAt)) / 60000));
durations.push(mins);
}
durations.sort((a, b) => a - b);
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
// --- 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 };
for (const s of subs) {
if (s.status === "active") subStats.active++;
else if (s.status === "suspended") subStats.suspended++;
else if (s.status === "revoked") subStats.revoked++;
const validNow =
s.status === "active" &&
(!s.validFrom || s.validFrom <= q.to) &&
(!s.validTo || s.validTo >= q.to);
if (validNow) {
subStats.currentlyValid++;
subStats.coveredCars += s.quantity ?? 1;
}
}
return {
from: q.from,
to: q.to,
bucket: q.bucket,
tz,
currency,
totals: {
...totals,
closedSessions: durations.length,
totalParkedMinutes,
avgParkedMinutes: durations.length ? Math.round(totalParkedMinutes / durations.length) : 0,
medianParkedMinutes: median(durations),
},
series,
entriesByHour,
subscriptions: subStats,
};
}