/** * 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(); } /** Whole days from `a` to `b`; negative when `b` is earlier. */ export function civilDaysBetween(a: CivilDate, b: CivilDate): number { return Math.round((civilToUtcMs(b) - civilToUtcMs(a)) / MS_PER_DAY); } /** 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; } /** The Monday of the week containing `date`. */ export function startOfCivilWeek(date: CivilDate): CivilDate { const dayOfWeek = civilDayOfWeek(date); // getDay() puts Sunday at 0; the shop's week starts on Monday. const daysSinceMonday = (dayOfWeek + 6) % 7; return addCivilDays(date, -daysSinceMonday); } /** * The instant as an ISO 8601 string carrying the shop's UTC offset, e.g. * "2026-09-20T10:15:00+02:00". * * The offset is derived from the zone itself rather than assumed, so the same * code prints +01:00 in winter and +02:00 in summer without being told. */ export function toIsoWithOffset(instant: Date, timeZone: string): string { const date = toCivilDate(instant, timeZone); const time = toCivilTime(instant, timeZone); const seconds = instant.getUTCSeconds().toString().padStart(2, '0'); // Reading the wall clock back as if it were UTC gives the offset directly. const asUtc = Date.UTC( Number(date.slice(0, 4)), Number(date.slice(5, 7)) - 1, Number(date.slice(8, 10)), Number(time.slice(0, 2)), Number(time.slice(3, 5)), instant.getUTCSeconds(), ); const offsetMinutes = Math.round((asUtc - instant.getTime()) / 60_000); const sign = offsetMinutes < 0 ? '-' : '+'; const absolute = Math.abs(offsetMinutes); const hours = Math.floor(absolute / 60) .toString() .padStart(2, '0'); const minutes = (absolute % 60).toString().padStart(2, '0'); return `${date}T${time}:${seconds}${sign}${hours}:${minutes}`; } /** 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)); }