import { compareWall, wallAsEpochMs } from "./nominal.js"; import { offsetMs, wallOfInstant } from "./timezone.js"; import type { WallClock } from "./timezone.js"; export { materializeOccurrence } from "./materialize-api.js"; const SIX_HOURS = 6 * 3600_000; /** * Resolve one nominal local wall time in a zone to its UTC fire instant * (epoch ms). * * Normal: the unique instant reading that wall time. Fold (ambiguous): fires * once, at the earlier instant. Gap (nonexistent): the first valid instant * after the gap — the earliest instant whose local reading is strictly later * than the target. * * The candidate instant is found by fixed-point iteration (guess = local-as-UTC * minus the offset at the guess), which converges for normal and fold times. * Neighbouring offsets are probed to catch the second reading of a fold; the * earliest matching instant wins. A non-matching iteration means the wall time * does not exist (gap), resolved by a bracketed binary search for the exact * first valid instant after the transition — correct for 1-hour gaps and * Pacific/Apia's whole-day skip alike. */ export function resolveWallToInstant(wall: WallClock, zone: string): number { if (zone === "UTC") return wallAsEpochMs(wall); const asUtc = wallAsEpochMs(wall); // Fixed-point iteration from the naive reading. let guess = asUtc; let resolved: number | null = null; for (let i = 0; i < 6; i++) { guess = asUtc - offsetMs(guess, zone); if (compareWall(wallOfInstant(guess, zone), wall) === 0) { resolved = guess; break; } } // Collect every instant reading this wall time: the resolved one plus // candidates derived from the offsets in effect on either side of it. const matches: number[] = []; const consider = (instant: number) => { if (compareWall(wallOfInstant(instant, zone), wall) === 0) matches.push(instant); }; if (resolved !== null) { consider(resolved); consider(asUtc - offsetMs(resolved - SIX_HOURS, zone)); consider(asUtc - offsetMs(resolved + SIX_HOURS, zone)); } if (matches.length > 0) { // Fold: exactly one firing, at the earliest (earlier-offset) instant. return Math.min(...matches); } // Gap: the wall time does not exist. First valid instant after the gap. return firstInstantAfterGap(asUtc, wall, zone); } /** * Earliest instant whose wall clock in `zone` is strictly after `wall`. * A coarse 15-minute scan brackets the transition, then a binary search * narrows to the exact instant (works for 1-hour and whole-day gaps). */ function firstInstantAfterGap(asUtc: number, wall: WallClock, zone: string): number { const stepMs = 15 * 60_000; // Establish the search invariant wall(lo) <= wall first: the local-as-UTC // reading can already lie past the gap when the transition is large (e.g. a // whole-day skip), so step back until the reading is at or before the target. let lo = asUtc; for (let i = 0; i < 8 * 96 && compareWall(wallOfInstant(lo, zone), wall) > 0; i++) { lo -= stepMs; } for (let i = 0; i < 16 * 96; i++) { const next = lo + stepMs; if (compareWall(wallOfInstant(next, zone), wall) > 0) { return binarySearchFirstAfter(lo, next, wall, zone); } lo = next; } throw new Error(`no instant found after gap at ${JSON.stringify(wall)} in ${zone}`); } /** Invariant: wall(lo) <= wall < wall(hi); returns the smallest such hi. */ function binarySearchFirstAfter(lo: number, hi: number, wall: WallClock, zone: string): number { while (hi - lo > 1) { const mid = lo + Math.floor((hi - lo) / 2); if (compareWall(wallOfInstant(mid, zone), wall) > 0) { hi = mid; } else { lo = mid; } } return hi; }