stats: add hourly views — continuous timeline + hour-of-day pattern

Closes #45.

Two new server-side aggregates in computeStats(), same JS-bucketing
approach as the existing by_day: by_hour (localHourBucket — literal clock
time, no business-day rollover, feeds a left-to-right timeline) and
by_hour_of_day (localHourOfDay — every day's hour 0-23 summed together,
zero-filled to a full 24-entry axis so a quiet hour reads as zero, not a
missing data point).

Client: two more revenue charts on /stats — 'Umsatz nach Stunde' (timeline)
and 'Umsatz nach Tageszeit' (pattern). Revenue only, not also a tx-count
variant, to keep the page from growing a chart per metric per granularity.

Build clean both sides. Manually verified by_hour_of_day returns all 24
zero-filled buckets against a fresh DB.
This commit is contained in:
iris 2026-07-30 20:14:45 +02:00
commit 6c2a938b91
3 changed files with 118 additions and 3 deletions

View file

@ -20,7 +20,16 @@ interface Totals { bar_id: number; bar_name: string; tx_count: number; paid_cent
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[] }
interface ByHour { hour: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number }
interface ByHourOfDay { hour_of_day: number; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number }
interface StatsData {
totals: Totals[];
per_drink: PerDrink[];
by_day: ByDay[];
per_drink_by_day: PerDrinkByDay[];
by_hour: ByHour[];
by_hour_of_day: ByHourOfDay[];
}
function fmtDay(day: string): string {
const [y, m, d] = day.split('-');
@ -35,6 +44,15 @@ function fmtDayShort(day: string): string {
return `${d}.${m}.`;
}
// `hour` is a "YYYY-MM-DD HH:00" bucket (see localHourBucket() server-side)
// — render as "30.07. 14h" for the continuous timeline chart.
function fmtHour(hour: string): string {
const [day, time] = hour.split(' ');
const [, m, d] = (day ?? '').split('-');
const h = (time ?? '').split(':')[0];
return `${d}.${m}. ${h}h`;
}
// 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
@ -126,6 +144,20 @@ function Dashboard({ data, isAdmin, onReset }: { data: StatsData; isAdmin: boole
[data.by_day]
);
// #45's two hourly readings — see the by_hour/by_hour_of_day comment on
// computeStats() server-side for why they're separate. Revenue only
// here (not also a tx-count variant like the per-day charts above) to
// keep the page from growing a chart per metric per granularity; ask if
// you want tx-count broken out hourly too.
const hourlyRevenue = useMemo(
() => data.by_hour.map(h => ({ x: fmtHour(h.hour), y: h.paid_cents / 100 })),
[data.by_hour]
);
const hourOfDayRevenue = useMemo(
() => data.by_hour_of_day.map(h => ({ x: `${String(h.hour_of_day).padStart(2, '0')}h`, y: h.paid_cents / 100 })),
[data.by_hour_of_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
@ -164,6 +196,16 @@ function Dashboard({ data, isAdmin, onReset }: { data: StatsData; isAdmin: boole
? <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>Umsatz nach Stunde</h2>
{hourlyRevenue.length === 0
? <p class="muted">Noch keine Verkäufe</p>
: <DayChart labels={hourlyRevenue.map(p => p.x)} series={[{ name: 'Umsatz (€)', values: hourlyRevenue.map(p => p.y) }]} />}
<h2>Umsatz nach Tageszeit</h2>
{hourOfDayRevenue.every(p => p.y === 0)
? <p class="muted">Noch keine Verkäufe</p>
: <DayChart labels={hourOfDayRevenue.map(p => p.x)} series={[{ name: 'Umsatz (€)', values: hourOfDayRevenue.map(p => p.y) }]} />}
<h2>Top-Getränke im Verlauf</h2>
{drinkTrend.series.length === 0
? <p class="muted">Noch keine Verkäufe</p>