From 1d99881e8837e494eab79f43d01d0543be8661bd Mon Sep 17 00:00:00 2001 From: iris Date: Thu, 30 Jul 2026 10:25:54 +0200 Subject: [PATCH] client: standalone /stats page with charts, off the admin dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/package.json | 3 +- client/src/admin/Admin.tsx | 78 +----------- client/src/stats/DayChart.tsx | 60 ++++++++++ client/src/stats/Stats.tsx | 219 ++++++++++++++++++++++++++++++++++ client/src/stats/main.tsx | 6 + client/src/styles.css | 7 ++ client/stats.html | 21 ++++ client/vite.config.ts | 1 + pnpm-lock.yaml | 8 ++ server/src/index.ts | 4 + 10 files changed, 332 insertions(+), 75 deletions(-) create mode 100644 client/src/stats/DayChart.tsx create mode 100644 client/src/stats/Stats.tsx create mode 100644 client/src/stats/main.tsx create mode 100644 client/stats.html diff --git a/client/package.json b/client/package.json index d807628..2354eae 100644 --- a/client/package.json +++ b/client/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "@wutzcalc/shared": "workspace:*", - "preact": "^10.22.0" + "preact": "^10.22.0", + "uplot": "^1.6.31" }, "devDependencies": { "@preact/preset-vite": "^2.8.2", diff --git a/client/src/admin/Admin.tsx b/client/src/admin/Admin.tsx index 093e3fe..dd80d69 100644 --- a/client/src/admin/Admin.tsx +++ b/client/src/admin/Admin.tsx @@ -27,9 +27,6 @@ async function errText(res: Response): Promise { // same wire field). BarRow/stats shapes below are admin-only — not part // of the wire contract the tablet also consumes — so they stay local. interface BarRow extends Bar { drink_ids: number[] } -interface Totals { bar_id: number; bar_name: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number } -interface PerDrink { drink_id: number; drink_name: string; sold_qty: number } -interface ByDay { day: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number } export function Admin() { const [authed, setAuthed] = useState(null); @@ -100,7 +97,10 @@ function Dashboard({ onLogout }: { onLogout: () => void }) { Abmelden - +
+

Statistik

+ Statistik ansehen → +
@@ -108,76 +108,6 @@ function Dashboard({ onLogout }: { onLogout: () => void }) { ); } -function fmtDay(day: string): string { - const [y, m, d] = day.split('-'); - return `${d}.${m}.${y}`; -} - -function Stats() { - const [data, setData] = useState<{ totals: Totals[]; per_drink: PerDrink[]; by_day: ByDay[] } | null>(null); - function reload() { - fetch('/admin/api/stats').then(j).then(setData).catch(e => alert(`Fehler beim Laden: ${e}`)); - } - useEffect(reload, []); - - async function reset() { - if (!confirm('Wirklich die gesamte Statistik (alle Transaktionen) unwiderruflich löschen?')) return; - await fetch('/admin/api/stats/reset', { method: 'POST' }); - reload(); - } - - if (!data) return

Lade Statistik…

; - return ( - <> -
-

Umsatz nach Tagen

- -
- - - - {data.by_day.length === 0 && } - {data.by_day.map(d => ( - - - - - - - - ))} - -
TagTransaktionenBezahltCrew-TransaktionenPfand zurück
Noch keine Verkäufe
{fmtDay(d.day)}{d.tx_count}{formatCents(d.paid_cents)}{d.crew_count}{d.pfand_returns}
-

Umsatz pro Bar

- - - - {data.totals.map(t => ( - - - - - - - - ))} - -
BarTransaktionenBezahltCrew-TransaktionenPfand zurück
{t.bar_name}{t.tx_count}{formatCents(t.paid_cents)}{t.crew_count}{t.pfand_returns}
-

Getränke

- - - - {data.per_drink.map(d => ( - - - - - ))} - -
GetränkVerkauft
{d.drink_name}{d.sold_qty ?? 0}
- - ); -} function Exports() { return ( diff --git a/client/src/stats/DayChart.tsx b/client/src/stats/DayChart.tsx new file mode 100644 index 0000000..780369b --- /dev/null +++ b/client/src/stats/DayChart.tsx @@ -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(null); + const plotRef = useRef(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
; +} diff --git a/client/src/stats/Stats.tsx b/client/src/stats/Stats.tsx new file mode 100644 index 0000000..b969954 --- /dev/null +++ b/client/src/stats/Stats.tsx @@ -0,0 +1,219 @@ +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { formatCents } from '../api'; +import { DayChart } from './DayChart'; + +async function j(res: Response): Promise { + if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); + return res.json(); +} + +async function errText(res: Response): Promise { + try { + const body = await res.json(); + return body.detail ?? body.title ?? body.error ?? res.statusText; + } catch { + return res.statusText; + } +} + +interface Totals { bar_id: number; bar_name: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number } +interface PerDrink { drink_id: number; drink_name: string; sold_qty: number } +interface ByDay { day: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number } +interface PerDrinkByDay { day: string; drinks: PerDrink[] } +interface StatsData { totals: Totals[]; per_drink: PerDrink[]; by_day: ByDay[]; per_drink_by_day: PerDrinkByDay[] } + +function fmtDay(day: string): string { + const [y, m, d] = day.split('-'); + return `${d}.${m}.${y}`; +} + +// Shorter, no-year form for chart axis labels — the table above already +// carries the full date, and a year suffix on every tick just crowds a +// narrow axis for what's realistically a single-event date range anyway. +function fmtDayShort(day: string): string { + const [, m, d] = day.split('-'); + return `${d}.${m}.`; +} + +// Auth here isn't a full -style flow — GET /api/stats itself is the +// source of truth for whether a login is needed (STATS_PUBLIC can make it +// open with no session at all), so we just try the fetch and fall back to +// the same admin login form on a 401, rather than pre-checking /admin/api/me +// like Admin.tsx does. +export function Stats() { + const [data, setData] = useState(null); + const [needsLogin, setNeedsLogin] = useState(false); + const [err, setErr] = useState(null); + // Separate from "did /api/stats succeed" — STATS_PUBLIC can make that + // true with no session at all, but "Statistik zurücksetzen" deletes + // every transaction and must stay gated on an actual admin login + // regardless of STATS_PUBLIC (the server enforces this too; this is just + // to not show a button that always 401s for a public/anonymous viewer). + const [isAdmin, setIsAdmin] = useState(false); + + function reload() { + setErr(null); + fetch('/admin/api/me').then(j).then(r => setIsAdmin(!!r.authed)).catch(() => setIsAdmin(false)); + fetch('/api/stats') + .then(async res => { + if (res.status === 401) { setNeedsLogin(true); return; } + if (!res.ok) throw new Error(await errText(res)); + setNeedsLogin(false); + setData(await res.json()); + }) + .catch(e => setErr(String(e))); + } + useEffect(reload, []); + + if (needsLogin) return ; + if (err) return

{err}

; + if (!data) return

Lade Statistik…

; + return ; +} + +function Login({ onAuthed }: { onAuthed: () => void }) { + const [pw, setPw] = useState(''); + const [err, setErr] = useState(null); + + async function submit(e: Event) { + e.preventDefault(); + setErr(null); + try { + await fetch('/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: pw }), + }).then(j); + onAuthed(); + } catch (e: any) { + setErr(String(e)); + } + } + + return ( +
+ +
+ ); +} + +function Dashboard({ data, isAdmin, onReset }: { data: StatsData; isAdmin: boolean; onReset: () => void }) { + async function reset() { + if (!confirm('Wirklich die gesamte Statistik (alle Transaktionen) unwiderruflich löschen?')) return; + await fetch('/admin/api/stats/reset', { method: 'POST' }); + onReset(); + } + + // by_day/per_drink_by_day come pre-sorted from the server (by_day newest + // first for the table below, per_drink_by_day oldest first for the + // chart's left-to-right reading order) — no re-sort needed here. + const revenueSeries = useMemo( + () => [...data.by_day].reverse().map(d => ({ x: fmtDayShort(d.day), y: d.paid_cents / 100 })), + [data.by_day] + ); + const txSeries = useMemo( + () => [...data.by_day].reverse().map(d => ({ x: fmtDayShort(d.day), y: d.tx_count })), + [data.by_day] + ); + + // Top 5 drinks by all-time volume — a per-drink-per-day line chart with + // every drink on it gets unreadable past a handful of series, and the + // long tail is rarely what "how's it developing" is actually asking + // about. + const topDrinkIds = useMemo( + () => data.per_drink.slice(0, 5).map(d => d.drink_id), + [data.per_drink] + ); + const drinkTrend = useMemo(() => { + const names = new Map(data.per_drink.map(d => [d.drink_id, d.drink_name])); + const labels = data.per_drink_by_day.map(d => fmtDayShort(d.day)); + const series = topDrinkIds.map(id => ({ + name: names.get(id) ?? `#${id}`, + values: data.per_drink_by_day.map(day => day.drinks.find(x => x.drink_id === id)?.sold_qty ?? 0), + })); + return { labels, series }; + }, [data.per_drink_by_day, topDrinkIds]); + + return ( +
+
+

Statistik

+
+ {isAdmin && } + ← Backoffice +
+
+ +

Umsatz pro Tag

+ {revenueSeries.length === 0 + ?

Noch keine Verkäufe

+ : p.x)} series={[{ name: 'Umsatz (€)', values: revenueSeries.map(p => p.y) }]} />} + +

Transaktionen pro Tag

+ {txSeries.length === 0 + ?

Noch keine Verkäufe

+ : p.x)} series={[{ name: 'Transaktionen', values: txSeries.map(p => p.y) }]} />} + +

