@forcecalendar/core 2.1.0 → 2.1.2

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.
@@ -7,631 +7,605 @@ import { TimezoneManager } from '../timezone/TimezoneManager.js';
7
7
  import { RRuleParser } from './RRuleParser.js';
8
8
 
9
9
  export class RecurrenceEngineV2 {
10
- constructor() {
11
- // Use singleton to share cache across all components
12
- this.tzManager = TimezoneManager.getInstance();
13
-
14
- // Cache for expanded occurrences
15
- this.occurrenceCache = new Map();
16
- this.cacheSize = 100;
17
-
18
- // Modified instances storage
19
- this.modifiedInstances = new Map(); // eventId -> Map(occurrenceDate -> modifications)
20
-
21
- // Exception storage with reasons
22
- this.exceptionStore = new Map(); // eventId -> Map(date -> reason)
10
+ constructor() {
11
+ // Use singleton to share cache across all components
12
+ this.tzManager = TimezoneManager.getInstance();
13
+
14
+ // Cache for expanded occurrences
15
+ this.occurrenceCache = new Map();
16
+ this.cacheSize = 100;
17
+
18
+ // Modified instances storage
19
+ this.modifiedInstances = new Map(); // eventId -> Map(occurrenceDate -> modifications)
20
+
21
+ // Exception storage with reasons
22
+ this.exceptionStore = new Map(); // eventId -> Map(date -> reason)
23
+ }
24
+
25
+ /**
26
+ * Expand recurring event with advanced handling
27
+ * @param {Event} event - Recurring event
28
+ * @param {Date} rangeStart - Start of expansion range
29
+ * @param {Date} rangeEnd - End of expansion range
30
+ * @param {Object} options - Expansion options
31
+ * @returns {Array} Expanded occurrences
32
+ */
33
+ expandEvent(event, rangeStart, rangeEnd, options = {}) {
34
+ const {
35
+ maxOccurrences = 365,
36
+ includeModified = true,
37
+ includeCancelled = false,
38
+ timezone = event.timeZone || 'UTC',
39
+ handleDST = true
40
+ } = options;
41
+
42
+ // Check cache
43
+ const cacheKey = this.getCacheKey(event.id, rangeStart, rangeEnd, options);
44
+ if (this.occurrenceCache.has(cacheKey)) {
45
+ return this.occurrenceCache.get(cacheKey);
23
46
  }
24
47
 
25
- /**
26
- * Expand recurring event with advanced handling
27
- * @param {Event} event - Recurring event
28
- * @param {Date} rangeStart - Start of expansion range
29
- * @param {Date} rangeEnd - End of expansion range
30
- * @param {Object} options - Expansion options
31
- * @returns {Array} Expanded occurrences
32
- */
33
- expandEvent(event, rangeStart, rangeEnd, options = {}) {
34
- const {
35
- maxOccurrences = 365,
36
- includeModified = true,
37
- includeCancelled = false,
38
- timezone = event.timeZone || 'UTC',
39
- handleDST = true
40
- } = options;
41
-
42
- // Check cache
43
- const cacheKey = this.getCacheKey(event.id, rangeStart, rangeEnd, options);
44
- if (this.occurrenceCache.has(cacheKey)) {
45
- return this.occurrenceCache.get(cacheKey);
46
- }
47
-
48
- if (!event.recurring || !event.recurrenceRule) {
49
- return [this.createOccurrence(event, event.start, event.end)];
50
- }
48
+ if (!event.recurring || !event.recurrenceRule) {
49
+ return [this.createOccurrence(event, event.start, event.end)];
50
+ }
51
51
 
52
- const rule = RRuleParser.parse(event.recurrenceRule);
53
- const occurrences = [];
54
- const duration = event.end - event.start;
55
-
56
- // Initialize expansion state
57
- const state = {
58
- currentDate: new Date(event.start),
59
- count: 0,
60
- tzOffsets: new Map(),
61
- dstTransitions: []
62
- };
63
-
64
- // Pre-calculate DST transitions in range
65
- if (handleDST) {
66
- state.dstTransitions = this.findDSTTransitions(
67
- rangeStart,
68
- rangeEnd,
69
- timezone
70
- );
71
- }
52
+ const rule = RRuleParser.parse(event.recurrenceRule);
53
+ const occurrences = [];
54
+ const duration = event.end - event.start;
55
+
56
+ // Initialize expansion state
57
+ const state = {
58
+ currentDate: new Date(event.start),
59
+ count: 0,
60
+ tzOffsets: new Map(),
61
+ dstTransitions: []
62
+ };
63
+
64
+ // Pre-calculate DST transitions in range
65
+ if (handleDST) {
66
+ state.dstTransitions = this.findDSTTransitions(rangeStart, rangeEnd, timezone);
67
+ }
72
68
 
73
- // Expand occurrences
74
- while (state.currentDate <= rangeEnd && state.count < maxOccurrences) {
75
- if (state.currentDate >= rangeStart) {
76
- const occurrence = this.generateOccurrence(
77
- event,
78
- state.currentDate,
79
- duration,
80
- timezone,
81
- state
82
- );
83
-
84
- // Check exceptions and modifications
85
- if (occurrence) {
86
- const dateKey = this.getDateKey(occurrence.start);
87
-
88
- // Skip if exception
89
- if (this.isException(event.id, occurrence.start, rule)) {
90
- if (!includeCancelled) {
91
- state.currentDate = this.getNextDate(
92
- state.currentDate,
93
- rule,
94
- timezone
95
- );
96
- state.count++;
97
- continue;
98
- }
99
- occurrence.status = 'cancelled';
100
- occurrence.cancellationReason = this.getExceptionReason(
101
- event.id,
102
- occurrence.start
103
- );
104
- }
105
-
106
- // Apply modifications if any
107
- if (includeModified) {
108
- const modified = this.getModifiedInstance(
109
- event.id,
110
- occurrence.start
111
- );
112
- if (modified) {
113
- Object.assign(occurrence, modified);
114
- occurrence.isModified = true;
115
- }
116
- }
117
-
118
- occurrences.push(occurrence);
119
- }
69
+ // Expand occurrences
70
+ while (state.currentDate <= rangeEnd && state.count < maxOccurrences) {
71
+ if (state.currentDate >= rangeStart) {
72
+ const occurrence = this.generateOccurrence(
73
+ event,
74
+ state.currentDate,
75
+ duration,
76
+ timezone,
77
+ state
78
+ );
79
+
80
+ // Check exceptions and modifications
81
+ if (occurrence) {
82
+ const dateKey = this.getDateKey(occurrence.start);
83
+
84
+ // Skip if exception
85
+ if (this.isException(event.id, occurrence.start, rule)) {
86
+ if (!includeCancelled) {
87
+ state.currentDate = this.getNextDate(state.currentDate, rule, timezone);
88
+ state.count++;
89
+ continue;
120
90
  }
121
-
122
- // Get next occurrence date
123
- state.currentDate = this.getNextDate(
124
- state.currentDate,
125
- rule,
126
- timezone,
127
- state
128
- );
129
- state.count++;
130
-
131
- // Check COUNT limit
132
- if (rule.count && state.count >= rule.count) {
133
- break;
91
+ occurrence.status = 'cancelled';
92
+ occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start);
93
+ }
94
+
95
+ // Apply modifications if any
96
+ if (includeModified) {
97
+ const modified = this.getModifiedInstance(event.id, occurrence.start);
98
+ if (modified) {
99
+ Object.assign(occurrence, modified);
100
+ occurrence.isModified = true;
134
101
  }
102
+ }
135
103
 
136
- // Check UNTIL limit
137
- if (rule.until && state.currentDate > rule.until) {
138
- break;
139
- }
104
+ occurrences.push(occurrence);
140
105
  }
106
+ }
141
107
 
142
- // Cache results
143
- this.cacheOccurrences(cacheKey, occurrences);
108
+ // Get next occurrence date
109
+ state.currentDate = this.getNextDate(state.currentDate, rule, timezone, state);
110
+ state.count++;
144
111
 
145
- return occurrences;
146
- }
112
+ // Check COUNT limit
113
+ if (rule.count && state.count >= rule.count) {
114
+ break;
115
+ }
147
116
 
148
- /**
149
- * Generate a single occurrence with timezone handling
150
- */
151
- generateOccurrence(event, date, duration, timezone, state) {
152
- const start = new Date(date);
153
- const end = new Date(date.getTime() + duration);
154
-
155
- // Handle DST transitions
156
- if (state.dstTransitions.length > 0) {
157
- const adjusted = this.adjustForDST(
158
- start,
159
- end,
160
- timezone,
161
- state.dstTransitions
162
- );
163
- start.setTime(adjusted.start.getTime());
164
- end.setTime(adjusted.end.getTime());
165
- }
166
-
167
- return {
168
- id: `${event.id}_${start.getTime()}`,
169
- recurringEventId: event.id,
170
- title: event.title,
171
- start,
172
- end,
173
- startUTC: this.tzManager.toUTC(start, timezone),
174
- endUTC: this.tzManager.toUTC(end, timezone),
175
- timezone,
176
- originalStart: event.start,
177
- allDay: event.allDay,
178
- description: event.description,
179
- location: event.location,
180
- categories: event.categories,
181
- status: 'confirmed',
182
- isRecurring: true,
183
- isModified: false
184
- };
117
+ // Check UNTIL limit
118
+ if (rule.until && state.currentDate > rule.until) {
119
+ break;
120
+ }
185
121
  }
186
122
 
187
- /**
188
- * Get next occurrence date with complex pattern support
189
- */
190
- getNextDate(currentDate, rule, timezone, state = {}) {
191
- const next = new Date(currentDate);
192
-
193
- switch (rule.freq) {
194
- case 'DAILY':
195
- return this.getNextDaily(next, rule);
123
+ // Cache results
124
+ this.cacheOccurrences(cacheKey, occurrences);
196
125
 
197
- case 'WEEKLY':
198
- return this.getNextWeekly(next, rule, timezone);
126
+ return occurrences;
127
+ }
199
128
 
200
- case 'MONTHLY':
201
- return this.getNextMonthly(next, rule, timezone);
129
+ /**
130
+ * Generate a single occurrence with timezone handling
131
+ */
132
+ generateOccurrence(event, date, duration, timezone, state) {
133
+ const start = new Date(date);
134
+ const end = new Date(date.getTime() + duration);
202
135
 
203
- case 'YEARLY':
204
- return this.getNextYearly(next, rule, timezone);
205
-
206
- case 'HOURLY':
207
- next.setHours(next.getHours() + rule.interval);
208
- return next;
209
-
210
- case 'MINUTELY':
211
- next.setMinutes(next.getMinutes() + rule.interval);
212
- return next;
213
-
214
- default:
215
- // Fallback to daily
216
- next.setDate(next.getDate() + rule.interval);
217
- return next;
218
- }
136
+ // Handle DST transitions
137
+ if (state.dstTransitions.length > 0) {
138
+ const adjusted = this.adjustForDST(start, end, timezone, state.dstTransitions);
139
+ start.setTime(adjusted.start.getTime());
140
+ end.setTime(adjusted.end.getTime());
219
141
  }
220
142
 
221
- /**
222
- * Get next daily occurrence
223
- */
224
- getNextDaily(date, rule) {
225
- const next = new Date(date);
226
- next.setDate(next.getDate() + rule.interval);
227
-
228
- // Apply BYHOUR, BYMINUTE, BYSECOND if specified
229
- if (rule.byHour && rule.byHour.length > 0) {
230
- const currentHour = next.getHours();
231
- const nextHour = rule.byHour.find(h => h > currentHour);
232
- if (nextHour !== undefined) {
233
- next.setHours(nextHour);
234
- } else {
235
- // Move to next day and use first hour
236
- next.setDate(next.getDate() + 1);
237
- next.setHours(rule.byHour[0]);
238
- }
239
- }
240
-
143
+ return {
144
+ id: `${event.id}_${start.getTime()}`,
145
+ recurringEventId: event.id,
146
+ title: event.title,
147
+ start,
148
+ end,
149
+ startUTC: this.tzManager.toUTC(start, timezone),
150
+ endUTC: this.tzManager.toUTC(end, timezone),
151
+ timezone,
152
+ originalStart: event.start,
153
+ allDay: event.allDay,
154
+ description: event.description,
155
+ location: event.location,
156
+ categories: event.categories,
157
+ status: 'confirmed',
158
+ isRecurring: true,
159
+ isModified: false
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Get next occurrence date with complex pattern support
165
+ */
166
+ getNextDate(currentDate, rule, timezone, state = {}) {
167
+ const next = new Date(currentDate);
168
+
169
+ switch (rule.freq) {
170
+ case 'DAILY':
171
+ return this.getNextDaily(next, rule);
172
+
173
+ case 'WEEKLY':
174
+ return this.getNextWeekly(next, rule, timezone);
175
+
176
+ case 'MONTHLY':
177
+ return this.getNextMonthly(next, rule, timezone);
178
+
179
+ case 'YEARLY':
180
+ return this.getNextYearly(next, rule, timezone);
181
+
182
+ case 'HOURLY':
183
+ next.setHours(next.getHours() + rule.interval);
241
184
  return next;
242
- }
243
-
244
- /**
245
- * Get next weekly occurrence with BYDAY support
246
- */
247
- getNextWeekly(date, rule, timezone) {
248
- const next = new Date(date);
249
-
250
- if (rule.byDay && rule.byDay.length > 0) {
251
- // Find next matching weekday
252
- const dayMap = {
253
- 'SU': 0, 'MO': 1, 'TU': 2, 'WE': 3,
254
- 'TH': 4, 'FR': 5, 'SA': 6
255
- };
256
-
257
- const currentDay = next.getDay();
258
- let daysToAdd = null;
259
-
260
- // Find next occurrence day
261
- for (const byDay of rule.byDay) {
262
- const targetDay = dayMap[byDay.weekday || byDay];
263
- if (targetDay > currentDay) {
264
- daysToAdd = targetDay - currentDay;
265
- break;
266
- }
267
- }
268
185
 
269
- // If no day found in current week, go to next week
270
- if (daysToAdd === null) {
271
- const firstDay = dayMap[rule.byDay[0].weekday || rule.byDay[0]];
272
- daysToAdd = 7 - currentDay + firstDay;
273
-
274
- // Apply interval for weekly recurrence
275
- if (rule.interval > 1) {
276
- daysToAdd += 7 * (rule.interval - 1);
277
- }
278
- }
279
-
280
- next.setDate(next.getDate() + daysToAdd);
281
- } else {
282
- // Simple weekly interval
283
- next.setDate(next.getDate() + (7 * rule.interval));
284
- }
186
+ case 'MINUTELY':
187
+ next.setMinutes(next.getMinutes() + rule.interval);
188
+ return next;
285
189
 
190
+ default:
191
+ // Fallback to daily
192
+ next.setDate(next.getDate() + rule.interval);
286
193
  return next;
287
194
  }
195
+ }
196
+
197
+ /**
198
+ * Get next daily occurrence
199
+ */
200
+ getNextDaily(date, rule) {
201
+ const next = new Date(date);
202
+ next.setDate(next.getDate() + rule.interval);
203
+
204
+ // Apply BYHOUR, BYMINUTE, BYSECOND if specified
205
+ if (rule.byHour && rule.byHour.length > 0) {
206
+ const currentHour = next.getHours();
207
+ const nextHour = rule.byHour.find(h => h > currentHour);
208
+ if (nextHour !== undefined) {
209
+ next.setHours(nextHour);
210
+ } else {
211
+ // Move to next day and use first hour
212
+ next.setDate(next.getDate() + 1);
213
+ next.setHours(rule.byHour[0]);
214
+ }
215
+ }
288
216
 
289
- /**
290
- * Get next monthly occurrence with complex patterns
291
- */
292
- getNextMonthly(date, rule, timezone) {
293
- const next = new Date(date);
294
-
295
- if (rule.byMonthDay && rule.byMonthDay.length > 0) {
296
- // Specific day(s) of month
297
- const targetDays = rule.byMonthDay.sort((a, b) => a - b);
298
- const currentDay = next.getDate();
299
-
300
- let targetDay = targetDays.find(d => d > currentDay);
301
- if (targetDay) {
302
- // Found a day in current month
303
- next.setDate(targetDay);
304
- } else {
305
- // Move to next month
306
- next.setMonth(next.getMonth() + rule.interval);
307
-
308
- // Handle negative days (from end of month)
309
- targetDay = targetDays[0];
310
- if (targetDay < 0) {
311
- const lastDay = new Date(
312
- next.getFullYear(),
313
- next.getMonth() + 1,
314
- 0
315
- ).getDate();
316
- next.setDate(lastDay + targetDay + 1);
317
- } else {
318
- next.setDate(targetDay);
319
- }
320
- }
321
- } else if (rule.byDay && rule.byDay.length > 0) {
322
- // Nth weekday of month (e.g., "2nd Tuesday")
323
- const byDay = rule.byDay[0];
324
- const nthOccurrence = byDay.nth || 1;
325
-
326
- next.setMonth(next.getMonth() + rule.interval);
327
- this.setToNthWeekdayOfMonth(next, byDay.weekday, nthOccurrence);
328
- } else if (rule.bySetPos && rule.bySetPos.length > 0) {
329
- // BYSETPOS for selecting from set
330
- next.setMonth(next.getMonth() + rule.interval);
331
- // Complex BYSETPOS logic would go here
332
- } else {
333
- // Same day of next month
334
- const currentDay = next.getDate();
335
- next.setMonth(next.getMonth() + rule.interval);
336
-
337
- // Handle month-end edge cases
338
- const lastDay = new Date(
339
- next.getFullYear(),
340
- next.getMonth() + 1,
341
- 0
342
- ).getDate();
343
- if (currentDay > lastDay) {
344
- next.setDate(lastDay);
345
- }
217
+ return next;
218
+ }
219
+
220
+ /**
221
+ * Get next weekly occurrence with BYDAY support
222
+ */
223
+ getNextWeekly(date, rule, timezone) {
224
+ const next = new Date(date);
225
+
226
+ if (rule.byDay && rule.byDay.length > 0) {
227
+ // Find next matching weekday
228
+ const dayMap = {
229
+ SU: 0,
230
+ MO: 1,
231
+ TU: 2,
232
+ WE: 3,
233
+ TH: 4,
234
+ FR: 5,
235
+ SA: 6
236
+ };
237
+
238
+ const currentDay = next.getDay();
239
+ let daysToAdd = null;
240
+
241
+ // Find next occurrence day
242
+ for (const byDay of rule.byDay) {
243
+ const targetDay = dayMap[byDay.weekday || byDay];
244
+ if (targetDay > currentDay) {
245
+ daysToAdd = targetDay - currentDay;
246
+ break;
346
247
  }
248
+ }
347
249
 
348
- return next;
349
- }
250
+ // If no day found in current week, go to next week
251
+ if (daysToAdd === null) {
252
+ const firstDay = dayMap[rule.byDay[0].weekday || rule.byDay[0]];
253
+ daysToAdd = 7 - currentDay + firstDay;
350
254
 
351
- /**
352
- * Get next yearly occurrence
353
- */
354
- getNextYearly(date, rule, timezone) {
355
- const next = new Date(date);
356
-
357
- if (rule.byMonth && rule.byMonth.length > 0) {
358
- const currentMonth = next.getMonth();
359
- const targetMonth = rule.byMonth.find(m => m - 1 > currentMonth);
360
-
361
- if (targetMonth) {
362
- // Found month in current year
363
- next.setMonth(targetMonth - 1);
364
- } else {
365
- // Move to next year
366
- next.setFullYear(next.getFullYear() + rule.interval);
367
- next.setMonth(rule.byMonth[0] - 1);
368
- }
369
-
370
- // Apply BYMONTHDAY if specified
371
- if (rule.byMonthDay && rule.byMonthDay.length > 0) {
372
- next.setDate(rule.byMonthDay[0]);
373
- }
374
- } else if (rule.byYearDay && rule.byYearDay.length > 0) {
375
- // Nth day of year
376
- next.setFullYear(next.getFullYear() + rule.interval);
377
- const yearDay = rule.byYearDay[0];
378
-
379
- if (yearDay > 0) {
380
- // Count from start of year
381
- next.setMonth(0, 1);
382
- next.setDate(yearDay);
383
- } else {
384
- // Count from end of year
385
- next.setMonth(11, 31);
386
- next.setDate(next.getDate() + yearDay + 1);
387
- }
388
- } else {
389
- // Same date next year
390
- next.setFullYear(next.getFullYear() + rule.interval);
255
+ // Apply interval for weekly recurrence
256
+ if (rule.interval > 1) {
257
+ daysToAdd += 7 * (rule.interval - 1);
391
258
  }
259
+ }
392
260
 
393
- return next;
261
+ next.setDate(next.getDate() + daysToAdd);
262
+ } else {
263
+ // Simple weekly interval
264
+ next.setDate(next.getDate() + 7 * rule.interval);
394
265
  }
395
266
 
396
- /**
397
- * Set date to Nth weekday of month
398
- */
399
- setToNthWeekdayOfMonth(date, weekday, nth) {
400
- const dayMap = {
401
- 'SU': 0, 'MO': 1, 'TU': 2, 'WE': 3,
402
- 'TH': 4, 'FR': 5, 'SA': 6
403
- };
404
-
405
- const targetDay = dayMap[weekday];
406
- date.setDate(1); // Start at first of month
407
-
408
- // Find first occurrence
409
- while (date.getDay() !== targetDay) {
410
- date.setDate(date.getDate() + 1);
411
- }
412
-
413
- if (nth > 0) {
414
- // Nth occurrence from start
415
- date.setDate(date.getDate() + (7 * (nth - 1)));
267
+ return next;
268
+ }
269
+
270
+ /**
271
+ * Get next monthly occurrence with complex patterns
272
+ */
273
+ getNextMonthly(date, rule, timezone) {
274
+ const next = new Date(date);
275
+
276
+ if (rule.byMonthDay && rule.byMonthDay.length > 0) {
277
+ // Specific day(s) of month
278
+ const targetDays = rule.byMonthDay.sort((a, b) => a - b);
279
+ const currentDay = next.getDate();
280
+
281
+ let targetDay = targetDays.find(d => d > currentDay);
282
+ if (targetDay) {
283
+ // Found a day in current month
284
+ next.setDate(targetDay);
285
+ } else {
286
+ // Move to next month
287
+ next.setMonth(next.getMonth() + rule.interval);
288
+
289
+ // Handle negative days (from end of month)
290
+ targetDay = targetDays[0];
291
+ if (targetDay < 0) {
292
+ const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
293
+ next.setDate(lastDay + targetDay + 1);
416
294
  } else {
417
- // Nth occurrence from end
418
- const lastDay = new Date(
419
- date.getFullYear(),
420
- date.getMonth() + 1,
421
- 0
422
- ).getDate();
423
-
424
- // Find last occurrence
425
- const temp = new Date(date);
426
- temp.setDate(lastDay);
427
- while (temp.getDay() !== targetDay) {
428
- temp.setDate(temp.getDate() - 1);
429
- }
430
-
431
- // Move back nth weeks
432
- temp.setDate(temp.getDate() + (7 * (nth + 1)));
433
- date.setTime(temp.getTime());
295
+ next.setDate(targetDay);
434
296
  }
297
+ }
298
+ } else if (rule.byDay && rule.byDay.length > 0) {
299
+ // Nth weekday of month (e.g., "2nd Tuesday")
300
+ const byDay = rule.byDay[0];
301
+ const nthOccurrence = byDay.nth || 1;
302
+
303
+ next.setMonth(next.getMonth() + rule.interval);
304
+ this.setToNthWeekdayOfMonth(next, byDay.weekday, nthOccurrence);
305
+ } else if (rule.bySetPos && rule.bySetPos.length > 0) {
306
+ // BYSETPOS for selecting from set
307
+ next.setMonth(next.getMonth() + rule.interval);
308
+ // Complex BYSETPOS logic would go here
309
+ } else {
310
+ // Same day of next month
311
+ const currentDay = next.getDate();
312
+ next.setMonth(next.getMonth() + rule.interval);
313
+
314
+ // Handle month-end edge cases
315
+ const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
316
+ if (currentDay > lastDay) {
317
+ next.setDate(lastDay);
318
+ }
435
319
  }
436
320
 
437
- /**
438
- * Find DST transitions in date range
439
- */
440
- findDSTTransitions(start, end, timezone) {
441
- const transitions = [];
442
- const current = new Date(start);
443
-
444
- // Check each day for offset changes
445
- let lastOffset = this.tzManager.getTimezoneOffset(current, timezone);
446
-
447
- while (current <= end) {
448
- const offset = this.tzManager.getTimezoneOffset(current, timezone);
449
-
450
- if (offset !== lastOffset) {
451
- transitions.push({
452
- date: new Date(current),
453
- oldOffset: lastOffset,
454
- newOffset: offset,
455
- type: offset < lastOffset ? 'spring-forward' : 'fall-back'
456
- });
457
- }
458
-
459
- lastOffset = offset;
460
- current.setDate(current.getDate() + 1);
461
- }
462
-
463
- return transitions;
321
+ return next;
322
+ }
323
+
324
+ /**
325
+ * Get next yearly occurrence
326
+ */
327
+ getNextYearly(date, rule, timezone) {
328
+ const next = new Date(date);
329
+
330
+ if (rule.byMonth && rule.byMonth.length > 0) {
331
+ const currentMonth = next.getMonth();
332
+ const targetMonth = rule.byMonth.find(m => m - 1 > currentMonth);
333
+
334
+ if (targetMonth) {
335
+ // Found month in current year
336
+ next.setMonth(targetMonth - 1);
337
+ } else {
338
+ // Move to next year
339
+ next.setFullYear(next.getFullYear() + rule.interval);
340
+ next.setMonth(rule.byMonth[0] - 1);
341
+ }
342
+
343
+ // Apply BYMONTHDAY if specified
344
+ if (rule.byMonthDay && rule.byMonthDay.length > 0) {
345
+ next.setDate(rule.byMonthDay[0]);
346
+ }
347
+ } else if (rule.byYearDay && rule.byYearDay.length > 0) {
348
+ // Nth day of year
349
+ next.setFullYear(next.getFullYear() + rule.interval);
350
+ const yearDay = rule.byYearDay[0];
351
+
352
+ if (yearDay > 0) {
353
+ // Count from start of year
354
+ next.setMonth(0, 1);
355
+ next.setDate(yearDay);
356
+ } else {
357
+ // Count from end of year
358
+ next.setMonth(11, 31);
359
+ next.setDate(next.getDate() + yearDay + 1);
360
+ }
361
+ } else {
362
+ // Same date next year
363
+ next.setFullYear(next.getFullYear() + rule.interval);
464
364
  }
465
365
 
466
- /**
467
- * Adjust occurrence for DST transitions
468
- */
469
- adjustForDST(start, end, timezone, transitions) {
470
- for (const transition of transitions) {
471
- if (start >= transition.date) {
472
- const offsetDiff = transition.oldOffset - transition.newOffset;
473
-
474
- // Spring forward: skip the "lost" hour
475
- if (transition.type === 'spring-forward') {
476
- const lostHourStart = new Date(transition.date);
477
- lostHourStart.setHours(2); // Typical transition time
478
- const lostHourEnd = new Date(lostHourStart);
479
- lostHourEnd.setHours(3);
480
-
481
- if (start >= lostHourStart && start < lostHourEnd) {
482
- start.setHours(start.getHours() + 1);
483
- end.setHours(end.getHours() + 1);
484
- }
485
- }
486
- // Fall back: handle the "repeated" hour
487
- else if (transition.type === 'fall-back') {
488
- // Maintain wall clock time
489
- start.setMinutes(start.getMinutes() - offsetDiff);
490
- end.setMinutes(end.getMinutes() - offsetDiff);
491
- }
492
- }
493
- }
494
-
495
- return { start, end };
366
+ return next;
367
+ }
368
+
369
+ /**
370
+ * Set date to Nth weekday of month
371
+ */
372
+ setToNthWeekdayOfMonth(date, weekday, nth) {
373
+ const dayMap = {
374
+ SU: 0,
375
+ MO: 1,
376
+ TU: 2,
377
+ WE: 3,
378
+ TH: 4,
379
+ FR: 5,
380
+ SA: 6
381
+ };
382
+
383
+ const targetDay = dayMap[weekday];
384
+ date.setDate(1); // Start at first of month
385
+
386
+ // Find first occurrence
387
+ while (date.getDay() !== targetDay) {
388
+ date.setDate(date.getDate() + 1);
496
389
  }
497
390
 
498
- /**
499
- * Add or update a modified instance
500
- */
501
- addModifiedInstance(eventId, occurrenceDate, modifications) {
502
- if (!this.modifiedInstances.has(eventId)) {
503
- this.modifiedInstances.set(eventId, new Map());
504
- }
505
-
506
- const dateKey = this.getDateKey(occurrenceDate);
507
- this.modifiedInstances.get(eventId).set(dateKey, {
508
- ...modifications,
509
- modifiedAt: new Date()
391
+ if (nth > 0) {
392
+ // Nth occurrence from start
393
+ date.setDate(date.getDate() + 7 * (nth - 1));
394
+ } else {
395
+ // Nth occurrence from end
396
+ const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
397
+
398
+ // Find last occurrence
399
+ const temp = new Date(date);
400
+ temp.setDate(lastDay);
401
+ while (temp.getDay() !== targetDay) {
402
+ temp.setDate(temp.getDate() - 1);
403
+ }
404
+
405
+ // Move back nth weeks
406
+ temp.setDate(temp.getDate() + 7 * (nth + 1));
407
+ date.setTime(temp.getTime());
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Find DST transitions in date range
413
+ */
414
+ findDSTTransitions(start, end, timezone) {
415
+ const transitions = [];
416
+ const current = new Date(start);
417
+
418
+ // Check each day for offset changes
419
+ let lastOffset = this.tzManager.getTimezoneOffset(current, timezone);
420
+
421
+ while (current <= end) {
422
+ const offset = this.tzManager.getTimezoneOffset(current, timezone);
423
+
424
+ if (offset !== lastOffset) {
425
+ transitions.push({
426
+ date: new Date(current),
427
+ oldOffset: lastOffset,
428
+ newOffset: offset,
429
+ type: offset < lastOffset ? 'spring-forward' : 'fall-back'
510
430
  });
431
+ }
511
432
 
512
- // Clear cache for this event
513
- this.clearEventCache(eventId);
433
+ lastOffset = offset;
434
+ current.setDate(current.getDate() + 1);
514
435
  }
515
436
 
516
- /**
517
- * Get modified instance data
518
- */
519
- getModifiedInstance(eventId, occurrenceDate) {
520
- if (!this.modifiedInstances.has(eventId)) {
521
- return null;
437
+ return transitions;
438
+ }
439
+
440
+ /**
441
+ * Adjust occurrence for DST transitions
442
+ */
443
+ adjustForDST(start, end, timezone, transitions) {
444
+ for (const transition of transitions) {
445
+ if (start >= transition.date) {
446
+ const offsetDiff = transition.oldOffset - transition.newOffset;
447
+
448
+ // Spring forward: skip the "lost" hour
449
+ if (transition.type === 'spring-forward') {
450
+ const lostHourStart = new Date(transition.date);
451
+ lostHourStart.setHours(2); // Typical transition time
452
+ const lostHourEnd = new Date(lostHourStart);
453
+ lostHourEnd.setHours(3);
454
+
455
+ if (start >= lostHourStart && start < lostHourEnd) {
456
+ start.setHours(start.getHours() + 1);
457
+ end.setHours(end.getHours() + 1);
458
+ }
522
459
  }
523
-
524
- const dateKey = this.getDateKey(occurrenceDate);
525
- return this.modifiedInstances.get(eventId).get(dateKey);
526
- }
527
-
528
- /**
529
- * Add exception with reason
530
- */
531
- addException(eventId, date, reason = 'Cancelled') {
532
- if (!this.exceptionStore.has(eventId)) {
533
- this.exceptionStore.set(eventId, new Map());
460
+ // Fall back: handle the "repeated" hour
461
+ else if (transition.type === 'fall-back') {
462
+ // Maintain wall clock time
463
+ start.setMinutes(start.getMinutes() - offsetDiff);
464
+ end.setMinutes(end.getMinutes() - offsetDiff);
534
465
  }
466
+ }
467
+ }
535
468
 
536
- const dateKey = this.getDateKey(date);
537
- this.exceptionStore.get(eventId).set(dateKey, reason);
469
+ return { start, end };
470
+ }
538
471
 
539
- // Clear cache
540
- this.clearEventCache(eventId);
472
+ /**
473
+ * Add or update a modified instance
474
+ */
475
+ addModifiedInstance(eventId, occurrenceDate, modifications) {
476
+ if (!this.modifiedInstances.has(eventId)) {
477
+ this.modifiedInstances.set(eventId, new Map());
541
478
  }
542
479
 
543
- /**
544
- * Check if date is an exception
545
- */
546
- isException(eventId, date, rule) {
547
- const dateKey = this.getDateKey(date);
548
-
549
- // Check enhanced exceptions
550
- if (this.exceptionStore.has(eventId)) {
551
- if (this.exceptionStore.get(eventId).has(dateKey)) {
552
- return true;
553
- }
554
- }
480
+ const dateKey = this.getDateKey(occurrenceDate);
481
+ this.modifiedInstances.get(eventId).set(dateKey, {
482
+ ...modifications,
483
+ modifiedAt: new Date()
484
+ });
485
+
486
+ // Clear cache for this event
487
+ this.clearEventCache(eventId);
488
+ }
489
+
490
+ /**
491
+ * Get modified instance data
492
+ */
493
+ getModifiedInstance(eventId, occurrenceDate) {
494
+ if (!this.modifiedInstances.has(eventId)) {
495
+ return null;
496
+ }
555
497
 
556
- // Check rule exceptions
557
- if (rule && rule.exceptions) {
558
- return rule.exceptions.some(ex => {
559
- const exDate = ex instanceof Date ? ex : new Date(ex.date || ex);
560
- return this.getDateKey(exDate) === dateKey;
561
- });
562
- }
498
+ const dateKey = this.getDateKey(occurrenceDate);
499
+ return this.modifiedInstances.get(eventId).get(dateKey);
500
+ }
563
501
 
564
- return false;
502
+ /**
503
+ * Add exception with reason
504
+ */
505
+ addException(eventId, date, reason = 'Cancelled') {
506
+ if (!this.exceptionStore.has(eventId)) {
507
+ this.exceptionStore.set(eventId, new Map());
565
508
  }
566
509
 
567
- /**
568
- * Get exception reason
569
- */
570
- getExceptionReason(eventId, date) {
571
- if (!this.exceptionStore.has(eventId)) {
572
- return 'Cancelled';
573
- }
510
+ const dateKey = this.getDateKey(date);
511
+ this.exceptionStore.get(eventId).set(dateKey, reason);
574
512
 
575
- const dateKey = this.getDateKey(date);
576
- return this.exceptionStore.get(eventId).get(dateKey) || 'Cancelled';
577
- }
513
+ // Clear cache
514
+ this.clearEventCache(eventId);
515
+ }
516
+
517
+ /**
518
+ * Check if date is an exception
519
+ */
520
+ isException(eventId, date, rule) {
521
+ const dateKey = this.getDateKey(date);
578
522
 
579
- /**
580
- * Create date key for indexing
581
- */
582
- getDateKey(date) {
583
- const d = date instanceof Date ? date : new Date(date);
584
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
523
+ // Check enhanced exceptions
524
+ if (this.exceptionStore.has(eventId)) {
525
+ if (this.exceptionStore.get(eventId).has(dateKey)) {
526
+ return true;
527
+ }
585
528
  }
586
529
 
587
- /**
588
- * Create cache key
589
- */
590
- getCacheKey(eventId, start, end, options) {
591
- return `${eventId}_${start.getTime()}_${end.getTime()}_${JSON.stringify(options)}`;
530
+ // Check rule exceptions
531
+ if (rule && rule.exceptions) {
532
+ return rule.exceptions.some(ex => {
533
+ const exDate = ex instanceof Date ? ex : new Date(ex.date || ex);
534
+ return this.getDateKey(exDate) === dateKey;
535
+ });
592
536
  }
593
537
 
594
- /**
595
- * Cache occurrences
596
- */
597
- cacheOccurrences(key, occurrences) {
598
- this.occurrenceCache.set(key, occurrences);
538
+ return false;
539
+ }
599
540
 
600
- // LRU eviction
601
- if (this.occurrenceCache.size > this.cacheSize) {
602
- const firstKey = this.occurrenceCache.keys().next().value;
603
- this.occurrenceCache.delete(firstKey);
604
- }
541
+ /**
542
+ * Get exception reason
543
+ */
544
+ getExceptionReason(eventId, date) {
545
+ if (!this.exceptionStore.has(eventId)) {
546
+ return 'Cancelled';
605
547
  }
606
548
 
607
- /**
608
- * Clear cache for specific event
609
- */
610
- clearEventCache(eventId) {
611
- for (const key of this.occurrenceCache.keys()) {
612
- if (key.startsWith(`${eventId }_`)) {
613
- this.occurrenceCache.delete(key);
614
- }
615
- }
549
+ const dateKey = this.getDateKey(date);
550
+ return this.exceptionStore.get(eventId).get(dateKey) || 'Cancelled';
551
+ }
552
+
553
+ /**
554
+ * Create date key for indexing
555
+ */
556
+ getDateKey(date) {
557
+ const d = date instanceof Date ? date : new Date(date);
558
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
559
+ }
560
+
561
+ /**
562
+ * Create cache key
563
+ */
564
+ getCacheKey(eventId, start, end, options) {
565
+ return `${eventId}_${start.getTime()}_${end.getTime()}_${JSON.stringify(options)}`;
566
+ }
567
+
568
+ /**
569
+ * Cache occurrences
570
+ */
571
+ cacheOccurrences(key, occurrences) {
572
+ this.occurrenceCache.set(key, occurrences);
573
+
574
+ // LRU eviction
575
+ if (this.occurrenceCache.size > this.cacheSize) {
576
+ const firstKey = this.occurrenceCache.keys().next().value;
577
+ this.occurrenceCache.delete(firstKey);
616
578
  }
617
-
618
- /**
619
- * Create occurrence object
620
- */
621
- createOccurrence(event, start, end) {
622
- return {
623
- id: event.id,
624
- title: event.title,
625
- start,
626
- end,
627
- allDay: event.allDay,
628
- description: event.description,
629
- location: event.location,
630
- categories: event.categories,
631
- timezone: event.timeZone,
632
- isRecurring: false
633
- };
579
+ }
580
+
581
+ /**
582
+ * Clear cache for specific event
583
+ */
584
+ clearEventCache(eventId) {
585
+ for (const key of this.occurrenceCache.keys()) {
586
+ if (key.startsWith(`${eventId}_`)) {
587
+ this.occurrenceCache.delete(key);
588
+ }
634
589
  }
590
+ }
591
+
592
+ /**
593
+ * Create occurrence object
594
+ */
595
+ createOccurrence(event, start, end) {
596
+ return {
597
+ id: event.id,
598
+ title: event.title,
599
+ start,
600
+ end,
601
+ allDay: event.allDay,
602
+ description: event.description,
603
+ location: event.location,
604
+ categories: event.categories,
605
+ timezone: event.timeZone,
606
+ isRecurring: false
607
+ };
608
+ }
635
609
  }
636
610
 
637
- export default RecurrenceEngineV2;
611
+ export default RecurrenceEngineV2;