@forcecalendar/core 0.3.0 → 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.
|
@@ -142,6 +142,14 @@ export class Calendar {
|
|
|
142
142
|
});
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Alias for goToDate (compat)
|
|
147
|
+
* @param {Date} date - The date to navigate to
|
|
148
|
+
*/
|
|
149
|
+
setDate(date) {
|
|
150
|
+
this.goToDate(date);
|
|
151
|
+
}
|
|
152
|
+
|
|
145
153
|
/**
|
|
146
154
|
* Get the current date
|
|
147
155
|
* @returns {Date}
|
|
@@ -199,6 +207,15 @@ export class Calendar {
|
|
|
199
207
|
return removed;
|
|
200
208
|
}
|
|
201
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Alias for removeEvent (compat)
|
|
212
|
+
* @param {string} eventId - The event ID
|
|
213
|
+
* @returns {boolean} True if removed
|
|
214
|
+
*/
|
|
215
|
+
deleteEvent(eventId) {
|
|
216
|
+
return this.removeEvent(eventId);
|
|
217
|
+
}
|
|
218
|
+
|
|
202
219
|
/**
|
|
203
220
|
* Get an event by ID
|
|
204
221
|
* @param {string} eventId - The event ID
|
|
@@ -281,6 +298,26 @@ export class Calendar {
|
|
|
281
298
|
return this.config.timeZone;
|
|
282
299
|
}
|
|
283
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Set the calendar locale
|
|
303
|
+
* @param {string} locale - Locale identifier (e.g. 'en-US')
|
|
304
|
+
*/
|
|
305
|
+
setLocale(locale) {
|
|
306
|
+
this.config.locale = locale;
|
|
307
|
+
this.state.setState({ locale });
|
|
308
|
+
this._emit('localeChange', { locale });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Set the week start day
|
|
313
|
+
* @param {number} weekStartsOn - 0 = Sunday, 1 = Monday, etc.
|
|
314
|
+
*/
|
|
315
|
+
setWeekStartsOn(weekStartsOn) {
|
|
316
|
+
this.config.weekStartsOn = weekStartsOn;
|
|
317
|
+
this.state.setState({ weekStartsOn });
|
|
318
|
+
this._emit('weekStartsOnChange', { weekStartsOn });
|
|
319
|
+
}
|
|
320
|
+
|
|
284
321
|
/**
|
|
285
322
|
* Convert a date from one timezone to another
|
|
286
323
|
* @param {Date} date - Date to convert
|
|
@@ -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.
|
|
31
|
+
export const VERSION = '0.4.0';
|
|
23
32
|
|
|
24
33
|
// Default export
|
|
25
34
|
export { Calendar as default } from './calendar/Calendar.js';
|