diff --git a/apps/server/src/reports.test.ts b/apps/server/src/reports.test.ts new file mode 100644 index 0000000..9ac8c14 --- /dev/null +++ b/apps/server/src/reports.test.ts @@ -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 { + await log.append({ type: "vehicle_entry", direction: "entry", identity: randomUUID(), occurredAt }); +} +async function exit(occurredAt: string): Promise { + 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 { + 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); + }); +}); diff --git a/apps/server/src/reports.ts b/apps/server/src/reports.ts new file mode 100644 index 0000000..ef6f7b8 --- /dev/null +++ b/apps/server/src/reports.ts @@ -0,0 +1,281 @@ +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(); + const entriesByHour = new Array(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, + }; +} diff --git a/apps/server/src/routes/reports.ts b/apps/server/src/routes/reports.ts new file mode 100644 index 0000000..e13e2d3 --- /dev/null +++ b/apps/server/src/routes/reports.ts @@ -0,0 +1,65 @@ +import type { FastifyInstance } from "fastify"; +import type { Db } from "@parking/db"; +import { requirePermission } from "../auth.js"; +import { reportSummary, type Bucket } from "../reports.js"; + +// Admin reporting API. Read-only aggregation over the signed ledger (+ the sessions +// cache for durations); no writes, no new event types. Gated on `report:read` — the +// same permission the events feed/occupancy use. See reports.ts, wiki/concepts/reports.md. + +const BUCKETS: Bucket[] = ["hour", "day", "month"]; + +/** Clamp a query into a valid [from, to) + bucket. Defaults: last 30 days, daily. */ +function parseQuery(q: { from?: string; to?: string; bucket?: string }): { + from: string; + to: string; + bucket: Bucket; +} { + const now = Date.now(); + const to = isFiniteIso(q.to) ? q.to! : new Date(now).toISOString(); + const from = isFiniteIso(q.from) ? q.from! : new Date(now - 30 * 86_400_000).toISOString(); + const bucket = BUCKETS.includes(q.bucket as Bucket) ? (q.bucket as Bucket) : "day"; + // Guard the inversion (from after to) — swap rather than return an empty report. + return from <= to ? { from, to, bucket } : { from: to, to: from, bucket }; +} + +function isFiniteIso(s: string | undefined): boolean { + return !!s && Number.isFinite(Date.parse(s)); +} + +export async function reportRoutes(app: FastifyInstance, db: Db): Promise { + const guard = requirePermission("report:read"); + + // The whole dashboard in one call: totals, the time series, peak-hour histogram, and + // subscription stats — aggregated server-side so the SPA just renders. Bucketed in the + // site timezone. See reports.ts. + app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>( + "/api/reports/summary", + { preHandler: guard }, + async (req) => reportSummary(db, parseQuery(req.query)), + ); + + // The same series as CSV (one row per bucket) for spreadsheet / accountant export. + // Amounts are in MAJOR units with 2 decimals here (a CSV is for humans/Excel), unlike + // the JSON which stays in minor units. text/csv with a download filename. + app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>( + "/api/reports/summary.csv", + { preHandler: guard }, + async (req, reply) => { + const summary = reportSummary(db, parseQuery(req.query)); + const lines = [ + "bucket,entries,exits,payments,revenue", + ...summary.series.map((p) => + [p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","), + ), + ]; + reply + .header("content-type", "text/csv; charset=utf-8") + .header( + "content-disposition", + `attachment; filename="parking-report-${summary.from.slice(0, 10)}_${summary.to.slice(0, 10)}.csv"`, + ) + .send(lines.join("\n") + "\n"); + }, + ); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a44df05..853726c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,7 @@ import { userRoutes } from "./routes/users.js"; import { roleRoutes } from "./routes/roles.js"; import { deviceRoutes } from "./routes/devices.js"; import { eventRoutes } from "./routes/events.js"; +import { reportRoutes } from "./routes/reports.js"; import { payRoutes } from "./routes/pay.js"; import { subscriptionRoutes } from "./routes/subscriptions.js"; import { subscriptionPlanRoutes } from "./routes/subscription-plans.js"; @@ -141,6 +142,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise("30d"); + const [bucketOverride, setBucketOverride] = useState(null); + + const range = useMemo(() => presetRange(preset), [preset]); + const bucket = bucketOverride ?? range.bucket; + + const { data, isLoading, isError, error } = useQuery({ + queryKey: qk.report(range.from, range.to, bucket), + queryFn: () => fetchReport(range.from, range.to, bucket), + }); + + const presets: { key: PresetKey; label: string }[] = [ + { key: "today", label: t("reports.preset.today") }, + { key: "7d", label: t("reports.preset.7d") }, + { key: "30d", label: t("reports.preset.30d") }, + { key: "90d", label: t("reports.preset.90d") }, + ]; + const buckets: ReportBucket[] = ["hour", "day", "month"]; + + return ( +
+
+

