/** * Civil (wall-clock) date and time helpers. * * The whole scheduling core works on civil values — "2026-09-20" and "18:30" * as the shop would read them off a wall — rather than on instants. That is a * deliberate choice: a slot that runs 10:00-18:30 runs 10:00-18:30 on the day * the clocks change too, and no UTC arithmetic can accidentally shift it by an * hour twice a year. * * Conversions from an instant to a civil value happen exactly once, at the * edge, through Intl with an explicit timezone. Everything downstream is * string and integer arithmetic. */ /** A civil date, `YYYY-MM-DD`. */ export type CivilDate = string; /** A civil time of day, `HH:mm`, 24-hour. */ export type CivilTime = string; const MS_PER_DAY = 86_400_000; const dateFormatters = new Map(); const timeFormatters = new Map(); function dateFormatter(timeZone: string): Intl.DateTimeFormat { let formatter = dateFormatters.get(timeZone); if (!formatter) { formatter = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', }); dateFormatters.set(timeZone, formatter); } return formatter; } function timeFormatter(timeZone: string): Intl.DateTimeFormat { let formatter = timeFormatters.get(timeZone); if (!formatter) { formatter = new Intl.DateTimeFormat('en-GB', { timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }); timeFormatters.set(timeZone, formatter); } return formatter; } function part(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string { const found = parts.find((candidate) => candidate.type === type); if (!found) { throw new Error(`Missing "${type}" in formatted date`); } return found.value; } /** The civil date the given instant falls on, in `timeZone`. */ export function toCivilDate(instant: Date, timeZone: string): CivilDate { const parts = dateFormatter(timeZone).formatToParts(instant); return `${part(parts, 'year')}-${part(parts, 'month')}-${part(parts, 'day')}`; } /** The wall-clock time the given instant shows, in `timeZone`. */ export function toCivilTime(instant: Date, timeZone: string): CivilTime { const parts = timeFormatter(timeZone).formatToParts(instant); return `${part(parts, 'hour')}:${part(parts, 'minute')}`; } /** * Adds whole days to a civil date. * * The arithmetic goes through UTC on purpose: UTC has no daylight saving, so * "one day later" is always exactly 86 400 000 ms and the result is the civil * date a human would name, including on the nights the clocks change. */ export function addCivilDays(date: CivilDate, days: number): CivilDate { const shifted = new Date(civilToUtcMs(date) + days * MS_PER_DAY); const year = shifted.getUTCFullYear().toString().padStart(4, '0'); const month = (shifted.getUTCMonth() + 1).toString().padStart(2, '0'); const day = shifted.getUTCDate().toString().padStart(2, '0'); return `${year}-${month}-${day}`; } /** Day of the week, 0 = Sunday .. 6 = Saturday, matching `Date.prototype.getDay`. */ export function civilDayOfWeek(date: CivilDate): number { return new Date(civilToUtcMs(date)).getUTCDay(); } /** Chronological comparator; civil dates are lexicographically ordered too. */ export function compareCivil(a: CivilDate, b: CivilDate): number { return a < b ? -1 : a > b ? 1 : 0; } /** True when `date` falls within `[start, end]`, both inclusive. */ export function isWithinCivilRange(date: CivilDate, start: CivilDate, end: CivilDate): boolean { return compareCivil(date, start) >= 0 && compareCivil(date, end) <= 0; } /** Minutes since midnight for a `HH:mm` wall-clock time. */ export function minutesOfTime(time: CivilTime): number { const [hours, minutes] = time.split(':'); return Number(hours) * 60 + Number(minutes); } function civilToUtcMs(date: CivilDate): number { const [year, month, day] = date.split('-'); return Date.UTC(Number(year), Number(month) - 1, Number(day)); }