feat(reports): occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs

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
This commit is contained in:
2026-07-06 12:35:54 +02:00
parent a2e102f3dd
commit 7ef332999e
9 changed files with 334 additions and 32 deletions
+95 -12
View File
@@ -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 (
<div className="space-y-4">
{/* KPI cards. */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-8">
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
<Kpi
label={t("reports.kpi.subscribers")}
value={String(data.subscriptions.currentlyValid)}
label={t("reports.kpi.peakOcc")}
value={data.capacity ? `${peakOcc} / ${data.capacity}` : String(peakOcc)}
/>
{/* The "look closer" counters — a spike here is what the signed chain is FOR. */}
<Kpi label={t("reports.kpi.voids")} value={String(tot.voids)} accent={tot.voids > 0 ? "amber" : undefined} />
<Kpi label={t("reports.kpi.anomalies")} value={String(tot.anomalies)} accent={tot.anomalies > 0 ? "red" : undefined} />
</div>
{/* Entry / exit over time. */}
@@ -192,8 +206,38 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
</ResponsiveContainer>
</Panel>
{/* 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. */}
<Panel title={t("reports.chart.occupancy")}>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
<Tooltip contentStyle={tooltipStyle} />
{data.capacity != null && (
<ReferenceLine
y={data.capacity}
stroke={C.red}
strokeDasharray="4 4"
label={{ value: t("reports.capacityLine"), fill: C.red, fontSize: 11, position: "insideTopRight" }}
/>
)}
<Area
type="stepAfter"
dataKey="occupancyEnd"
name={t("reports.chart.occupancySeries")}
stroke={C.cyan}
fill={C.cyan}
fillOpacity={0.15}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</Panel>
<div className="grid gap-4 lg:grid-cols-2">
{/* Revenue per bucket. */}
{/* Revenue per bucket, stacked by tender — the drawer's cash vs the bank's card. */}
<Panel title={t("reports.chart.revenue", { currency: cur })}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
@@ -201,7 +245,9 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} />
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="cash" stackId="tender" name={t("reports.row.cash")} fill={C.amber} />
<Bar dataKey="card" stackId="tender" name={t("reports.row.card")} fill={C.cyan} />
</BarChart>
</ResponsiveContainer>
</Panel>
@@ -232,15 +278,15 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
)}
</Panel>
{/* Peak hours (entries by hour-of-day). */}
<Panel title={t("reports.chart.peakHours")}>
{/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */}
<Panel title={t("reports.chart.stay")}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<BarChart data={stay} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} />
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
<Tooltip contentStyle={tooltipStyle} />
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} />
<Bar dataKey="count" name={t("reports.row.closed")} fill={C.green} />
</BarChart>
</ResponsiveContainer>
</Panel>
@@ -262,6 +308,12 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
</Panel>
</div>
{/* 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). */}
<Panel title={t("reports.chart.heatmap")}>
<Heatmap matrix={data.entriesByDowHour} dows={t("reports.dowShort", { returnObjects: true }) as string[]} />
</Panel>
<p className="text-[0.6875rem] text-term-muted">
{t("reports.footnote", { tz: data.tz })}
</p>
@@ -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 (
<div className="overflow-x-auto">
<div className="grid min-w-[560px] grid-cols-[max-content_repeat(24,1fr)] gap-px text-[0.625rem]">
<span />
{Array.from({ length: 24 }, (_, h) => (
<span key={h} className="pb-0.5 text-center text-term-muted">
{h % 3 === 0 ? h : ""}
</span>
))}
{matrix.map((row, d) => (
<Fragment key={d}>
<span className="pr-1.5 leading-4 text-term-muted">{dows[d]}</span>
{row.map((v, h) => (
<span
key={h}
title={`${dows[d]} ${String(h).padStart(2, "0")}:00 — ${v}`}
className="h-4 rounded-[1px]"
style={{ background: v === 0 ? C.panel2 : C.amber, opacity: v === 0 ? 1 : 0.25 + 0.75 * (v / max) }}
/>
))}
</Fragment>
))}
</div>
</div>
);
}
const tooltipStyle = {
background: C.panel,
border: `1px solid ${C.border}`,