diff --git a/client/package.json b/client/package.json index 2354eae..d807628 100644 --- a/client/package.json +++ b/client/package.json @@ -11,8 +11,7 @@ }, "dependencies": { "@wutzcalc/shared": "workspace:*", - "preact": "^10.22.0", - "uplot": "^1.6.31" + "preact": "^10.22.0" }, "devDependencies": { "@preact/preset-vite": "^2.8.2", diff --git a/client/src/admin/Admin.tsx b/client/src/admin/Admin.tsx index dd80d69..093e3fe 100644 --- a/client/src/admin/Admin.tsx +++ b/client/src/admin/Admin.tsx @@ -27,6 +27,9 @@ 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); @@ -97,10 +100,7 @@ function Dashboard({ onLogout }: { onLogout: () => void }) { Abmelden -
-

Statistik

- Statistik ansehen → -
+ @@ -108,6 +108,76 @@ 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 deleted file mode 100644 index 780369b..0000000 --- a/client/src/stats/DayChart.tsx +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index b969954..0000000 --- a/client/src/stats/Stats.tsx +++ /dev/null @@ -1,219 +0,0 @@ -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 deleted file mode 100644 index 6f8309f..0000000 --- a/client/src/stats/main.tsx +++ /dev/null @@ -1,6 +0,0 @@ -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 6048e93..077469a 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -287,13 +287,6 @@ 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 deleted file mode 100644 index 212db33..0000000 --- a/client/stats.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - wutzcalc — Statistik - - - - - -
- - - diff --git a/client/vite.config.ts b/client/vite.config.ts index 8590f10..60635be 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -25,7 +25,6 @@ export default defineConfig({ input: { main: resolve(__dirname, 'index.html'), admin: resolve(__dirname, 'admin.html'), - stats: resolve(__dirname, 'stats.html'), }, }, }, diff --git a/deploy/wutzcalc.env.example b/deploy/wutzcalc.env.example index 5898470..6eed85d 100644 --- a/deploy/wutzcalc.env.example +++ b/deploy/wutzcalc.env.example @@ -16,8 +16,3 @@ 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e783300..723771c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,9 +16,6 @@ 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 @@ -1868,9 +1865,6 @@ 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==} @@ -3772,8 +3766,6 @@ 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 5b22df9..81f0925 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -105,10 +105,6 @@ if (existsSync(clientDist)) { reply.sendFile('admin.html'); return; } - if (req.url.startsWith('/stats')) { - reply.sendFile('stats.html'); - return; - } reply.sendFile('index.html'); }); } else { diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 113c785..21c0374 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -40,17 +40,6 @@ 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 @@ -64,111 +53,6 @@ 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(); - 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>(); - 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; @@ -381,17 +265,71 @@ 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); - }); - app.get('/api/stats', async (req, reply) => { - if (!requireStatsAuth(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(); + 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.post('/admin/api/stats/reset', async (req, reply) => {