- Stats: bars table is now the LEFT side of the join to transactions, so a bar with zero sales still gets a zero row instead of vanishing from the totals table until its first sale (indistinguishable from a deleted bar). by_day stays JS-computed on purpose — a SQL rewrite would trade DST-aware timezone handling for a fixed-hour-offset 'localtime' expression that's wrong on DST transition nights, to fix a cost the original review noted is 'fine today'. Not worth that trade for a money-adjacent report; left a comment explaining why. - CSV export: cells starting with =/+/-/@ are now prefixed with ' before quoting, closing a formula-injection path (an admin-entered drink/bar name like =HYPERLINK(...) would otherwise execute when the export is opened in Excel/LibreOffice). - server/index.ts: PORT is now parsed and range-checked instead of a bare Number(...) (an unparseable value silently became NaN, and Fastify listens on a random free port for that); ADMIN_PASSWORD missing now warns at boot instead of only surfacing as a 500 at the first login attempt; new WUTZ_TRUST_PROXY env flag (off by default) so req.ip can actually reflect the real client behind a reverse proxy, documented in the README alongside the other env vars. - time.ts: WUTZ_DAY_CUTOFF_HOUR gets the same parse+range-check treatment, for the same reason (a typo used to silently disable the business-day rollback with no error). - shared/src/index.ts: Drink.archived is now typed 0 | 1, matching what SQLite actually returns (was boolean, which only worked by accident since 0 is falsy); removed TransactionRecord/ TransactionItemRecord, declared but never returned by any route — leftovers from a planned endpoint that was never built. Verified: pnpm --filter server|client typecheck/build all clean; also ran the built server with a bad PORT and no ADMIN_PASSWORD to confirm both warnings fire and the port falls back correctly.
95 lines
3.4 KiB
TypeScript
95 lines
3.4 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)}`;
|
|
}
|
|
|
|
/**
|
|
* 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())}`;
|
|
}
|