diff --git a/src/lib/tz/__tests__/zone-offset.test.ts b/src/lib/tz/__tests__/zone-offset.test.ts new file mode 100644 index 000000000..6c411738f --- /dev/null +++ b/src/lib/tz/__tests__/zone-offset.test.ts @@ -0,0 +1,164 @@ +/** + * The hourly offset memo must give the same wall clock as asking Intl + * directly, for every instant, including the hours around a transition and + * zones whose transitions do not fall on a UTC hour. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { formatInUserTz, tzOffsetMinutes, userDayKey } from "../format"; +import { wallClockInTz } from "../wall-clock"; +import { __resetZoneOffsetCacheForTests, zoneOffsetMs } from "../zone-offset"; + +const ZONES = [ + "UTC", + "Europe/Berlin", + "America/New_York", + "America/St_Johns", + "Australia/Lord_Howe", + "Pacific/Chatham", + "Asia/Kathmandu", + "Asia/Kolkata", + "Africa/Casablanca", + "America/Sao_Paulo", +]; + +const REFERENCE_OPTIONS: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + weekday: "short", +}; +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function reference(date: Date, tz: string) { + const parts = new Intl.DateTimeFormat("en-US", { + ...REFERENCE_OPTIONS, + timeZone: tz, + }).formatToParts(date); + const get = (type: string) => parts.find((p) => p.type === type)!.value; + const hour = Number(get("hour")) % 24; + const wall = { + year: Number(get("year")), + month: Number(get("month")), + day: Number(get("day")), + hour, + minute: Number(get("minute")), + second: Number(get("second")), + weekday: WEEKDAYS.indexOf(get("weekday")), + }; + const asIfUtc = Date.UTC( + wall.year, + wall.month - 1, + wall.day, + wall.hour, + wall.minute, + wall.second, + ); + const pad = (n: number) => String(n).padStart(2, "0"); + return { + wall, + offsetMinutes: Math.round((asIfUtc - date.getTime()) / 60000), + dayKey: `${wall.year}-${pad(wall.month)}-${pad(wall.day)}`, + datetime: `${wall.year}-${pad(wall.month)}-${pad(wall.day)} ${pad(wall.hour)}:${pad(wall.minute)}`, + }; +} + +/** Instants every 7 min 13.5 s across two days around each 2026 transition. */ +function instantsAroundTransitions(): Date[] { + const anchors = [ + Date.UTC(2026, 2, 28), // EU spring forward (29 Mar) + Date.UTC(2026, 9, 24), // EU fall back (25 Oct) + Date.UTC(2026, 2, 7), // US spring forward (8 Mar) + Date.UTC(2026, 9, 31), // US fall back (1 Nov) + Date.UTC(2026, 3, 4), // Lord Howe / Chatham autumn (4-5 Apr) + Date.UTC(2026, 8, 26), // Chatham spring (27 Sep) + Date.UTC(2026, 9, 3), // Lord Howe spring (3 Oct 15:30 UTC) / Chatham (27 Sep) + ]; + const out: Date[] = []; + for (const anchor of anchors) { + for (let t = anchor; t < anchor + 2 * 86_400_000; t += 433_500) { + out.push(new Date(t)); + } + } + return out; +} + +beforeEach(() => { + __resetZoneOffsetCacheForTests(); +}); + +describe("zone offset memo", () => { + it("matches Intl for every instant around the transitions", () => { + const instants = instantsAroundTransitions(); + for (const tz of ZONES) { + for (const date of instants) { + const ref = reference(date, tz); + expect(wallClockInTz(date, tz)).toEqual(ref.wall); + expect(tzOffsetMinutes(date, tz)).toBe(ref.offsetMinutes); + expect(userDayKey(date, tz)).toBe(ref.dayKey); + expect(formatInUserTz(date, tz, "datetime")).toBe(ref.datetime); + } + } + }); + + it("matches Intl on a warm memo for scattered instants across decades", () => { + let seed = 7; + const next = () => { + seed = (seed * 1_103_515_245 + 12_345) % 2_147_483_648; + return seed / 2_147_483_648; + }; + const from = Date.UTC(1972, 0, 1); + const span = Date.UTC(2060, 0, 1) - from; + for (let i = 0; i < 4000; i++) { + const date = new Date(from + Math.floor(next() * span)); + const tz = ZONES[i % ZONES.length]; + // Twice: the first read fills the hour, the second reads the memo. + for (let pass = 0; pass < 2; pass++) { + const ref = reference(date, tz); + expect(wallClockInTz(date, tz)).toEqual(ref.wall); + expect(tzOffsetMinutes(date, tz)).toBe(ref.offsetMinutes); + expect(userDayKey(date, tz)).toBe(ref.dayKey); + } + } + }); + + it("refuses an hour that contains a transition", () => { + // Europe/Berlin springs forward at 01:00 UTC on 29 March 2026. + expect( + zoneOffsetMs(new Date(Date.UTC(2026, 2, 29, 1, 30)), "Europe/Berlin"), + ).toBe(2 * 3_600_000); + // Lord Howe springs forward half an hour at 15:30 UTC on 3 October: + // the 15:00 UTC hour straddles it and stays on the formatter path, + // on both sides of the change. + expect( + zoneOffsetMs( + new Date(Date.UTC(2026, 9, 3, 15, 10)), + "Australia/Lord_Howe", + ), + ).toBeNull(); + for (const minute of [10, 29, 30, 45]) { + const at = new Date(Date.UTC(2026, 9, 3, 15, minute)); + expect(wallClockInTz(at, "Australia/Lord_Howe")).toEqual( + reference(at, "Australia/Lord_Howe").wall, + ); + } + }); + + it("leaves out-of-range and invalid inputs to the formatter path", () => { + expect( + zoneOffsetMs(new Date(Date.UTC(1950, 0, 1)), "Europe/Berlin"), + ).toBeNull(); + expect(zoneOffsetMs(new Date(Number.NaN), "Europe/Berlin")).toBeNull(); + expect(() => wallClockInTz(new Date(), "Not/A_Zone")).toThrow(RangeError); + // The formatter reads a missing date as "now"; the memo must not turn + // that into a TypeError. + const now = reference(new Date(), "Europe/Berlin").wall; + const loose = wallClockInTz(undefined as unknown as Date, "Europe/Berlin"); + expect(loose.year).toBe(now.year); + expect(loose.day).toBe(now.day); + }); +}); diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts index 5e6bde119..01809a9c2 100644 --- a/src/lib/tz/format.ts +++ b/src/lib/tz/format.ts @@ -13,6 +13,7 @@ */ import { getDateTimeFormat } from "./intl-cache"; +import { wallClockFromOffset, zoneOffsetMs } from "./zone-offset"; export const DEFAULT_TIMEZONE = "Europe/Berlin"; @@ -52,6 +53,9 @@ const WALL_CLOCK_PARTS_OPTIONS: Omit = */ const OFFSET_ZONE = /^[+-]\d/; +const validityByZone = new Map(); +const MAX_VALIDITY_ENTRIES = 256; + /** * Validate a timezone string: a named IANA zone (or `UTC`) the runtime * accepts. Returns `false` for anything else. Cheap (microseconds) — call @@ -67,6 +71,12 @@ export function isValidTimezone(tz: string): boolean { if (!tz || typeof tz !== "string" || tz.length === 0 || tz.length > 64) { return false; } + // The answer for a given name never changes within a process, and the + // day-key helpers below ask it once per row; `resolvedOptions()` allocates + // a fresh options object on every call. + const memo = validityByZone.get(tz); + if (memo !== undefined) return memo; + let valid: boolean; try { // The memo only caches successful constructions, so probing an // invalid zone through it cannot poison the formatter map — and a @@ -76,10 +86,13 @@ export function isValidTimezone(tz: string): boolean { tz, VALIDATION_OPTIONS, ).resolvedOptions().timeZone; - return !OFFSET_ZONE.test(resolved); + valid = !OFFSET_ZONE.test(resolved); } catch { - return false; + valid = false; } + if (validityByZone.size >= MAX_VALIDITY_ENTRIES) validityByZone.clear(); + validityByZone.set(tz, valid); + return valid; } /** @@ -275,7 +288,23 @@ interface WallClockParts { second: string; } +function pad2(value: number): string { + return value < 10 ? `0${value}` : String(value); +} + function wallClockParts(date: Date, tz: string): WallClockParts { + const offsetMs = zoneOffsetMs(date, tz); + if (offsetMs !== null) { + const at = wallClockFromOffset(date, offsetMs); + return { + year: String(at.year), + month: pad2(at.month), + day: pad2(at.day), + hour: pad2(at.hour), + minute: pad2(at.minute), + second: pad2(at.second), + }; + } const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS); const parts = fmt.formatToParts(date); const get = (type: Intl.DateTimeFormatPartTypes): string => @@ -319,6 +348,12 @@ function formatIsoWithOffset(date: Date, tz: string): string { * difference between the wall-clock parts and the UTC parts. */ export function tzOffsetMinutes(date: Date, tz: string): number { + const offsetMs = zoneOffsetMs(date, tz); + if (offsetMs !== null) { + // Same arithmetic as below: the wall clock is read to the whole second, + // so the sub-second part of the instant shifts the rounded result. + return Math.round((offsetMs - (date.getTime() % 1000)) / 60000); + } const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS); const parts = fmt.formatToParts(date); const get = (type: Intl.DateTimeFormatPartTypes): number => diff --git a/src/lib/tz/wall-clock.ts b/src/lib/tz/wall-clock.ts index 917e8f595..7ec7f1617 100644 --- a/src/lib/tz/wall-clock.ts +++ b/src/lib/tz/wall-clock.ts @@ -19,6 +19,7 @@ * silent drift a real failure mode. */ import { getDateTimeFormat } from "./intl-cache"; +import { wallClockFromOffset, zoneOffsetMs } from "./zone-offset"; export interface WallClockParts { year: number; @@ -190,6 +191,8 @@ export function wallClockInTz( weekday: date.getDay(), }; } + const offsetMs = zoneOffsetMs(date, tz); + if (offsetMs !== null) return wallClockFromOffset(date, offsetMs); const parts = getDateTimeFormat( "en-US", tz, diff --git a/src/lib/tz/zone-offset.ts b/src/lib/tz/zone-offset.ts new file mode 100644 index 000000000..12db19e02 --- /dev/null +++ b/src/lib/tz/zone-offset.ts @@ -0,0 +1,147 @@ +/** + * Per-hour memo of a zone's UTC offset, so wall-clock conversions in hot loops + * stop paying for `Intl.DateTimeFormat#formatToParts` on every row. + * + * A day-key or wall-clock read is pure arithmetic once the offset in force at + * the instant is known. The offset is probed through the same cached + * formatter the slow path uses, at both ends of the UTC hour that contains the + * instant; only when both ends agree (and the offset is a whole number of + * seconds) is it memoised for that hour. An hour that straddles a transition + * is stored as "not constant" and every instant inside it keeps going through + * the formatter, so a transition at any minute — Lord Howe's half-hour shift + * at 15:30 UTC, a historical LMT offset with seconds — still resolves exactly. + * + * The fast path is limited to 1971..2199 so the arithmetic never meets the + * Julian / proleptic-Gregorian split or a non-four-digit year, and so `t % 1000` + * is never negative. Outside that range, for an invalid date, or for a zone + * the formatter rejects, callers fall through to the formatter unchanged + * (an invalid zone throws from the probe exactly as it throws from the + * formatter path, and a value that is not a Date is left to the formatter). + */ +import { getDateTimeFormat } from "./intl-cache"; + +const PROBE_OPTIONS: Omit = + Object.freeze({ + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + +const BUCKET_MS = 3_600_000; +const FAST_FROM_MS = Date.UTC(1971, 0, 1); +const FAST_UNTIL_MS = Date.UTC(2200, 0, 1); +/** Hours memoised across all zones before the memo starts over (~2 MB). */ +const MAX_BUCKETS = 20_000; +/** Stored for an hour whose offset changes inside it. */ +const NOT_CONSTANT = Number.NaN; + +const offsetsByZone = new Map>(); +let bucketCount = 0; + +/** Exact offset (ms) at an instant that falls on a whole second. */ +function probeOffsetMs(instantMs: number, tz: string): number { + const parts = getDateTimeFormat("en-CA", tz, PROBE_OPTIONS).formatToParts( + new Date(instantMs), + ); + let year = 0; + let month = 0; + let day = 0; + let hour = 0; + let minute = 0; + let second = 0; + for (const part of parts) { + switch (part.type) { + case "year": + year = Number(part.value); + break; + case "month": + month = Number(part.value); + break; + case "day": + day = Number(part.value); + break; + case "hour": + hour = Number(part.value); + break; + case "minute": + minute = Number(part.value); + break; + case "second": + second = Number(part.value); + break; + } + } + if (hour === 24) hour = 0; + return Date.UTC(year, month - 1, day, hour, minute, second) - instantMs; +} + +/** + * The zone's offset from UTC in milliseconds at `date`, or `null` when the + * caller must use the formatter (out of range, invalid date, or an hour that + * contains a transition). Throws for a zone `Intl` rejects. + */ +export function zoneOffsetMs(date: Date, tz: string): number | null { + // A caller that slips a non-Date through (an absent field read off a loose + // row) keeps the formatter's own handling: `formatToParts(undefined)` + // formats the current instant rather than throwing. + if (!(date instanceof Date)) return null; + const t = date.getTime(); + if (!(t >= FAST_FROM_MS && t < FAST_UNTIL_MS)) return null; + const bucket = Math.floor(t / BUCKET_MS); + let zone = offsetsByZone.get(tz); + const memo = zone?.get(bucket); + if (memo !== undefined) return Number.isNaN(memo) ? null : memo; + + const start = bucket * BUCKET_MS; + const atStart = probeOffsetMs(start, tz); + const atEnd = probeOffsetMs(start + BUCKET_MS - 1000, tz); + const offset = + atStart === atEnd && atStart % 1000 === 0 ? atStart : NOT_CONSTANT; + + if (bucketCount >= MAX_BUCKETS) { + offsetsByZone.clear(); + bucketCount = 0; + zone = undefined; + } + if (!zone) { + zone = new Map(); + offsetsByZone.set(tz, zone); + } + zone.set(bucket, offset); + bucketCount += 1; + return Number.isNaN(offset) ? null : offset; +} + +/** Wall-clock fields at `date` in the zone whose offset is `offsetMs`. */ +export function wallClockFromOffset( + date: Date, + offsetMs: number, +): { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; + weekday: number; +} { + const local = new Date(date.getTime() + offsetMs); + return { + year: local.getUTCFullYear(), + month: local.getUTCMonth() + 1, + day: local.getUTCDate(), + hour: local.getUTCHours(), + minute: local.getUTCMinutes(), + second: local.getUTCSeconds(), + weekday: local.getUTCDay(), + }; +} + +export function __resetZoneOffsetCacheForTests(): void { + offsetsByZone.clear(); + bucketCount = 0; +}