/** * IANA time-zone helpers. * * Phase 1-4 scope: validation and offset introspection used by config * validation and status output. Matter TimeZone/DSTOffset structure * generation (Phase 6) will build on these helpers once the real device's * Time Synchronization capabilities have been inspected. */ /** Returns true when the host's ICU data recognizes the IANA time-zone name. */ export function isValidTimeZone(timezone: string): boolean { if (typeof timezone !== "string" || timezone.length === 0) return false; try { new Intl.DateTimeFormat("en-US", { timeZone: timezone }); return true; } catch { return false; } } /** * Current UTC offset of `timezone` at `date`, in seconds. * * Derived from ICU rules, never from a hard-coded offset, so daylight-saving * transitions are honored automatically. */ export function utcOffsetSeconds(timezone: string, date: Date = new Date()): number { const formatter = new Intl.DateTimeFormat("en-US", { timeZone: timezone, timeZoneName: "longOffset", }); const offsetPart = formatter.formatToParts(date).find(part => part.type === "timeZoneName")?.value; if (offsetPart === undefined) { throw new Error(`Unable to determine UTC offset for time zone ${timezone}`); } // Formats look like "GMT-05:00", "GMT+05:30", or plain "GMT" for UTC itself. const match = /^GMT(?:([+-])(\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(offsetPart); if (!match) { throw new Error(`Unrecognized UTC offset format "${offsetPart}" for time zone ${timezone}`); } if (match[1] === undefined) return 0; const sign = match[1] === "-" ? -1 : 1; const hours = Number(match[2]); const minutes = Number(match[3]); const seconds = match[4] === undefined ? 0 : Number(match[4]); return sign * (hours * 3600 + minutes * 60 + seconds); } /** Formats an offset in seconds as "UTC-05:00" style text for logs and status output. */ export function formatUtcOffset(offsetSeconds: number): string { const sign = offsetSeconds < 0 ? "-" : "+"; const absolute = Math.abs(offsetSeconds); const hours = String(Math.floor(absolute / 3600)).padStart(2, "0"); const minutes = String(Math.floor((absolute % 3600) / 60)).padStart(2, "0"); return `UTC${sign}${hours}:${minutes}`; } /** * Finds the next instant at which the zone's UTC offset changes, scanning up * to `horizonDays` ahead. Returns null when no transition occurs in the * window (e.g. fixed-offset zones). * * Uses day-granularity scan plus binary search, so it is exact to the second * without iterating minute-by-minute. */ export function nextOffsetTransition( timezone: string, from: Date = new Date(), horizonDays = 400, ): { at: Date; offsetBeforeSeconds: number; offsetAfterSeconds: number } | null { const startOffset = utcOffsetSeconds(timezone, from); const dayMs = 24 * 60 * 60 * 1000; // Probe on whole-second boundaries so the binary search converges on the // exact transition instant (transitions occur at whole seconds). const startMs = Math.ceil(from.getTime() / 1000) * 1000; let previous = startMs; let changedAtOrBefore: number | null = null; for (let day = 1; day <= horizonDays; day++) { const probe = startMs + day * dayMs; if (utcOffsetSeconds(timezone, new Date(probe)) !== startOffset) { changedAtOrBefore = probe; break; } previous = probe; } if (changedAtOrBefore === null) return null; // Binary search the exact transition instant between the last unchanged // probe and the first changed probe. let low = previous; let high = changedAtOrBefore; while (high - low > 1000) { const middle = low + Math.floor((high - low) / 2 / 1000) * 1000; if (utcOffsetSeconds(timezone, new Date(middle)) === startOffset) { low = middle; } else { high = middle; } } return { at: new Date(high), offsetBeforeSeconds: startOffset, offsetAfterSeconds: utcOffsetSeconds(timezone, new Date(high)), }; }