@chipmobilesdk/rn-notification 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -42
- package/README.md +399 -306
- package/package.json +2 -1
- package/skills/sdk-rn-notification/SKILL.md +68 -3
- package/src/channel/migrate.ts +86 -12
- package/src/channel/tone-map.ts +130 -90
- package/src/channel/validate-tone.ts +61 -0
- package/src/contract/channel.ts +188 -89
- package/src/contract/diagnostics.ts +102 -102
- package/src/contract/errors.ts +130 -123
- package/src/contract/index.ts +10 -10
- package/src/contract/interaction.ts +62 -62
- package/src/contract/notification.ts +118 -118
- package/src/contract/permission.ts +60 -60
- package/src/contract/reconcile.ts +88 -88
- package/src/contract/recurrence.ts +123 -123
- package/src/diagnostics/burst.ts +101 -101
- package/src/diagnostics/events.ts +72 -72
- package/src/diagnostics/redact.ts +72 -72
- package/src/diagnostics/snapshot.ts +64 -64
- package/src/engine/adapter.ts +172 -79
- package/src/engine/notify-kit-engine.ts +761 -535
- package/src/index.ts +24 -0
- package/src/initialize.ts +174 -174
- package/src/interaction/index.ts +146 -146
- package/src/maintenance/cycle.ts +147 -147
- package/src/maintenance/permission-refresh.ts +41 -41
- package/src/maintenance/reanchor.ts +95 -95
- package/src/maintenance/toggle.ts +60 -60
- package/src/maintenance/verify.ts +83 -83
- package/src/maintenance/window-refresh.ts +61 -61
- package/src/permission/index.ts +183 -183
- package/src/permission/state.ts +77 -77
- package/src/push/envelope.ts +165 -165
- package/src/push/fcm-provider.ts +146 -146
- package/src/push/gate.ts +190 -190
- package/src/push/instructions.ts +109 -109
- package/src/push/provider-adapter.ts +54 -54
- package/src/push/signout.ts +65 -65
- package/src/reconcile/chunk.ts +53 -53
- package/src/reconcile/diff.ts +145 -145
- package/src/reconcile/duplicates.ts +73 -73
- package/src/reconcile/index.ts +14 -1
- package/src/reconcile/truncate.ts +56 -56
- package/src/recurrence/clock-guard.ts +71 -71
- package/src/recurrence/expand.ts +292 -292
- package/src/repeat/expiry.ts +55 -0
- package/src/repeat/index.ts +54 -0
- package/src/repeat/marker.ts +72 -0
- package/src/repeat/place.ts +73 -0
- package/src/schedule/anchor.ts +267 -267
- package/src/schedule/index.ts +184 -173
- package/src/schedule/partition.ts +85 -85
- package/src/schedule/resolve-moment.ts +80 -80
- package/src/schedule/validate-content.ts +111 -111
- package/src/schedule/validate-identifier.ts +126 -114
- package/src/state/caps.ts +71 -71
- package/src/state/interaction-buffer.ts +73 -73
- package/src/state/mmkv-store.ts +71 -71
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +258 -246
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +84 -84
- package/src/state/store.ts +40 -40
- package/src/testing/conformance/index.ts +248 -194
- package/src/testing/fake-engine.ts +469 -284
- package/src/testing/fake-provider.ts +129 -129
- package/src/testing/faults.ts +72 -72
- package/src/testing/measure.ts +336 -333
package/src/schedule/anchor.ts
CHANGED
|
@@ -1,267 +1,267 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Time-zone anchoring and re-anchoring.
|
|
3
|
-
*
|
|
4
|
-
* Wall-clock is the default because reminders are wall-clock things: an 08:00
|
|
5
|
-
* reminder should stay 08:00 after the user crosses a time zone, and a caller
|
|
6
|
-
* who does not choose should get what their users expect rather than a silent
|
|
7
|
-
* shift.
|
|
8
|
-
*
|
|
9
|
-
* The cost is re-anchoring. When the device zone changes, every scheduled
|
|
10
|
-
* wall-clock entry must be rewritten to preserve the clock time it named, and
|
|
11
|
-
* every absolute entry must be left completely alone.
|
|
12
|
-
*
|
|
13
|
-
* The limit this cannot fix is stated rather than hidden: a wall-clock
|
|
14
|
-
* notification whose moment arrives while the app has not run since the zone
|
|
15
|
-
* changed fires at the previously anchored instant. The anchoring generation on
|
|
16
|
-
* each entry is what makes that detectable instead of invisible.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type { AnchoringMode, PendingEntry } from '../contract/notification';
|
|
20
|
-
import { DEFAULT_ANCHORING_MODE } from '../contract/notification';
|
|
21
|
-
|
|
22
|
-
/** Reads the platform time zone. Injected so the pure logic needs no native module. */
|
|
23
|
-
export type TimeZoneReader = () => string;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Resolves the platform zone via `react-native-localize`.
|
|
27
|
-
*
|
|
28
|
-
* Pure JavaScript is not sufficient here: `Intl.DateTimeFormat().resolvedOptions().timeZone`
|
|
29
|
-
* is documented in React Native as not reliably updating when the device zone
|
|
30
|
-
* changes, so it cannot back re-anchoring. Falls back to `Intl` only when the
|
|
31
|
-
* module is absent, which is better than failing outright.
|
|
32
|
-
*/
|
|
33
|
-
export function createPlatformTimeZoneReader(): TimeZoneReader {
|
|
34
|
-
return () => {
|
|
35
|
-
try {
|
|
36
|
-
const localize = require('react-native-localize') as { getTimeZone(): string };
|
|
37
|
-
return localize.getTimeZone();
|
|
38
|
-
} catch {
|
|
39
|
-
try {
|
|
40
|
-
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
|
|
41
|
-
} catch {
|
|
42
|
-
return 'UTC';
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** The calendar fields a wall-clock moment actually names. */
|
|
49
|
-
export interface WallClockParts {
|
|
50
|
-
readonly year: number;
|
|
51
|
-
readonly month: number;
|
|
52
|
-
readonly day: number;
|
|
53
|
-
readonly hour: number;
|
|
54
|
-
readonly minute: number;
|
|
55
|
-
readonly second: number;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Whether a zone name is one the runtime can actually format against.
|
|
60
|
-
*
|
|
61
|
-
* Hermes throws a hard `JSRangeErrorException: Invalid timezone name!` for a
|
|
62
|
-
* bad zone, which kills the JavaScript thread rather than returning an error.
|
|
63
|
-
* A stored anchoring generation is data that can be wrong — a corrupted record,
|
|
64
|
-
* an entry written by an older build, or a defect like the one that stored a
|
|
65
|
-
* tone id here — so it must be checked before it reaches `Intl`.
|
|
66
|
-
*/
|
|
67
|
-
export function isValidTimeZone(timeZone: string): boolean {
|
|
68
|
-
if (timeZone.length === 0) return false;
|
|
69
|
-
try {
|
|
70
|
-
new Intl.DateTimeFormat('en-US', { timeZone }).format(0);
|
|
71
|
-
return true;
|
|
72
|
-
} catch {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Decomposes an instant into the clock reading it produces in a given zone.
|
|
79
|
-
*
|
|
80
|
-
* Falls back to UTC for an unusable zone rather than throwing: a bad stored
|
|
81
|
-
* value should degrade one entry's anchoring, not crash the app.
|
|
82
|
-
*/
|
|
83
|
-
export function partsInZone(instant: number, timeZone: string): WallClockParts {
|
|
84
|
-
const zone = isValidTimeZone(timeZone) ? timeZone : 'UTC';
|
|
85
|
-
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
86
|
-
timeZone: zone,
|
|
87
|
-
year: 'numeric',
|
|
88
|
-
month: '2-digit',
|
|
89
|
-
day: '2-digit',
|
|
90
|
-
hour: '2-digit',
|
|
91
|
-
minute: '2-digit',
|
|
92
|
-
second: '2-digit',
|
|
93
|
-
hour12: false,
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
const fields: Record<string, number> = {};
|
|
97
|
-
for (const part of formatter.formatToParts(new Date(instant))) {
|
|
98
|
-
if (part.type !== 'literal') {
|
|
99
|
-
fields[part.type] = Number.parseInt(part.value, 10);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return {
|
|
104
|
-
year: fields.year,
|
|
105
|
-
month: fields.month,
|
|
106
|
-
day: fields.day,
|
|
107
|
-
// Intl renders midnight as hour 24 in some environments; normalize it.
|
|
108
|
-
hour: fields.hour === 24 ? 0 : fields.hour,
|
|
109
|
-
minute: fields.minute,
|
|
110
|
-
second: fields.second,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** The zone's offset from UTC, in milliseconds, at a given instant. */
|
|
115
|
-
function offsetAt(instant: number, timeZone: string): number {
|
|
116
|
-
const parts = partsInZone(instant, timeZone);
|
|
117
|
-
const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
|
118
|
-
return asUtc - Math.floor(instant / 1000) * 1000;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Finds the instant at which a zone shows the given clock reading.
|
|
123
|
-
*
|
|
124
|
-
* Two passes, because the offset itself depends on the instant: the first guess
|
|
125
|
-
* uses the offset at the naive UTC interpretation, the second corrects for a
|
|
126
|
-
* daylight-saving boundary crossed by that guess.
|
|
127
|
-
*
|
|
128
|
-
* Daylight-saving resolution, per the documented rule:
|
|
129
|
-
* - a clock time that does not exist resolves **forward** to the next valid instant;
|
|
130
|
-
* - a clock time that occurs twice resolves to its **first** occurrence, so
|
|
131
|
-
* exactly one notification results rather than two.
|
|
132
|
-
*/
|
|
133
|
-
export function instantForParts(parts: WallClockParts, timeZone: string): number {
|
|
134
|
-
const naive = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
|
135
|
-
|
|
136
|
-
let candidate = naive - offsetAt(naive, timeZone);
|
|
137
|
-
candidate = naive - offsetAt(candidate, timeZone);
|
|
138
|
-
|
|
139
|
-
const rendered = partsInZone(candidate, timeZone);
|
|
140
|
-
|
|
141
|
-
if (rendered.hour === parts.hour && rendered.minute === parts.minute) {
|
|
142
|
-
// Ambiguous readings (the repeated hour) land here on the first, earlier
|
|
143
|
-
// offset, which is the documented choice.
|
|
144
|
-
return candidate;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// The reading does not exist in this zone — a spring-forward gap. Step
|
|
148
|
-
// forward to the next instant that does exist rather than skipping the
|
|
149
|
-
// occurrence or failing.
|
|
150
|
-
const gapStep = 60 * 60 * 1000;
|
|
151
|
-
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
|
152
|
-
const shifted = candidate + gapStep * attempt;
|
|
153
|
-
const shiftedParts = partsInZone(shifted, timeZone);
|
|
154
|
-
if (shiftedParts.day === parts.day || shiftedParts.hour > parts.hour) {
|
|
155
|
-
return shifted;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return candidate;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export interface AnchorInput {
|
|
163
|
-
readonly at: number;
|
|
164
|
-
readonly anchoring?: AnchoringMode;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export interface AnchoredMoment {
|
|
168
|
-
readonly resolvedAt: number;
|
|
169
|
-
readonly anchoring: AnchoringMode;
|
|
170
|
-
/** The zone this was anchored against. Absent for absolute-instant moments. */
|
|
171
|
-
readonly generation?: string;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Resolves a caller-supplied moment into a firing instant.
|
|
176
|
-
*
|
|
177
|
-
* An absolute moment passes through untouched — that is the entire distinction.
|
|
178
|
-
*/
|
|
179
|
-
export function anchorMoment(input: AnchorInput, timeZone: string): AnchoredMoment {
|
|
180
|
-
const anchoring = input.anchoring ?? DEFAULT_ANCHORING_MODE;
|
|
181
|
-
|
|
182
|
-
if (anchoring === 'absolute') {
|
|
183
|
-
return { resolvedAt: input.at, anchoring };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return { resolvedAt: input.at, anchoring, generation: timeZone };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export interface ReAnchorPlan {
|
|
190
|
-
/** Entries whose firing instant must be rewritten, with their new instant. */
|
|
191
|
-
readonly rewrites: readonly { readonly entry: PendingEntry; readonly resolvedAt: number }[];
|
|
192
|
-
readonly fromZone: string | undefined;
|
|
193
|
-
readonly toZone: string;
|
|
194
|
-
readonly zoneChanged: boolean;
|
|
195
|
-
/**
|
|
196
|
-
* Entries carrying an anchoring generation the runtime cannot interpret.
|
|
197
|
-
*
|
|
198
|
-
* Left alone rather than re-anchored against a guess. Reported so a corrupted
|
|
199
|
-
* or older-build record is visible instead of quietly producing wrong firing
|
|
200
|
-
* instants.
|
|
201
|
-
*/
|
|
202
|
-
readonly unreadableGeneration: readonly string[];
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Computes what a zone change requires.
|
|
207
|
-
*
|
|
208
|
-
* Returns an empty plan when the zone has not moved, which is what makes
|
|
209
|
-
* re-anchoring idempotent: the no-change check costs one comparison per entry
|
|
210
|
-
* and issues zero platform operations.
|
|
211
|
-
*/
|
|
212
|
-
export function planReAnchor(
|
|
213
|
-
pending: readonly PendingEntry[],
|
|
214
|
-
currentZone: string,
|
|
215
|
-
): ReAnchorPlan {
|
|
216
|
-
const rewrites: { entry: PendingEntry; resolvedAt: number }[] = [];
|
|
217
|
-
const unreadableGeneration: string[] = [];
|
|
218
|
-
let fromZone: string | undefined;
|
|
219
|
-
|
|
220
|
-
for (const entry of pending) {
|
|
221
|
-
// Absolute-instant entries are never re-anchored, re-scheduled, or touched.
|
|
222
|
-
if (entry.anchoring === 'absolute') continue;
|
|
223
|
-
|
|
224
|
-
const generation = entry.anchoringGeneration;
|
|
225
|
-
if (generation === undefined || generation === currentZone) continue;
|
|
226
|
-
|
|
227
|
-
// A stored generation is data, and data can be wrong. Re-anchoring against
|
|
228
|
-
// a zone the runtime cannot read would move the entry to an arbitrary
|
|
229
|
-
// instant, which is worse than leaving it where it is and saying so.
|
|
230
|
-
if (!isValidTimeZone(generation)) {
|
|
231
|
-
unreadableGeneration.push(entry.id);
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
fromZone ??= generation;
|
|
236
|
-
|
|
237
|
-
// Preserve the clock reading the entry originally named, not the instant.
|
|
238
|
-
const originalParts = partsInZone(entry.resolvedAt, generation);
|
|
239
|
-
const resolvedAt = instantForParts(originalParts, currentZone);
|
|
240
|
-
|
|
241
|
-
if (resolvedAt !== entry.resolvedAt) {
|
|
242
|
-
rewrites.push({ entry, resolvedAt });
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return {
|
|
247
|
-
rewrites,
|
|
248
|
-
fromZone,
|
|
249
|
-
toZone: currentZone,
|
|
250
|
-
zoneChanged: rewrites.length > 0 || fromZone !== undefined,
|
|
251
|
-
unreadableGeneration,
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* True when an entry was anchored against a zone the device has since left.
|
|
257
|
-
*
|
|
258
|
-
* This is the FR-007c condition: the entry will fire at its old instant, and a
|
|
259
|
-
* consumer can detect and explain that rather than being told nothing happened.
|
|
260
|
-
*/
|
|
261
|
-
export function isStaleAnchor(entry: PendingEntry, currentZone: string): boolean {
|
|
262
|
-
return (
|
|
263
|
-
entry.anchoring === 'wallClock' &&
|
|
264
|
-
entry.anchoringGeneration !== undefined &&
|
|
265
|
-
entry.anchoringGeneration !== currentZone
|
|
266
|
-
);
|
|
267
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Time-zone anchoring and re-anchoring.
|
|
3
|
+
*
|
|
4
|
+
* Wall-clock is the default because reminders are wall-clock things: an 08:00
|
|
5
|
+
* reminder should stay 08:00 after the user crosses a time zone, and a caller
|
|
6
|
+
* who does not choose should get what their users expect rather than a silent
|
|
7
|
+
* shift.
|
|
8
|
+
*
|
|
9
|
+
* The cost is re-anchoring. When the device zone changes, every scheduled
|
|
10
|
+
* wall-clock entry must be rewritten to preserve the clock time it named, and
|
|
11
|
+
* every absolute entry must be left completely alone.
|
|
12
|
+
*
|
|
13
|
+
* The limit this cannot fix is stated rather than hidden: a wall-clock
|
|
14
|
+
* notification whose moment arrives while the app has not run since the zone
|
|
15
|
+
* changed fires at the previously anchored instant. The anchoring generation on
|
|
16
|
+
* each entry is what makes that detectable instead of invisible.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { AnchoringMode, PendingEntry } from '../contract/notification';
|
|
20
|
+
import { DEFAULT_ANCHORING_MODE } from '../contract/notification';
|
|
21
|
+
|
|
22
|
+
/** Reads the platform time zone. Injected so the pure logic needs no native module. */
|
|
23
|
+
export type TimeZoneReader = () => string;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves the platform zone via `react-native-localize`.
|
|
27
|
+
*
|
|
28
|
+
* Pure JavaScript is not sufficient here: `Intl.DateTimeFormat().resolvedOptions().timeZone`
|
|
29
|
+
* is documented in React Native as not reliably updating when the device zone
|
|
30
|
+
* changes, so it cannot back re-anchoring. Falls back to `Intl` only when the
|
|
31
|
+
* module is absent, which is better than failing outright.
|
|
32
|
+
*/
|
|
33
|
+
export function createPlatformTimeZoneReader(): TimeZoneReader {
|
|
34
|
+
return () => {
|
|
35
|
+
try {
|
|
36
|
+
const localize = require('react-native-localize') as { getTimeZone(): string };
|
|
37
|
+
return localize.getTimeZone();
|
|
38
|
+
} catch {
|
|
39
|
+
try {
|
|
40
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
|
|
41
|
+
} catch {
|
|
42
|
+
return 'UTC';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The calendar fields a wall-clock moment actually names. */
|
|
49
|
+
export interface WallClockParts {
|
|
50
|
+
readonly year: number;
|
|
51
|
+
readonly month: number;
|
|
52
|
+
readonly day: number;
|
|
53
|
+
readonly hour: number;
|
|
54
|
+
readonly minute: number;
|
|
55
|
+
readonly second: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether a zone name is one the runtime can actually format against.
|
|
60
|
+
*
|
|
61
|
+
* Hermes throws a hard `JSRangeErrorException: Invalid timezone name!` for a
|
|
62
|
+
* bad zone, which kills the JavaScript thread rather than returning an error.
|
|
63
|
+
* A stored anchoring generation is data that can be wrong — a corrupted record,
|
|
64
|
+
* an entry written by an older build, or a defect like the one that stored a
|
|
65
|
+
* tone id here — so it must be checked before it reaches `Intl`.
|
|
66
|
+
*/
|
|
67
|
+
export function isValidTimeZone(timeZone: string): boolean {
|
|
68
|
+
if (timeZone.length === 0) return false;
|
|
69
|
+
try {
|
|
70
|
+
new Intl.DateTimeFormat('en-US', { timeZone }).format(0);
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Decomposes an instant into the clock reading it produces in a given zone.
|
|
79
|
+
*
|
|
80
|
+
* Falls back to UTC for an unusable zone rather than throwing: a bad stored
|
|
81
|
+
* value should degrade one entry's anchoring, not crash the app.
|
|
82
|
+
*/
|
|
83
|
+
export function partsInZone(instant: number, timeZone: string): WallClockParts {
|
|
84
|
+
const zone = isValidTimeZone(timeZone) ? timeZone : 'UTC';
|
|
85
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
86
|
+
timeZone: zone,
|
|
87
|
+
year: 'numeric',
|
|
88
|
+
month: '2-digit',
|
|
89
|
+
day: '2-digit',
|
|
90
|
+
hour: '2-digit',
|
|
91
|
+
minute: '2-digit',
|
|
92
|
+
second: '2-digit',
|
|
93
|
+
hour12: false,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const fields: Record<string, number> = {};
|
|
97
|
+
for (const part of formatter.formatToParts(new Date(instant))) {
|
|
98
|
+
if (part.type !== 'literal') {
|
|
99
|
+
fields[part.type] = Number.parseInt(part.value, 10);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
year: fields.year,
|
|
105
|
+
month: fields.month,
|
|
106
|
+
day: fields.day,
|
|
107
|
+
// Intl renders midnight as hour 24 in some environments; normalize it.
|
|
108
|
+
hour: fields.hour === 24 ? 0 : fields.hour,
|
|
109
|
+
minute: fields.minute,
|
|
110
|
+
second: fields.second,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The zone's offset from UTC, in milliseconds, at a given instant. */
|
|
115
|
+
function offsetAt(instant: number, timeZone: string): number {
|
|
116
|
+
const parts = partsInZone(instant, timeZone);
|
|
117
|
+
const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
|
118
|
+
return asUtc - Math.floor(instant / 1000) * 1000;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Finds the instant at which a zone shows the given clock reading.
|
|
123
|
+
*
|
|
124
|
+
* Two passes, because the offset itself depends on the instant: the first guess
|
|
125
|
+
* uses the offset at the naive UTC interpretation, the second corrects for a
|
|
126
|
+
* daylight-saving boundary crossed by that guess.
|
|
127
|
+
*
|
|
128
|
+
* Daylight-saving resolution, per the documented rule:
|
|
129
|
+
* - a clock time that does not exist resolves **forward** to the next valid instant;
|
|
130
|
+
* - a clock time that occurs twice resolves to its **first** occurrence, so
|
|
131
|
+
* exactly one notification results rather than two.
|
|
132
|
+
*/
|
|
133
|
+
export function instantForParts(parts: WallClockParts, timeZone: string): number {
|
|
134
|
+
const naive = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
|
135
|
+
|
|
136
|
+
let candidate = naive - offsetAt(naive, timeZone);
|
|
137
|
+
candidate = naive - offsetAt(candidate, timeZone);
|
|
138
|
+
|
|
139
|
+
const rendered = partsInZone(candidate, timeZone);
|
|
140
|
+
|
|
141
|
+
if (rendered.hour === parts.hour && rendered.minute === parts.minute) {
|
|
142
|
+
// Ambiguous readings (the repeated hour) land here on the first, earlier
|
|
143
|
+
// offset, which is the documented choice.
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The reading does not exist in this zone — a spring-forward gap. Step
|
|
148
|
+
// forward to the next instant that does exist rather than skipping the
|
|
149
|
+
// occurrence or failing.
|
|
150
|
+
const gapStep = 60 * 60 * 1000;
|
|
151
|
+
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
|
152
|
+
const shifted = candidate + gapStep * attempt;
|
|
153
|
+
const shiftedParts = partsInZone(shifted, timeZone);
|
|
154
|
+
if (shiftedParts.day === parts.day || shiftedParts.hour > parts.hour) {
|
|
155
|
+
return shifted;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return candidate;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface AnchorInput {
|
|
163
|
+
readonly at: number;
|
|
164
|
+
readonly anchoring?: AnchoringMode;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface AnchoredMoment {
|
|
168
|
+
readonly resolvedAt: number;
|
|
169
|
+
readonly anchoring: AnchoringMode;
|
|
170
|
+
/** The zone this was anchored against. Absent for absolute-instant moments. */
|
|
171
|
+
readonly generation?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolves a caller-supplied moment into a firing instant.
|
|
176
|
+
*
|
|
177
|
+
* An absolute moment passes through untouched — that is the entire distinction.
|
|
178
|
+
*/
|
|
179
|
+
export function anchorMoment(input: AnchorInput, timeZone: string): AnchoredMoment {
|
|
180
|
+
const anchoring = input.anchoring ?? DEFAULT_ANCHORING_MODE;
|
|
181
|
+
|
|
182
|
+
if (anchoring === 'absolute') {
|
|
183
|
+
return { resolvedAt: input.at, anchoring };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { resolvedAt: input.at, anchoring, generation: timeZone };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface ReAnchorPlan {
|
|
190
|
+
/** Entries whose firing instant must be rewritten, with their new instant. */
|
|
191
|
+
readonly rewrites: readonly { readonly entry: PendingEntry; readonly resolvedAt: number }[];
|
|
192
|
+
readonly fromZone: string | undefined;
|
|
193
|
+
readonly toZone: string;
|
|
194
|
+
readonly zoneChanged: boolean;
|
|
195
|
+
/**
|
|
196
|
+
* Entries carrying an anchoring generation the runtime cannot interpret.
|
|
197
|
+
*
|
|
198
|
+
* Left alone rather than re-anchored against a guess. Reported so a corrupted
|
|
199
|
+
* or older-build record is visible instead of quietly producing wrong firing
|
|
200
|
+
* instants.
|
|
201
|
+
*/
|
|
202
|
+
readonly unreadableGeneration: readonly string[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Computes what a zone change requires.
|
|
207
|
+
*
|
|
208
|
+
* Returns an empty plan when the zone has not moved, which is what makes
|
|
209
|
+
* re-anchoring idempotent: the no-change check costs one comparison per entry
|
|
210
|
+
* and issues zero platform operations.
|
|
211
|
+
*/
|
|
212
|
+
export function planReAnchor(
|
|
213
|
+
pending: readonly PendingEntry[],
|
|
214
|
+
currentZone: string,
|
|
215
|
+
): ReAnchorPlan {
|
|
216
|
+
const rewrites: { entry: PendingEntry; resolvedAt: number }[] = [];
|
|
217
|
+
const unreadableGeneration: string[] = [];
|
|
218
|
+
let fromZone: string | undefined;
|
|
219
|
+
|
|
220
|
+
for (const entry of pending) {
|
|
221
|
+
// Absolute-instant entries are never re-anchored, re-scheduled, or touched.
|
|
222
|
+
if (entry.anchoring === 'absolute') continue;
|
|
223
|
+
|
|
224
|
+
const generation = entry.anchoringGeneration;
|
|
225
|
+
if (generation === undefined || generation === currentZone) continue;
|
|
226
|
+
|
|
227
|
+
// A stored generation is data, and data can be wrong. Re-anchoring against
|
|
228
|
+
// a zone the runtime cannot read would move the entry to an arbitrary
|
|
229
|
+
// instant, which is worse than leaving it where it is and saying so.
|
|
230
|
+
if (!isValidTimeZone(generation)) {
|
|
231
|
+
unreadableGeneration.push(entry.id);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
fromZone ??= generation;
|
|
236
|
+
|
|
237
|
+
// Preserve the clock reading the entry originally named, not the instant.
|
|
238
|
+
const originalParts = partsInZone(entry.resolvedAt, generation);
|
|
239
|
+
const resolvedAt = instantForParts(originalParts, currentZone);
|
|
240
|
+
|
|
241
|
+
if (resolvedAt !== entry.resolvedAt) {
|
|
242
|
+
rewrites.push({ entry, resolvedAt });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
rewrites,
|
|
248
|
+
fromZone,
|
|
249
|
+
toZone: currentZone,
|
|
250
|
+
zoneChanged: rewrites.length > 0 || fromZone !== undefined,
|
|
251
|
+
unreadableGeneration,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* True when an entry was anchored against a zone the device has since left.
|
|
257
|
+
*
|
|
258
|
+
* This is the FR-007c condition: the entry will fire at its old instant, and a
|
|
259
|
+
* consumer can detect and explain that rather than being told nothing happened.
|
|
260
|
+
*/
|
|
261
|
+
export function isStaleAnchor(entry: PendingEntry, currentZone: string): boolean {
|
|
262
|
+
return (
|
|
263
|
+
entry.anchoring === 'wallClock' &&
|
|
264
|
+
entry.anchoringGeneration !== undefined &&
|
|
265
|
+
entry.anchoringGeneration !== currentZone
|
|
266
|
+
);
|
|
267
|
+
}
|