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