@forcecalendar/core 1.1.0 → 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.
package/core/events/Event.js
CHANGED
|
@@ -15,11 +15,11 @@ export class Event {
|
|
|
15
15
|
static normalize(data) {
|
|
16
16
|
const normalized = { ...data };
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
if (normalized.start
|
|
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
|
|
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
|
-
|
|
385
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
-
//
|
|
509
|
-
|
|
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
|