// 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 = {}; 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())}`; }