@chipmobilesdk/rn-notification 0.4.0 → 0.4.1
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 +174 -166
- package/README.md +414 -410
- package/package.json +58 -75
- package/skills/sdk-rn-notification/SKILL.md +172 -173
- package/src/channel/migrate.ts +285 -289
- package/src/channel/tone-map.ts +130 -130
- package/src/channel/validate-tone.ts +61 -61
- package/src/contract/channel.ts +192 -188
- package/src/contract/diagnostics.ts +102 -102
- package/src/contract/errors.ts +134 -134
- 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 +55 -60
- package/src/contract/reconcile.ts +88 -88
- package/src/contract/recurrence.ts +129 -129
- 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 +67 -64
- package/src/engine/adapter.ts +172 -172
- package/src/engine/codec.ts +89 -89
- package/src/engine/notify-kit-engine.ts +706 -707
- package/src/index.ts +208 -208
- package/src/initialize.ts +240 -236
- package/src/interaction/index.ts +146 -146
- package/src/maintenance/cycle.ts +150 -154
- package/src/maintenance/permission-refresh.ts +39 -41
- package/src/maintenance/reanchor.ts +101 -101
- package/src/maintenance/toggle.ts +66 -64
- package/src/maintenance/verify.ts +83 -83
- package/src/maintenance/window-refresh.ts +64 -64
- 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 +143 -146
- package/src/push/gate.ts +190 -190
- package/src/push/index.ts +33 -29
- package/src/push/instructions.ts +114 -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 +142 -145
- package/src/reconcile/duplicates.ts +73 -73
- package/src/reconcile/index.ts +180 -180
- package/src/reconcile/truncate.ts +56 -56
- package/src/recurrence/clock-guard.ts +73 -71
- package/src/recurrence/expand.ts +326 -319
- package/src/recurrence/index.ts +270 -259
- package/src/repeat/expiry.ts +53 -55
- package/src/repeat/index.ts +54 -54
- package/src/repeat/marker.ts +72 -72
- package/src/repeat/place.ts +73 -73
- package/src/schedule/anchor.ts +278 -267
- package/src/schedule/index.ts +174 -181
- package/src/schedule/partition.ts +88 -85
- package/src/schedule/resolve-moment.ts +80 -80
- package/src/schedule/validate-content.ts +111 -111
- package/src/schedule/validate-identifier.ts +126 -126
- package/src/schedule/writer.ts +55 -55
- package/src/state/caps.ts +71 -71
- package/src/state/dedup-ledger.ts +35 -35
- package/src/state/interaction-buffer.ts +73 -73
- package/src/state/mmkv-store.ts +75 -75
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +266 -263
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +91 -91
- package/src/state/store.ts +40 -40
- package/src/testing/conformance/index.ts +258 -248
- package/src/testing/fake-engine.ts +462 -468
- package/src/testing/fake-provider.ts +126 -129
- package/src/testing/faults.ts +72 -72
- package/src/testing/index.ts +23 -28
- package/src/testing/measure.ts +337 -337
package/src/recurrence/expand.ts
CHANGED
|
@@ -1,319 +1,326 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Recurrence expansion.
|
|
3
|
-
*
|
|
4
|
-
* Written first-party rather than delegated to an RFC 5545 library, because the
|
|
5
|
-
* daylight-saving rules here are specified behaviour (FR-008) rather than a
|
|
6
|
-
* dependency's opinion, and because expansion must be provable with no device.
|
|
7
|
-
*
|
|
8
|
-
* Determinism is the hard requirement: the same rule, anchoring mode, zone, and
|
|
9
|
-
* evaluation moment must produce exactly the same occurrences in the same order
|
|
10
|
-
* on every run and on both platforms. Without that, reconciliation could not be
|
|
11
|
-
* idempotent over a recurring series.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type {
|
|
15
|
-
RecurrenceRule,
|
|
16
|
-
RecurrencePattern,
|
|
17
|
-
DailyPattern,
|
|
18
|
-
WeeklyPattern,
|
|
19
|
-
MonthlyByDayPattern,
|
|
20
|
-
LookaheadConfig,
|
|
21
|
-
Weekday,
|
|
22
|
-
} from '../contract/recurrence';
|
|
23
|
-
import type { AnchoringMode } from '../contract/notification';
|
|
24
|
-
import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
|
|
25
|
-
import { instantForParts, partsInZone, type WallClockParts } from '../schedule/anchor';
|
|
26
|
-
|
|
27
|
-
export interface Occurrence {
|
|
28
|
-
/** Derived from the series id and the occurrence's calendar slot — never random. */
|
|
29
|
-
readonly id: string;
|
|
30
|
-
/** The calendar slot, `YYYYMMDDTHHmm`. Orders occurrences and survives a zone change. */
|
|
31
|
-
readonly key: string;
|
|
32
|
-
readonly seriesId: string;
|
|
33
|
-
readonly at: number;
|
|
34
|
-
readonly anchoring: AnchoringMode;
|
|
35
|
-
/** True when an earlier expansion already counted this occurrence. */
|
|
36
|
-
readonly previouslyEmitted: boolean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const pad = (value: number, width: number) => String(value).padStart(width, '0');
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The calendar slot of an occurrence: the date and clock time the rule asked
|
|
43
|
-
* for, in the frame the series is anchored in. Fixed-width, so comparing two
|
|
44
|
-
* keys as strings compares them in time.
|
|
45
|
-
*/
|
|
46
|
-
export function occurrenceKey(parts: WallClockParts): string {
|
|
47
|
-
return `${pad(parts.year, 4)}${pad(parts.month, 2)}${pad(parts.day, 2)}T${pad(parts.hour, 2)}${pad(parts.minute, 2)}`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Occurrence identity.
|
|
52
|
-
*
|
|
53
|
-
* Derived from the series and the calendar slot, not the instant. Re-expanding
|
|
54
|
-
* the same series produces the same identifiers, which is what lets a refresh
|
|
55
|
-
* recognize an occurrence it already scheduled. Until 0.4.0 the instant was
|
|
56
|
-
* used, so after a time-zone change re-anchoring kept the old identifier while
|
|
57
|
-
* the window refresh derived a new one, and the occurrence was scheduled twice.
|
|
58
|
-
*/
|
|
59
|
-
export function occurrenceId(seriesId: string, slot: WallClockParts | string): string {
|
|
60
|
-
return `${seriesId}#${typeof slot === 'string' ? slot : occurrenceKey(slot)}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
64
|
-
|
|
65
|
-
function validatePattern(pattern: RecurrencePattern): void {
|
|
66
|
-
const { hour, minute } = pattern;
|
|
67
|
-
|
|
68
|
-
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
|
69
|
-
throw new NotificationError(
|
|
70
|
-
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
71
|
-
'Recurrence hour must be an integer from 0 to 23.',
|
|
72
|
-
{ hour },
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
|
76
|
-
throw new NotificationError(
|
|
77
|
-
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
78
|
-
'Recurrence minute must be an integer from 0 to 59.',
|
|
79
|
-
{ minute },
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const interval = pattern.interval ?? 1;
|
|
84
|
-
if (!Number.isInteger(interval) || interval < 1) {
|
|
85
|
-
throw new NotificationError(
|
|
86
|
-
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
87
|
-
'Recurrence interval must be a positive integer.',
|
|
88
|
-
{ interval },
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (pattern.kind === 'weekly') {
|
|
93
|
-
if (pattern.weekdays.length === 0) {
|
|
94
|
-
throw new NotificationError(
|
|
95
|
-
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
96
|
-
'A weekly recurrence must name at least one weekday.',
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
for (const day of pattern.weekdays) {
|
|
100
|
-
if (!Number.isInteger(day) || day < 0 || day > 6) {
|
|
101
|
-
throw new NotificationError(
|
|
102
|
-
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
103
|
-
'Weekdays must be integers from 0 (Sunday) to 6 (Saturday).',
|
|
104
|
-
{ day },
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (pattern.kind === 'monthlyByDay') {
|
|
111
|
-
if (
|
|
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
|
-
readonly
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
readonly
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
function
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Recurrence expansion.
|
|
3
|
+
*
|
|
4
|
+
* Written first-party rather than delegated to an RFC 5545 library, because the
|
|
5
|
+
* daylight-saving rules here are specified behaviour (FR-008) rather than a
|
|
6
|
+
* dependency's opinion, and because expansion must be provable with no device.
|
|
7
|
+
*
|
|
8
|
+
* Determinism is the hard requirement: the same rule, anchoring mode, zone, and
|
|
9
|
+
* evaluation moment must produce exactly the same occurrences in the same order
|
|
10
|
+
* on every run and on both platforms. Without that, reconciliation could not be
|
|
11
|
+
* idempotent over a recurring series.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
RecurrenceRule,
|
|
16
|
+
RecurrencePattern,
|
|
17
|
+
DailyPattern,
|
|
18
|
+
WeeklyPattern,
|
|
19
|
+
MonthlyByDayPattern,
|
|
20
|
+
LookaheadConfig,
|
|
21
|
+
Weekday,
|
|
22
|
+
} from '../contract/recurrence';
|
|
23
|
+
import type { AnchoringMode } from '../contract/notification';
|
|
24
|
+
import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
|
|
25
|
+
import { instantForParts, partsInZone, type WallClockParts } from '../schedule/anchor';
|
|
26
|
+
|
|
27
|
+
export interface Occurrence {
|
|
28
|
+
/** Derived from the series id and the occurrence's calendar slot — never random. */
|
|
29
|
+
readonly id: string;
|
|
30
|
+
/** The calendar slot, `YYYYMMDDTHHmm`. Orders occurrences and survives a zone change. */
|
|
31
|
+
readonly key: string;
|
|
32
|
+
readonly seriesId: string;
|
|
33
|
+
readonly at: number;
|
|
34
|
+
readonly anchoring: AnchoringMode;
|
|
35
|
+
/** True when an earlier expansion already counted this occurrence. */
|
|
36
|
+
readonly previouslyEmitted: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pad = (value: number, width: number) => String(value).padStart(width, '0');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The calendar slot of an occurrence: the date and clock time the rule asked
|
|
43
|
+
* for, in the frame the series is anchored in. Fixed-width, so comparing two
|
|
44
|
+
* keys as strings compares them in time.
|
|
45
|
+
*/
|
|
46
|
+
export function occurrenceKey(parts: WallClockParts): string {
|
|
47
|
+
return `${pad(parts.year, 4)}${pad(parts.month, 2)}${pad(parts.day, 2)}T${pad(parts.hour, 2)}${pad(parts.minute, 2)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Occurrence identity.
|
|
52
|
+
*
|
|
53
|
+
* Derived from the series and the calendar slot, not the instant. Re-expanding
|
|
54
|
+
* the same series produces the same identifiers, which is what lets a refresh
|
|
55
|
+
* recognize an occurrence it already scheduled. Until 0.4.0 the instant was
|
|
56
|
+
* used, so after a time-zone change re-anchoring kept the old identifier while
|
|
57
|
+
* the window refresh derived a new one, and the occurrence was scheduled twice.
|
|
58
|
+
*/
|
|
59
|
+
export function occurrenceId(seriesId: string, slot: WallClockParts | string): string {
|
|
60
|
+
return `${seriesId}#${typeof slot === 'string' ? slot : occurrenceKey(slot)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
64
|
+
|
|
65
|
+
function validatePattern(pattern: RecurrencePattern): void {
|
|
66
|
+
const { hour, minute } = pattern;
|
|
67
|
+
|
|
68
|
+
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
|
69
|
+
throw new NotificationError(
|
|
70
|
+
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
71
|
+
'Recurrence hour must be an integer from 0 to 23.',
|
|
72
|
+
{ hour },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
|
76
|
+
throw new NotificationError(
|
|
77
|
+
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
78
|
+
'Recurrence minute must be an integer from 0 to 59.',
|
|
79
|
+
{ minute },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const interval = pattern.interval ?? 1;
|
|
84
|
+
if (!Number.isInteger(interval) || interval < 1) {
|
|
85
|
+
throw new NotificationError(
|
|
86
|
+
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
87
|
+
'Recurrence interval must be a positive integer.',
|
|
88
|
+
{ interval },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (pattern.kind === 'weekly') {
|
|
93
|
+
if (pattern.weekdays.length === 0) {
|
|
94
|
+
throw new NotificationError(
|
|
95
|
+
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
96
|
+
'A weekly recurrence must name at least one weekday.',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
for (const day of pattern.weekdays) {
|
|
100
|
+
if (!Number.isInteger(day) || day < 0 || day > 6) {
|
|
101
|
+
throw new NotificationError(
|
|
102
|
+
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
103
|
+
'Weekdays must be integers from 0 (Sunday) to 6 (Saturday).',
|
|
104
|
+
{ day },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (pattern.kind === 'monthlyByDay') {
|
|
111
|
+
if (
|
|
112
|
+
!Number.isInteger(pattern.dayOfMonth) ||
|
|
113
|
+
pattern.dayOfMonth < 1 ||
|
|
114
|
+
pattern.dayOfMonth > 31
|
|
115
|
+
) {
|
|
116
|
+
throw new NotificationError(
|
|
117
|
+
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
118
|
+
'Day of month must be an integer from 1 to 31.',
|
|
119
|
+
{ dayOfMonth: pattern.dayOfMonth },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Calendar date in the anchoring zone, carrying the pattern's clock time. */
|
|
126
|
+
function partsFor(
|
|
127
|
+
date: { year: number; month: number; day: number },
|
|
128
|
+
pattern: RecurrencePattern,
|
|
129
|
+
): WallClockParts {
|
|
130
|
+
return {
|
|
131
|
+
year: date.year,
|
|
132
|
+
month: date.month,
|
|
133
|
+
day: date.day,
|
|
134
|
+
hour: pattern.hour,
|
|
135
|
+
minute: pattern.minute,
|
|
136
|
+
second: 0,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function addDays(parts: WallClockParts, days: number): WallClockParts {
|
|
141
|
+
const shifted = new Date(Date.UTC(parts.year, parts.month - 1, parts.day) + days * MS_PER_DAY);
|
|
142
|
+
return {
|
|
143
|
+
year: shifted.getUTCFullYear(),
|
|
144
|
+
month: shifted.getUTCMonth() + 1,
|
|
145
|
+
day: shifted.getUTCDate(),
|
|
146
|
+
hour: parts.hour,
|
|
147
|
+
minute: parts.minute,
|
|
148
|
+
second: 0,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function weekdayOf(parts: WallClockParts): Weekday {
|
|
153
|
+
return new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay() as Weekday;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function daysInMonth(year: number, month: number): number {
|
|
157
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface ExpandInput {
|
|
161
|
+
readonly rule: RecurrenceRule;
|
|
162
|
+
/** Occurrences strictly after this instant are produced. */
|
|
163
|
+
readonly from: number;
|
|
164
|
+
readonly lookahead: LookaheadConfig;
|
|
165
|
+
/** Zone the series is anchored against. Ignored for absolute anchoring. */
|
|
166
|
+
readonly timeZone: string;
|
|
167
|
+
/** How many occurrences the series has already emitted, for count-limited rules. */
|
|
168
|
+
readonly alreadyEmitted?: number;
|
|
169
|
+
/**
|
|
170
|
+
* Key of the latest occurrence an earlier expansion emitted. Occurrences at or
|
|
171
|
+
* before it are produced again — they are still in the window — but are not
|
|
172
|
+
* counted again. Counting them on every refresh is what ended count-limited
|
|
173
|
+
* series early.
|
|
174
|
+
*/
|
|
175
|
+
readonly lastEmittedKey?: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface ExpandResult {
|
|
179
|
+
readonly occurrences: readonly Occurrence[];
|
|
180
|
+
/** The horizon expansion reached. */
|
|
181
|
+
readonly expandedThrough: number;
|
|
182
|
+
/** True when the series produced its final occurrence. */
|
|
183
|
+
readonly exhausted: boolean;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Expands a rule inside the lookahead window.
|
|
188
|
+
*
|
|
189
|
+
* Bounded by both a duration and a count, because either alone fails: a
|
|
190
|
+
* duration alone lets a frequent series exhaust the platform ceiling, and a
|
|
191
|
+
* count alone leaves an annual series scheduled a decade out.
|
|
192
|
+
*/
|
|
193
|
+
export function expand(input: ExpandInput): ExpandResult {
|
|
194
|
+
const { rule, from, lookahead, timeZone } = input;
|
|
195
|
+
validatePattern(rule.pattern);
|
|
196
|
+
|
|
197
|
+
const anchoring = rule.anchoring ?? 'wallClock';
|
|
198
|
+
const horizon = from + lookahead.days * MS_PER_DAY;
|
|
199
|
+
const start = Math.max(rule.startAt ?? from, from);
|
|
200
|
+
const { lastEmittedKey } = input;
|
|
201
|
+
|
|
202
|
+
const occurrences: Occurrence[] = [];
|
|
203
|
+
let cursor = partsFor(partsInZone(start, timeZone), rule.pattern);
|
|
204
|
+
// Position of the next not-yet-counted occurrence, for count-limited rules.
|
|
205
|
+
let index = input.alreadyEmitted ?? 0;
|
|
206
|
+
let exhausted = false;
|
|
207
|
+
|
|
208
|
+
// Bounded independently of the emit conditions so a pattern that matches
|
|
209
|
+
// nothing (e.g. day 31 in February) can never spin forever.
|
|
210
|
+
const maxIterations = lookahead.days * 2 + 400;
|
|
211
|
+
|
|
212
|
+
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
|
|
213
|
+
if (occurrences.length >= lookahead.occurrencesPerSeries) break;
|
|
214
|
+
|
|
215
|
+
const candidate = nextCandidate(cursor, rule.pattern, iteration === 0);
|
|
216
|
+
if (candidate === undefined) break;
|
|
217
|
+
cursor = candidate;
|
|
218
|
+
|
|
219
|
+
const at =
|
|
220
|
+
anchoring === 'absolute'
|
|
221
|
+
? Date.UTC(cursor.year, cursor.month - 1, cursor.day, cursor.hour, cursor.minute, 0)
|
|
222
|
+
: instantForParts(cursor, timeZone);
|
|
223
|
+
|
|
224
|
+
if (at > horizon) break;
|
|
225
|
+
|
|
226
|
+
if (at > start) {
|
|
227
|
+
if (rule.end.kind === 'untilDate' && at > rule.end.until) {
|
|
228
|
+
exhausted = true;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
const key = occurrenceKey(cursor);
|
|
232
|
+
const previouslyEmitted = lastEmittedKey !== undefined && key <= lastEmittedKey;
|
|
233
|
+
if (!previouslyEmitted && rule.end.kind === 'count' && index >= rule.end.count) {
|
|
234
|
+
exhausted = true;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
occurrences.push({
|
|
239
|
+
id: occurrenceId(rule.id, key),
|
|
240
|
+
key,
|
|
241
|
+
seriesId: rule.id,
|
|
242
|
+
at,
|
|
243
|
+
anchoring,
|
|
244
|
+
previouslyEmitted,
|
|
245
|
+
});
|
|
246
|
+
if (!previouslyEmitted) index += 1;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
cursor = advance(cursor, rule.pattern);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
occurrences,
|
|
254
|
+
expandedThrough: horizon,
|
|
255
|
+
exhausted,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Moves the cursor onto the first date the pattern actually matches. */
|
|
260
|
+
function nextCandidate(
|
|
261
|
+
cursor: WallClockParts,
|
|
262
|
+
pattern: RecurrencePattern,
|
|
263
|
+
isFirst: boolean,
|
|
264
|
+
): WallClockParts | undefined {
|
|
265
|
+
if (pattern.kind === 'daily') return cursor;
|
|
266
|
+
|
|
267
|
+
if (pattern.kind === 'weekly') {
|
|
268
|
+
const wanted = new Set((pattern as WeeklyPattern).weekdays);
|
|
269
|
+
let candidate = cursor;
|
|
270
|
+
for (let step = 0; step < 14; step += 1) {
|
|
271
|
+
if (wanted.has(weekdayOf(candidate))) return candidate;
|
|
272
|
+
candidate = addDays(candidate, 1);
|
|
273
|
+
}
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const monthly = pattern as MonthlyByDayPattern;
|
|
278
|
+
let candidate = cursor;
|
|
279
|
+
// Months shorter than the requested day are skipped rather than clamped: a
|
|
280
|
+
// caller asking for the 31st means the 31st, and inventing the 28th would be
|
|
281
|
+
// the package deciding something it was not asked to decide.
|
|
282
|
+
for (let step = 0; step < 48; step += 1) {
|
|
283
|
+
if (daysInMonth(candidate.year, candidate.month) >= monthly.dayOfMonth) {
|
|
284
|
+
const onDay = { ...candidate, day: monthly.dayOfMonth };
|
|
285
|
+
if (isFirst || onDay.day >= candidate.day || step > 0) return onDay;
|
|
286
|
+
}
|
|
287
|
+
candidate = firstOfNextMonth(candidate);
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function firstOfNextMonth(parts: WallClockParts): WallClockParts {
|
|
293
|
+
const month = parts.month === 12 ? 1 : parts.month + 1;
|
|
294
|
+
const year = parts.month === 12 ? parts.year + 1 : parts.year;
|
|
295
|
+
return { ...parts, year, month, day: 1 };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Steps the cursor past the occurrence just produced. */
|
|
299
|
+
function advance(cursor: WallClockParts, pattern: RecurrencePattern): WallClockParts {
|
|
300
|
+
const interval = pattern.interval ?? 1;
|
|
301
|
+
|
|
302
|
+
if (pattern.kind === 'daily') {
|
|
303
|
+
return addDays(cursor, (pattern as DailyPattern).interval ?? 1);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (pattern.kind === 'weekly') {
|
|
307
|
+
const wanted = [...new Set((pattern as WeeklyPattern).weekdays)].sort((a, b) => a - b);
|
|
308
|
+
const current = weekdayOf(cursor);
|
|
309
|
+
const next = wanted.find(day => day > current);
|
|
310
|
+
|
|
311
|
+
// Wrapping into a new week is where the interval applies — stepping day by
|
|
312
|
+
// day within the week would multiply the interval by the weekday count.
|
|
313
|
+
return next === undefined
|
|
314
|
+
? addDays(cursor, 7 * interval - current + wanted[0])
|
|
315
|
+
: addDays(cursor, next - current);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const monthly = pattern as MonthlyByDayPattern;
|
|
319
|
+
let year = cursor.year;
|
|
320
|
+
let month = cursor.month + interval;
|
|
321
|
+
while (month > 12) {
|
|
322
|
+
month -= 12;
|
|
323
|
+
year += 1;
|
|
324
|
+
}
|
|
325
|
+
return { ...cursor, year, month, day: Math.min(monthly.dayOfMonth, daysInMonth(year, month)) };
|
|
326
|
+
}
|