@forcecalendar/core 1.1.0 → 2.1.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) {
@@ -373,7 +374,7 @@ export class Event {
373
374
  * @returns {boolean} True if event spans multiple days
374
375
  */
375
376
  get isMultiDay() {
376
- if (!this._cache.hasOwnProperty('isMultiDay')) {
377
+ if (!Object.prototype.hasOwnProperty.call(this._cache, 'isMultiDay')) {
377
378
  const startDay = this.start.toDateString();
378
379
  const endDay = this.end.toDateString();
379
380
  this._cache.isMultiDay = startDay !== endDay;
@@ -416,10 +417,10 @@ export class Event {
416
417
  dayEnd.setHours(23, 59, 59, 999);
417
418
 
418
419
  return this.start <= dayEnd && this.end >= dayStart;
419
- } else {
420
+ }
420
421
  // Single day event: check if it's on the same day
421
422
  return startString === dateString;
422
- }
423
+
423
424
  }
424
425
 
425
426
  /**
@@ -435,9 +436,9 @@ export class Event {
435
436
  } else if (otherEvent && otherEvent.start && otherEvent.end) {
436
437
  // Allow checking against time ranges
437
438
  return !(this.end <= otherEvent.start || this.start >= otherEvent.end);
438
- } else {
439
+ }
439
440
  throw new Error('Parameter must be an Event instance or have start/end properties');
440
- }
441
+
441
442
  }
442
443
 
443
444
  /**
@@ -226,12 +226,12 @@ export class EventStore {
226
226
  }
227
227
 
228
228
  // Filter by all-day events
229
- if (filters.hasOwnProperty('allDay')) {
229
+ if (Object.prototype.hasOwnProperty.call(filters, 'allDay')) {
230
230
  results = results.filter(event => event.allDay === filters.allDay);
231
231
  }
232
232
 
233
233
  // Filter by recurring
234
- if (filters.hasOwnProperty('recurring')) {
234
+ if (Object.prototype.hasOwnProperty.call(filters, 'recurring')) {
235
235
  results = results.filter(event => event.recurring === filters.recurring);
236
236
  }
237
237
 
@@ -250,7 +250,7 @@ export class EventStore {
250
250
  }
251
251
 
252
252
  // Filter by having attendees
253
- if (filters.hasOwnProperty('hasAttendees')) {
253
+ if (Object.prototype.hasOwnProperty.call(filters, 'hasAttendees')) {
254
254
  results = results.filter(event => filters.hasAttendees ? event.hasAttendees : !event.hasAttendees);
255
255
  }
256
256
 
@@ -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
+ const 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
@@ -248,11 +267,11 @@ export class RecurrenceEngine {
248
267
  return Math.abs(exceptionDate.getTime() - dateTime) < 1000; // Within 1 second
249
268
  }
250
269
  return exceptionDate.toDateString() === dateStr;
251
- } else {
270
+ }
252
271
  // Simple date exception
253
272
  const exceptionDate = exDate instanceof Date ? exDate : new Date(exDate);
254
273
  return exceptionDate.toDateString() === dateStr;
255
- }
274
+
256
275
  });
257
276
  }
258
277
 
@@ -308,9 +327,9 @@ export class RecurrenceEngine {
308
327
 
309
328
  if (dateStr.endsWith('Z')) {
310
329
  return new Date(Date.UTC(year, month, day, hour, minute, second));
311
- } else {
330
+ }
312
331
  return new Date(year, month, day, hour, minute, second);
313
- }
332
+
314
333
  }
315
334
 
316
335
  // Fallback to standard date parsing
@@ -609,7 +609,7 @@ export class RecurrenceEngineV2 {
609
609
  */
610
610
  clearEventCache(eventId) {
611
611
  for (const key of this.occurrenceCache.keys()) {
612
- if (key.startsWith(eventId + '_')) {
612
+ if (key.startsWith(`${eventId }_`)) {
613
613
  this.occurrenceCache.delete(key);
614
614
  }
615
615
  }
@@ -39,7 +39,7 @@ export class ICSParser {
39
39
  let inEvent = false;
40
40
  let inAlarm = false;
41
41
 
42
- for (let line of lines) {
42
+ for (const line of lines) {
43
43
  // Skip empty lines
44
44
  if (!line.trim()) continue;
45
45
 
@@ -248,7 +248,7 @@ export class ICSParser {
248
248
  event.category = value.split(',')[0]; // Take first category
249
249
  break;
250
250
 
251
- case 'STATUS':
251
+ case 'STATUS': {
252
252
  const statusMap = {
253
253
  'TENTATIVE': 'tentative',
254
254
  'CONFIRMED': 'confirmed',
@@ -256,6 +256,7 @@ export class ICSParser {
256
256
  };
257
257
  event.status = statusMap[value] || 'confirmed';
258
258
  break;
259
+ }
259
260
 
260
261
  case 'TRANSP':
261
262
  event.showAs = value === 'TRANSPARENT' ? 'free' : 'busy';
@@ -265,7 +266,7 @@ export class ICSParser {
265
266
  event.organizer = value.replace('mailto:', '');
266
267
  break;
267
268
 
268
- case 'ATTENDEE':
269
+ case 'ATTENDEE': {
269
270
  if (!event.attendees) event.attendees = [];
270
271
  const email = value.replace('mailto:', '');
271
272
  event.attendees.push({
@@ -273,6 +274,7 @@ export class ICSParser {
273
274
  name: email.split('@')[0] // Use email prefix as name
274
275
  });
275
276
  break;
277
+ }
276
278
 
277
279
  case 'RRULE':
278
280
  event.recurrence = value;
@@ -308,10 +310,10 @@ export class ICSParser {
308
310
  if (dateString.endsWith('Z')) {
309
311
  // UTC time
310
312
  return new Date(Date.UTC(year, month, day, hour, minute, second));
311
- } else {
313
+ }
312
314
  // Local time
313
315
  return new Date(year, month, day, hour, minute, second);
314
- }
316
+
315
317
  }
316
318
 
317
319
  /**
@@ -365,7 +367,7 @@ export class ICSParser {
365
367
  // Continuation lines (with space prefix)
366
368
  while (remaining.length > 0) {
367
369
  const chunk = remaining.substr(0, this.maxLineLength - 1);
368
- folded.push(' ' + chunk);
370
+ folded.push(` ${ chunk}`);
369
371
  remaining = remaining.substr(chunk.length);
370
372
  }
371
373
 
@@ -288,7 +288,7 @@ export class EventSearch {
288
288
  const events = this.eventStore.getAllEvents();
289
289
 
290
290
  for (const event of events) {
291
- const value = event[field] || (includeEmpty ? '(No ' + field + ')' : null);
291
+ const value = event[field] || (includeEmpty ? `(No ${ field })` : null);
292
292
  if (value === null) continue;
293
293
 
294
294
  if (!groups.has(value)) {
@@ -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
@@ -343,13 +343,13 @@ export class TimezoneManager {
343
343
  if (!tzString) return 'UTC';
344
344
 
345
345
  // Check if it's already an IANA identifier
346
- if (this.database.timezones.hasOwnProperty(tzString)) {
346
+ if (Object.prototype.hasOwnProperty.call(this.database.timezones, tzString)) {
347
347
  return tzString;
348
348
  }
349
349
 
350
350
  // Check abbreviations
351
351
  const upperTz = tzString.toUpperCase();
352
- if (this.database.abbreviations && this.database.abbreviations.hasOwnProperty(upperTz)) {
352
+ if (this.database.abbreviations && Object.prototype.hasOwnProperty.call(this.database.abbreviations, upperTz)) {
353
353
  return this.database.abbreviations[upperTz];
354
354
  }
355
355
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/core",
3
- "version": "1.1.0",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",
@@ -11,7 +11,12 @@
11
11
  "scripts": {
12
12
  "test": "node tests/run-all.js",
13
13
  "test:ics": "node tests/integration/test-ics.js",
14
- "test:search": "node tests/integration/test-search.js"
14
+ "test:search": "node tests/integration/test-search.js",
15
+ "lint": "eslint core/ --ext .js",
16
+ "lint:fix": "eslint core/ --ext .js --fix",
17
+ "format": "prettier --write \"core/**/*.js\"",
18
+ "format:check": "prettier --check \"core/**/*.js\"",
19
+ "quality": "npm run lint && npm run format:check"
15
20
  },
16
21
  "exports": {
17
22
  ".": "./core/index.js",