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
380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
import {
|
||
and,
|
||
asc,
|
||
desc,
|
||
eq,
|
||
gte,
|
||
lt,
|
||
lte,
|
||
ledgerEvents,
|
||
sessions,
|
||
siteConfig,
|
||
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;
|
||
/** 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 {
|
||
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;
|
||
/** 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 {
|
||
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;
|
||
}
|
||
|
||
/** 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;
|
||
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[];
|
||
/** 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). */
|
||
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
|
||
};
|
||
}
|
||
|
||
/** 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 entriesByDowHour = Array.from({ length: 7 }, () => 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,
|
||
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, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 };
|
||
seriesMap.set(label, p);
|
||
}
|
||
return p;
|
||
}
|
||
|
||
// Pre-pass: identities cancelled by a `void` in range. A voided entry was a wrongly-
|
||
// printed ticket (no car entered), so it must NOT inflate the "entries" stat. (The void's
|
||
// entry is normally in the same window; this skips it when both are in range.)
|
||
const voided = new Set<string>();
|
||
for (const row of rows) if (row.type === "void" && row.identity) voided.add(row.identity);
|
||
|
||
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") {
|
||
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||
totals.entries++;
|
||
p.entries++;
|
||
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;
|
||
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;
|
||
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;
|
||
else if (pl.subscriptionWindowCharge === true) totals.subscriptionWindowMinor += amt;
|
||
else totals.ticketMinor += amt;
|
||
}
|
||
}
|
||
|
||
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) {
|
||
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);
|
||
|
||
// 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 };
|
||
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,
|
||
entriesByDowHour,
|
||
stayHistogram,
|
||
occupancyStart,
|
||
capacity,
|
||
subscriptions: subStats,
|
||
};
|
||
}
|