@forcecalendar/core 2.1.65 → 2.1.67

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.
@@ -1,6 +1,6 @@
1
1
  import { Event } from './Event.js';
2
2
  import { DateUtils } from '../calendar/DateUtils.js';
3
- import { RecurrenceEngine } from './RecurrenceEngine.js';
3
+ import { RecurrenceEngineV2 } from './RecurrenceEngineV2.js';
4
4
  import { PerformanceOptimizer } from '../performance/PerformanceOptimizer.js';
5
5
  import { ConflictDetector } from '../conflicts/ConflictDetector.js';
6
6
  import { TimezoneManager } from '../timezone/TimezoneManager.js';
@@ -29,6 +29,7 @@ export class EventStore {
29
29
  /** @type {Map<string, Set<string>>} Status -> Set of event IDs */
30
30
  byStatus: new Map()
31
31
  };
32
+ this.eventIndexRefs = new Map();
32
33
 
33
34
  // Timezone manager for conversions (use singleton to share cache)
34
35
  this.timezoneManager = TimezoneManager.getInstance();
@@ -39,6 +40,9 @@ export class EventStore {
39
40
  // Performance optimizer
40
41
  this.optimizer = new PerformanceOptimizer(config.performance);
41
42
 
43
+ // Recurrence expansion engine
44
+ this.recurrenceEngine = config.recurrenceEngine || new RecurrenceEngineV2();
45
+
42
46
  // Conflict detector
43
47
  this.conflictDetector = new ConflictDetector(this);
44
48
 
@@ -334,9 +338,6 @@ export class EventStore {
334
338
  getEventsForDate(date, timezone = null) {
335
339
  timezone = timezone || this.defaultTimezone;
336
340
 
337
- // Use local date string for the query date (in the calendar's timezone)
338
- const dateStr = DateUtils.getLocalDateString(date);
339
-
340
341
  // Collect candidate event IDs from indices
341
342
  const candidateIds = new Set();
342
343
 
@@ -409,23 +410,40 @@ export class EventStore {
409
410
 
410
411
  // Collect all events from those dates
411
412
  const checkedIds = new Set();
412
- dates.forEach(date => {
413
- // Use getLocalDateString to match the index key format (YYYY-MM-DD)
414
- const dateStr = DateUtils.getLocalDateString(date);
415
- const eventIds = this.indices.byDate.get(dateStr) || new Set();
413
+ const addCandidateIds = eventIds => {
414
+ if (!eventIds) return;
416
415
 
417
416
  eventIds.forEach(id => {
418
417
  if (!checkedIds.has(id) && id !== excludeId) {
419
418
  checkedIds.add(id);
420
- const event = this.events.get(id);
421
-
422
- if (event && event.overlaps({ start, end })) {
423
- overlapping.push(event);
424
- }
425
419
  }
426
420
  });
421
+ };
422
+
423
+ dates.forEach(date => {
424
+ // Use getLocalDateString to match the index key format (YYYY-MM-DD)
425
+ const dateStr = DateUtils.getLocalDateString(date);
426
+ addCandidateIds(this.indices.byDate.get(dateStr));
427
427
  });
428
428
 
429
+ // Lazy-indexed long events may not have every day in byDate. Use month
430
+ // buckets as candidates and rely on precise overlap filtering below.
431
+ const currentMonth = new Date(startDate.getFullYear(), startDate.getMonth(), 1);
432
+ const endMonth = new Date(endDate.getFullYear(), endDate.getMonth(), 1);
433
+ while (currentMonth <= endMonth) {
434
+ const monthKey = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}`;
435
+ addCandidateIds(this.indices.byMonth.get(monthKey));
436
+ currentMonth.setMonth(currentMonth.getMonth() + 1);
437
+ }
438
+
439
+ for (const id of checkedIds) {
440
+ const event = this.events.get(id);
441
+
442
+ if (event && event.overlaps({ start, end })) {
443
+ overlapping.push(event);
444
+ }
445
+ }
446
+
429
447
  return overlapping.sort((a, b) => a.start - b.start);
430
448
  }
431
449
 
@@ -579,6 +597,20 @@ export class EventStore {
579
597
  return baseEvents;
580
598
  }
581
599
 
600
+ // Recurring series can start before the requested range but still have
601
+ // occurrences inside it, so include all recurring series as expansion
602
+ // candidates and let the recurrence engine filter by range.
603
+ const baseEventIds = new Set(baseEvents.map(event => event.id));
604
+ for (const eventId of this.indices.recurring) {
605
+ if (!baseEventIds.has(eventId)) {
606
+ const event = this.events.get(eventId);
607
+ if (event) {
608
+ baseEvents.push(event);
609
+ baseEventIds.add(eventId);
610
+ }
611
+ }
612
+ }
613
+
582
614
  // Expand recurring events
583
615
  const expandedEvents = [];
584
616
  baseEvents.forEach(event => {
@@ -615,7 +647,9 @@ export class EventStore {
615
647
 
616
648
  // Expand in the event's timezone for accurate recurrence calculation
617
649
  const eventTimezone = event.timeZone || timezone;
618
- const occurrences = RecurrenceEngine.expandEvent(event, rangeStart, rangeEnd);
650
+ const occurrences = this.recurrenceEngine.expandEvent(event, rangeStart, rangeEnd, {
651
+ timezone: eventTimezone
652
+ });
619
653
 
620
654
  return occurrences.map((occurrence, index) => {
621
655
  // Create a new event instance for each occurrence
@@ -623,10 +657,11 @@ export class EventStore {
623
657
  id: `${event.id}_occurrence_${index}`,
624
658
  start: occurrence.start,
625
659
  end: occurrence.end,
626
- timeZone: eventTimezone,
660
+ timeZone: occurrence.timezone || eventTimezone,
627
661
  metadata: {
628
662
  ...event.metadata,
629
663
  recurringEventId: event.id,
664
+ occurrenceId: occurrence.id,
630
665
  occurrenceIndex: index
631
666
  }
632
667
  });
@@ -647,6 +682,7 @@ export class EventStore {
647
682
  this.indices.recurring.clear();
648
683
  this.indices.byCategory.clear();
649
684
  this.indices.byStatus.clear();
685
+ this.eventIndexRefs.clear();
650
686
 
651
687
  this._notifyChange({
652
688
  type: 'clear',
@@ -687,6 +723,8 @@ export class EventStore {
687
723
  * @private
688
724
  */
689
725
  _indexEvent(event) {
726
+ this._createIndexRefs(event.id);
727
+
690
728
  // Check if should use lazy indexing for large date ranges
691
729
  if (this.optimizer.shouldUseLazyIndexing(event)) {
692
730
  this._indexEventLazy(event);
@@ -706,26 +744,14 @@ export class EventStore {
706
744
 
707
745
  dates.forEach(date => {
708
746
  const dateStr = DateUtils.getLocalDateString(date);
709
-
710
- if (!this.indices.byDate.has(dateStr)) {
711
- this.indices.byDate.set(dateStr, new Set());
712
- }
713
- this.indices.byDate.get(dateStr).add(event.id);
747
+ this._addToKeyedIndex('byDate', dateStr, event.id);
714
748
  });
715
749
 
716
- // Index by month(s) using UTC
717
- const startMonth = `${startDate.getFullYear()}-${String(startDate.getMonth() + 1).padStart(2, '0')}`;
718
- const endMonth = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, '0')}`;
719
-
720
750
  // Add to all months the event spans
721
751
  const currentMonth = new Date(startDate.getFullYear(), startDate.getMonth(), 1);
722
752
  while (currentMonth <= endDate) {
723
753
  const monthKey = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}`;
724
-
725
- if (!this.indices.byMonth.has(monthKey)) {
726
- this.indices.byMonth.set(monthKey, new Set());
727
- }
728
- this.indices.byMonth.get(monthKey).add(event.id);
754
+ this._addToKeyedIndex('byMonth', monthKey, event.id);
729
755
 
730
756
  currentMonth.setMonth(currentMonth.getMonth() + 1);
731
757
  }
@@ -733,24 +759,19 @@ export class EventStore {
733
759
  // Index by categories
734
760
  if (event.categories && event.categories.length > 0) {
735
761
  event.categories.forEach(category => {
736
- if (!this.indices.byCategory.has(category)) {
737
- this.indices.byCategory.set(category, new Set());
738
- }
739
- this.indices.byCategory.get(category).add(event.id);
762
+ this._addToKeyedIndex('byCategory', category, event.id);
740
763
  });
741
764
  }
742
765
 
743
766
  // Index by status
744
767
  if (event.status) {
745
- if (!this.indices.byStatus.has(event.status)) {
746
- this.indices.byStatus.set(event.status, new Set());
747
- }
748
- this.indices.byStatus.get(event.status).add(event.id);
768
+ this._addToKeyedIndex('byStatus', event.status, event.id);
749
769
  }
750
770
 
751
771
  // Index recurring events
752
772
  if (event.recurring) {
753
773
  this.indices.recurring.add(event.id);
774
+ this.eventIndexRefs.get(event.id).recurring = true;
754
775
  }
755
776
  }
756
777
 
@@ -759,8 +780,7 @@ export class EventStore {
759
780
  * @private
760
781
  */
761
782
  _indexEventLazy(event) {
762
- // Create lazy index markers
763
- const markers = this.optimizer.createLazyIndexMarkers(event);
783
+ this.optimizer.createLazyIndexMarkers(event);
764
784
 
765
785
  // Index only the boundaries initially (in event's local timezone)
766
786
  const eventStartLocal = event.getStartInTimezone(event.timeZone);
@@ -779,10 +799,7 @@ export class EventStore {
779
799
 
780
800
  firstWeekDates.forEach(date => {
781
801
  const dateStr = DateUtils.getLocalDateString(date);
782
- if (!this.indices.byDate.has(dateStr)) {
783
- this.indices.byDate.set(dateStr, new Set());
784
- }
785
- this.indices.byDate.get(dateStr).add(event.id);
802
+ this._addToKeyedIndex('byDate', dateStr, event.id);
786
803
  });
787
804
 
788
805
  // Index last week if different from first
@@ -796,10 +813,7 @@ export class EventStore {
796
813
 
797
814
  lastWeekDates.forEach(date => {
798
815
  const dateStr = DateUtils.getLocalDateString(date);
799
- if (!this.indices.byDate.has(dateStr)) {
800
- this.indices.byDate.set(dateStr, new Set());
801
- }
802
- this.indices.byDate.get(dateStr).add(event.id);
816
+ this._addToKeyedIndex('byDate', dateStr, event.id);
803
817
  });
804
818
  }
805
819
 
@@ -807,33 +821,53 @@ export class EventStore {
807
821
  const currentMonth = new Date(startDate.getFullYear(), startDate.getMonth(), 1);
808
822
  while (currentMonth <= endDate) {
809
823
  const monthKey = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}`;
810
- if (!this.indices.byMonth.has(monthKey)) {
811
- this.indices.byMonth.set(monthKey, new Set());
812
- }
813
- this.indices.byMonth.get(monthKey).add(event.id);
824
+ this._addToKeyedIndex('byMonth', monthKey, event.id);
814
825
  currentMonth.setMonth(currentMonth.getMonth() + 1);
815
826
  }
816
827
 
817
828
  // Index other properties normally
818
829
  if (event.categories && event.categories.length > 0) {
819
830
  event.categories.forEach(category => {
820
- if (!this.indices.byCategory.has(category)) {
821
- this.indices.byCategory.set(category, new Set());
822
- }
823
- this.indices.byCategory.get(category).add(event.id);
831
+ this._addToKeyedIndex('byCategory', category, event.id);
824
832
  });
825
833
  }
826
834
 
827
835
  if (event.status) {
828
- if (!this.indices.byStatus.has(event.status)) {
829
- this.indices.byStatus.set(event.status, new Set());
830
- }
831
- this.indices.byStatus.get(event.status).add(event.id);
836
+ this._addToKeyedIndex('byStatus', event.status, event.id);
832
837
  }
833
838
 
834
839
  if (event.recurring) {
835
840
  this.indices.recurring.add(event.id);
841
+ this.eventIndexRefs.get(event.id).recurring = true;
842
+ }
843
+ }
844
+
845
+ /**
846
+ * Create reverse index references for an event.
847
+ * @private
848
+ */
849
+ _createIndexRefs(eventId) {
850
+ this.eventIndexRefs.set(eventId, {
851
+ byDate: new Set(),
852
+ byMonth: new Set(),
853
+ byCategory: new Set(),
854
+ byStatus: new Set(),
855
+ recurring: false
856
+ });
857
+ }
858
+
859
+ /**
860
+ * Add an event to a keyed index and record the reverse reference.
861
+ * @private
862
+ */
863
+ _addToKeyedIndex(indexName, key, eventId) {
864
+ const index = this.indices[indexName];
865
+ if (!index.has(key)) {
866
+ index.set(key, new Set());
836
867
  }
868
+
869
+ index.get(key).add(eventId);
870
+ this.eventIndexRefs.get(eventId)?.[indexName].add(key);
837
871
  }
838
872
 
839
873
  /**
@@ -841,6 +875,22 @@ export class EventStore {
841
875
  * @private
842
876
  */
843
877
  _unindexEvent(event) {
878
+ const refs = this.eventIndexRefs.get(event.id);
879
+
880
+ if (refs) {
881
+ this._removeFromReferencedIndex('byDate', refs.byDate, event.id);
882
+ this._removeFromReferencedIndex('byMonth', refs.byMonth, event.id);
883
+ this._removeFromReferencedIndex('byCategory', refs.byCategory, event.id);
884
+ this._removeFromReferencedIndex('byStatus', refs.byStatus, event.id);
885
+
886
+ if (refs.recurring) {
887
+ this.indices.recurring.delete(event.id);
888
+ }
889
+
890
+ this.eventIndexRefs.delete(event.id);
891
+ return;
892
+ }
893
+
844
894
  // Remove from date indices
845
895
  for (const [dateStr, eventIds] of this.indices.byDate) {
846
896
  eventIds.delete(event.id);
@@ -877,6 +927,24 @@ export class EventStore {
877
927
  this.indices.recurring.delete(event.id);
878
928
  }
879
929
 
930
+ /**
931
+ * Remove an event from only the keys it was indexed into.
932
+ * @private
933
+ */
934
+ _removeFromReferencedIndex(indexName, keys, eventId) {
935
+ const index = this.indices[indexName];
936
+
937
+ for (const key of keys) {
938
+ const eventIds = index.get(key);
939
+ if (!eventIds) continue;
940
+
941
+ eventIds.delete(eventId);
942
+ if (eventIds.size === 0) {
943
+ index.delete(key);
944
+ }
945
+ }
946
+ }
947
+
880
948
  /**
881
949
  * Notify listeners of changes
882
950
  * @private
@@ -938,6 +1006,18 @@ export class EventStore {
938
1006
  Array.from(this.indices.byStatus.entries()).map(([k, v]) => [k, new Set(v)])
939
1007
  )
940
1008
  },
1009
+ eventIndexRefs: new Map(
1010
+ Array.from(this.eventIndexRefs.entries()).map(([eventId, refs]) => [
1011
+ eventId,
1012
+ {
1013
+ byDate: new Set(refs.byDate),
1014
+ byMonth: new Set(refs.byMonth),
1015
+ byCategory: new Set(refs.byCategory),
1016
+ byStatus: new Set(refs.byStatus),
1017
+ recurring: refs.recurring
1018
+ }
1019
+ ])
1020
+ ),
941
1021
  version: this.version
942
1022
  };
943
1023
  }
@@ -981,6 +1061,7 @@ export class EventStore {
981
1061
  if (this.batchBackup) {
982
1062
  this.events = this.batchBackup.events;
983
1063
  this.indices = this.batchBackup.indices;
1064
+ this.eventIndexRefs = this.batchBackup.eventIndexRefs;
984
1065
  this.version = this.batchBackup.version;
985
1066
  this.batchBackup = null;
986
1067
 
@@ -1154,7 +1235,6 @@ export class EventStore {
1154
1235
  cutoffDate.setMonth(cutoffDate.getMonth() - 6); // Default: 6 months ago
1155
1236
  }
1156
1237
 
1157
- const cutoffStr = cutoffDate.toDateString();
1158
1238
  let removed = 0;
1159
1239
 
1160
1240
  // Clean up date indices
@@ -1172,13 +1252,15 @@ export class EventStore {
1172
1252
  }
1173
1253
 
1174
1254
  if (!stillNeeded) {
1255
+ for (const eventId of eventIds) {
1256
+ this.eventIndexRefs.get(eventId)?.byDate.delete(dateStr);
1257
+ }
1175
1258
  this.indices.byDate.delete(dateStr);
1176
1259
  removed++;
1177
1260
  }
1178
1261
  }
1179
1262
  }
1180
1263
 
1181
- console.log(`Optimized indices: removed ${removed} old date entries`);
1182
1264
  return removed;
1183
1265
  }
1184
1266
 
@@ -48,11 +48,11 @@ export class RecurrenceEngineV2 {
48
48
  // Check cache
49
49
  const cacheKey = this.getCacheKey(event.id, rangeStart, rangeEnd, options);
50
50
  if (this.occurrenceCache.has(cacheKey)) {
51
- return this.occurrenceCache.get(cacheKey);
51
+ return this.cloneOccurrences(this.occurrenceCache.get(cacheKey));
52
52
  }
53
53
 
54
54
  if (!event.recurring || !event.recurrenceRule) {
55
- return [this.createOccurrence(event, event.start, event.end)];
55
+ return this.cloneOccurrences([this.createOccurrence(event, event.start, event.end)]);
56
56
  }
57
57
 
58
58
  const rule = RRuleParser.parse(event.recurrenceRule);
@@ -64,7 +64,8 @@ export class RecurrenceEngineV2 {
64
64
  currentDate: new Date(event.start),
65
65
  count: 0,
66
66
  tzOffsets: new Map(),
67
- dstTransitions: []
67
+ dstTransitions: [],
68
+ stuckIterations: 0
68
69
  };
69
70
 
70
71
  // Pre-calculate DST transitions in range
@@ -85,21 +86,20 @@ export class RecurrenceEngineV2 {
85
86
 
86
87
  // Check exceptions and modifications
87
88
  if (occurrence) {
88
- const dateKey = this.getDateKey(occurrence.start);
89
+ let shouldInclude = true;
89
90
 
90
91
  // Skip if exception
91
92
  if (this.isException(event.id, occurrence.start, rule)) {
92
93
  if (!includeCancelled) {
93
- state.currentDate = this.getNextDate(state.currentDate, rule, timezone);
94
- state.count++;
95
- continue;
94
+ shouldInclude = false;
95
+ } else {
96
+ occurrence.status = 'cancelled';
97
+ occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start);
96
98
  }
97
- occurrence.status = 'cancelled';
98
- occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start);
99
99
  }
100
100
 
101
101
  // Apply modifications if any
102
- if (includeModified) {
102
+ if (shouldInclude && includeModified) {
103
103
  const modified = this.getModifiedInstance(event.id, occurrence.start);
104
104
  if (modified) {
105
105
  Object.assign(occurrence, modified);
@@ -107,14 +107,26 @@ export class RecurrenceEngineV2 {
107
107
  }
108
108
  }
109
109
 
110
- occurrences.push(occurrence);
110
+ if (shouldInclude) {
111
+ occurrences.push(occurrence);
112
+ }
111
113
  }
112
114
  }
113
115
 
114
116
  // Get next occurrence date
117
+ const previousTimestamp = state.currentDate.getTime();
115
118
  state.currentDate = this.getNextDate(state.currentDate, rule, timezone, state);
116
119
  state.count++;
117
120
 
121
+ if (state.currentDate.getTime() <= previousTimestamp) {
122
+ state.stuckIterations++;
123
+ if (state.stuckIterations >= 3) {
124
+ break;
125
+ }
126
+ } else {
127
+ state.stuckIterations = 0;
128
+ }
129
+
118
130
  // Check COUNT limit
119
131
  if (rule.count && state.count >= rule.count) {
120
132
  break;
@@ -129,7 +141,7 @@ export class RecurrenceEngineV2 {
129
141
  // Cache results
130
142
  this.cacheOccurrences(cacheKey, occurrences);
131
143
 
132
- return occurrences;
144
+ return this.cloneOccurrences(occurrences);
133
145
  }
134
146
 
135
147
  /**
@@ -169,7 +181,7 @@ export class RecurrenceEngineV2 {
169
181
  /**
170
182
  * Get next occurrence date with complex pattern support
171
183
  */
172
- getNextDate(currentDate, rule, timezone, state = {}) {
184
+ getNextDate(currentDate, rule, timezone, _state = {}) {
173
185
  const next = new Date(currentDate);
174
186
 
175
187
  switch (rule.freq) {
@@ -226,7 +238,7 @@ export class RecurrenceEngineV2 {
226
238
  /**
227
239
  * Get next weekly occurrence with BYDAY support
228
240
  */
229
- getNextWeekly(date, rule, timezone) {
241
+ getNextWeekly(date, rule, _timezone) {
230
242
  const next = new Date(date);
231
243
 
232
244
  if (rule.byDay && rule.byDay.length > 0) {
@@ -276,7 +288,7 @@ export class RecurrenceEngineV2 {
276
288
  /**
277
289
  * Get next monthly occurrence with complex patterns
278
290
  */
279
- getNextMonthly(date, rule, timezone) {
291
+ getNextMonthly(date, rule, _timezone) {
280
292
  const next = new Date(date);
281
293
 
282
294
  if (rule.byMonthDay && rule.byMonthDay.length > 0) {
@@ -372,7 +384,7 @@ export class RecurrenceEngineV2 {
372
384
  /**
373
385
  * Get next yearly occurrence
374
386
  */
375
- getNextYearly(date, rule, timezone) {
387
+ getNextYearly(date, rule, _timezone) {
376
388
  const next = new Date(date);
377
389
 
378
390
  if (rule.byMonth && rule.byMonth.length > 0) {
@@ -617,7 +629,7 @@ export class RecurrenceEngineV2 {
617
629
  * Cache occurrences
618
630
  */
619
631
  cacheOccurrences(key, occurrences) {
620
- this.occurrenceCache.set(key, occurrences);
632
+ this.occurrenceCache.set(key, this.cloneOccurrences(occurrences));
621
633
 
622
634
  // LRU eviction
623
635
  if (this.occurrenceCache.size > this.cacheSize) {
@@ -626,6 +638,25 @@ export class RecurrenceEngineV2 {
626
638
  }
627
639
  }
628
640
 
641
+ /**
642
+ * Clone occurrence results before returning or caching.
643
+ */
644
+ cloneOccurrences(occurrences) {
645
+ return occurrences.map(occurrence => ({
646
+ ...occurrence,
647
+ start: occurrence.start ? new Date(occurrence.start) : occurrence.start,
648
+ end: occurrence.end ? new Date(occurrence.end) : occurrence.end,
649
+ startUTC: occurrence.startUTC ? new Date(occurrence.startUTC) : occurrence.startUTC,
650
+ endUTC: occurrence.endUTC ? new Date(occurrence.endUTC) : occurrence.endUTC,
651
+ originalStart: occurrence.originalStart
652
+ ? new Date(occurrence.originalStart)
653
+ : occurrence.originalStart,
654
+ categories: Array.isArray(occurrence.categories)
655
+ ? [...occurrence.categories]
656
+ : occurrence.categories
657
+ }));
658
+ }
659
+
629
660
  /**
630
661
  * Clear cache for specific event
631
662
  */
package/core/index.js CHANGED
@@ -28,7 +28,7 @@ export { RRuleParser } from './events/RRuleParser.js';
28
28
  export { EnhancedCalendar } from './integration/EnhancedCalendar.js';
29
29
 
30
30
  // Version — keep in sync with package.json
31
- export const VERSION = '2.1.65';
31
+ export const VERSION = '2.1.67';
32
32
 
33
33
  // Default export
34
34
  export { Calendar as default } from './calendar/Calendar.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/core",
3
- "version": "2.1.65",
3
+ "version": "2.1.67",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",