src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/timezone.ts
blob: 062a811aace4ea4c544319344d901c6188181fc3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
/**
 * IANA time-zone helpers: validation and offset introspection (config
 * validation, status output) plus Matter TimeZone/DSTOffset structure
 * generation for the sync command.
 */

import { MATTER_EPOCH_AS_UNIX_MICROS } from "./sync.js";

/** 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)),
  };
}

/**
 * Matter TimeZoneStruct and DSTOffsetStruct as passed to matter.js. All
 * timestamps here are Unix-epoch microseconds: matter.js's TlvEpochUs
 * converts to Matter-epoch on the wire and rejects pre-converted values.
 * "Valid since the beginning of time" is therefore Matter epoch zero
 * expressed in Unix microseconds, not 0.
 */
export interface MatterTimeZoneEntry {
  /** Standard (non-DST) UTC offset in seconds. */
  offset: number;
  /** Unix-epoch microseconds at which the entry takes effect. */
  validAt: bigint;
  name?: string;
}

/** One DST period; offset is added on top of the TimeZone offset. */
export interface MatterDstOffsetEntry {
  offset: number;
  validStarting: bigint;
  /** Unix-epoch microseconds; null = valid until further notice (last entry only). */
  validUntil: bigint | null;
}

/**
 * The zone's standard (non-DST) UTC offset in seconds: the smaller of the
 * mid-January and mid-July offsets of the year. DST increases the offset in
 * every zone as reported by ICU (including Europe/Dublin, which ICU models
 * as +00:00 standard / +01:00 summer despite IANA's negative-SAVE encoding).
 */
export function standardOffsetSeconds(timezone: string, at: Date = new Date()): number {
  const year = at.getUTCFullYear();
  const january = utcOffsetSeconds(timezone, new Date(Date.UTC(year, 0, 15)));
  const july = utcOffsetSeconds(timezone, new Date(Date.UTC(year, 6, 15)));
  return Math.min(january, july);
}

/**
 * The TimeZone attribute list for SetTimeZone: a single entry carrying the
 * zone's standard offset and IANA name, valid from the beginning of time.
 */
export function buildTimeZoneList(timezone: string, at: Date = new Date()): MatterTimeZoneEntry[] {
  return [
    {
      offset: standardOffsetSeconds(timezone, at),
      validAt: MATTER_EPOCH_AS_UNIX_MICROS,
      name: timezone.slice(0, 64),
    },
  ];
}

/**
 * The DSTOffset list for SetDstOffset: the DST state in effect at `from`
 * followed by upcoming transitions, at most `maxEntries` entries (the
 * device's DSTOffsetListMaxSize; spec minimum 1). Entries carry concrete
 * validUntil bounds where a next transition is known, so a device left
 * unrefreshed falls back to standard time rather than trusting stale DST;
 * the periodic sync refreshes the list long before it expires. Zones
 * without transitions yield a single open-ended zero entry.
 */
export function buildDstOffsetList(
  timezone: string,
  maxEntries: number,
  from: Date = new Date(),
): MatterDstOffsetEntry[] {
  const standard = standardOffsetSeconds(timezone, from);
  const entries: MatterDstOffsetEntry[] = [];
  let cursor = from;
  let currentDst = utcOffsetSeconds(timezone, cursor) - standard;
  let validStarting = MATTER_EPOCH_AS_UNIX_MICROS;
  const limit = Math.max(1, maxEntries);
  while (entries.length < limit) {
    const transition = nextOffsetTransition(timezone, cursor);
    if (transition === null) {
      entries.push({ offset: currentDst, validStarting, validUntil: null });
      break;
    }
    const untilMicros = BigInt(transition.at.getTime()) * 1_000n;
    entries.push({ offset: currentDst, validStarting, validUntil: untilMicros });
    validStarting = untilMicros;
    currentDst = transition.offsetAfterSeconds - standard;
    cursor = new Date(transition.at.getTime() + 1000);
  }
  return entries;
}