From b8bf269de89b1a18845dc852dd2384e293065659 Mon Sep 17 00:00:00 2001 From: sstent Date: Thu, 19 Feb 2026 06:57:15 -0800 Subject: [PATCH] conductor(checkpoint): Checkpoint end of Phase 1: Foundation --- timezones.ts | 159 ++++++++++++++++++++++++++++++++++++++++++++++ timezones_test.ts | 43 +++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 timezones.ts create mode 100644 timezones_test.ts diff --git a/timezones.ts b/timezones.ts new file mode 100644 index 0000000..309b862 --- /dev/null +++ b/timezones.ts @@ -0,0 +1,159 @@ +// timezones.ts + +/** + * Mapping of Windows Timezone names to IANA Timezone names. + * Sourced from Unicode CLDR data. + */ +export const WINDOWS_TO_IANA: Record = { + "Dateline Standard Time": "Etc/GMT+12", + "UTC-11": "Etc/GMT+11", + "Hawaiian Standard Time": "Pacific/Honolulu", + "Alaskan Standard Time": "America/Anchorage", + "Pacific Standard Time (Mexico)": "America/Santa_Isabel", + "Pacific Standard Time": "America/Los_Angeles", + "US Mountain Standard Time": "America/Phoenix", + "Mountain Standard Time (Mexico)": "America/Chihuahua", + "Mountain Standard Time": "America/Denver", + "Central America Standard Time": "America/Guatemala", + "Central Standard Time": "America/Chicago", + "Central Standard Time (Mexico)": "America/Mexico_City", + "Canada Central Standard Time": "America/Regina", + "SA Pacific Standard Time": "America/Bogota", + "Eastern Standard Time": "America/New_York", + "US Eastern Standard Time": "America/Indiana/Indianapolis", + "Venezuela Standard Time": "America/Caracas", + "Paraguay Standard Time": "America/Asuncion", + "Atlantic Standard Time": "America/Halifax", + "Central Brazilian Standard Time": "America/Cuiaba", + "SA Western Standard Time": "America/La_Paz", + "Pacific SA Standard Time": "America/Santiago", + "Newfoundland Standard Time": "America/St_Johns", + "E. South America Standard Time": "America/Sao_Paulo", + "Argentina Standard Time": "America/Buenos_Aires", + "SA Eastern Standard Time": "America/Cayenne", + "Greenland Standard Time": "America/Godthab", + "Montevideo Standard Time": "America/Montevideo", + "Bahia Standard Time": "America/Bahia", + "Azores Standard Time": "Atlantic/Azores", + "Cape Verde Standard Time": "Atlantic/Cape_Verde", + "Morocco Standard Time": "Africa/Casablanca", + "GMT Standard Time": "Europe/London", + "Greenwich Standard Time": "Atlantic/Reykjavik", + "W. Europe Standard Time": "Europe/Berlin", + "Central Europe Standard Time": "Europe/Budapest", + "Romance Standard Time": "Europe/Paris", + "Central European Standard Time": "Europe/Warsaw", + "W. Central Africa Standard Time": "Africa/Lagos", + "Namibia Standard Time": "Africa/Windhoek", + "Jordan Standard Time": "Asia/Amman", + "GTB Standard Time": "Europe/Bucharest", + "Middle East Standard Time": "Asia/Beirut", + "Egypt Standard Time": "Africa/Cairo", + "Syria Standard Time": "Asia/Damascus", + "E. Europe Standard Time": "Europe/Chisinau", + "South Africa Standard Time": "Africa/Johannesburg", + "FLE Standard Time": "Europe/Kiev", + "Turkey Standard Time": "Europe/Istanbul", + "Israel Standard Time": "Asia/Jerusalem", + "Kaliningrad Standard Time": "Europe/Kaliningrad", + "Libya Standard Time": "Africa/Tripoli", + "Arabic Standard Time": "Asia/Baghdad", + "Arab Standard Time": "Asia/Riyadh", + "Belarus Standard Time": "Europe/Minsk", + "Russian Standard Time": "Europe/Moscow", + "E. Africa Standard Time": "Africa/Nairobi", + "Iran Standard Time": "Asia/Tehran", + "Arabian Standard Time": "Asia/Dubai", + "Azerbaijan Standard Time": "Asia/Baku", + "Russia Time Zone 3": "Europe/Samara", + "Mauritius Standard Time": "Indian/Mauritius", + "Georgian Standard Time": "Asia/Tbilisi", + "Caucasus Standard Time": "Asia/Yerevan", + "Afghanistan Standard Time": "Asia/Kabul", + "West Asia Standard Time": "Asia/Tashkent", + "Ekaterinburg Standard Time": "Asia/Yekaterinburg", + "Pakistan Standard Time": "Asia/Karachi", + "India Standard Time": "Asia/Kolkata", + "Sri Lanka Standard Time": "Asia/Colombo", + "Nepal Standard Time": "Asia/Kathmandu", + "Central Asia Standard Time": "Asia/Almaty", + "Bangladesh Standard Time": "Asia/Dhaka", + "N. Central Asia Standard Time": "Asia/Novosibirsk", + "Myanmar Standard Time": "Asia/Rangoon", + "SE Asia Standard Time": "Asia/Bangkok", + "North Asia Standard Time": "Asia/Krasnoyarsk", + "China Standard Time": "Asia/Shanghai", + "North Asia East Standard Time": "Asia/Irkutsk", + "Singapore Standard Time": "Asia/Singapore", + "W. Australia Standard Time": "Australia/Perth", + "Taipei Standard Time": "Asia/Taipei", + "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", + "Tokyo Standard Time": "Asia/Tokyo", + "Korea Standard Time": "Asia/Seoul", + "Yakutsk Standard Time": "Asia/Yakutsk", + "Cen. Australia Standard Time": "Australia/Adelaide", + "AUS Central Standard Time": "Australia/Darwin", + "E. Australia Standard Time": "Australia/Brisbane", + "AUS Eastern Standard Time": "Australia/Sydney", + "West Pacific Standard Time": "Pacific/Port_Moresby", + "Tasmania Standard Time": "Australia/Hobart", + "Magadan Standard Time": "Asia/Magadan", + "Vladivostok Standard Time": "Asia/Vladivostok", + "Russia Time Zone 10": "Asia/Srednekolymsk", + "Central Pacific Standard Time": "Pacific/Guadalcanal", + "Russia Time Zone 11": "Asia/Anadyr", + "New Zealand Standard Time": "Pacific/Auckland", + "Fiji Standard Time": "Pacific/Fiji", + "Tonga Standard Time": "Pacific/Tongatapu", + "Samoa Standard Time": "Pacific/Apia", + "Line Islands Standard Time": "Pacific/Kiritimati" +}; + +/** + * Resolves an IANA timezone name from a given TZID string. + * Supports Windows timezone names, direct IANA names, and UTC. + */ +export function resolveIanaName(tzid: string): string | null { + if (!tzid || tzid === "UTC" || tzid === "None") return "UTC"; + + // Heuristic: IANA names typically include a forward slash + if (tzid.includes("/")) return tzid; + + return WINDOWS_TO_IANA[tzid] ?? null; +} + +/** + * Returns the UTC offset in milliseconds for a given IANA timezone at a + * specific point in time. Positive = ahead of UTC, negative = behind UTC. + * e.g. "America/New_York" in summer -> -14400000 (-4h) + */ +export function getUtcOffsetMs(ianaName: string, atDate: Date): number { + // Trick: format the same instant in UTC and in the target zone, + // parse both, and subtract. + // "en-CA" produces "YYYY-MM-DD, HH:MM:SS" (unambiguous) + const options: Intl.DateTimeFormatOptions = { + timeZone: "UTC", + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }; + + const utcFormatter = new Intl.DateTimeFormat("en-CA", options); + const localFormatter = new Intl.DateTimeFormat("en-CA", { ...options, timeZone: ianaName }); + + const formatToIso = (formatter: Intl.DateTimeFormat, date: Date) => { + return formatter.format(date).replace(", ", "T"); + }; + + const utcStr = formatToIso(utcFormatter, atDate); + const localStr = formatToIso(localFormatter, atDate); + + const utcMs = new Date(utcStr + "Z").getTime(); + const localMs = new Date(localStr + "Z").getTime(); + + return localMs - utcMs; +} diff --git a/timezones_test.ts b/timezones_test.ts new file mode 100644 index 0000000..be3d4cf --- /dev/null +++ b/timezones_test.ts @@ -0,0 +1,43 @@ +import { assertEquals } from "jsr:@std/assert"; +import { resolveIanaName, getUtcOffsetMs } from "./timezones.ts"; + +Deno.test("resolveIanaName - Windows names", () => { + assertEquals(resolveIanaName("Eastern Standard Time"), "America/New_York"); + assertEquals(resolveIanaName("Romance Standard Time"), "Europe/Paris"); + assertEquals(resolveIanaName("Pacific Standard Time"), "America/Los_Angeles"); +}); + +Deno.test("resolveIanaName - IANA names (identity)", () => { + assertEquals(resolveIanaName("America/Chicago"), "America/Chicago"); + assertEquals(resolveIanaName("Europe/London"), "Europe/London"); +}); + +Deno.test("resolveIanaName - UTC and special cases", () => { + assertEquals(resolveIanaName("UTC"), "UTC"); + assertEquals(resolveIanaName("None"), "UTC"); + assertEquals(resolveIanaName(""), "UTC"); +}); + +Deno.test("resolveIanaName - Unknown names", () => { + assertEquals(resolveIanaName("Mars Standard Time"), null); +}); + +Deno.test("getUtcOffsetMs - New York (DST check)", () => { + const jan = new Date("2025-01-15T12:00:00Z"); + const july = new Date("2025-07-15T12:00:00Z"); + + // America/New_York is UTC-5 in Winter + assertEquals(getUtcOffsetMs("America/New_York", jan), -5 * 3600000); + // America/New_York is UTC-4 in Summer + assertEquals(getUtcOffsetMs("America/New_York", july), -4 * 3600000); +}); + +Deno.test("getUtcOffsetMs - Paris (DST check)", () => { + const jan = new Date("2025-01-15T12:00:00Z"); + const july = new Date("2025-07-15T12:00:00Z"); + + // Europe/Paris is UTC+1 in Winter + assertEquals(getUtcOffsetMs("Europe/Paris", jan), 1 * 3600000); + // Europe/Paris is UTC+2 in Summer + assertEquals(getUtcOffsetMs("Europe/Paris", july), 2 * 3600000); +});