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