import type { BusyRange } from './types'; const formatterCache = new Map(); function formatter(timeZone: string): Intl.DateTimeFormat { const cached = formatterCache.get(timeZone); if (cached) return cached; const next = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', weekday: 'short', }); formatterCache.set(timeZone, next); return next; } export function partsInZone(date: Date, timeZone: string): Record { return Object.fromEntries( formatter(timeZone) .formatToParts(date) .filter((part) => part.type !== 'literal') .map((part) => [part.type, part.value]), ); } export function dateKeyInZone(date: Date, timeZone: string): string { const parts = partsInZone(date, timeZone); return `${parts.year}-${parts.month}-${parts.day}`; } export function addDaysToDateKey(dateKey: string, days: number): string { const [year, month, day] = dateKey.split('-').map(Number); return new Date(Date.UTC(year, month - 1, day + days, 12, 0, 0)).toISOString().slice(0, 10); } export function weekdayForDate(dateKey: string, timeZone: string): number { const noon = zonedDateTimeToUtc(dateKey, '12:00', timeZone); const label = partsInZone(noon, timeZone).weekday; return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(label); } export function zonedDateTimeToUtc(dateKey: string, time: string, timeZone: string): Date { const [year, month, day] = dateKey.split('-').map(Number); const [hour, minute] = time.split(':').map(Number); let guess = Date.UTC(year, month - 1, day, hour, minute, 0); // Iterate to account for the zone offset, including DST transitions. for (let i = 0; i < 3; i += 1) { const parts = partsInZone(new Date(guess), timeZone); const representedAsUtc = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second), ); const targetAsUtc = Date.UTC(year, month - 1, day, hour, minute, 0); const delta = targetAsUtc - representedAsUtc; if (delta === 0) break; guess += delta; } return new Date(guess); } export function minutesToTime(total: number): string { const hour = Math.floor(total / 60) % 24; const minute = total % 60; return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; } export function timeToMinutes(value: string): number { const [hour, minute] = value.split(':').map(Number); return hour * 60 + minute; } export function overlaps(start: number, end: number, busy: BusyRange[]): boolean { return busy.some((range) => start < range.end && end > range.start); } export function addMinutes(timestamp: number, minutes: number): number { return timestamp + minutes * 60_000; } export function slotKeys(start: number, end: number, incrementMinutes = 15): string[] { const increment = incrementMinutes * 60_000; const first = Math.floor(start / increment) * increment; const last = Math.ceil(end / increment) * increment; const keys: string[] = []; for (let cursor = first; cursor < last; cursor += increment) { keys.push(new Date(cursor).toISOString()); } return keys; }