src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/timezone.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/timezone.ts')
-rw-r--r--src/timezone.ts106
1 files changed, 106 insertions, 0 deletions
diff --git a/src/timezone.ts b/src/timezone.ts
new file mode 100644
index 0000000..f4240f4
--- /dev/null
+++ b/src/timezone.ts
@@ -0,0 +1,106 @@
+/**
+ * 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)),
+ };
+}