@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.
- 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 +35 -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/dedup-ledger.ts +35 -39
- 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/index.ts +2 -0
- package/src/testing/measure.ts +337 -0
package/src/recurrence/expand.ts
CHANGED
|
@@ -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
|
+
}
|