+ {t("reports.title")} +

+
+ {presets.map((p) => ( + + ))} +
+
+ {t("reports.groupBy")} + +
+ + {t("reports.exportCsv")} + +
+ + {isLoading &&

{t("common.loading")}

} + {isError && ( +

+ {t("reports.loadFailed", { error: (error as Error)?.message ?? "?" })} +

+ )} + {data && } +
+ ); +} + +function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) { + const cur = data.currency ?? "ALL"; + const money = (m: number) => formatMoney(m, cur); + const tot = data.totals; + + // Recharts series: label + the metrics. Keep the server's lexically-sortable bucket + // labels; trim the date prefix off hour labels for a tighter axis. + const series = data.series.map((p) => ({ + ...p, + label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket, + revenue: p.revenueMinor / 100, + })); + const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries })); + const mix = [ + { 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.subWindow"), value: tot.subscriptionWindowMinor, color: C.green }, + ].filter((s) => s.value > 0); + + return ( +
+ {/* KPI cards. */} +
+ + + + + + +
+ + {/* Entry / exit over time. */} + + + + + + + + + + + + + + +
+ {/* Revenue per bucket. */} + + + + + + + money(Math.round(Number(v) * 100))} /> + + + + + + {/* Revenue mix (ticket vs subscription vs window). */} + + {mix.length === 0 ? ( + + ) : ( + + + + {mix.map((s) => ( + + ))} + + money(Number(v))} /> + + + + )} + + + {/* Peak hours (entries by hour-of-day). */} + + + + + + + + + + + + + {/* Cash / card + duration + subscription breakdown (numbers). */} + +
+ + + + + + + + + + +
+
+
+ +

+ {t("reports.footnote", { tz: data.tz })} +

+
+ ); +} + +const tooltipStyle = { + background: C.panel, + border: `1px solid ${C.border}`, + borderRadius: 6, + color: C.text, + fontSize: 12, +}; + +function Kpi({ label, value, accent }: { label: string; value: string; accent?: "green" | "red" | "amber" | "cyan" }) { + const color = + accent === "green" + ? "text-term-green" + : accent === "red" + ? "text-term-red" + : accent === "amber" + ? "text-term-amber" + : accent === "cyan" + ? "text-term-cyan" + : "text-term-text"; + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Panel({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + <> +
{label}
+
{value}
+ + ); +} + +function Empty({ t }: { t: TFunction }) { + return

{t("reports.noData")}

; +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index fedb1d5..b0e23de 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -308,6 +308,66 @@ export function testAnpr(driverId: string, config: DeviceConfig): Promise { + const qs = new URLSearchParams({ from, to, bucket }).toString(); + return apiFetch(`/api/reports/summary?${qs}`); +} + +/** URL for the CSV export of the per-bucket series (opened/downloaded directly; the + * auth cookie rides along same-origin). */ +export function reportCsvUrl(from: string, to: string, bucket: ReportBucket): string { + const qs = new URLSearchParams({ from, to, bucket }).toString(); + return apiUrl(`/api/reports/summary.csv?${qs}`); +} + export interface BackendIpCandidate { ip: string; iface: string; diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index 57cec76..18fedf6 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -22,6 +22,14 @@ export function formatDuration(fromIso: string, toIso: string): string { return h > 0 ? `${h}h ${m}m` : `${m}m`; } +/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */ +export function formatMinutes(mins: number): string { + if (!Number.isFinite(mins) || mins < 0) return "—"; + const m = Math.round(mins); + const h = Math.floor(m / 60); + return h > 0 ? `${h}h ${m % 60}m` : `${m}m`; +} + /** Local time-of-day HH:MM:SS from an ISO string. */ export function formatTime(iso: string | null): string { if (!iso) return "—"; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 21fea61..851d96d 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -55,6 +55,7 @@ export const en: Catalog = { users: "Users", roles: "Roles", shifts: "Shifts", + reports: "Reports", logs: "Logs", }, status: { @@ -677,6 +678,40 @@ export const en: Catalog = { cashRemoved: "Cash removed", loadFailed: "Failed to load shifts.", }, + reports: { + title: "Reports", + groupBy: "Group by", + exportCsv: "Export CSV", + loadFailed: "Couldn't load the report: {{error}}", + noData: "No data in this range.", + footnote: "Counts and money are summed from the signed event log. Times shown in {{tz}}.", + preset: { today: "Today", "7d": "7 days", "30d": "30 days", "90d": "90 days" }, + bucket: { hour: "Hour", day: "Day", month: "Month" }, + kpi: { + entries: "Entries", + exits: "Exits", + revenue: "Revenue", + payments: "Payments", + avgStay: "Avg stay", + subscribers: "Subscribers", + }, + chart: { + flow: "Entries & exits over time", + revenue: "Revenue ({{currency}})", + mix: "Revenue mix", + peakHours: "Entries by hour of day", + breakdown: "Breakdown", + }, + mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" }, + row: { + cash: "Cash", + card: "Card", + closed: "Closed sessions", + medianStay: "Median stay", + subActive: "Active subscriptions", + subCars: "Cars covered", + }, + }, logs: { title: "System logs", refresh: "Refresh", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 08b9729..a42d9d7 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -57,6 +57,7 @@ export const sq = { users: "Përdoruesit", roles: "Rolet", shifts: "Turnet", + reports: "Raportet", logs: "Loget", }, status: { @@ -691,6 +692,40 @@ export const sq = { cashRemoved: "Para të hequra", loadFailed: "Ngarkimi i turneve dështoi.", }, + reports: { + title: "Raportet", + groupBy: "Grupo sipas", + exportCsv: "Eksporto CSV", + loadFailed: "Raporti nuk u ngarkua dot: {{error}}", + noData: "Nuk ka të dhëna në këtë interval.", + footnote: "Numërimet dhe paratë mblidhen nga regjistri i nënshkruar. Oraret në {{tz}}.", + preset: { today: "Sot", "7d": "7 ditë", "30d": "30 ditë", "90d": "90 ditë" }, + bucket: { hour: "Orë", day: "Ditë", month: "Muaj" }, + kpi: { + entries: "Hyrje", + exits: "Dalje", + revenue: "Të ardhura", + payments: "Pagesa", + avgStay: "Qëndrim mes.", + subscribers: "Abonentë", + }, + chart: { + flow: "Hyrjet & daljet me kalimin e kohës", + revenue: "Të ardhurat ({{currency}})", + mix: "Përbërja e të ardhurave", + peakHours: "Hyrjet sipas orës së ditës", + breakdown: "Ndarja", + }, + mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" }, + row: { + cash: "Para në dorë", + card: "Kartë", + closed: "Sesione të mbyllura", + medianStay: "Qëndrim mesatar (median)", + subActive: "Abonime aktive", + subCars: "Makina të mbuluara", + }, + }, logs: { title: "Loget e sistemit", refresh: "Rifresko", diff --git a/apps/web/src/lib/query.ts b/apps/web/src/lib/query.ts index 306bcb5..e90ec90 100644 --- a/apps/web/src/lib/query.ts +++ b/apps/web/src/lib/query.ts @@ -27,4 +27,6 @@ export const qk = { siteConfig: ["site-config"] as const, shift: ["shift"] as const, deviceStatus: ["device-status"] as const, + report: (from: string, to: string, bucket: string) => + ["report", from, to, bucket] as const, } as const; diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 1d78fcc..fe997d5 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -6,7 +6,7 @@ import { Outlet, redirect, } from "@tanstack/react-router"; -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { Lang, Permission, SessionUser, Theme } from "./api.js"; @@ -30,6 +30,9 @@ import { UsersManager } from "./UsersManager.js"; import { RolesManager } from "./RolesManager.js"; import { ShiftsHistory } from "./ShiftsHistory.js"; import { LogsViewer } from "./LogsViewer.js"; +// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's +// initial bundle and only downloads when an admin opens /setup/reports. +const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports }))); // Code-based TanStack Router (no file-based codegen — the app is small enough that // an explicit tree is clearer). The router context carries the signed-in user and @@ -85,6 +88,7 @@ function SetupLayout() { {show("site:read") && } {show("user:read") && } {show("role:read") && } + {show("report:read") && } {show("log:read") && } @@ -382,6 +386,7 @@ function RootLayout() { show("site:read") || show("user:read") || show("role:read") || + show("report:read") || show("shift:read")) && }
@@ -491,6 +496,7 @@ const SETUP_TABS: { to: string; perm: Permission }[] = [ { to: "/setup/site", perm: "site:read" }, { to: "/setup/users", perm: "user:read" }, { to: "/setup/roles", perm: "role:read" }, + { to: "/setup/reports", perm: "report:read" }, { to: "/shifts", perm: "shift:read" }, { to: "/setup/logs", perm: "log:read" }, ]; @@ -588,6 +594,20 @@ const rolesRoute = createRoute({ // (Shift history lives at the standalone /shifts route — see shiftRoute. It was // removed as a Setup tab; /setup/shifts and the old /shift both redirect there.) +// Admin reports/charts. Gated by report:read. Lazy component (Recharts) in a Suspense. +const reportsRoute = createRoute({ + getParentRoute: () => setupRoute, + path: "reports", + beforeLoad: ({ context }) => requirePerm("report:read")(context), + component: function ReportsRoute() { + return ( + …
}> + + + ); + }, +}); + // Diagnostic logs. Gated by log:read (an admin/diagnostic permission). const logsRoute = createRoute({ getParentRoute: () => setupRoute, @@ -612,6 +632,7 @@ const routeTree = rootRoute.addChildren([ siteRoute, usersRoute, rolesRoute, + reportsRoute, logsRoute, ]), ]); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index a9b0fe2..1728a3d 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -5,7 +5,7 @@ import * as schema from "./schema.js"; export * from "./schema.js"; // Re-export the query helpers consumers need, so they don't depend on // drizzle-orm directly (it's an implementation detail of this package). -export { eq, and, desc, gte, lte, sql } from "drizzle-orm"; +export { eq, and, asc, desc, gte, lte, sql } from "drizzle-orm"; /** * Open the local SQLite database in WAL mode. WAL allows many concurrent readers diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dde853f..ee85d98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,9 +123,12 @@ importers: react-i18next: specifier: ^17.0.8 version: 17.0.8(i18next@26.3.1(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + recharts: + specifier: ^3.2.1 + version: 3.8.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1) zustand: specifier: ^5.0.14 - version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + version: 5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) devDependencies: '@tailwindcss/vite': specifier: ^4.3.1 @@ -1095,6 +1098,17 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1190,6 +1204,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tailwindcss/node@4.3.1': resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} @@ -1478,6 +1495,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1495,6 +1539,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@vitejs/plugin-react@6.0.2': resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1661,6 +1708,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -1674,6 +1765,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -1843,6 +1937,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.48.1: + resolution: {integrity: sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -1864,6 +1961,9 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -2024,12 +2124,22 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ipaddr.js@2.4.0: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} @@ -2316,6 +2426,18 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -2361,10 +2483,29 @@ packages: real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2506,6 +2647,9 @@ packages: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2595,6 +2739,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3438,6 +3585,18 @@ snapshots: '@radix-ui/rect@1.1.2': {} + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.8 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.7 + react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) + '@rolldown/binding-android-arm64@1.0.3': optional: true @@ -3491,6 +3650,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@tailwindcss/node@4.3.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3728,6 +3889,30 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} @@ -3744,6 +3929,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/use-sync-external-store@0.0.6': {} + '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -3902,6 +4089,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -3911,6 +4136,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} decompress-response@6.0.0: @@ -3988,6 +4215,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + es-toolkit@1.48.1: {} + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -4077,6 +4306,8 @@ snapshots: dependencies: '@types/estree': 1.0.9 + eventemitter3@5.0.4: {} + expand-template@2.0.3: {} expect-type@1.3.0: {} @@ -4265,10 +4496,16 @@ snapshots: ieee754@1.2.1: {} + immer@10.2.0: {} + + immer@11.1.8: {} + inherits@2.0.4: {} ini@1.3.8: {} + internmap@2.0.3: {} + ipaddr.js@2.4.0: {} is-potential-custom-element-name@1.0.1: {} @@ -4532,6 +4769,15 @@ snapshots: react-is@17.0.2: {} + react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): dependencies: react: 19.2.7 @@ -4571,8 +4817,36 @@ snapshots: real-require@1.0.0: {} + recharts@3.8.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.48.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-is: 17.0.2 + react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.7) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + require-from-string@2.0.2: {} + reselect@5.1.1: {} + resolve-pkg-maps@1.0.0: {} ret@0.5.0: {} @@ -4708,6 +4982,8 @@ snapshots: dependencies: real-require: 1.0.0 + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -4783,6 +5059,23 @@ snapshots: util-deprecate@1.0.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4): dependencies: lightningcss: 1.32.0 @@ -4859,8 +5152,9 @@ snapshots: xtend@4.0.2: {} - zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): + zustand@5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): optionalDependencies: '@types/react': 19.2.17 + immer: 11.1.8 react: 19.2.7 use-sync-external-store: 1.6.0(react@19.2.7) diff --git a/wiki/concepts/reporting-analytics.md b/wiki/concepts/reporting-analytics.md index 59ff9ea..2c8e8e7 100644 --- a/wiki/concepts/reporting-analytics.md +++ b/wiki/concepts/reporting-analytics.md @@ -2,7 +2,7 @@ type: concept tags: [parking, domain, business, reporting] sources: [] -updated: 2026-06-15 +updated: 2026-06-22 status: open --- @@ -12,6 +12,36 @@ Turning the signed event log into the numbers an owner runs the business on. All **projections over the [[append-only-event-chain]]** — the chain is the single source, reports are derived and rebuildable, never a separate ledger. +## Built — admin Reports dashboard v1 (2026-06-22) + +A first cut shipped: an admin **Reports** screen (`/setup/reports`, gated on `report:read`), an +on-demand **dashboard** (not a live feed). Server aggregates everything in **one call** +(`GET /api/reports/summary?from&to&bucket`) so the SPA only renders; `…/summary.csv` exports the +per-bucket series. Code: `apps/server/src/reports.ts` (+ `routes/reports.ts`), `apps/web/src/Reports.tsx`. + +- **Ledger-first** (decision 2026-06-22). Entry/exit **counts** and all **money** are summed + straight from the signed `ledger_events` — the SAME source the `shift_z_report` reconciles, so a + chart total always ties out to the drawer. The revenue **split** (transient ticket / + subscription sale / out-of-window window-charge) mirrors the Z-report's split exactly + (`subscriptionSale` / `subscriptionWindowCharge` payload flags). Duration/occupancy stats are the + one exception: read from the derived `sessions` cache (pairing each entry with its exit on the + chain by hand is awkward) — flagged as a cache, not the financial truth. +- **Site-timezone bucketing.** A "day"/"hour" bucket is **local wall-clock** in `siteConfig.timezone` + (reuses `siteTz()`), so a 23:30Z entry lands on the right local date and the peak-hour histogram + reads in wall-clock. Bucket grain: hour / day / month, with date-range presets (today / 7d / 30d / 90d). +- **Views:** KPI cards (entries, exits, revenue, payments, avg stay, current subscribers); entry/exit + line; revenue bar (per bucket) + cash/card split; revenue-mix pie; **peak-hours** histogram + (entries by local hour-of-day); a numeric breakdown (cash/card, the 3-way revenue split, closed + sessions, avg/median stay, active subs + cars covered); subscription status counts + currently-valid + coverage as of the range end. Charts via **Recharts** (MIT), **lazy-loaded** into its own bundle + chunk so the booth never downloads it. Tested: `reports.test.ts` (10) pin the sums, the tz bucketing, + the money split, duration stats, and subscription counts. + +**Not yet** (deferred from the list below): anomalies/voids reporting, per-operator takings, the +plate/entry search (next section), PDF export, and a live dashboard. The `report:read` permission +already existed for "events feed, occupancy, future reports" — this is its first real consumer +beyond the feed. + ## Reports (driven by the events already designed) - **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source: diff --git a/wiki/log.md b/wiki/log.md index 75863b7..9e0cf3a 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1340,3 +1340,17 @@ deploy leaves it unset. Added a "Deploy-time server configuration (runbook)" sec [[disk-os-hardening]] documenting COOKIE_SECURE=0 (+ JWT_SECRET / EVENT_SIGNING_KEY) and corrected the stale "Secure when NODE_ENV=production" line on [[local-jwt-auth]]. auth.test.ts (5) pins the matrix; server 80/80. + +## [2026-06-22] feat | Admin Reports dashboard v1 (ledger-first charts) + camera "Test ANPR" +Built the admin Reports screen (`/setup/reports`, gated `report:read`): one server call +(`GET /api/reports/summary?from&to&bucket`, + `.csv` export) aggregates entry/exit counts and all +money straight from the signed `ledger_events` (LEDGER-FIRST decision) — the same source the +`shift_z_report` reconciles, so totals tie out to the drawer; the 3-way revenue split (ticket / +subscription sale / out-of-window) mirrors the Z-report. Duration/occupancy stats come from the +`sessions` cache (flagged). All bucketing is in the SITE timezone (`siteTz()`). Views: 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. reports.test.ts (10) pins the sums/tz/split/duration/subs; server +90/90, build+lint 14/14. Also (earlier same session): a camera "Test ANPR" probe in first-run setup +(`POST /api/setup/test-anpr`) — snapshot→vision analyze, fail-soft, shown only when a camera's ANPR +opt-in is checked. See [[reporting-analytics]], [[opencv-anpr-service]].