Top-Getränke im Verlauf

+ {drinkTrend.series.length === 0 + ?

Noch keine Verkäufe

+ : } + +

Umsatz nach Tagen

+ + + + {data.by_day.length === 0 && } + {data.by_day.map(d => ( + + + + + + + + ))} + +
TagTransaktionenBezahltCrew-TransaktionenPfand zurück
Noch keine Verkäufe
{fmtDay(d.day)}{d.tx_count}{formatCents(d.paid_cents)}{d.crew_count}{d.pfand_returns}
+ +

Umsatz pro Bar

+ + + + {data.totals.map(t => ( + + + + + + + + ))} + +
BarTransaktionenBezahltCrew-TransaktionenPfand zurück
{t.bar_name}{t.tx_count}{formatCents(t.paid_cents)}{t.crew_count}{t.pfand_returns}
+ +

Getränke (gesamt)

+ + + + {data.per_drink.map(d => ( + + + + + ))} + +
GetränkVerkauft
{d.drink_name}{d.sold_qty ?? 0}
+
+ ); +} diff --git a/client/src/stats/main.tsx b/client/src/stats/main.tsx new file mode 100644 index 0000000..6f8309f --- /dev/null +++ b/client/src/stats/main.tsx @@ -0,0 +1,6 @@ +import { render } from 'preact'; +import { Stats } from './Stats'; +import { applyTheme } from '../api'; + +applyTheme(); +render(, document.getElementById('app')!); diff --git a/client/src/styles.css b/client/src/styles.css index 077469a..6048e93 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -287,6 +287,13 @@ button.danger { background: #5a2a2a; border-color: #7a3a3a; color: #fff; } .admin .row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; } .admin .muted { opacity: 0.6; } .admin .login { max-width: 320px; margin: 80px auto; display: flex; flex-direction: column; gap: 12px; } +.admin .chart { + background: var(--surface); + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 12px 12px 4px; + margin-bottom: 16px; +} .dnd-list { list-style: none; padding: 0; margin: 0 0 8px; } .dnd-item { diff --git a/client/stats.html b/client/stats.html new file mode 100644 index 0000000..212db33 --- /dev/null +++ b/client/stats.html @@ -0,0 +1,21 @@ + + + + + + wutzcalc — Statistik + + + + + +
+ + + diff --git a/client/vite.config.ts b/client/vite.config.ts index 60635be..8590f10 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ input: { main: resolve(__dirname, 'index.html'), admin: resolve(__dirname, 'admin.html'), + stats: resolve(__dirname, 'stats.html'), }, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 723771c..e783300 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: preact: specifier: ^10.22.0 version: 10.29.2 + uplot: + specifier: ^1.6.31 + version: 1.6.32 devDependencies: '@preact/preset-vite': specifier: ^2.8.2 @@ -1865,6 +1868,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uplot@1.6.32: + resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3766,6 +3772,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uplot@1.6.32: {} + util-deprecate@1.0.2: {} vite-prerender-plugin@0.5.13(vite@5.4.21(@types/node@20.19.41)(terser@5.47.1)): diff --git a/server/src/index.ts b/server/src/index.ts index 81f0925..5b22df9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -105,6 +105,10 @@ if (existsSync(clientDist)) { reply.sendFile('admin.html'); return; } + if (req.url.startsWith('/stats')) { + reply.sendFile('stats.html'); + return; + } reply.sendFile('index.html'); }); } else {