Compare commits

...
Author SHA1 Message Date
iris
1d99881e88 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.
2026-07-30 11:40:34 +02:00
iris
9c75ce5243 server: /api/stats endpoint + per-day-per-drink trend data
New GET /api/stats — same computeStats() shared with the existing
/admin/api/stats, so the numbers stay identical. Auth is gated by
STATS_PUBLIC (default: requires the admin session, same as every other
/admin/api endpoint) rather than always-open, so a bare deploy doesn't
expose revenue by default.

Also adds per_drink_by_day (day -> per-drink sold qty) alongside the
existing all-time per_drink totals — the trend-over-time data mara asked
for on #40. Same businessDay() JS bucketing as by_day for DST correctness,
same reasoning as the existing comment on that.

Client page for /stats itself is a follow-up commit.
2026-07-30 11:40:34 +02:00
12 changed files with 461 additions and 137 deletions

View file

@ -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",

View file

@ -27,9 +27,6 @@ async function errText(res: Response): Promise<string> {
// 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<boolean | null>(null);
@ -100,7 +97,10 @@ function Dashboard({ onLogout }: { onLogout: () => void }) {
Abmelden
</button>
</div>
<Stats />
<div class="row" style="justify-content: space-between; align-items: center">
<h2 style="margin:0">Statistik</h2>
<a href="/stats">Statistik ansehen </a>
</div>
<Exports />
<Drinks drinks={drinks} reload={reloadDrinks} />
<Bars drinks={drinks} />
@ -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 <p>Lade Statistik</p>;
return (
<>
<div class="row" style="justify-content: space-between">
<h2 style="margin:0">Umsatz nach Tagen</h2>
<button class="danger" onClick={reset}>Statistik zurücksetzen</button>
</div>
<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</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>
</>
);
}
function Exports() {
return (

View 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" />;
}

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>
);
}

View file

@ -0,0 +1,6 @@
import { render } from 'preact';
import { Stats } from './Stats';
import { applyTheme } from '../api';
applyTheme();
render(<Stats />, document.getElementById('app')!);

View file

@ -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 {

21
client/stats.html Normal file
View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>wutzcalc — Statistik</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script>
// Set the theme before first paint to avoid a flash of the wrong colors.
document.documentElement.setAttribute(
'data-theme',
localStorage.getItem('wutz.theme') === 'light' ? 'light' : 'dark'
);
</script>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/stats/main.tsx"></script>
</body>
</html>

View file

@ -25,6 +25,7 @@ export default defineConfig({
input: {
main: resolve(__dirname, 'index.html'),
admin: resolve(__dirname, 'admin.html'),
stats: resolve(__dirname, 'stats.html'),
},
},
},

View file

@ -16,3 +16,8 @@ HOST=0.0.0.0
# previous business day, so a 03:00 sale lands on the night before.
#WUTZ_TZ=Europe/Berlin
#WUTZ_DAY_CUTOFF_HOUR=5
# /stats page: by default it requires the same admin login as /admin. Set to
# 1 to make it viewable without logging in (e.g. a screen permanently
# mounted at a festival infopoint).
#STATS_PUBLIC=0

8
pnpm-lock.yaml generated
View file

@ -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)):

View file

