@chipmobilesdk/rn-notification 0.1.1 → 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.
Files changed (71) hide show
  1. package/CHANGELOG.md +80 -42
  2. package/README.md +399 -306
  3. package/package.json +2 -1
  4. package/skills/sdk-rn-notification/SKILL.md +68 -3
  5. package/src/channel/migrate.ts +86 -12
  6. package/src/channel/tone-map.ts +130 -90
  7. package/src/channel/validate-tone.ts +61 -0
  8. package/src/contract/channel.ts +188 -89
  9. package/src/contract/diagnostics.ts +102 -102
  10. package/src/contract/errors.ts +130 -123
  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 +60 -60
  15. package/src/contract/reconcile.ts +88 -88
  16. package/src/contract/recurrence.ts +123 -123
  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 +64 -64
  21. package/src/engine/adapter.ts +172 -79
  22. package/src/engine/notify-kit-engine.ts +761 -535
  23. package/src/index.ts +35 -0
  24. package/src/initialize.ts +174 -174
  25. package/src/interaction/index.ts +146 -146
  26. package/src/maintenance/cycle.ts +147 -147
  27. package/src/maintenance/permission-refresh.ts +41 -41
  28. package/src/maintenance/reanchor.ts +95 -95
  29. package/src/maintenance/toggle.ts +60 -60
  30. package/src/maintenance/verify.ts +83 -83
  31. package/src/maintenance/window-refresh.ts +61 -61
  32. package/src/permission/index.ts +183 -183
  33. package/src/permission/state.ts +77 -77
  34. package/src/push/envelope.ts +165 -165
  35. package/src/push/fcm-provider.ts +146 -146
  36. package/src/push/gate.ts +190 -190
  37. package/src/push/instructions.ts +109 -109
  38. package/src/push/provider-adapter.ts +54 -54
  39. package/src/push/signout.ts +65 -65
  40. package/src/reconcile/chunk.ts +53 -53
  41. package/src/reconcile/diff.ts +145 -145
  42. package/src/reconcile/duplicates.ts +73 -73
  43. package/src/reconcile/index.ts +14 -1
  44. package/src/reconcile/truncate.ts +56 -56
  45. package/src/recurrence/clock-guard.ts +71 -71
  46. package/src/recurrence/expand.ts +292 -292
  47. package/src/repeat/expiry.ts +55 -0
  48. package/src/repeat/index.ts +54 -0
  49. package/src/repeat/marker.ts +72 -0
  50. package/src/repeat/place.ts +73 -0
  51. package/src/schedule/anchor.ts +267 -267
  52. package/src/schedule/index.ts +184 -173
  53. package/src/schedule/partition.ts +85 -85
  54. package/src/schedule/resolve-moment.ts +80 -80
  55. package/src/schedule/validate-content.ts +111 -111
  56. package/src/schedule/validate-identifier.ts +126 -114
  57. package/src/state/caps.ts +71 -71
  58. package/src/state/dedup-ledger.ts +35 -39
  59. package/src/state/interaction-buffer.ts +73 -73
  60. package/src/state/mmkv-store.ts +71 -71
  61. package/src/state/permission-history.ts +41 -41
  62. package/src/state/record.ts +258 -246
  63. package/src/state/reset.ts +41 -41
  64. package/src/state/series-ledger.ts +84 -84
  65. package/src/state/store.ts +40 -40
  66. package/src/testing/conformance/index.ts +248 -194
  67. package/src/testing/fake-engine.ts +469 -284
  68. package/src/testing/fake-provider.ts +129 -129
  69. package/src/testing/faults.ts +72 -72
  70. package/src/testing/index.ts +2 -0
  71. package/src/testing/measure.ts +337 -0
