diff --git a/src/lib/tz/__tests__/resolver.test.ts b/src/lib/tz/__tests__/resolver.test.ts index 8291e19bb..2b133c7d8 100644 --- a/src/lib/tz/__tests__/resolver.test.ts +++ b/src/lib/tz/__tests__/resolver.test.ts @@ -23,6 +23,7 @@ import { formatInUserTz, invalidateServerDefaultTimezone, invalidateUserTimezone, + isNearUtc, isValidTimezone, resolveServerDefaultTimezone, resolveUserTimezone, @@ -261,6 +262,51 @@ describe("userDayKey", () => { }); }); +describe("isNearUtc", () => { + // Pin a summer-side instant so DST is on for Berlin (+2) and off for + // most southern-hemisphere zones — keeps the assertions readable. + const summer = new Date("2026-07-15T12:00:00.000Z"); + // Winter-side instant so Berlin is on CET (+1). + const winter = new Date("2026-01-15T12:00:00.000Z"); + + it("returns true for UTC", () => { + expect(isNearUtc("UTC", summer)).toBe(true); + }); + + it("returns true for Europe/Berlin in both summer and winter", () => { + expect(isNearUtc("Europe/Berlin", summer)).toBe(true); + expect(isNearUtc("Europe/Berlin", winter)).toBe(true); + }); + + it("returns true for the ±3h boundary zones", () => { + // Europe/Moscow = +3 year-round. + expect(isNearUtc("Europe/Moscow", summer)).toBe(true); + // Atlantic/Azores = -1 in winter, 0 in summer. + expect(isNearUtc("Atlantic/Azores", summer)).toBe(true); + }); + + it("returns false for zones more than 3 hours from UTC", () => { + // Pacific/Honolulu = -10 year-round. + expect(isNearUtc("Pacific/Honolulu", summer)).toBe(false); + // Asia/Tokyo = +9 year-round. + expect(isNearUtc("Asia/Tokyo", summer)).toBe(false); + // America/New_York = -4 in summer, -5 in winter. + expect(isNearUtc("America/New_York", summer)).toBe(false); + // Pacific/Auckland = +13 in southern summer (Jan), +12 in May. + expect(isNearUtc("Pacific/Auckland", winter)).toBe(false); + }); + + it("defaults to near-UTC when the zone string is invalid", () => { + // Invalid zones fall back to Europe/Berlin, which is near-UTC. + expect(isNearUtc("Mars/Olympus_Mons", summer)).toBe(true); + }); + + it("uses the current instant when `now` is omitted", () => { + // Pure smoke — exercises the default parameter branch. + expect(typeof isNearUtc("Europe/Berlin")).toBe("boolean"); + }); +}); + describe("DEFAULT_TIMEZONE", () => { it("is Europe/Berlin", () => { expect(DEFAULT_TIMEZONE).toBe("Europe/Berlin"); diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts index b922cedb8..46d9cd65a 100644 --- a/src/lib/tz/format.ts +++ b/src/lib/tz/format.ts @@ -67,6 +67,38 @@ export function detectBrowserTimezone(): string { } } +/** + * v1.4.38 W-A — cross-tz fast-path runtime guard. + * + * Returns `true` when the supplied IANA zone is within ±3 hours of UTC + * at the supplied instant. Used by the analytics fast-paths + * (`bp-in-target-fast-path`, `correlations-fast-path`) to gate the + * rollup read-swap: those helpers key rollup rows on UTC-midnight + * `bucketStart` and pair them with per-event streams keyed in the + * user's local timezone. For Berlin (+1/+2) the day-key on the rollup + * and the day-key on the per-event row line up; for Honolulu (-10) + * they slip by a calendar day. Three hours is a conservative pin — + * the worst-case slip inside the window is the wall-clock hour-of-day + * the user happens to log at, and three hours covers every European + * zone plus the western edge of Mid-Atlantic / eastern edge of the + * Americas. + * + * Falls back to "near-UTC" (i.e. returns `true`) when the zone string + * is invalid — the resolver layer is already defensive about junk + * values, and a `true` here means the caller takes the rollup + * fast-path which is the safer default for the canonical Berlin + * tenant. + * + * The `now` argument lets callers honour DST transitions; the offset + * of `Europe/Berlin` is +1 in January and +2 in July. Defaults to + * "now" so most callers can `isNearUtc(userTz)`. + */ +export function isNearUtc(tz: string, now: Date = new Date()): boolean { + const safeTz = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE; + const offsetMinutes = tzOffsetMinutes(now, safeTz); + return Math.abs(offsetMinutes) <= 3 * 60; +} + export type FormatInUserTzShape = | "iso-with-offset" | "wall-clock" diff --git a/src/lib/tz/resolver.ts b/src/lib/tz/resolver.ts index df45b6260..940321cf1 100644 --- a/src/lib/tz/resolver.ts +++ b/src/lib/tz/resolver.ts @@ -47,6 +47,7 @@ import { export { DEFAULT_TIMEZONE, isValidTimezone, + isNearUtc, listSupportedTimezones, detectBrowserTimezone, formatInUserTz,