109 lines
3.6 KiB
TypeScript
109 lines
3.6 KiB
TypeScript
/**
|
|
* 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<string, Intl.DateTimeFormat>();
|
|
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<string, Weekday> = { 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<string, string> = {};
|
|
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<string, string> = {};
|
|
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();
|
|
}
|