@@ -1,292 +1,292 @@
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 instant — never random. */
29
- readonly id: string;
30
- readonly seriesId: string;
31
- readonly at: number;
32
- readonly anchoring: AnchoringMode;
33
- readonly index: number;
34
- }
35
-
36
- /**
37
- * Occurrence identity.
38
- *
39
- * Derived from the series and the instant so re-expanding the same series
40
- * produces the same identifiers — which is what lets reconciliation recognize
41
- * an occurrence it already scheduled instead of creating a duplicate.
42
- */
43
- export function occurrenceId(seriesId: string, at: number): string {
44
- return `${seriesId}#${at}`;
45
- }
46
-
47
- const MS_PER_DAY = 24 * 60 * 60 * 1000;
48
-
49
- function validatePattern(pattern: RecurrencePattern): void {
50
- const { hour, minute } = pattern;
51
-
52
- if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
53
- throw new NotificationError(
54
- DIAGNOSTIC_CODES.INVALID_RECURRENCE,
55
- 'Recurrence hour must be an integer from 0 to 23.',
56
- { hour },
57
- );
58
- }
59
- if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
60
- throw new NotificationError(
61
- DIAGNOSTIC_CODES.INVALID_RECURRENCE,
62
- 'Recurrence minute must be an integer from 0 to 59.',
63
- { minute },
64
- );
65
- }
66
-
67
- const interval = pattern.interval ?? 1;
68
- if (!Number.isInteger(interval) || interval < 1) {
69
- throw new NotificationError(
70
- DIAGNOSTIC_CODES.INVALID_RECURRENCE,
71
- 'Recurrence interval must be a positive integer.',
72
- { interval },
73
- );
74
- }
75
-
76
- if (pattern.kind === 'weekly') {
77
- if (pattern.weekdays.length === 0) {
78
- throw new NotificationError(
79
- DIAGNOSTIC_CODES.INVALID_RECURRENCE,
80
- 'A weekly recurrence must name at least one weekday.',
81
- );
82
- }
83
- for (const day of pattern.weekdays) {
84
- if (!Number.isInteger(day) || day < 0 || day > 6) {
85
- throw new NotificationError(
86
- DIAGNOSTIC_CODES.INVALID_RECURRENCE,
87
- 'Weekdays must be integers from 0 (Sunday) to 6 (Saturday).',
88
- { day },
89
- );
90
- }
91
- }
92
- }
93
-
94
- if (pattern.kind === 'monthlyByDay') {
95
- if (!Number.isInteger(pattern.dayOfMonth) || pattern.dayOfMonth < 1 || pattern.dayOfMonth > 31) {
96
- throw new NotificationError(
97
- DIAGNOSTIC_CODES.INVALID_RECURRENCE,
98
- 'Day of month must be an integer from 1 to 31.',
99
- { dayOfMonth: pattern.dayOfMonth },
100
- );
101
- }
102
- }
103
- }
104
-
105
- /** Calendar date in the anchoring zone, carrying the pattern's clock time. */
106
- function partsFor(date: { year: number; month: number; day: number }, pattern: RecurrencePattern): WallClockParts {
107
- return {
108
- year: date.year,
109
- month: date.month,
110
- day: date.day,
111
- hour: pattern.hour,
112
- minute: pattern.minute,
113
- second: 0,
114
- };
115
- }
116
-
117
- function addDays(parts: WallClockParts, days: number): WallClockParts {
118
- const shifted = new Date(Date.UTC(parts.year, parts.month - 1, parts.day) + days * MS_PER_DAY);
119
- return {
120
- year: shifted.getUTCFullYear(),
121
- month: shifted.getUTCMonth() + 1,
122
- day: shifted.getUTCDate(),
123
- hour: parts.hour,
124
- minute: parts.minute,
125
- second: 0,
126
- };
127
- }
128
-
129
- function weekdayOf(parts: WallClockParts): Weekday {
130
- return new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay() as Weekday;
131
- }
132
-
133
- function daysInMonth(year: number, month: number): number {
134
- return new Date(Date.UTC(year, month, 0)).getUTCDate();
135
- }
136
-
137
- export interface ExpandInput {
138
- readonly rule: RecurrenceRule;
139
- /** Occurrences strictly after this instant are produced. */
140
- readonly from: number;
141
- readonly lookahead: LookaheadConfig;
142
- /** Zone the series is anchored against. Ignored for absolute anchoring. */
143
- readonly timeZone: string;
144
- /** How many occurrences the series has already emitted, for count-limited rules. */
145
- readonly alreadyEmitted?: number;
146
- }
147
-
148
- export interface ExpandResult {
149
- readonly occurrences: readonly Occurrence[];
150
- /** The horizon expansion reached. */
151
- readonly expandedThrough: number;
152
- /** True when the series produced its final occurrence. */
153
- readonly exhausted: boolean;
154
- }
155
-
156
- /**
157
- * Expands a rule inside the lookahead window.
158
- *
159
- * Bounded by both a duration and a count, because either alone fails: a
160
- * duration alone lets a frequent series exhaust the platform ceiling, and a
161
- * count alone leaves an annual series scheduled a decade out.
162
- */
163
- export function expand(input: ExpandInput): ExpandResult {
164
- const { rule, from, lookahead, timeZone } = input;
165
- validatePattern(rule.pattern);
166
-
167
- const anchoring = rule.anchoring ?? 'wallClock';
168
- const horizon = from + lookahead.days * MS_PER_DAY;
169
- const start = Math.max(rule.startAt ?? from, from);
170
- const alreadyEmitted = input.alreadyEmitted ?? 0;
171
-
172
- const occurrences: Occurrence[] = [];
173
- let cursor = partsFor(partsInZone(start, timeZone), rule.pattern);
174
- let index = alreadyEmitted;
175
- let exhausted = false;
176
-
177
- // Bounded independently of the emit conditions so a pattern that matches
178
- // nothing (e.g. day 31 in February) can never spin forever.
179
- const maxIterations = lookahead.days * 2 + 400;
180
-
181
- for (let iteration = 0; iteration < maxIterations; iteration += 1) {
182
- if (occurrences.length >= lookahead.occurrencesPerSeries) break;
183
-
184
- const candidate = nextCandidate(cursor, rule.pattern, iteration === 0);
185
- if (candidate === undefined) break;
186
- cursor = candidate;
187
-
188
- const at =
189
- anchoring === 'absolute'
190
- ? Date.UTC(cursor.year, cursor.month - 1, cursor.day, cursor.hour, cursor.minute, 0)
191
- : instantForParts(cursor, timeZone);
192
-
193
- if (at > horizon) break;
194
-
195
- if (at > start) {
196
- if (rule.end.kind === 'untilDate' && at > rule.end.until) {
197
- exhausted = true;
198
- break;
199
- }
200
- if (rule.end.kind === 'count' && index >= rule.end.count) {
201
- exhausted = true;
202
- break;
203
- }
204
-
205
- occurrences.push({
206
- id: occurrenceId(rule.id, at),
207
- seriesId: rule.id,
208
- at,
209
- anchoring,
210
- index,
211
- });
212
- index += 1;
213
- }
214
-
215
- cursor = advance(cursor, rule.pattern);
216
- }
217
-
218
- return {
219
- occurrences,
220
- expandedThrough: horizon,
221
- exhausted,
222
- };
223
- }
224
-
225
- /** Moves the cursor onto the first date the pattern actually matches. */
226
- function nextCandidate(
227
- cursor: WallClockParts,
228
- pattern: RecurrencePattern,
229
- isFirst: boolean,
230
- ): WallClockParts | undefined {
231
- if (pattern.kind === 'daily') return cursor;
232
-
233
- if (pattern.kind === 'weekly') {
234
- const wanted = new Set((pattern as WeeklyPattern).weekdays);
235
- let candidate = cursor;
236
- for (let step = 0; step < 14; step += 1) {
237
- if (wanted.has(weekdayOf(candidate))) return candidate;
238
- candidate = addDays(candidate, 1);
239
- }
240
- return undefined;
241
- }
242
-
243
- const monthly = pattern as MonthlyByDayPattern;
244
- let candidate = cursor;
245
- // Months shorter than the requested day are skipped rather than clamped: a
246
- // caller asking for the 31st means the 31st, and inventing the 28th would be
247
- // the package deciding something it was not asked to decide.
248
- for (let step = 0; step < 48; step += 1) {
249
- if (daysInMonth(candidate.year, candidate.month) >= monthly.dayOfMonth) {
250
- const onDay = { ...candidate, day: monthly.dayOfMonth };
251
- if (isFirst || onDay.day >= candidate.day || step > 0) return onDay;
252
- }
253
- candidate = firstOfNextMonth(candidate);
254
- }
255
- return undefined;
256
- }
257
-
258
- function firstOfNextMonth(parts: WallClockParts): WallClockParts {
259
- const month = parts.month === 12 ? 1 : parts.month + 1;
260
- const year = parts.month === 12 ? parts.year + 1 : parts.year;
261
- return { ...parts, year, month, day: 1 };
262
- }
263
-
264
- /** Steps the cursor past the occurrence just produced. */
265
- function advance(cursor: WallClockParts, pattern: RecurrencePattern): WallClockParts {
266
- const interval = pattern.interval ?? 1;
267
-
268
- if (pattern.kind === 'daily') {
269
- return addDays(cursor, (pattern as DailyPattern).interval ?? 1);
270
- }
271
-
272
- if (pattern.kind === 'weekly') {
273
- const wanted = [...new Set((pattern as WeeklyPattern).weekdays)].sort((a, b) => a - b);
274
- const current = weekdayOf(cursor);
275
- const next = wanted.find(day => day > current);
276
-
277
- // Wrapping into a new week is where the interval applies — stepping day by
278
- // day within the week would multiply the interval by the weekday count.
279
- return next === undefined
280
- ? addDays(cursor, 7 * interval - current + wanted[0])
281
- : addDays(cursor, next - current);
282
- }
283
-
284
- const monthly = pattern as MonthlyByDayPattern;
285
- let year = cursor.year;
286
- let month = cursor.month + interval;
287
- while (month > 12) {
288
- month -= 12;
289
- year += 1;
290
- }
291
- return { ...cursor, year, month, day: Math.min(monthly.dayOfMonth, daysInMonth(year, month)) };
292
- }
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 instant — never random. */
29
+ readonly id: string;
30
+ readonly seriesId: string;
31
+ readonly at: number;
32
+ readonly anchoring: AnchoringMode;
33
+ readonly index: number;
34
+ }
35
+
36
+ /**
37
+ * Occurrence identity.
38
+ *
39
+ * Derived from the series and the instant so re-expanding the same series
40
+ * produces the same identifiers — which is what lets reconciliation recognize
41
+ * an occurrence it already scheduled instead of creating a duplicate.
42
+ */
43
+ export function occurrenceId(seriesId: string, at: number): string {
44
+ return `${seriesId}#${at}`;
45
+ }
46
+
47
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
48
+
49
+ function validatePattern(pattern: RecurrencePattern): void {
50
+ const { hour, minute } = pattern;
51
+
52
+ if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
53
+ throw new NotificationError(
54
+ DIAGNOSTIC_CODES.INVALID_RECURRENCE,
55
+ 'Recurrence hour must be an integer from 0 to 23.',
56
+ { hour },
57
+ );
58
+ }
59
+ if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
60
+ throw new NotificationError(
61
+ DIAGNOSTIC_CODES.INVALID_RECURRENCE,
62
+ 'Recurrence minute must be an integer from 0 to 59.',
63
+ { minute },
64
+ );
65
+ }
66
+
67
+ const interval = pattern.interval ?? 1;
68
+ if (!Number.isInteger(interval) || interval < 1) {
69
+ throw new NotificationError(
70
+ DIAGNOSTIC_CODES.INVALID_RECURRENCE,
71
+ 'Recurrence interval must be a positive integer.',
72
+ { interval },
73
+ );
74
+ }
75
+
76
+ if (pattern.kind === 'weekly') {
77
+ if (pattern.weekdays.length === 0) {
78
+ throw new NotificationError(
79
+ DIAGNOSTIC_CODES.INVALID_RECURRENCE,
80
+ 'A weekly recurrence must name at least one weekday.',
81
+ );
82
+ }
83
+ for (const day of pattern.weekdays) {
84
+ if (!Number.isInteger(day) || day < 0 || day > 6) {
85
+ throw new NotificationError(
86
+ DIAGNOSTIC_CODES.INVALID_RECURRENCE,
87
+ 'Weekdays must be integers from 0 (Sunday) to 6 (Saturday).',
88
+ { day },
89
+ );
90
+ }
91
+ }
92
+ }
93
+
94
+ if (pattern.kind === 'monthlyByDay') {
95
+ if (!Number.isInteger(pattern.dayOfMonth) || pattern.dayOfMonth < 1 || pattern.dayOfMonth > 31) {
96
+ throw new NotificationError(
97
+ DIAGNOSTIC_CODES.INVALID_RECURRENCE,
98
+ 'Day of month must be an integer from 1 to 31.',
99
+ { dayOfMonth: pattern.dayOfMonth },
100
+ );
101
+ }
102
+ }
103
+ }
104
+
105
+ /** Calendar date in the anchoring zone, carrying the pattern's clock time. */
106
+ function partsFor(date: { year: number; month: number; day: number }, pattern: RecurrencePattern): WallClockParts {
107
+ return {
108
+ year: date.year,
109
+ month: date.month,
110
+ day: date.day,
111
+ hour: pattern.hour,
112
+ minute: pattern.minute,
113
+ second: 0,
114
+ };
115
+ }
116
+
117
+ function addDays(parts: WallClockParts, days: number): WallClockParts {
118
+ const shifted = new Date(Date.UTC(parts.year, parts.month - 1, parts.day) + days * MS_PER_DAY);
119
+ return {
120
+ year: shifted.getUTCFullYear(),
121
+ month: shifted.getUTCMonth() + 1,
122
+ day: shifted.getUTCDate(),
123
+ hour: parts.hour,
124
+ minute: parts.minute,
125
+ second: 0,
126
+ };
127
+ }
128
+
129
+ function weekdayOf(parts: WallClockParts): Weekday {
130
+ return new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay() as Weekday;
131
+ }
132
+
133
+ function daysInMonth(year: number, month: number): number {
134
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
135
+ }
136
+
137
+ export interface ExpandInput {
138
+ readonly rule: RecurrenceRule;
139
+ /** Occurrences strictly after this instant are produced. */
140
+ readonly from: number;
141
+ readonly lookahead: LookaheadConfig;
142
+ /** Zone the series is anchored against. Ignored for absolute anchoring. */
143
+ readonly timeZone: string;
144
+ /** How many occurrences the series has already emitted, for count-limited rules. */
145
+ readonly alreadyEmitted?: number;
146
+ }
147
+
148
+ export interface ExpandResult {
149
+ readonly occurrences: readonly Occurrence[];
150
+ /** The horizon expansion reached. */
151
+ readonly expandedThrough: number;
152
+ /** True when the series produced its final occurrence. */
153
+ readonly exhausted: boolean;
154
+ }
155
+
156
+ /**
157
+ * Expands a rule inside the lookahead window.
158
+ *
159
+ * Bounded by both a duration and a count, because either alone fails: a
160
+ * duration alone lets a frequent series exhaust the platform ceiling, and a
161
+ * count alone leaves an annual series scheduled a decade out.
162
+ */
163
+ export function expand(input: ExpandInput): ExpandResult {
164
+ const { rule, from, lookahead, timeZone } = input;
165
+ validatePattern(rule.pattern);
166
+
167
+ const anchoring = rule.anchoring ?? 'wallClock';
168
+ const horizon = from + lookahead.days * MS_PER_DAY;
169
+ const start = Math.max(rule.startAt ?? from, from);
170
+ const alreadyEmitted = input.alreadyEmitted ?? 0;
171
+
172
+ const occurrences: Occurrence[] = [];
173
+ let cursor = partsFor(partsInZone(start, timeZone), rule.pattern);
174
+ let index = alreadyEmitted;
175
+ let exhausted = false;
176
+
177
+ // Bounded independently of the emit conditions so a pattern that matches
178
+ // nothing (e.g. day 31 in February) can never spin forever.
179
+ const maxIterations = lookahead.days * 2 + 400;
180
+
181
+ for (let iteration = 0; iteration < maxIterations; iteration += 1) {
182
+ if (occurrences.length >= lookahead.occurrencesPerSeries) break;
183
+
184
+ const candidate = nextCandidate(cursor, rule.pattern, iteration === 0);
185
+ if (candidate === undefined) break;
186
+ cursor = candidate;
187
+
188
+ const at =
189
+ anchoring === 'absolute'
190
+ ? Date.UTC(cursor.year, cursor.month - 1, cursor.day, cursor.hour, cursor.minute, 0)
191
+ : instantForParts(cursor, timeZone);
192
+
193
+ if (at > horizon) break;
194
+
195
+ if (at > start) {
196
+ if (rule.end.kind === 'untilDate' && at > rule.end.until) {
197
+ exhausted = true;
198
+ break;
199
+ }
200
+ if (rule.end.kind === 'count' && index >= rule.end.count) {
201
+ exhausted = true;
202
+ break;
203
+ }
204
+
205
+ occurrences.push({
206
+ id: occurrenceId(rule.id, at),
207
+ seriesId: rule.id,
208
+ at,
209
+ anchoring,
210
+ index,
211
+ });
212
+ index += 1;
213
+ }
214
+
215
+ cursor = advance(cursor, rule.pattern);
216
+ }
217
+
218
+ return {
219
+ occurrences,
220
+ expandedThrough: horizon,
221
+ exhausted,
222
+ };
223
+ }
224
+
225
+ /** Moves the cursor onto the first date the pattern actually matches. */
226
+ function nextCandidate(
227
+ cursor: WallClockParts,
228
+ pattern: RecurrencePattern,
229
+ isFirst: boolean,
230
+ ): WallClockParts | undefined {
231
+ if (pattern.kind === 'daily') return cursor;
232
+
233
+ if (pattern.kind === 'weekly') {
234
+ const wanted = new Set((pattern as WeeklyPattern).weekdays);
235
+ let candidate = cursor;
236
+ for (let step = 0; step < 14; step += 1) {
237
+ if (wanted.has(weekdayOf(candidate))) return candidate;
238
+ candidate = addDays(candidate, 1);
239
+ }
240
+ return undefined;
241
+ }
242
+
243
+ const monthly = pattern as MonthlyByDayPattern;
244
+ let candidate = cursor;
245
+ // Months shorter than the requested day are skipped rather than clamped: a
246
+ // caller asking for the 31st means the 31st, and inventing the 28th would be
247
+ // the package deciding something it was not asked to decide.
248
+ for (let step = 0; step < 48; step += 1) {
249
+ if (daysInMonth(candidate.year, candidate.month) >= monthly.dayOfMonth) {
250
+ const onDay = { ...candidate, day: monthly.dayOfMonth };
251
+ if (isFirst || onDay.day >= candidate.day || step > 0) return onDay;
252
+ }
253
+ candidate = firstOfNextMonth(candidate);
254
+ }
255
+ return undefined;
256
+ }
257
+
258
+ function firstOfNextMonth(parts: WallClockParts): WallClockParts {
259
+ const month = parts.month === 12 ? 1 : parts.month + 1;
260
+ const year = parts.month === 12 ? parts.year + 1 : parts.year;
261
+ return { ...parts, year, month, day: 1 };
262
+ }
263
+
264
+ /** Steps the cursor past the occurrence just produced. */
265
+ function advance(cursor: WallClockParts, pattern: RecurrencePattern): WallClockParts {
266
+ const interval = pattern.interval ?? 1;
267
+
268
+ if (pattern.kind === 'daily') {
269
+ return addDays(cursor, (pattern as DailyPattern).interval ?? 1);
270
+ }
271
+
272
+ if (pattern.kind === 'weekly') {
273
+ const wanted = [...new Set((pattern as WeeklyPattern).weekdays)].sort((a, b) => a - b);
274
+ const current = weekdayOf(cursor);
275
+ const next = wanted.find(day => day > current);
276
+
277
+ // Wrapping into a new week is where the interval applies — stepping day by
278
+ // day within the week would multiply the interval by the weekday count.
279
+ return next === undefined
280
+ ? addDays(cursor, 7 * interval - current + wanted[0])
281
+ : addDays(cursor, next - current);
282
+ }
283
+
284
+ const monthly = pattern as MonthlyByDayPattern;
285
+ let year = cursor.year;
286
+ let month = cursor.month + interval;
287
+ while (month > 12) {
288
+ month -= 12;
289
+ year += 1;
290
+ }
291
+ return { ...cursor, year, month, day: Math.min(monthly.dayOfMonth, daysInMonth(year, month)) };
292
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Repeat expiry.
3
+ *
4
+ * When a stop marker is delivered, the alert it guards has run its declared
5
+ * course. This handler ends the sound and leaves the notification exactly where
6
+ * it is, then removes the marker.
7
+ *
8
+ * Nothing here imports the engine. It talks to the port, which is what lets the
9
+ * whole mechanism be proven on a virtual clock with no device.
10
+ */
11
+
12
+ import type { DeliveryEvent, NotificationEngineAdapter } from '../engine/adapter';
13
+
14
+ export interface RepeatExpiryDependencies {
15
+ readonly engine: NotificationEngineAdapter;
16
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
17
+ }
18
+
19
+ export interface RepeatExpiryHandler {
20
+ /** Reacts to one delivery. Ignores anything that is not a stop marker. */
21
+ handle(event: DeliveryEvent): Promise<void>;
22
+ /** Removes the marker guarding a target, when the target is cancelled or replaced. */
23
+ release(targetId: string): Promise<void>;
24
+ }
25
+
26
+ export function createRepeatExpiryHandler(
27
+ deps: RepeatExpiryDependencies,
28
+ ): RepeatExpiryHandler {
29
+ return {
30
+ async handle(event: DeliveryEvent): Promise<void> {
31
+ const stop = event.repeatStop;
32
+ if (stop === undefined) return;
33
+
34
+ // Order matters. Silence first, remove the marker second: if the
35
+ // process dies between the two, a marker left behind is harmless
36
+ // noise, whereas a device left sounding is the failure this exists to
37
+ // prevent.
38
+ //
39
+ // `silenceDelivered` returning false is the ordinary case, not an
40
+ // error — it means the user already handled the notification. Checking
41
+ // the tray rather than racing to cancel the marker on interaction is
42
+ // what makes early handling correct by construction: even if the marker
43
+ // fires, there is nothing left to act on.
44
+ await deps.engine.silenceDelivered(stop.targetId);
45
+ await deps.engine.cancel(stop.markerId);
46
+ },
47
+
48
+ async release(targetId: string): Promise<void> {
49
+ const outstanding = await deps.engine.listRepeatStops();
50
+ const marker = outstanding.find(stop => stop.targetId === targetId);
51
+ if (marker === undefined) return;
52
+ await deps.engine.cancel(marker.markerId);
53
+ },
54
+ };
55
+ }