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:
iris 2026-07-30 10:25:54 +02:00 committed by mara
commit 1d99881e88
10 changed files with 332 additions and 75 deletions

219
client/src/stats/Stats.tsx Normal file
View file

@ -0,0 +1,219 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { formatCents } from '../api';
import { DayChart } from './DayChart';
async function j<T = any>(res: Response): Promise<T> {
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json();
}
async function errText(res: Response): Promise<string> {
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 <Admin/>-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<StatsData | null>(null);
const [needsLogin, setNeedsLogin] = useState(false);
const [err, setErr] = useState<string | null>(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 <Login onAuthed={reload} />;
if (err) return <div class="admin"><p style="color:var(--danger)">{err}</p></div>;
if (!data) return <div class="admin"><p>Lade Statistik</p></div>;
return <Dashboard data={data} isAdmin={isAdmin} onReset={reload} />;
}
function Login({ onAuthed }: { onAuthed: () => void }) {
const [pw, setPw] = useState('');
const [err, setErr] = useState<string | null>(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 (
<form class="admin" onSubmit={submit}>
<div class="login">
<h1>Statistik</h1>
<input
type="password"
placeholder="Passwort"
value={pw}
onInput={(e: any) => setPw(e.currentTarget.value)}
/>
{err && <div style="color:var(--danger)">{err}</div>}
<button type="submit">Anmelden</button>
</div>
</form>
);
}
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 (
<div class="admin">
<div class="row" style="justify-content: space-between">
<h1 style="margin:0">Statistik</h1>
<div class="row" style="gap: 8px">
{isAdmin && <button class="danger" onClick={reset}>Statistik zurücksetzen</button>}
<a href="/admin"> Backoffice</a>
</div>
</div>
<h2>Umsatz pro Tag</h2>
{revenueSeries.length === 0
? <p class="muted">Noch keine Verkäufe</p>
: <DayChart labels={revenueSeries.map(p => p.x)} series={[{ name: 'Umsatz (€)', values: revenueSeries.map(p => p.y) }]} />}
<h2>Transaktionen pro Tag</h2>
{txSeries.length === 0
? <p class="muted">Noch keine Verkäufe</p>
: <DayChart labels={txSeries.map(p => p.x)} series={[{ name: 'Transaktionen', values: txSeries.map(p => p.y) }]} />}
<h2>Top-Getränke im Verlauf</h2>
{drinkTrend.series.length === 0
? <p class="muted">Noch keine Verkäufe</p>
: <DayChart labels={drinkTrend.labels} series={drinkTrend.series} />}
<h2>Umsatz nach Tagen</h2>
<table>
<thead><tr><th>Tag</th><th>Transaktionen</th><th>Bezahlt</th><th>Crew-Transaktionen</th><th>Pfand zurück</th></tr></thead>
<tbody>
{data.by_day.length === 0 && <tr><td colSpan={5} class="muted">Noch keine Verkäufe</td></tr>}
{data.by_day.map(d => (
<tr key={d.day}>
<td>{fmtDay(d.day)}</td>
<td>{d.tx_count}</td>
<td>{formatCents(d.paid_cents)}</td>
<td>{d.crew_count}</td>
<td>{d.pfand_returns}</td>
</tr>
))}
</tbody>
</table>
<h2>Umsatz pro Bar</h2>
<table>
<thead><tr><th>Bar</th><th>Transaktionen</th><th>Bezahlt</th><th>Crew-Transaktionen</th><th>Pfand zurück</th></tr></thead>
<tbody>
{data.totals.map(t => (
<tr key={t.bar_id}>
<td>{t.bar_name}</td>
<td>{t.tx_count}</td>
<td>{formatCents(t.paid_cents)}</td>
<td>{t.crew_count}</td>
<td>{t.pfand_returns}</td>
</tr>
))}
</tbody>
</table>
<h2>Getränke (gesamt)</h2>
<table>
<thead><tr><th>Getränk</th><th>Verkauft</th></tr></thead>
<tbody>
{data.per_drink.map(d => (
<tr key={d.drink_id}>
<td>{d.drink_name}</td>
<td>{d.sold_qty ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}