@j13b/scheduler 0.0.0-stage → 0.1.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 (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -2
  3. package/dist/alterations.d.ts +6 -0
  4. package/dist/alterations.d.ts.map +1 -0
  5. package/dist/alterations.js +61 -0
  6. package/dist/alterations.js.map +1 -0
  7. package/dist/cjs/alterations.d.ts +5 -0
  8. package/dist/cjs/alterations.js +96 -0
  9. package/dist/cjs/dispatcher.d.ts +38 -0
  10. package/dist/cjs/dispatcher.js +165 -0
  11. package/dist/cjs/dispatcher_store.d.ts +9 -0
  12. package/dist/cjs/dispatcher_store.js +15 -0
  13. package/dist/cjs/generators/anchored.d.ts +3 -0
  14. package/dist/cjs/generators/anchored.js +57 -0
  15. package/dist/cjs/generators/days_of_month.d.ts +3 -0
  16. package/dist/cjs/generators/days_of_month.js +76 -0
  17. package/dist/cjs/generators/days_of_week.d.ts +5 -0
  18. package/dist/cjs/generators/days_of_week.js +65 -0
  19. package/dist/cjs/generators/days_of_year.d.ts +3 -0
  20. package/dist/cjs/generators/days_of_year.js +70 -0
  21. package/dist/cjs/generators/instant.d.ts +3 -0
  22. package/dist/cjs/generators/instant.js +15 -0
  23. package/dist/cjs/generators/local_date_time.d.ts +3 -0
  24. package/dist/cjs/generators/local_date_time.js +31 -0
  25. package/dist/cjs/generators/months_of_year.d.ts +3 -0
  26. package/dist/cjs/generators/months_of_year.js +109 -0
  27. package/dist/cjs/generators/types.d.ts +9 -0
  28. package/dist/cjs/generators/types.js +19 -0
  29. package/dist/cjs/generators/weeks_of_month.d.ts +3 -0
  30. package/dist/cjs/generators/weeks_of_month.js +99 -0
  31. package/dist/cjs/generators/yearly.d.ts +3 -0
  32. package/dist/cjs/generators/yearly.js +52 -0
  33. package/dist/cjs/in_memory_port.d.ts +18 -0
  34. package/dist/cjs/in_memory_port.js +59 -0
  35. package/dist/cjs/index.d.ts +7 -0
  36. package/dist/cjs/index.js +18 -0
  37. package/dist/cjs/occurrence_key.d.ts +5 -0
  38. package/dist/cjs/occurrence_key.js +21 -0
  39. package/dist/cjs/package.json +3 -0
  40. package/dist/cjs/port.d.ts +15 -0
  41. package/dist/cjs/port.js +2 -0
  42. package/dist/cjs/scheduler.d.ts +23 -0
  43. package/dist/cjs/scheduler.js +302 -0
  44. package/dist/cjs/types.d.ts +149 -0
  45. package/dist/cjs/types.js +58 -0
  46. package/dist/cjs/tz.d.ts +22 -0
  47. package/dist/cjs/tz.js +197 -0
  48. package/dist/cjs/validate_rule.d.ts +4 -0
  49. package/dist/cjs/validate_rule.js +267 -0
  50. package/dist/dispatcher.d.ts +39 -0
  51. package/dist/dispatcher.d.ts.map +1 -0
  52. package/dist/dispatcher.js +111 -0
  53. package/dist/dispatcher.js.map +1 -0
  54. package/dist/dispatcher_store.d.ts +10 -0
  55. package/dist/dispatcher_store.d.ts.map +1 -0
  56. package/dist/dispatcher_store.js +13 -0
  57. package/dist/dispatcher_store.js.map +1 -0
  58. package/dist/generators/anchored.d.ts +4 -0
  59. package/dist/generators/anchored.d.ts.map +1 -0
  60. package/dist/generators/anchored.js +42 -0
  61. package/dist/generators/anchored.js.map +1 -0
  62. package/dist/generators/days_of_month.d.ts +4 -0
  63. package/dist/generators/days_of_month.d.ts.map +1 -0
  64. package/dist/generators/days_of_month.js +53 -0
  65. package/dist/generators/days_of_month.js.map +1 -0
  66. package/dist/generators/days_of_week.d.ts +6 -0
  67. package/dist/generators/days_of_week.d.ts.map +1 -0
  68. package/dist/generators/days_of_week.js +43 -0
  69. package/dist/generators/days_of_week.js.map +1 -0
  70. package/dist/generators/days_of_year.d.ts +4 -0
  71. package/dist/generators/days_of_year.d.ts.map +1 -0
  72. package/dist/generators/days_of_year.js +50 -0
  73. package/dist/generators/days_of_year.js.map +1 -0
  74. package/dist/generators/instant.d.ts +4 -0
  75. package/dist/generators/instant.d.ts.map +1 -0
  76. package/dist/generators/instant.js +15 -0
  77. package/dist/generators/instant.js.map +1 -0
  78. package/dist/generators/local_date_time.d.ts +4 -0
  79. package/dist/generators/local_date_time.d.ts.map +1 -0
  80. package/dist/generators/local_date_time.js +20 -0
  81. package/dist/generators/local_date_time.js.map +1 -0
  82. package/dist/generators/months_of_year.d.ts +4 -0
  83. package/dist/generators/months_of_year.d.ts.map +1 -0
  84. package/dist/generators/months_of_year.js +64 -0
  85. package/dist/generators/months_of_year.js.map +1 -0
  86. package/dist/generators/types.d.ts +10 -0
  87. package/dist/generators/types.d.ts.map +1 -0
  88. package/dist/generators/types.js +14 -0
  89. package/dist/generators/types.js.map +1 -0
  90. package/dist/generators/weeks_of_month.d.ts +4 -0
  91. package/dist/generators/weeks_of_month.d.ts.map +1 -0
  92. package/dist/generators/weeks_of_month.js +61 -0
  93. package/dist/generators/weeks_of_month.js.map +1 -0
  94. package/dist/generators/yearly.d.ts +4 -0
  95. package/dist/generators/yearly.d.ts.map +1 -0
  96. package/dist/generators/yearly.js +37 -0
  97. package/dist/generators/yearly.js.map +1 -0
  98. package/dist/in_memory_port.d.ts +19 -0
  99. package/dist/in_memory_port.d.ts.map +1 -0
  100. package/dist/in_memory_port.js +45 -0
  101. package/dist/in_memory_port.js.map +1 -0
  102. package/dist/index.d.ts +8 -0
  103. package/dist/index.d.ts.map +1 -0
  104. package/dist/index.js +7 -0
  105. package/dist/occurrence_key.d.ts +6 -0
  106. package/dist/occurrence_key.d.ts.map +1 -0
  107. package/dist/occurrence_key.js +25 -0
  108. package/dist/occurrence_key.js.map +1 -0
  109. package/dist/port.d.ts +16 -0
  110. package/dist/port.d.ts.map +1 -0
  111. package/dist/scheduler.d.ts +24 -0
  112. package/dist/scheduler.d.ts.map +1 -0
  113. package/dist/scheduler.js +241 -0
  114. package/dist/scheduler.js.map +1 -0
  115. package/dist/types.d.ts +150 -0
  116. package/dist/types.d.ts.map +1 -0
  117. package/dist/types.js +29 -0
  118. package/dist/types.js.map +1 -0
  119. package/dist/tz.d.ts +23 -0
  120. package/dist/tz.d.ts.map +1 -0
  121. package/dist/tz.js +131 -0
  122. package/dist/tz.js.map +1 -0
  123. package/dist/validate_rule.d.ts +5 -0
  124. package/dist/validate_rule.d.ts.map +1 -0
  125. package/dist/validate_rule.js +280 -0
  126. package/dist/validate_rule.js.map +1 -0
  127. package/package.json +76 -4
  128. package/src/__tests__/alterations.test.ts +177 -0
  129. package/src/__tests__/anchored.test.ts +107 -0
  130. package/src/__tests__/days_of_month.test.ts +162 -0
  131. package/src/__tests__/days_of_week.test.ts +134 -0
  132. package/src/__tests__/days_of_year.test.ts +158 -0
  133. package/src/__tests__/dispatcher.test.ts +317 -0
  134. package/src/__tests__/instant.test.ts +50 -0
  135. package/src/__tests__/local_date_time.test.ts +139 -0
  136. package/src/__tests__/months_of_year.test.ts +229 -0
  137. package/src/__tests__/scheduler.test.ts +888 -0
  138. package/src/__tests__/tz.test.ts +201 -0
  139. package/src/__tests__/validate_rule.test.ts +190 -0
  140. package/src/__tests__/weeks_of_month.test.ts +195 -0
  141. package/src/__tests__/yearly.test.ts +108 -0
  142. package/src/alterations.ts +108 -0
  143. package/src/dispatcher.ts +184 -0
  144. package/src/dispatcher_store.ts +26 -0
  145. package/src/generators/anchored.ts +69 -0
  146. package/src/generators/days_of_month.ts +100 -0
  147. package/src/generators/days_of_week.ts +101 -0
  148. package/src/generators/days_of_year.ts +88 -0
  149. package/src/generators/instant.ts +18 -0
  150. package/src/generators/local_date_time.ts +38 -0
  151. package/src/generators/months_of_year.ts +143 -0
  152. package/src/generators/types.ts +26 -0
  153. package/src/generators/weeks_of_month.ts +133 -0
  154. package/src/generators/yearly.ts +73 -0
  155. package/src/in_memory_port.ts +70 -0
  156. package/src/index.ts +25 -0
  157. package/src/occurrence_key.ts +42 -0
  158. package/src/port.ts +31 -0
  159. package/src/scheduler.ts +353 -0
  160. package/src/types.ts +369 -0
  161. package/src/tz.ts +246 -0
  162. package/src/validate_rule.ts +311 -0
  163. package/tsconfig.json +25 -0
@@ -0,0 +1,353 @@
1
+ import { Event, type IEventBroadcast } from "@j13b/state";
2
+ import { applyAlterations } from "./alterations.js";
3
+ import { generateAnchored } from "./generators/anchored.js";
4
+ import { generateDaysOfMonth } from "./generators/days_of_month.js";
5
+ import { generateDaysOfWeek } from "./generators/days_of_week.js";
6
+ import { generateDaysOfYear } from "./generators/days_of_year.js";
7
+ import { generateInstant } from "./generators/instant.js";
8
+ import { generateLocalDateTime } from "./generators/local_date_time.js";
9
+ import { generateMonthsOfYear } from "./generators/months_of_year.js";
10
+ import type { Occurrence } from "./generators/types.js";
11
+ import { generateWeeksOfMonth } from "./generators/weeks_of_month.js";
12
+ import { generateYearly } from "./generators/yearly.js";
13
+ import { assignOccurrenceKeys } from "./occurrence_key.js";
14
+ import type { SchedulerPort } from "./port.js";
15
+ import type {
16
+ Alteration,
17
+ Disambiguation,
18
+ RecurrenceRule,
19
+ ScheduleTemplate,
20
+ ScheduledEvent,
21
+ TimeSpan,
22
+ } from "./types.js";
23
+ import { isValidTimeZone } from "./tz.js";
24
+ import { validateRule, validateTimeSpan } from "./validate_rule.js";
25
+
26
+ const DAY_MS = 24 * 60 * 60 * 1000;
27
+ // Search horizon for next() / nextDue() before giving up. Five years covers
28
+ // every realistic use; if you need longer, call list() with an explicit
29
+ // window.
30
+ const NEXT_SEARCH_HORIZON_MS = 5 * 365 * DAY_MS;
31
+
32
+ export class Scheduler<TPayload extends { type: string }> {
33
+ private readonly _port: SchedulerPort<TPayload>;
34
+ // Fires after every successful write (add/update/remove/reschedule/cancel).
35
+ // The Dispatcher subscribes via .changed so a new event added during a
36
+ // sleep can interrupt the sleep and re-query nextDue().
37
+ private readonly _changed = new Event<void>();
38
+
39
+ constructor(port: SchedulerPort<TPayload>) {
40
+ this._port = port;
41
+ }
42
+
43
+ get changed(): IEventBroadcast<void> {
44
+ return this._changed.broadcast;
45
+ }
46
+
47
+ // ─── Reads ──────────────────────────────────────────────────
48
+
49
+ // All occurrences whose [start, end) intersects [windowStart, windowEnd),
50
+ // sorted ascending by startTimestamp. Honors alterations.
51
+ async list(windowStart: number, windowEnd: number): Promise<ScheduledEvent<TPayload>[]> {
52
+ if (windowEnd <= windowStart) return [];
53
+ const [templates, alterations] = await Promise.all([
54
+ this._port.listTemplates(windowStart, windowEnd),
55
+ this._port.listAlterations(windowStart, windowEnd),
56
+ ]);
57
+ const out: ScheduledEvent<TPayload>[] = [];
58
+ for (const tpl of templates) {
59
+ const keyed = this._generateKeyed(tpl, windowStart, windowEnd);
60
+ const events = applyAlterations(
61
+ tpl.id,
62
+ tpl.payload,
63
+ keyed,
64
+ alterations,
65
+ tpl.timezone,
66
+ windowStart,
67
+ windowEnd
68
+ );
69
+ out.push(...events);
70
+ }
71
+ out.sort((a, b) => a.startTimestamp - b.startTimestamp);
72
+ return out;
73
+ }
74
+
75
+ // Earliest occurrence of one template strictly after `after`. Returns null
76
+ // if no occurrence exists within NEXT_SEARCH_HORIZON_MS.
77
+ async next(templateId: string, after: number): Promise<ScheduledEvent<TPayload> | null> {
78
+ const tpl = await this._port.getTemplate(templateId);
79
+ if (tpl == null) return null;
80
+
81
+ // Expanding-window search. Doubles each iteration until horizon.
82
+ const horizonEnd = after + NEXT_SEARCH_HORIZON_MS;
83
+ let windowStart = after;
84
+ let windowSize = DAY_MS;
85
+ while (windowStart < horizonEnd) {
86
+ const windowEnd = Math.min(windowStart + windowSize, horizonEnd);
87
+ const alterations = await this._port.listAlterationsByTemplate(templateId);
88
+ const keyed = this._generateKeyed(tpl, windowStart, windowEnd);
89
+ const events = applyAlterations(
90
+ tpl.id,
91
+ tpl.payload,
92
+ keyed,
93
+ alterations,
94
+ tpl.timezone,
95
+ windowStart,
96
+ windowEnd
97
+ );
98
+ const firstAfter = events.find((e) => e.startTimestamp > after);
99
+ if (firstAfter != null) return firstAfter;
100
+ windowStart = windowEnd;
101
+ windowSize = Math.min(windowSize * 2, 365 * DAY_MS);
102
+ }
103
+ return null;
104
+ }
105
+
106
+ // The next `limit` occurrences globally after `after`, sorted ascending.
107
+ // Defaults to 10. Searches in expanding windows like next().
108
+ async nextDue(after: number, limit = 10): Promise<ScheduledEvent<TPayload>[]> {
109
+ if (limit <= 0) return [];
110
+ const horizonEnd = after + NEXT_SEARCH_HORIZON_MS;
111
+ const out: ScheduledEvent<TPayload>[] = [];
112
+ let windowStart = after;
113
+ let windowSize = DAY_MS;
114
+ while (windowStart < horizonEnd && out.length < limit) {
115
+ const windowEnd = Math.min(windowStart + windowSize, horizonEnd);
116
+ const events = await this.list(windowStart, windowEnd);
117
+ for (const e of events) {
118
+ if (e.startTimestamp > after) {
119
+ out.push(e);
120
+ if (out.length >= limit) break;
121
+ }
122
+ }
123
+ windowStart = windowEnd;
124
+ windowSize = Math.min(windowSize * 2, 365 * DAY_MS);
125
+ }
126
+ return out;
127
+ }
128
+
129
+ // ─── Writes ─────────────────────────────────────────────────
130
+
131
+ async add(template: ScheduleTemplate<TPayload>): Promise<void> {
132
+ validateRule(template.rule);
133
+ _validateTimezone(template.timezone);
134
+ _validateDisambiguation(template.disambiguation);
135
+ const normalized: ScheduleTemplate<TPayload> = {
136
+ ...template,
137
+ rule: _normalizeRule(template.rule),
138
+ };
139
+ await this._port.addTemplate(normalized);
140
+ this._changed.notify();
141
+ }
142
+
143
+ // Partial update. Caller supplies any subset of the template fields plus
144
+ // the id. If the rule changes (kind or any field), all alterations for
145
+ // this template are wiped — same policy as activity-board, made explicit.
146
+ async update(
147
+ delta: { id: string } & Partial<Omit<ScheduleTemplate<TPayload>, "id">>
148
+ ): Promise<void> {
149
+ const existing = await this._port.getTemplate(delta.id);
150
+ if (existing == null) throw new Error(`No template with id ${delta.id}`);
151
+ const merged: ScheduleTemplate<TPayload> = {
152
+ ...existing,
153
+ ...delta,
154
+ id: delta.id,
155
+ };
156
+ validateRule(merged.rule);
157
+ _validateTimezone(merged.timezone);
158
+ _validateDisambiguation(merged.disambiguation);
159
+ const updated: ScheduleTemplate<TPayload> = {
160
+ ...merged,
161
+ rule: _normalizeRule(merged.rule),
162
+ };
163
+ const ruleChanged = !_rulesEqual(existing.rule, updated.rule);
164
+ await this._port.updateTemplate(updated);
165
+ if (ruleChanged) {
166
+ await this._port.removeAlterationsForTemplate(updated.id);
167
+ }
168
+ this._changed.notify();
169
+ }
170
+
171
+ async remove(id: string): Promise<void> {
172
+ await this._port.removeAlterationsForTemplate(id);
173
+ await this._port.removeTemplate(id);
174
+ this._changed.notify();
175
+ }
176
+
177
+ // Adds or replaces a reschedule alteration for the given occurrence.
178
+ // Caller is responsible for providing a valid occurrenceKey (typically
179
+ // taken from a prior list() result). If `newTimeSpan` is omitted, the
180
+ // rescheduled occurrence inherits the original's UTC duration (i.e. moves
181
+ // in time but keeps the same length).
182
+ async reschedule(
183
+ templateId: string,
184
+ occurrenceKey: string,
185
+ newStart: number,
186
+ newTimeSpan?: TimeSpan,
187
+ payloadOverride?: Partial<TPayload>
188
+ ): Promise<string> {
189
+ if (newTimeSpan != null) validateTimeSpan(newTimeSpan);
190
+ const id = `alt-${templateId}-${occurrenceKey}-${Date.now()}`;
191
+ await this._removeExistingAlteration(templateId, occurrenceKey);
192
+ const alt: Alteration<TPayload> = {
193
+ id,
194
+ templateId,
195
+ occurrenceKey,
196
+ kind: "reschedule",
197
+ startTimestamp: newStart,
198
+ timeSpan: newTimeSpan,
199
+ payloadOverride,
200
+ };
201
+ await this._port.addAlteration(alt);
202
+ this._changed.notify();
203
+ return id;
204
+ }
205
+
206
+ async cancel(templateId: string, occurrenceKey: string): Promise<string> {
207
+ const id = `alt-${templateId}-${occurrenceKey}-${Date.now()}`;
208
+ await this._removeExistingAlteration(templateId, occurrenceKey);
209
+ const alt: Alteration<TPayload> = {
210
+ id,
211
+ templateId,
212
+ occurrenceKey,
213
+ kind: "cancel",
214
+ };
215
+ await this._port.addAlteration(alt);
216
+ this._changed.notify();
217
+ return id;
218
+ }
219
+
220
+ // ─── Internals ──────────────────────────────────────────────
221
+
222
+ private _generateKeyed(tpl: ScheduleTemplate<TPayload>, windowStart: number, windowEnd: number) {
223
+ const tz = tpl.timezone;
224
+ const dis = tpl.disambiguation ?? "compatible";
225
+ let occurrences: Occurrence[];
226
+ switch (tpl.rule.kind) {
227
+ case "instant":
228
+ // Absolute start — no wall-clock ambiguity, disambiguation N/A.
229
+ occurrences = generateInstant(tpl.rule, tz, windowStart, windowEnd);
230
+ break;
231
+ case "localDateTime":
232
+ occurrences = generateLocalDateTime(tpl.rule, tz, windowStart, windowEnd, dis);
233
+ break;
234
+ case "anchored":
235
+ // Absolute stepping — no wall-clock ambiguity, disambiguation N/A.
236
+ occurrences = generateAnchored(tpl.rule, tz, windowStart, windowEnd);
237
+ break;
238
+ case "daysOfWeek":
239
+ occurrences = generateDaysOfWeek(tpl.rule, tz, windowStart, windowEnd, dis);
240
+ break;
241
+ case "weeksOfMonth":
242
+ occurrences = generateWeeksOfMonth(tpl.rule, tz, windowStart, windowEnd, dis);
243
+ break;
244
+ case "monthsOfYear":
245
+ occurrences = generateMonthsOfYear(tpl.rule, tz, windowStart, windowEnd, dis);
246
+ break;
247
+ case "yearly":
248
+ occurrences = generateYearly(tpl.rule, tz, windowStart, windowEnd, dis);
249
+ break;
250
+ case "daysOfMonth":
251
+ occurrences = generateDaysOfMonth(tpl.rule, tz, windowStart, windowEnd, dis);
252
+ break;
253
+ case "daysOfYear":
254
+ occurrences = generateDaysOfYear(tpl.rule, tz, windowStart, windowEnd, dis);
255
+ break;
256
+ default: {
257
+ // Exhaustiveness guard — adding a new kind without handling it here
258
+ // is a compile error.
259
+ const _exhaustive: never = tpl.rule;
260
+ throw new Error(`Unknown rule kind: ${JSON.stringify(_exhaustive)}`);
261
+ }
262
+ }
263
+ return assignOccurrenceKeys(occurrences, tz);
264
+ }
265
+
266
+ private async _removeExistingAlteration(
267
+ templateId: string,
268
+ occurrenceKey: string
269
+ ): Promise<void> {
270
+ const existing = await this._port.listAlterationsByTemplate(templateId);
271
+ for (const alt of existing) {
272
+ if (alt.occurrenceKey === occurrenceKey) {
273
+ await this._port.removeAlteration(alt.id);
274
+ }
275
+ }
276
+ }
277
+ }
278
+
279
+ // ─── Validation ───────────────────────────────────────────────
280
+
281
+ // Returns a copy of `rule` with timeSpan defaulted when omitted, so
282
+ // generators don't have to handle undefined repeatedly.
283
+ function _normalizeRule(rule: RecurrenceRule): RecurrenceRule {
284
+ if (rule.timeSpan != null) return rule;
285
+ return { ...rule, timeSpan: { type: "duration", ms: 1 } };
286
+ }
287
+
288
+ function _validateTimezone(tz: string): void {
289
+ if (typeof tz !== "string" || tz.length === 0) {
290
+ throw new Error("ScheduleTemplate.timezone is required (use 'UTC' for zone-free scheduling)");
291
+ }
292
+ if (!isValidTimeZone(tz)) {
293
+ throw new Error(`Unknown timezone '${tz}' (expected an IANA zone name or 'UTC')`);
294
+ }
295
+ }
296
+
297
+ const _DISAMBIGUATIONS: readonly Disambiguation[] = [
298
+ "compatible",
299
+ "earlier",
300
+ "later",
301
+ "reject",
302
+ "skip",
303
+ ];
304
+
305
+ function _validateDisambiguation(d: Disambiguation | undefined): void {
306
+ if (d == null) return;
307
+ if (!_DISAMBIGUATIONS.includes(d)) {
308
+ throw new Error(
309
+ `Unknown disambiguation '${d}' (expected one of ${_DISAMBIGUATIONS.join(", ")})`
310
+ );
311
+ }
312
+ }
313
+
314
+ function _rulesEqual(a: RecurrenceRule, b: RecurrenceRule): boolean {
315
+ return JSON.stringify(_canonicalize(a)) === JSON.stringify(_canonicalize(b));
316
+ }
317
+
318
+ function _canonicalize(rule: RecurrenceRule): unknown {
319
+ // Sort + dedup any array selectors so reorderings or duplicates don't
320
+ // false-flag a rule change (which would wipe alterations).
321
+ if (rule.kind === "daysOfWeek") {
322
+ return { ...rule, days: [...new Set(rule.days)].sort((a, b) => a - b) };
323
+ }
324
+ if (rule.kind === "weeksOfMonth") {
325
+ return {
326
+ ...rule,
327
+ days: [...new Set(rule.days)].sort((a, b) => a - b),
328
+ weeks: [...new Set(rule.weeks)].sort((a, b) => a - b),
329
+ };
330
+ }
331
+ if (rule.kind === "monthsOfYear") {
332
+ return {
333
+ ...rule,
334
+ months: [...new Set(rule.months)].sort((a, b) => a - b),
335
+ weeks: [...new Set(rule.weeks)].sort((a, b) => a - b),
336
+ days: [...new Set(rule.days)].sort((a, b) => a - b),
337
+ };
338
+ }
339
+ if (rule.kind === "daysOfMonth") {
340
+ return {
341
+ ...rule,
342
+ months: [...new Set(rule.months)].sort((a, b) => a - b),
343
+ daysOfMonth: [...new Set(rule.daysOfMonth)].sort((a, b) => a - b),
344
+ };
345
+ }
346
+ if (rule.kind === "daysOfYear") {
347
+ return {
348
+ ...rule,
349
+ daysOfYear: [...new Set(rule.daysOfYear)].sort((a, b) => a - b),
350
+ };
351
+ }
352
+ return rule;
353
+ }