@forcecalendar/core 1.0.7 → 2.0.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.
@@ -15,11 +15,11 @@ export class Event {
15
15
  static normalize(data) {
16
16
  const normalized = { ...data };
17
17
 
18
- // Ensure dates are Date objects
19
- if (normalized.start && !(normalized.start instanceof Date)) {
18
+ // Always clone Date objects to avoid mutating caller's data
19
+ if (normalized.start) {
20
20
  normalized.start = new Date(normalized.start);
21
21
  }
22
- if (normalized.end && !(normalized.end instanceof Date)) {
22
+ if (normalized.end) {
23
23
  normalized.end = new Date(normalized.end);
24
24
  }
25
25
 
@@ -29,6 +29,7 @@ export class Event {
29
29
  }
30
30
 
31
31
  // For all-day events, normalize times to midnight
32
+ // (safe to mutate now since we cloned above)
32
33
  if (normalized.allDay && normalized.start) {
33
34
  normalized.start.setHours(0, 0, 0, 0);
34
35
  if (normalized.end) {
@@ -265,6 +265,11 @@ export class RRuleParser {
265
265
  // By* rules
266
266
  if (rule.byDay && rule.byDay.length > 0) {
267
267
  const dayStr = rule.byDay.map(d => {
268
+ // Handle both string format ('MO', '2TU', '-1FR') from parseByDay
269
+ // and object format ({nth: 2, weekday: 'MO'})
270
+ if (typeof d === 'string') {
271
+ return d;
272
+ }
268
273
  return d.nth ? `${d.nth}${d.weekday}` : d.weekday;
269
274
  }).join(',');
270
275
  parts.push(`BYDAY=${dayStr}`);
@@ -374,17 +379,30 @@ export class RRuleParser {
374
379
  description += 's';
375
380
  }
376
381
 
377
- // By day
382
+ // By day - handle both string format ('MO', '2TU') and object format ({nth, weekday})
378
383
  if (rule.byDay && rule.byDay.length > 0) {
384
+ // Helper to extract weekday and nth from string or object
385
+ const parseDay = (d) => {
386
+ if (typeof d === 'string') {
387
+ const match = d.match(/^(-?\d+)?([A-Z]{2})$/);
388
+ if (match) {
389
+ return { nth: match[1] ? parseInt(match[1], 10) : null, weekday: match[2] };
390
+ }
391
+ return { nth: null, weekday: d };
392
+ }
393
+ return d;
394
+ };
395
+
379
396
  if (rule.freq === 'WEEKLY') {
380
- const days = rule.byDay.map(d => weekdayMap[d.weekday]).join(', ');
397
+ const days = rule.byDay.map(d => weekdayMap[parseDay(d).weekday]).join(', ');
381
398
  description += ` on ${days}`;
382
399
  } else if (rule.freq === 'MONTHLY' || rule.freq === 'YEARLY') {
383
400
  const dayDescs = rule.byDay.map(d => {
384
- if (d.nth) {
385
- return `the ${nthMap[d.nth] || d.nth} ${weekdayMap[d.weekday]}`;
401
+ const parsed = parseDay(d);
402
+ if (parsed.nth) {
403
+ return `the ${nthMap[parsed.nth] || parsed.nth} ${weekdayMap[parsed.weekday]}`;
386
404
  }
387
- return weekdayMap[d.weekday];
405
+ return weekdayMap[parsed.weekday];
388
406
  }).join(', ');
389
407
  description += ` on ${dayDescs}`;
390
408
  }
@@ -39,6 +39,11 @@ export class RecurrenceEngine {
39
39
  // Track DST transitions for proper timezone handling
40
40
  let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
41
41
 
42
+ // Track last date to detect infinite loop (date not advancing)
43
+ let lastDateTimestamp = null;
44
+ let stuckCount = 0;
45
+ const maxStuckIterations = 3;
46
+
42
47
  while (currentDate <= rangeEnd && count < maxOccurrences) {
43
48
  // Check if this occurrence is within the range
44
49
  if (currentDate >= rangeStart) {
@@ -68,9 +73,21 @@ export class RecurrenceEngine {
68
73
  }
69
74
 
70
75
  // Calculate next occurrence
76
+ const previousTimestamp = currentDate.getTime();
71
77
  currentDate = this.getNextOccurrence(currentDate, rule, eventTimezone);
72
78
  count++;
73
79
 
80
+ // Safeguard: detect if date is not advancing (infinite loop risk)
81
+ if (currentDate.getTime() === previousTimestamp) {
82
+ stuckCount++;
83
+ if (stuckCount >= maxStuckIterations) {
84
+ console.warn('RecurrenceEngine: Date not advancing, breaking to prevent infinite loop');
85
+ break;
86
+ }
87
+ } else {
88
+ stuckCount = 0;
89
+ }
90
+
74
91
  // Check COUNT limit
75
92
  if (rule.count && count >= rule.count) {
76
93
  break;
@@ -111,15 +128,17 @@ export class RecurrenceEngine {
111
128
  // Limit iterations to prevent infinite loop with malformed byDay
112
129
  const maxIterations = 8; // 7 days + 1 for safety
113
130
  let iterations = 0;
131
+ const originalDate = next.getDate();
114
132
  next.setDate(next.getDate() + 1);
115
133
  while (!this.matchesByDay(next, rule.byDay) && iterations < maxIterations) {
116
134
  next.setDate(next.getDate() + 1);
117
135
  iterations++;
118
136
  }
119
- // If no match found, fall back to simple weekly interval
137
+ // If no match found, fall back to simple weekly interval from original date
120
138
  if (iterations >= maxIterations) {
121
139
  console.warn('RecurrenceEngine: Invalid byDay rule, falling back to weekly interval');
122
- next.setDate(next.getDate() + (7 * rule.interval) - maxIterations);
140
+ // Reset to original and add weekly interval
141
+ next.setDate(originalDate + (7 * rule.interval));
123
142
  }
124
143
  } else {
125
144
  // Simple weekly recurrence
@@ -505,8 +505,10 @@ export class StateManager {
505
505
  this.history = this.history.slice(0, this.historyIndex + 1);
506
506
  }
507
507
 
508
- // Add new state
509
- this.history.push({ ...state });
508
+ // Deep clone state to prevent shared references in history
509
+ // (shallow copy would share nested objects like filters, businessHours, metadata)
510
+ const clonedState = this._deepClone(state);
511
+ this.history.push(clonedState);
510
512
  this.historyIndex++;
511
513
 
512
514
  // Limit history size
@@ -516,6 +518,32 @@ export class StateManager {
516
518
  }
517
519
  }
518
520
 
521
+ /**
522
+ * Deep clone a value for history storage
523
+ * @private
524
+ */
525
+ _deepClone(value) {
526
+ if (value === null || typeof value !== 'object') {
527
+ return value;
528
+ }
529
+
530
+ if (value instanceof Date) {
531
+ return new Date(value);
532
+ }
533
+
534
+ if (Array.isArray(value)) {
535
+ return value.map(item => this._deepClone(item));
536
+ }
537
+
538
+ const cloned = {};
539
+ for (const key in value) {
540
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
541
+ cloned[key] = this._deepClone(value[key]);
542
+ }
543
+ }
544
+ return cloned;
545
+ }
546
+
519
547
  /**
520
548
  * Notify listeners of state changes
521
549
  * @private
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/core",
3
- "version": "1.0.7",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",