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:
parent
cb316d16d1
commit
6c2a938b91
3 changed files with 118 additions and 3 deletions
|
|
@ -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 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 ByDay { day: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number }
|
||||||
interface PerDrinkByDay { day: string; drinks: PerDrink[] }
|
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 {
|
function fmtDay(day: string): string {
|
||||||
const [y, m, d] = day.split('-');
|
const [y, m, d] = day.split('-');
|
||||||
|
|
@ -35,6 +44,15 @@ function fmtDayShort(day: string): string {
|
||||||
return `${d}.${m}.`;
|
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
|
// 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
|
// 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
|
// 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]
|
[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
|
// 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
|
// 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
|
// 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>
|
? <p class="muted">Noch keine Verkäufe</p>
|
||||||
: <DayChart labels={txSeries.map(p => p.x)} series={[{ name: 'Transaktionen', values: txSeries.map(p => p.y) }]} />}
|
: <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>
|
<h2>Top-Getränke im Verlauf</h2>
|
||||||
{drinkTrend.series.length === 0
|
{drinkTrend.series.length === 0
|
||||||
? <p class="muted">Noch keine Verkäufe</p>
|
? <p class="muted">Noch keine Verkäufe</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import type { DB } from '../db.js';
|
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 { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
import { sendProblem } from '../problem-details.js';
|
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.
|
// /stats "recent days" table), where the most recent day belongs on top.
|
||||||
const byDay = [...dayMap.values()].sort((a, b) => b.day.localeCompare(a.day));
|
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
|
// 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()
|
// for, on top of the all-time perDrink totals above. Same businessDay()
|
||||||
// JS bucketing as byDay, for the same DST-correctness reason; joined
|
// 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]))
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
.map(([day, drinks]) => ({ day, drinks: [...drinks.values()].sort((a, b) => b.sold_qty - a.sold_qty) }));
|
.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) {
|
export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
||||||
|
|
|
||||||
|
|
@ -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)}`;
|
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`.
|
* The business day a timestamp belongs to, as `YYYY-MM-DD`.
|
||||||
* Hours before BUSINESS_DAY_CUTOFF_HOUR are attributed to the previous day.
|
* Hours before BUSINESS_DAY_CUTOFF_HOUR are attributed to the previous day.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue