/** * Time-zone helpers built on Intl only (no dependencies), so the same code * runs in Node, edge runtimes and the browser. */ import type { Weekday } from "./types"; const fmtCache = new Map(); function fmt(tz: string): Intl.DateTimeFormat { let f = fmtCache.get(tz); if (!f) { f = new Intl.DateTimeFormat("en-US", { timeZone: tz, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", weekday: "short", }); fmtCache.set(tz, f); } return f; } const WD: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; export interface LocalParts { date: string; // YYYY-MM-DD time: string; // HH:MM minutes: number; // minutes since local midnight weekday: Weekday; } export function localParts(utcMs: number, tz: string): LocalParts { const p: Record = {}; for (const part of fmt(tz).formatToParts(new Date(utcMs))) p[part.type] = part.value; const hour = p.hour === "24" ? "00" : p.hour; return { date: `${p.year}-${p.month}-${p.day}`, time: `${hour}:${p.minute}`, minutes: Number(hour) * 60 + Number(p.minute), weekday: WD[p.weekday], }; } /** Offset of `tz` from UTC at instant `utcMs`, in minutes (Toronto EDT = -240). */ export function tzOffsetMinutes(utcMs: number, tz: string): number { const p: Record = {}; for (const part of fmt(tz).formatToParts(new Date(utcMs))) p[part.type] = part.value; const hour = p.hour === "24" ? 0 : Number(p.hour); const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, hour, +p.minute, +p.second); return Math.round((asUtc - Math.floor(utcMs / 1000) * 1000) / 60000); } /** * Convert a local wall-clock time in `tz` to a UTC instant. * Non-existent times (spring-forward gap) resolve forward; ambiguous times * (fall-back overlap) resolve to the first occurrence. */ export function zonedToUtc(date: string, time: string, tz: string): number { const [y, m, d] = date.split("-").map(Number); const [hh, mm] = time.split(":").map(Number); const guess = Date.UTC(y, m - 1, d, hh, mm); const off1 = tzOffsetMinutes(guess, tz); let utc = guess - off1 * 60000; const off2 = tzOffsetMinutes(utc, tz); if (off2 !== off1) { const alt = guess - off2 * 60000; // Prefer the candidate that round-trips; otherwise (gap) take the later instant. if (localParts(alt, tz).time === time) utc = Math.min(utc, alt); else if (localParts(utc, tz).time !== time) utc = Math.max(utc, alt); } return utc; } export function toMinutes(hhmm: string): number { const [h, m] = hhmm.split(":").map(Number); return h * 60 + m; } export function fromMinutes(min: number): string { const h = Math.floor(min / 60); const m = min % 60; return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; } export function addDays(date: string, days: number): string { const [y, m, d] = date.split("-").map(Number); const t = new Date(Date.UTC(y, m - 1, d + days)); return t.toISOString().slice(0, 10); } export function diffDays(a: string, b: string): number { const [y1, m1, d1] = a.split("-").map(Number); const [y2, m2, d2] = b.split("-").map(Number); return Math.round((Date.UTC(y1, m1 - 1, d1) - Date.UTC(y2, m2 - 1, d2)) / 86400000); } export function weekdayOf(date: string): Weekday { const [y, m, d] = date.split("-").map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay() as Weekday; } export function iso(ms: number): string { return new Date(ms).toISOString(); }