client: standalone /stats page with charts, off the admin dashboard
Closes #40. New /stats route (client/stats.html + src/stats/), served by the same catch-all pattern as /admin. Reuses the admin login (STATS_PUBLIC env var on the server side decides whether it needs one at all). Three uPlot charts (daily revenue, daily transaction count, top-5-drink sold-qty trend) plus the same three tables Admin.tsx used to render inline — those move here wholesale, Admin.tsx now just links to /stats instead of fetching /admin/api/stats itself. The 'Statistik zurücksetzen' reset button moves here too, gated on an actual admin session (checked separately from whether /api/stats itself succeeded, since STATS_PUBLIC can make that true for an anonymous viewer). Chart lib is uPlot (~45kb) per mara's steer not to hand-roll this. Both client and server build/typecheck clean; manually smoke-tested the auth gate (401 unauthed, 200 after login) and the /stats route against a fresh DB.
This commit is contained in:
parent
9c75ce5243
commit
1d99881e88
10 changed files with 332 additions and 75 deletions
60
client/src/stats/DayChart.tsx
Normal file
60
client/src/stats/DayChart.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
|
||||
// A handful of distinguishable line colors, theme-independent (uPlot draws
|
||||
// on canvas, so it can't pick up CSS custom properties the way the rest of
|
||||
// the app's chrome does) — enough for the "top 5 drinks" case, cycles if a
|
||||
// caller ever passes more series.
|
||||
const COLORS = ['#7aa2f7', '#f7768e', '#9ece6a', '#e0af68', '#bb9af7'];
|
||||
|
||||
export interface ChartSeries { name: string; values: number[] }
|
||||
|
||||
// Thin uPlot wrapper: x-axis is just sequential day labels (business days
|
||||
// with zero sales are already dropped server-side, so evenly-spaced ticks
|
||||
// read better than gapped real dates would) rather than real timestamps —
|
||||
// avoids re-deriving a Date from the server's businessDay() string, which
|
||||
// would need the same DST-aware timezone handling admin.ts already
|
||||
// centralizes rather than a second copy in the browser.
|
||||
export function DayChart({ labels, series }: { labels: string[]; series: ChartSeries[] }) {
|
||||
const holderRef = useRef<HTMLDivElement>(null);
|
||||
const plotRef = useRef<uPlot | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const holder = holderRef.current;
|
||||
if (!holder) return;
|
||||
|
||||
const xs = labels.map((_, i) => i);
|
||||
const data: uPlot.AlignedData = [xs, ...series.map(s => s.values)];
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
width: holder.clientWidth || 600,
|
||||
height: 220,
|
||||
legend: { show: series.length > 1 },
|
||||
cursor: { drag: { x: false, y: false } },
|
||||
scales: { x: { time: false } },
|
||||
axes: [
|
||||
{ stroke: '#888', grid: { stroke: '#333' }, values: (_u, vals) => vals.map(v => labels[v] ?? '') },
|
||||
{ stroke: '#888', grid: { stroke: '#333' } },
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
...series.map((s, i) => ({
|
||||
label: s.name,
|
||||
stroke: COLORS[i % COLORS.length],
|
||||
width: 2,
|
||||
points: { show: labels.length <= 14 },
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
plotRef.current = new uPlot(opts, data, holder);
|
||||
// Re-create on every data change rather than plot.setData: these charts
|
||||
// redraw once per page load / manual refresh, not on a hot path, so
|
||||
// the simplicity of "just rebuild it" outweighs the incremental-update
|
||||
// complexity setData would otherwise save.
|
||||
return () => { plotRef.current?.destroy(); plotRef.current = null; };
|
||||
}, [labels, series]);
|
||||
|
||||
return <div ref={holderRef} class="chart" />;
|
||||
}
|
||||
Loading…
Reference in a new issue