/** * Shared timezone-aware date helpers for statistics aggregations. */ /** Milliseconds in a single day. */ export const DAY_MS = 24 * 60 * 60 * 1000; /** Maps a short weekday name (as produced by Intl) to its index (Sun = 0). */ export const WEEKDAY: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, }; /** Parses a `YYYY-MM-DD` string into `[year, month, day]` numbers. */ export const parseYmd = (dateStr: string): [number, number, number] => { const [y, m, d] = dateStr.split("-").map(Number); return [y, m, d]; }; /** Builds a zero-padded `YYYY-MM-DD` string from numeric parts. */ export const ymdToStr = (y: number, m: number, d: number): string => `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`; /** Parses a `YYYY-MM-DD` string as midnight UTC. */ export const toUtcDate = (dateStr: string): Date => new Date(`${dateStr}T00:00:00.000Z`); /** Formats a date as a `YYYY-MM-DD` string in UTC. */ export const formatDate = (date: Date): string => date.toISOString().slice(0, 10); /** Adds `days` to a `YYYY-MM-DD` string, returning the resulting `YYYY-MM-DD`. */ export const addDaysToDateStr = (dateStr: string, days: number): string => formatDate(new Date(toUtcDate(dateStr).getTime() + days * DAY_MS)); /** Next civil calendar day (proleptic Gregorian). Avoids DST 24h steps that can stall iteration. */ export const incrementYmd = ( y: number, m: number, d: number, ): [number, number, number] => { const next = new Date(Date.UTC(y, m - 1, d + 1)); return [next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate()]; }; /** * Formats a date as the calendar day in the given timezone. * `en-CA` yields ISO-8601 `YYYY-MM-DD`, which is lexicographically sortable * (string compare == chronological compare) and matches MongoDB's `%Y-%m-%d`. */ export const formatYmdInTz = (date: Date, timezone: string): string => new Intl.DateTimeFormat("en-CA", { timeZone: timezone }).format(date); /** * Returns the calendar day in the given timezone as numeric parts. * Reads parts by `type` (not string order), so `en-US` is fine here. */ export const getYmdInTz = (date: Date, timezone: string) => { const parts = Object.fromEntries( new Intl.DateTimeFormat("en-US", { timeZone: timezone, year: "numeric", month: "numeric", day: "numeric", }) .formatToParts(date) .map((p) => [p.type, Number(p.value)]), ); return { y: parts.year, m: parts.month, d: parts.day }; }; /** Weekday index (Sun = 0) of a date in the given timezone. */ export const getWeekdayInTz = (date: Date, timezone: string): number => WEEKDAY[ new Intl.DateTimeFormat("en-US", { timeZone: timezone, weekday: "short", }).format(date) ] ?? 0; /** * Returns the given `YYYY-MM-DD` day at noon UTC — a stable anchor inside the * calendar day, far from midnight so timezone/DST shifts can't cross a date boundary. */ export const dateAtNoonUtc = (dateStr: string): Date => { const [y, m, d] = parseYmd(dateStr); return new Date(Date.UTC(y, m - 1, d, 12, 0, 0)); }; /** * UTC instant at the start of the given `YYYY-MM-DD` calendar day in `timezone`. * Binary-searches the boundary so it stays correct across DST transitions. */ export const startOfCalendarDayInTz = ( dateStr: string, timezone: string, ): Date => { const anchor = dateAtNoonUtc(dateStr).getTime(); let low = anchor - 2 * DAY_MS; let high = anchor + DAY_MS; while (high - low > 1 /* ms */) { const mid = Math.floor((low + high) / 2); if (formatYmdInTz(new Date(mid), timezone) >= dateStr) high = mid; else low = mid; } return new Date(high); }; /** UTC instant at the last millisecond of the given calendar day in `timezone`. */ export const endOfCalendarDayInTz = ( dateStr: string, timezone: string, ): Date => { const [y, m, d] = parseYmd(dateStr); const nextDay = new Date(Date.UTC(y, m - 1, d + 1)) .toISOString() .slice(0, 10); return new Date(startOfCalendarDayInTz(nextDay, timezone).getTime() - 1); };