spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1// Time-zone utilities without external dependencies (Intl only).23const dtfCache = new Map<string, Intl.DateTimeFormat>();45function dtf(timeZone: string): Intl.DateTimeFormat {6 let f = dtfCache.get(timeZone);7 if (!f) {8 f = new Intl.DateTimeFormat("en-US", {9 timeZone,10 hourCycle: "h23",11 year: "numeric",12 month: "2-digit",13 day: "2-digit",14 hour: "2-digit",15 minute: "2-digit",16 second: "2-digit",17 weekday: "short",18 });19 dtfCache.set(timeZone, f);20 }21 return f;22}2324export interface ZonedParts {25 year: number;26 month: number; // 1-1227 day: number;28 hour: number;29 minute: number;30 second: number;31 weekday: number; // 0 = Sunday32 date: string; // YYYY-MM-DD33 time: string; // HH:MM34}3536const WEEKDAYS: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };3738export function zonedParts(ms: number, timeZone: string): ZonedParts {39 const parts = dtf(timeZone).formatToParts(new Date(ms));40 const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";41 const year = Number(get("year"));42 const month = Number(get("month"));43 const day = Number(get("day"));44 const hour = Number(get("hour")) % 24;45 const minute = Number(get("minute"));46 const second = Number(get("second"));47 const pad = (n: number) => String(n).padStart(2, "0");48 return {49 year,50 month,51 day,52 hour,53 minute,54 second,55 weekday: WEEKDAYS[get("weekday")] ?? 0,56 date: `${year}-${pad(month)}-${pad(day)}`,57 time: `${pad(hour)}:${pad(minute)}`,58 };59}6061/** Offset (ms) of `timeZone` relative to UTC at instant `ms`. */62export function tzOffsetMs(ms: number, timeZone: string): number {63 const p = zonedParts(ms, timeZone);64 const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);65 return asUtc - Math.floor(ms / 1000) * 1000;66}6768/**69 * Interpret a wall-clock time in `timeZone` as a UTC instant.70 * Accepts "YYYY-MM-DD", "YYYY-MM-DDTHH:MM[:SS[.sss]]" or "YYYY-MM-DD HH:MM[:SS]".71 */72export function zonedTimeToUtc(local: string, timeZone: string): number | null {73 const m = local74 .trim()75 .match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/);76 if (!m) return null;77 const [_, y, mo, d, h = "0", mi = "0", s = "0", frac = "0"] = m;78 const guess = Date.UTC(+y!, +mo! - 1, +d!, +h, +mi, +s, +frac.padEnd(3, "0"));79 // Two-pass correction handles DST edges well enough for market data.80 const off1 = tzOffsetMs(guess, timeZone);81 const utc1 = guess - off1;82 const off2 = tzOffsetMs(utc1, timeZone);83 return guess - off2;84}8586/** Local wall-clock "HH:MM" of `timeZone` → ms since local midnight. */87export function hmToMs(hm: string): number {88 const [h, m] = hm.split(":").map(Number);89 return ((h ?? 0) * 60 + (m ?? 0)) * 60_000;90}9192/** Parse a variety of timestamp encodings; returns ms epoch or null. */93export function parseTimestamp(input: unknown): number | null {94 if (input == null) return null;95 if (typeof input === "number") {96 if (!Number.isFinite(input)) return null;97 if (input > 1e17) return Math.floor(input / 1e6); // ns98 if (input > 1e14) return Math.floor(input / 1e3); // µs99 if (input > 1e11) return Math.floor(input); // ms100 return Math.floor(input * 1000); // s101 }102 if (typeof input === "string") {103 const t = input.trim();104 if (/^\d+(\.\d+)?$/.test(t)) return parseTimestamp(Number(t));105 const ms = Date.parse(t);106 return Number.isFinite(ms) ? ms : null;107 }108 return null;109}110111export function floorTo(ms: number, bucketMs: number): number {112 return Math.floor(ms / bucketMs) * bucketMs;113}114