@ -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 {

View file

@ -40,6 +40,17 @@ function requireAuth(req: FastifyRequest, reply: FastifyReply): boolean {
return false;
}
// The standalone /stats page (issue #40) reuses the admin login/session —
// there's only one password/cookie in this app, no separate stats-only
// credential. STATS_PUBLIC opts out of that gate entirely (e.g. a screen
// permanently mounted at a festival infopoint) — default is auth required,
// same as every other /admin/api endpoint, so a bare deploy doesn't
// accidentally expose revenue numbers.
function requireStatsAuth(req: FastifyRequest, reply: FastifyReply): boolean {
if (process.env.STATS_PUBLIC === '1') return true;
return requireAuth(req, reply);
}
// `Number(nonNumericString)` is NaN, and NaN binds as SQL NULL — an
// `UPDATE ... WHERE id = NULL` silently matches zero rows instead of
// throwing, so a bad :id param used to "succeed" the same way a genuinely
@ -53,6 +64,111 @@ function parseId(raw: string, req: FastifyRequest, reply: FastifyReply): number
return id;
}
// Shared by GET /admin/api/stats (legacy, still used by the admin login
// flow's session check) and GET /api/stats (the standalone /stats page,
// issue #40). LEFT JOIN (not JOIN) so a bar with zero sales still gets a
// zero row — an inner join made a brand-new bar indistinguishable from a
// deleted one until its first sale, which reads as "my new bar isn't
// working" during setup. COUNT(t.id), not COUNT(*): the outer join
// produces one NULL-filled row per bar-with-no-transactions, and COUNT(*)
// would count that as 1 instead of 0.
function computeStats(db: DB) {
const totals = db
.prepare(
`SELECT b.id AS bar_id, b.name AS bar_name,
COUNT(t.id) AS tx_count,
COALESCE(SUM(CASE WHEN t.crew = 0 THEN t.total_cents ELSE 0 END), 0) AS paid_cents,
COALESCE(SUM(CASE WHEN t.crew = 1 THEN 1 ELSE 0 END), 0) AS crew_count,
COALESCE(SUM(t.pfand_returns), 0) AS pfand_returns
FROM bars b
LEFT JOIN transactions t ON t.bar_id = b.id
GROUP BY b.id, b.name
ORDER BY b.id`
)
.all();
const perDrink = db
.prepare(
`SELECT d.id AS drink_id, d.name AS drink_name,
COALESCE(SUM(ti.qty), 0) AS sold_qty
FROM transaction_items ti
JOIN drinks d ON d.id = ti.drink_id
GROUP BY d.id, d.name
ORDER BY sold_qty DESC, d.id`
)
.all();
// Per business day (sales night runs past midnight — see time.ts).
//
// Deliberately still computed in JS rather than SQL, despite selecting
// every transaction row on every stats load: businessDay() uses
// Intl.DateTimeFormat with a named IANA zone (WUTZ_TZ), which handles
// DST transitions correctly. A SQL `date(created_at, '-Nh', 'localtime')`
// rewrite would use the *server process's* OS timezone (not WUTZ_TZ) and
// a fixed hour offset that's wrong on the two nights a year DST changes
// — a real correctness regression for a money-adjacent report, to fix a
// performance concern that (per the code review that flagged this) is
// "fine today" at festival scale. Not worth the trade.
const txRows = db
.prepare('SELECT created_at, total_cents, crew, pfand_returns FROM transactions')
.all() as Array<{ created_at: string; total_cents: number; crew: number; pfand_returns: number }>;
const dayMap = new Map<string, { day: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number }>();
for (const r of txRows) {
const day = businessDay(parseDbTime(r.created_at));
let agg = dayMap.get(day);
if (!agg) {
agg = { day, tx_count: 0, paid_cents: 0, crew_count: 0, pfand_returns: 0 };
dayMap.set(day, agg);
}
agg.tx_count += 1;
if (r.crew) agg.crew_count += 1;
else agg.paid_cents += r.total_cents;
agg.pfand_returns += r.pfand_returns;
}
// Newest first — this order feeds the existing admin table (and the
// /stats "recent days" table), where the most recent day belongs on top.
const byDay = [...dayMap.values()].sort((a, b) => b.day.localeCompare(a.day));
// Per-day-per-drink sold quantity — the "trend over time" data #40 asked
// for, on top of the all-time perDrink totals above. Same businessDay()
// JS bucketing as byDay, for the same DST-correctness reason; joined
// against transaction_items rather than reusing txRows since the qty
// lives one table over.
const itemRows = db
.prepare(
`SELECT t.created_at, ti.drink_id, d.name AS drink_name, ti.qty
FROM transaction_items ti
JOIN transactions t ON t.id = ti.transaction_id
JOIN drinks d ON d.id = ti.drink_id`
)
.all() as Array<{ created_at: string; drink_id: number; drink_name: string; qty: number }>;
const dayDrinkMap = new Map<string, Map<number, { drink_id: number; drink_name: string; sold_qty: number }>>();
for (const r of itemRows) {
const day = businessDay(parseDbTime(r.created_at));
let drinks = dayDrinkMap.get(day);
if (!drinks) {
drinks = new Map();
dayDrinkMap.set(day, drinks);
}
let agg = drinks.get(r.drink_id);
if (!agg) {
agg = { drink_id: r.drink_id, drink_name: r.drink_name, sold_qty: 0 };
drinks.set(r.drink_id, agg);
}
agg.sold_qty += r.qty;
}
// Oldest first — this feeds a trend chart, where left-to-right = time
// moving forward is the expected reading direction (opposite of byDay's
// "most recent on top" table order above).
const perDrinkByDay = [...dayDrinkMap.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([day, drinks]) => ({ day, drinks: [...drinks.values()].sort((a, b) => b.sold_qty - a.sold_qty) }));
return { totals, per_drink: perDrink, by_day: byDay, per_drink_by_day: perDrinkByDay };
}
export function registerAdminRoutes(app: FastifyInstance, db: DB) {
app.post<{ Body: { password?: string } }>('/admin/login', async (req, reply) => {
const expected = process.env.ADMIN_PASSWORD;
@ -265,71 +381,17 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
});
// ----- Stats -----
// Kept at the old /admin/api/stats path too (the admin dashboard's own
// "Statistik" link into /stats reuses the session, no separate call
// needed there) — computeStats() is the single source both paths share.
app.get('/admin/api/stats', async (req, reply) => {
if (!requireAuth(req, reply)) return;
return computeStats(db);
});
// LEFT JOIN (not JOIN) so a bar with zero sales still gets a zero row —
// an inner join made a brand-new bar indistinguishable from a deleted
// one until its first sale, which reads as "my new bar isn't working"
// during setup. COUNT(t.id), not COUNT(*): the outer join produces one
// NULL-filled row per bar-with-no-transactions, and COUNT(*) would
// count that as 1 instead of 0.
const totals = db
.prepare(
`SELECT b.id AS bar_id, b.name AS bar_name,
COUNT(t.id) AS tx_count,
COALESCE(SUM(CASE WHEN t.crew = 0 THEN t.total_cents ELSE 0 END), 0) AS paid_cents,
COALESCE(SUM(CASE WHEN t.crew = 1 THEN 1 ELSE 0 END), 0) AS crew_count,
COALESCE(SUM(t.pfand_returns), 0) AS pfand_returns
FROM bars b
LEFT JOIN transactions t ON t.bar_id = b.id
GROUP BY b.id, b.name
ORDER BY b.id`
)
.all();
const perDrink = db
.prepare(
`SELECT d.id AS drink_id, d.name AS drink_name,
COALESCE(SUM(ti.qty), 0) AS sold_qty
FROM transaction_items ti
JOIN drinks d ON d.id = ti.drink_id
GROUP BY d.id, d.name
ORDER BY sold_qty DESC, d.id`
)
.all();
// Per business day (sales night runs past midnight — see time.ts).
//
// Deliberately still computed in JS rather than SQL, despite selecting
// every transaction row on every stats load: businessDay() uses
// Intl.DateTimeFormat with a named IANA zone (WUTZ_TZ), which handles
// DST transitions correctly. A SQL `date(created_at, '-Nh', 'localtime')`
// rewrite would use the *server process's* OS timezone (not WUTZ_TZ) and
// a fixed hour offset that's wrong on the two nights a year DST changes
// — a real correctness regression for a money-adjacent report, to fix a
// performance concern that (per the code review that flagged this) is
// "fine today" at festival scale. Not worth the trade.
const txRows = db
.prepare('SELECT created_at, total_cents, crew, pfand_returns FROM transactions')
.all() as Array<{ created_at: string; total_cents: number; crew: number; pfand_returns: number }>;
const dayMap = new Map<string, { day: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number }>();
for (const r of txRows) {
const day = businessDay(parseDbTime(r.created_at));
let agg = dayMap.get(day);
if (!agg) {
agg = { day, tx_count: 0, paid_cents: 0, crew_count: 0, pfand_returns: 0 };
dayMap.set(day, agg);
}
agg.tx_count += 1;
if (r.crew) agg.crew_count += 1;
else agg.paid_cents += r.total_cents;
agg.pfand_returns += r.pfand_returns;
}
const byDay = [...dayMap.values()].sort((a, b) => b.day.localeCompare(a.day));
return { totals, per_drink: perDrink, by_day: byDay };
app.get('/api/stats', async (req, reply) => {
if (!requireStatsAuth(req, reply)) return;
return computeStats(db);
});
app.post('/admin/api/stats/reset', async (req, reply) => {