wutzcalc/server/src/time.ts
iris 6c2a938b91 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.
2026-07-30 20:14:45 +02:00

116 lines
4.2 KiB
TypeScript

// Time handling for the sales day.
//
// Timestamps are stored in the DB as UTC ISO strings (`new Date().toISOString()`).
// For display and for grouping into business days we convert to a fixed local
// timezone (default Europe/Berlin) so the numbers match the bar's wall clock
// regardless of where the server runs.
export const TZ = process.env.WUTZ_TZ ?? 'Europe/Berlin';
// A sale at e.g. 03:00 still belongs to the previous night's business day.
// Anything before this local hour counts towards the day before.
//
// Parsed and range-checked rather than a bare Number(...) — an unparseable
// value (a typo like "5am" instead of "5") used to silently become NaN,
// and `hour < NaN` is always false, so the business-day rollback would
// just stop happening with no error anywhere: every after-midnight sale
// would land on the wrong day in the stats table.
export const BUSINESS_DAY_CUTOFF_HOUR = parseHourEnv('WUTZ_DAY_CUTOFF_HOUR', 5);
function parseHourEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined) return fallback;
const n = Number(raw);
if (!Number.isInteger(n) || n < 0 || n > 23) {
console.warn(`${name}=${JSON.stringify(raw)} is not a valid hour (0-23) — using default ${fallback}`);
return fallback;
}
return n;
}
/**
* Parse a value stored in `created_at`. New rows are UTC ISO strings (with `Z`),
* but legacy rows written by SQLite's CURRENT_TIMESTAMP look like
* `YYYY-MM-DD HH:MM:SS` and are also UTC — normalise both to a Date.
*/
export function parseDbTime(s: string): Date {
if (/[zZ]|[+-]\d{2}:\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
interface LocalParts {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
function localParts(d: Date, tz: string = TZ): LocalParts {
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23', // force midnight as 00, never the 24 artifact
});
const parts: Record<string, string> = {};
for (const p of fmt.formatToParts(d)) {
if (p.type !== 'literal') parts[p.type] = p.value;
}
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
hour: Number(parts.hour),
minute: Number(parts.minute),
second: Number(parts.second),
};
}
/** Local wall-clock time as `YYYY-MM-DD HH:MM:SS` for CSV / display. */
export function formatLocal(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)}:${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.
*/
export function businessDay(d: Date, tz: string = TZ): string {
const p = localParts(d, tz);
// Build a date from the local Y-M-D and roll back a day when before cutoff.
const date = new Date(Date.UTC(p.year, p.month - 1, p.day));
if (p.hour < BUSINESS_DAY_CUTOFF_HOUR) {
date.setUTCDate(date.getUTCDate() - 1);
}
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
}