From 7ef332999eeb7d0f4df1f9d0e2eb4a125f9a5713 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 6 Jul 2026 12:35:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(reports):=20occupancy=20curve,=20hour?= =?UTF-8?q?=C3=97dow=20heatmap,=20stay=20histogram,=20fraud=20KPIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/reports.test.ts | 65 +++++++++++++++ apps/server/src/reports.ts | 119 +++++++++++++++++++++++---- apps/server/src/routes/reports.ts | 13 ++- apps/web/src/Reports.tsx | 107 +++++++++++++++++++++--- apps/web/src/api.ts | 12 +++ apps/web/src/lib/i18n/en.ts | 11 ++- apps/web/src/lib/i18n/sq.ts | 11 ++- packages/db/src/index.ts | 2 +- wiki/concepts/reporting-analytics.md | 26 +++++- 9 files changed, 334 insertions(+), 32 deletions(-) diff --git a/apps/server/src/reports.test.ts b/apps/server/src/reports.test.ts index 9ac8c14..b9fdd6e 100644 --- a/apps/server/src/reports.test.ts +++ b/apps/server/src/reports.test.ts @@ -181,3 +181,68 @@ describe("reportSummary — duration (sessions cache) + subscriptions", () => { 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 + }); +}); diff --git a/apps/server/src/reports.ts b/apps/server/src/reports.ts index b8830ba..a83a8ad 100644 --- a/apps/server/src/reports.ts +++ b/apps/server/src/reports.ts @@ -4,9 +4,11 @@ import { desc, eq, gte, + lt, lte, ledgerEvents, sessions, + siteConfig, subscriptions, tariffVersions, tariffs, @@ -46,8 +48,13 @@ export interface SeriesPoint { 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 { @@ -67,6 +74,10 @@ export interface ReportTotals { 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 { @@ -79,6 +90,13 @@ export interface SubscriptionStats { 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; @@ -89,25 +107,43 @@ export interface ReportSummary { 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). */ -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 fmtCache = new Map(); +const DOW_INDEX: Record = { 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 }; } @@ -162,6 +198,7 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary { const seriesMap = new Map(); const entriesByHour = new Array(24).fill(0); + const entriesByDowHour = Array.from({ length: 7 }, () => new Array(24).fill(0)); const totals = { entries: 0, exits: 0, @@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary { 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, payments: 0 }; + p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 }; seriesMap.set(label, p); } return p; @@ -196,11 +235,16 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary { if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry totals.entries++; p.entries++; - const h = localParts(row.occurredAt, tz).h; - entriesByHour[h] = (entriesByHour[h] ?? 0) + 1; + 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; @@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary { totals.revenueMinor += amt; p.payments++; p.revenueMinor += amt; - if (pl.tender === "card") totals.cardMinor += amt; - else totals.cashMinor += 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; @@ -221,6 +270,31 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary { 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(); + 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) { @@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary { 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 }; @@ -283,6 +370,10 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary { }, series, entriesByHour, + entriesByDowHour, + stayHistogram, + occupancyStart, + capacity, subscriptions: subStats, }; } diff --git a/apps/server/src/routes/reports.ts b/apps/server/src/routes/reports.ts index e13e2d3..4235783 100644 --- a/apps/server/src/routes/reports.ts +++ b/apps/server/src/routes/reports.ts @@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise async (req, reply) => { const summary = reportSummary(db, parseQuery(req.query)); const lines = [ - "bucket,entries,exits,payments,revenue", + "bucket,entries,exits,payments,revenue,cash,card,occupancy_end", ...summary.series.map((p) => - [p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","), + [ + p.bucket, + p.entries, + p.exits, + p.payments, + (p.revenueMinor / 100).toFixed(2), + (p.cashMinor / 100).toFixed(2), + (p.cardMinor / 100).toFixed(2), + p.occupancyEnd, + ].join(","), ), ]; reply diff --git a/apps/web/src/Reports.tsx b/apps/web/src/Reports.tsx index a0e645b..9ec15d4 100644 --- a/apps/web/src/Reports.tsx +++ b/apps/web/src/Reports.tsx @@ -1,8 +1,10 @@ -import { useMemo, useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { useQuery } from "@tanstack/react-query"; import { + Area, + AreaChart, Bar, BarChart, CartesianGrid, @@ -12,6 +14,7 @@ import { LineChart, Pie, PieChart, + ReferenceLine, ResponsiveContainer, Tooltip, XAxis, @@ -37,6 +40,7 @@ const C = { border: "#2a2f38", text: "#f2f2ee", panel: "#14171c", + panel2: "#1e222a", }; type PresetKey = "today" | "7d" | "30d" | "90d"; @@ -140,27 +144,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) { ...p, label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket, revenue: p.revenueMinor / 100, + cash: p.cashMinor / 100, + card: p.cardMinor / 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); + const peakOcc = Math.max(data.occupancyStart, ...data.series.map((p) => p.occupancyEnd)); + // Stay-duration bars: "≤30m … ≤24h" + the open-ended tail. + const stay = data.stayHistogram.map((b) => ({ + label: b.uptoMin == null ? `>24${t("reports.stay.h")}` : b.uptoMin < 60 ? `≤${b.uptoMin}${t("reports.stay.m")}` : `≤${b.uptoMin / 60}${t("reports.stay.h")}`, + count: b.count, + })); return (
{/* KPI cards. */} -
+
+ {/* The "look closer" counters — a spike here is what the signed chain is FOR. */} + 0 ? "amber" : undefined} /> + 0 ? "red" : undefined} />
{/* Entry / exit over time. */} @@ -192,8 +206,38 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) { + {/* Occupancy over time — THE parking curve: cars inside vs capacity. Step-shaped + (occupancy only moves at entries/exits); the red line is the configured cap. */} + + + + + + + + {data.capacity != null && ( + + )} + + + + +
- {/* Revenue per bucket. */} + {/* Revenue per bucket, stacked by tender — the drawer's cash vs the bank's card. */} @@ -201,7 +245,9 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) { money(Math.round(Number(v) * 100))} /> - + + + @@ -232,15 +278,15 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) { )} - {/* Peak hours (entries by hour-of-day). */} - + {/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */} + - + - + - + @@ -262,6 +308,12 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
+ {/* Entries heatmap: hour × day-of-week. Weekday-vs-weekend patterns at a glance — + the direct input for tariff windows (night rates, weekend cards, early bird). */} + + + +

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

@@ -269,6 +321,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) { ); } +/** Hour-of-day × day-of-week entries heatmap: pure CSS grid, amber intensity scaled to + * the busiest cell. Row 0 = Monday (server contract). Cell tooltip = exact count. */ +function Heatmap({ matrix, dows }: { matrix: number[][]; dows: string[] }) { + const max = Math.max(1, ...matrix.flat()); + return ( +
+
+ + {Array.from({ length: 24 }, (_, h) => ( + + {h % 3 === 0 ? h : ""} + + ))} + {matrix.map((row, d) => ( + + {dows[d]} + {row.map((v, h) => ( + + ))} + + ))} +
+
+ ); +} + const tooltipStyle = { background: C.panel, border: `1px solid ${C.border}`, diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 274a68f..4eabc75 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -483,7 +483,11 @@ export interface ReportSeriesPoint { entries: number; exits: number; revenueMinor: number; + cashMinor: number; + cardMinor: number; payments: number; + /** Cars inside at the END of the bucket. */ + occupancyEnd: number; } export interface ReportTotals { @@ -500,6 +504,8 @@ export interface ReportTotals { totalParkedMinutes: number; avgParkedMinutes: number; medianParkedMinutes: number; + voids: number; + anomalies: number; } export interface ReportSubscriptionStats { @@ -519,6 +525,12 @@ export interface ReportSummary { totals: ReportTotals; series: ReportSeriesPoint[]; entriesByHour: number[]; + /** 7×24, row 0 = Monday — entries heatmap (weekday-vs-weekend patterns). */ + entriesByDowHour: number[][]; + /** Stay-duration histogram; last bucket has uptoMin null (>24h tail). */ + stayHistogram: { uptoMin: number | null; count: number }[]; + occupancyStart: number; + capacity: number | null; subscriptions: ReportSubscriptionStats; } diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 9690e57..12b4f0e 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -866,14 +866,23 @@ export const en: Catalog = { payments: "Payments", avgStay: "Avg stay", subscribers: "Subscribers", + peakOcc: "Peak occupancy", + voids: "Voided tickets", + anomalies: "Anomalies", }, chart: { flow: "Entries & exits over time", + occupancy: "Occupancy — cars inside", + occupancySeries: "Cars inside", revenue: "Revenue ({{currency}})", mix: "Revenue mix", - peakHours: "Entries by hour of day", + stay: "Stay duration (closed sessions)", + heatmap: "Entries heatmap — hour × day", breakdown: "Breakdown", }, + capacityLine: "capacity", + stay: { m: "m", h: "h" }, + dowShort: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" }, row: { cash: "Cash", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 52a0aa9..aadad1b 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -881,14 +881,23 @@ export const sq = { payments: "Pagesa", avgStay: "Qëndrim mes.", subscribers: "Abonentë", + peakOcc: "Zënia maksimale", + voids: "Bileta të anuluara", + anomalies: "Anomali", }, chart: { flow: "Hyrjet & daljet me kalimin e kohës", + occupancy: "Zënia — makina brenda", + occupancySeries: "Makina brenda", revenue: "Të ardhurat ({{currency}})", mix: "Përbërja e të ardhurave", - peakHours: "Hyrjet sipas orës së ditës", + stay: "Kohëzgjatja e qëndrimit (sesione të mbyllura)", + heatmap: "Harta e hyrjeve — orë × ditë", breakdown: "Ndarja", }, + capacityLine: "kapaciteti", + stay: { m: "m", h: "o" }, + dowShort: ["Hën", "Mar", "Mër", "Enj", "Pre", "Sht", "Die"], mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" }, row: { cash: "Para në dorë", diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ac11352..ada8794 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, ne, and, or, asc, desc, gte, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm"; +export { eq, ne, and, or, asc, desc, gt, gte, lt, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm"; /** * Open the local SQLite database in WAL mode. WAL allows many concurrent readers diff --git a/wiki/concepts/reporting-analytics.md b/wiki/concepts/reporting-analytics.md index 37f9b3b..7479b2e 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-22 +updated: 2026-07-05 status: open --- @@ -73,6 +73,30 @@ disputes ("I was charged for a car that left earlier"), lost-ticket lookup, and - **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path ([[open-questions]] #4). +## As-built additions (2026-07-05) — the parking-shaped graphics + +Operator ask: "check /reports for improvements and meaningful graphics." The dashboard (Recharts, +ledger-first aggregation in `apps/server/src/reports.ts`) gained the three views that are +parking-specific rather than generic BI, plus fraud counters: + +- **Occupancy curve** — cars-inside 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`. The at-a-glance answer to "when are we + near full" — the input for capacity and dynamic-window decisions. +- **Entries heatmap (hour × day-of-week)** — 7×24 matrix (`entriesByDowHour`, row 0 = Monday, + site-tz), rendered as a pure CSS-grid amber-intensity map. Shows weekday-vs-weekend and + morning/evening patterns — the direct evidence for tariff windows (night rates, weekend cards, + early-bird — see [[tariff-industry-survey]]). Replaces the flat entries-by-hour bar (strictly + contains it). +- **Stay-duration histogram** — closed sessions bucketed at 30m/1h/2h/4h/8h/24h/tail + (`stayHistogram`): where the ladder/up-to breakpoints should sit. +- **Look-closer counters** — voids + anomalies in range as KPI cards (accented when >0): a spike + is exactly what the signed chain exists to surface (the operator is the threat model). +- Revenue bars now **stack cash vs card** per bucket (`cashMinor`/`cardMinor` on each point — + the drawer's money vs the bank's); peak-occupancy KPI (`peak / capacity`); CSV export gained + cash, card, occupancy_end columns. `localParts` now caches its Intl formatter per tz (was one + `new Intl.DateTimeFormat` per ledger row). + ## Open - Which reports matter at launch vs. later; the export format/cadence.