// Time-zone utilities without external dependencies (Intl only). const dtfCache = new Map(); function dtf(timeZone: string): Intl.DateTimeFormat { let f = dtfCache.get(timeZone); if (!f) { f = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", weekday: "short", }); dtfCache.set(timeZone, f); } return f; } export interface ZonedParts { year: number; month: number; // 1-12 day: number; hour: number; minute: number; second: number; weekday: number; // 0 = Sunday date: string; // YYYY-MM-DD time: string; // HH:MM } const WEEKDAYS: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; export function zonedParts(ms: number, timeZone: string): ZonedParts { const parts = dtf(timeZone).formatToParts(new Date(ms)); const get = (t: string) => parts.find((p) => p.type === t)?.value ?? ""; const year = Number(get("year")); const month = Number(get("month")); const day = Number(get("day")); const hour = Number(get("hour")) % 24; const minute = Number(get("minute")); const second = Number(get("second")); const pad = (n: number) => String(n).padStart(2, "0"); return { year, month, day, hour, minute, second, weekday: WEEKDAYS[get("weekday")] ?? 0, date: `${year}-${pad(month)}-${pad(day)}`, time: `${pad(hour)}:${pad(minute)}`, }; } /** Offset (ms) of `timeZone` relative to UTC at instant `ms`. */ export function tzOffsetMs(ms: number, timeZone: string): number { const p = zonedParts(ms, timeZone); const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); return asUtc - Math.floor(ms / 1000) * 1000; } /** * Interpret a wall-clock time in `timeZone` as a UTC instant. * Accepts "YYYY-MM-DD", "YYYY-MM-DDTHH:MM[:SS[.sss]]" or "YYYY-MM-DD HH:MM[:SS]". */ export function zonedTimeToUtc(local: string, timeZone: string): number | null { const m = local .trim() .match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/); if (!m) return null; const [_, y, mo, d, h = "0", mi = "0", s = "0", frac = "0"] = m; const guess = Date.UTC(+y!, +mo! - 1, +d!, +h, +mi, +s, +frac.padEnd(3, "0")); // Two-pass correction handles DST edges well enough for market data. const off1 = tzOffsetMs(guess, timeZone); const utc1 = guess - off1; const off2 = tzOffsetMs(utc1, timeZone); return guess - off2; } /** Local wall-clock "HH:MM" of `timeZone` → ms since local midnight. */ export function hmToMs(hm: string): number { const [h, m] = hm.split(":").map(Number); return ((h ?? 0) * 60 + (m ?? 0)) * 60_000; } /** Parse a variety of timestamp encodings; returns ms epoch or null. */ export function parseTimestamp(input: unknown): number | null { if (input == null) return null; if (typeof input === "number") { if (!Number.isFinite(input)) return null; if (input > 1e17) return Math.floor(input / 1e6); // ns if (input > 1e14) return Math.floor(input / 1e3); // µs if (input > 1e11) return Math.floor(input); // ms return Math.floor(input * 1000); // s } if (typeof input === "string") { const t = input.trim(); if (/^\d+(\.\d+)?$/.test(t)) return parseTimestamp(Number(t)); const ms = Date.parse(t); return Number.isFinite(ms) ? ms : null; } return null; } export function floorTo(ms: number, bucketMs: number): number { return Math.floor(ms / bucketMs) * bucketMs; }