diff --git a/client/src/stats/Stats.tsx b/client/src/stats/Stats.tsx index b969954..6596c3b 100644 --- a/client/src/stats/Stats.tsx +++ b/client/src/stats/Stats.tsx @@ -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 -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 ?

Noch keine Verkäufe

: p.x)} series={[{ name: 'Transaktionen', values: txSeries.map(p => p.y) }]} />} +

Umsatz nach Stunde

+ {hourlyRevenue.length === 0 + ?

Noch keine Verkäufe

+ : p.x)} series={[{ name: 'Umsatz (€)', values: hourlyRevenue.map(p => p.y) }]} />} + +

Umsatz nach Tageszeit

+ {hourOfDayRevenue.every(p => p.y === 0) + ?

Noch keine Verkäufe

+ : p.x)} series={[{ name: 'Umsatz (€)', values: hourOfDayRevenue.map(p => p.y) }]} />} +

Top-Getränke im Verlauf

{drinkTrend.series.length === 0 ?

Noch keine Verkäufe

diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 113c785..363e808 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -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(); + const hourOfDayMap = new Map(); + 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) { diff --git a/server/src/time.ts b/server/src/time.ts index daee036..68a3880 100644 --- a/server/src/time.ts +++ b/server/src/time.ts @@ -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.