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 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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue