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

@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import type { DB } from '../db.js';
import { businessDay, formatLocal, parseDbTime } from '../time.js';
import { businessDay, formatLocal, localHourBucket, localHourOfDay, parseDbTime } from '../time.js';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { sendProblem } from '../problem-details.js';
@ -130,6 +130,51 @@ function computeStats(db: DB) {
// /stats "recent days" table), where the most recent day belongs on top.
const byDay = [...dayMap.values()].sort((a, b) => b.day.localeCompare(a.day));
// Two hourly views for #45 ("why not both" — the continuous timeline and
// the hour-of-day pattern answer different questions, see the issue
// comment). Same shape as dayMap's aggregates, same JS-bucketing
// reasoning, just keyed differently: localHourBucket() is literal clock
// time (no business-day rollover — the point is "when did this happen"),
// localHourOfDay() collapses every day onto a 0-23 axis.
type HourAgg = { tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number };
function bumpHourAgg(agg: HourAgg, r: { total_cents: number; crew: number; pfand_returns: number }) {
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 hourMap = new Map<string, { hour: string } & HourAgg>();
const hourOfDayMap = new Map<number, { hour_of_day: number } & HourAgg>();
for (const r of txRows) {
const d = parseDbTime(r.created_at);
const hourKey = localHourBucket(d);
let hourAgg = hourMap.get(hourKey);
if (!hourAgg) {
hourAgg = { hour: hourKey, tx_count: 0, paid_cents: 0, crew_count: 0, pfand_returns: 0 };
hourMap.set(hourKey, hourAgg);
}
bumpHourAgg(hourAgg, r);
const hod = localHourOfDay(d);
let hodAgg = hourOfDayMap.get(hod);
if (!hodAgg) {
hodAgg = { hour_of_day: hod, tx_count: 0, paid_cents: 0, crew_count: 0, pfand_returns: 0 };
hourOfDayMap.set(hod, hodAgg);
}
bumpHourAgg(hodAgg, r);
}
// Oldest first, same reasoning as perDrinkByDay below — this feeds a
// left-to-right timeline chart.
const byHour = [...hourMap.values()].sort((a, b) => a.hour.localeCompare(b.hour));
// Full 0-23 axis, zero-filled — a bar chart with silently missing hours
// (e.g. no sales at 6am) reads as a data gap, not "zero", if the bucket
// is just absent.
const byHourOfDay = Array.from({ length: 24 }, (_, h) => hourOfDayMap.get(h) ?? {
hour_of_day: h, tx_count: 0, paid_cents: 0, crew_count: 0, pfand_returns: 0,
});
// 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
@ -166,7 +211,14 @@ function computeStats(db: DB) {
.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 };
return {
totals,
per_drink: perDrink,
by_day: byDay,
per_drink_by_day: perDrinkByDay,
by_hour: byHour,
by_hour_of_day: byHourOfDay,
};
}
export function registerAdminRoutes(app: FastifyInstance, db: DB) {

View file

@ -79,6 +79,27 @@ export function formatLocal(d: Date, tz: string = TZ): string {
return `${p.year}-${pad(p.month)}-${pad(p.day)} ${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)}`;
}
/**
* Local wall-clock hour bucket as `YYYY-MM-DD HH:00`, for the continuous
* hourly timeline in /stats (#45) literal clock time, no business-day
* rollover (unlike businessDay() below): the point of this bucket is "what
* hour did this actually happen", not which sales night it counts toward.
*/
export function localHourBucket(d: Date, tz: string = TZ): string {
const p = localParts(d, tz);
const pad = (n: number) => String(n).padStart(2, '0');
return `${p.year}-${pad(p.month)}-${pad(p.day)} ${pad(p.hour)}:00`;
}
/**
* Local hour-of-day (0-23), for the "which hour is busiest" pattern
* summed across every day in /stats (#45) deliberately not business-day
* aware either, same reasoning as localHourBucket().
*/
export function localHourOfDay(d: Date, tz: string = TZ): number {
return localParts(d, tz).hour;
}
/**
* The business day a timestamp belongs to, as `YYYY-MM-DD`.
* Hours before BUSINESS_DAY_CUTOFF_HOUR are attributed to the previous day.