@forcecalendar/core 0.2.0 → 0.2.1

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.
@@ -0,0 +1,382 @@
1
+ import { DateUtils } from '../calendar/DateUtils.js';
2
+ import { TimezoneManager } from '../timezone/TimezoneManager.js';
3
+ import { RRuleParser } from './RRuleParser.js';
4
+
5
+ /**
6
+ * RecurrenceEngine - Handles expansion of recurring events
7
+ * Full support for RFC 5545 (iCalendar) RRULE specification
8
+ */
9
+ export class RecurrenceEngine {
10
+ /**
11
+ * Expand a recurring event into individual occurrences
12
+ * @param {import('./Event.js').Event} event - The recurring event
13
+ * @param {Date} rangeStart - Start of the expansion range
14
+ * @param {Date} rangeEnd - End of the expansion range
15
+ * @param {number} [maxOccurrences=365] - Maximum number of occurrences to generate
16
+ * @param {string} [timezone] - Timezone for expansion (important for DST)
17
+ * @returns {import('../../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
18
+ */
19
+ static expandEvent(event, rangeStart, rangeEnd, maxOccurrences = 365, timezone = null) {
20
+ if (!event.recurring || !event.recurrenceRule) {
21
+ return [{ start: event.start, end: event.end, timezone: event.timeZone }];
22
+ }
23
+
24
+ const rule = this.parseRule(event.recurrenceRule);
25
+ const occurrences = [];
26
+ const duration = event.end - event.start;
27
+ const eventTimezone = timezone || event.timeZone || 'UTC';
28
+ const tzManager = new TimezoneManager();
29
+
30
+ // Work in event's timezone for accurate recurrence calculation
31
+ let currentDate = new Date(event.start);
32
+ let count = 0;
33
+
34
+ // If UNTIL is specified, use it as the range end
35
+ if (rule.until && rule.until < rangeEnd) {
36
+ rangeEnd = rule.until;
37
+ }
38
+
39
+ // Track DST transitions for proper timezone handling
40
+ let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
41
+
42
+ while (currentDate <= rangeEnd && count < maxOccurrences) {
43
+ // Check if this occurrence is within the range
44
+ if (currentDate >= rangeStart) {
45
+ const occurrenceStart = new Date(currentDate);
46
+ const occurrenceEnd = new Date(currentDate.getTime() + duration);
47
+
48
+ // Handle DST transitions
49
+ const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
50
+ if (currentOffset !== lastOffset) {
51
+ // Adjust for DST change
52
+ const offsetDiff = lastOffset - currentOffset;
53
+ occurrenceStart.setMinutes(occurrenceStart.getMinutes() + offsetDiff);
54
+ occurrenceEnd.setMinutes(occurrenceEnd.getMinutes() + offsetDiff);
55
+ }
56
+ lastOffset = currentOffset;
57
+
58
+ // Apply exceptions if any
59
+ if (!this.isException(occurrenceStart, rule, event.id)) {
60
+ occurrences.push({
61
+ start: occurrenceStart,
62
+ end: occurrenceEnd,
63
+ recurringEventId: event.id,
64
+ timezone: eventTimezone,
65
+ originalStart: event.start
66
+ });
67
+ }
68
+ }
69
+
70
+ // Calculate next occurrence
71
+ currentDate = this.getNextOccurrence(currentDate, rule, eventTimezone);
72
+ count++;
73
+
74
+ // Check COUNT limit
75
+ if (rule.count && count >= rule.count) {
76
+ break;
77
+ }
78
+ }
79
+
80
+ return occurrences;
81
+ }
82
+
83
+ /**
84
+ * Parse an RRULE string into a rule object
85
+ * @param {string|import('../../types.js').RecurrenceRule} ruleString - RRULE string (e.g., "FREQ=DAILY;INTERVAL=1;COUNT=10") or rule object
86
+ * @returns {import('../../types.js').RecurrenceRule} Parsed rule object
87
+ */
88
+ static parseRule(ruleString) {
89
+ // Use the new comprehensive parser
90
+ return RRuleParser.parse(ruleString);
91
+ }
92
+
93
+ /**
94
+ * Calculate the next occurrence based on the rule
95
+ * @param {Date} currentDate - Current occurrence date
96
+ * @param {Object} rule - Recurrence rule object
97
+ * @param {string} [timezone] - Timezone for calculation
98
+ * @returns {Date} Next occurrence date
99
+ */
100
+ static getNextOccurrence(currentDate, rule, timezone = 'UTC') {
101
+ const next = new Date(currentDate);
102
+
103
+ switch (rule.freq) {
104
+ case 'DAILY':
105
+ next.setDate(next.getDate() + rule.interval);
106
+ break;
107
+
108
+ case 'WEEKLY':
109
+ if (rule.byDay && rule.byDay.length > 0) {
110
+ // Find next day that matches byDay
111
+ next.setDate(next.getDate() + 1);
112
+ while (!this.matchesByDay(next, rule.byDay)) {
113
+ next.setDate(next.getDate() + 1);
114
+ }
115
+ } else {
116
+ // Simple weekly recurrence
117
+ next.setDate(next.getDate() + (7 * rule.interval));
118
+ }
119
+ break;
120
+
121
+ case 'MONTHLY':
122
+ if (rule.byMonthDay && rule.byMonthDay.length > 0) {
123
+ // Specific day(s) of month
124
+ const currentMonth = next.getMonth();
125
+ next.setMonth(currentMonth + rule.interval);
126
+ next.setDate(rule.byMonthDay[0]); // Use first specified day
127
+ } else if (rule.byDay && rule.byDay.length > 0) {
128
+ // Specific weekday of month (e.g., "2nd Tuesday")
129
+ next.setMonth(next.getMonth() + rule.interval);
130
+ this.setToWeekdayOfMonth(next, rule.byDay[0], rule.bySetPos[0] || 1);
131
+ } else {
132
+ // Same day of month
133
+ next.setMonth(next.getMonth() + rule.interval);
134
+ }
135
+ break;
136
+
137
+ case 'YEARLY':
138
+ if (rule.byMonth && rule.byMonth.length > 0) {
139
+ next.setFullYear(next.getFullYear() + rule.interval);
140
+ next.setMonth(rule.byMonth[0] - 1); // Months are 0-indexed
141
+ } else {
142
+ next.setFullYear(next.getFullYear() + rule.interval);
143
+ }
144
+ break;
145
+
146
+ default:
147
+ // Unsupported frequency
148
+ next.setTime(next.getTime() + (24 * 60 * 60 * 1000)); // Daily fallback
149
+ }
150
+
151
+ return next;
152
+ }
153
+
154
+ /**
155
+ * Check if a date matches the BYDAY rule
156
+ * @param {Date} date - Date to check
157
+ * @param {Array<string>} byDay - Array of day codes (e.g., ['MO', 'WE', 'FR'])
158
+ * @returns {boolean}
159
+ */
160
+ static matchesByDay(date, byDay) {
161
+ const dayMap = {
162
+ 'SU': 0, 'MO': 1, 'TU': 2, 'WE': 3,
163
+ 'TH': 4, 'FR': 5, 'SA': 6
164
+ };
165
+
166
+ const dayOfWeek = date.getDay();
167
+ return byDay.some(day => {
168
+ // Handle numbered weekdays (e.g., "2MO" for 2nd Monday)
169
+ const match = day.match(/^(-?\d+)?([A-Z]{2})$/);
170
+ if (match) {
171
+ const weekdayCode = match[2];
172
+ return dayMap[weekdayCode] === dayOfWeek;
173
+ }
174
+ return false;
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Set date to specific weekday of month
180
+ * @param {Date} date - Date to modify
181
+ * @param {string} weekday - Weekday code (e.g., 'MO', 'TU')
182
+ * @param {number} position - Position in month (1-5, or -1 for last)
183
+ */
184
+ static setToWeekdayOfMonth(date, weekday, position = 1) {
185
+ const dayMap = {
186
+ 'SU': 0, 'MO': 1, 'TU': 2, 'WE': 3,
187
+ 'TH': 4, 'FR': 5, 'SA': 6
188
+ };
189
+
190
+ // Extract weekday code if it has a number prefix
191
+ const match = weekday.match(/^(-?\d+)?([A-Z]{2})$/);
192
+ const weekdayCode = match ? match[2] : weekday;
193
+ const targetDay = dayMap[weekdayCode];
194
+
195
+ date.setDate(1); // Start at first of month
196
+
197
+ // Find first occurrence of the weekday
198
+ while (date.getDay() !== targetDay) {
199
+ date.setDate(date.getDate() + 1);
200
+ }
201
+
202
+ // Move to the nth occurrence
203
+ if (position > 1) {
204
+ date.setDate(date.getDate() + (7 * (position - 1)));
205
+ } else if (position === -1) {
206
+ // Last occurrence of the month
207
+ const nextMonth = new Date(date);
208
+ nextMonth.setMonth(nextMonth.getMonth() + 1);
209
+ nextMonth.setDate(0); // Last day of current month
210
+
211
+ while (nextMonth.getDay() !== targetDay) {
212
+ nextMonth.setDate(nextMonth.getDate() - 1);
213
+ }
214
+ date.setTime(nextMonth.getTime());
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Check if a date is an exception
220
+ * @param {Date} date - Date to check
221
+ * @param {Object} rule - Rule object with exceptions
222
+ * @param {string} [eventId] - Event ID for better exception tracking
223
+ * @returns {boolean}
224
+ */
225
+ static isException(date, rule, eventId = null) {
226
+ if (!rule.exceptions || rule.exceptions.length === 0) {
227
+ return false;
228
+ }
229
+
230
+ // Support both date-only and date-time exceptions
231
+ const dateStr = date.toDateString();
232
+ const dateTime = date.getTime();
233
+
234
+ return rule.exceptions.some(exDate => {
235
+ if (typeof exDate === 'object' && exDate.date) {
236
+ // Enhanced exception format with reason
237
+ const exceptionDate = exDate.date instanceof Date ? exDate.date : new Date(exDate.date);
238
+ if (exDate.matchTime) {
239
+ return Math.abs(exceptionDate.getTime() - dateTime) < 1000; // Within 1 second
240
+ }
241
+ return exceptionDate.toDateString() === dateStr;
242
+ } else {
243
+ // Simple date exception
244
+ const exceptionDate = exDate instanceof Date ? exDate : new Date(exDate);
245
+ return exceptionDate.toDateString() === dateStr;
246
+ }
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Add exception dates to a recurrence rule
252
+ * @param {Object} rule - Recurrence rule
253
+ * @param {Date|Date[]} exceptions - Exception date(s) to add
254
+ * @param {Object} [options] - Options for exception
255
+ * @returns {Object} Updated rule
256
+ */
257
+ static addExceptions(rule, exceptions, options = {}) {
258
+ if (!rule.exceptions) {
259
+ rule.exceptions = [];
260
+ }
261
+
262
+ const exceptionArray = Array.isArray(exceptions) ? exceptions : [exceptions];
263
+
264
+ exceptionArray.forEach(date => {
265
+ if (options.reason || options.matchTime) {
266
+ rule.exceptions.push({
267
+ date: date,
268
+ reason: options.reason,
269
+ matchTime: options.matchTime || false
270
+ });
271
+ } else {
272
+ rule.exceptions.push(date);
273
+ }
274
+ });
275
+
276
+ return rule;
277
+ }
278
+
279
+ /**
280
+ * Parse date from RRULE format (YYYYMMDDTHHMMSSZ)
281
+ * @param {string} dateStr - Date string in RRULE format
282
+ * @returns {Date}
283
+ */
284
+ static parseDate(dateStr) {
285
+ if (dateStr.length === 8) {
286
+ // YYYYMMDD
287
+ const year = parseInt(dateStr.substr(0, 4), 10);
288
+ const month = parseInt(dateStr.substr(4, 2), 10) - 1;
289
+ const day = parseInt(dateStr.substr(6, 2), 10);
290
+ return new Date(year, month, day);
291
+ } else if (dateStr.length === 15 || dateStr.length === 16) {
292
+ // YYYYMMDDTHHMMSS[Z]
293
+ const year = parseInt(dateStr.substr(0, 4), 10);
294
+ const month = parseInt(dateStr.substr(4, 2), 10) - 1;
295
+ const day = parseInt(dateStr.substr(6, 2), 10);
296
+ const hour = parseInt(dateStr.substr(9, 2), 10);
297
+ const minute = parseInt(dateStr.substr(11, 2), 10);
298
+ const second = parseInt(dateStr.substr(13, 2), 10);
299
+
300
+ if (dateStr.endsWith('Z')) {
301
+ return new Date(Date.UTC(year, month, day, hour, minute, second));
302
+ } else {
303
+ return new Date(year, month, day, hour, minute, second);
304
+ }
305
+ }
306
+
307
+ // Fallback to standard date parsing
308
+ return new Date(dateStr);
309
+ }
310
+
311
+ /**
312
+ * Generate a human-readable description of the recurrence rule
313
+ * @param {Object|string} rule - Recurrence rule
314
+ * @returns {string} Human-readable description
315
+ */
316
+ static getDescription(rule) {
317
+ if (typeof rule === 'string') {
318
+ rule = this.parseRule(rule);
319
+ }
320
+
321
+ let description = '';
322
+ const interval = rule.interval || 1;
323
+
324
+ switch (rule.freq) {
325
+ case 'DAILY':
326
+ description = interval === 1 ? 'Daily' : `Every ${interval} days`;
327
+ break;
328
+ case 'WEEKLY':
329
+ description = interval === 1 ? 'Weekly' : `Every ${interval} weeks`;
330
+ if (rule.byDay && rule.byDay.length > 0) {
331
+ const days = rule.byDay.map(d => this.getDayName(d)).join(', ');
332
+ description += ` on ${days}`;
333
+ }
334
+ break;
335
+ case 'MONTHLY':
336
+ description = interval === 1 ? 'Monthly' : `Every ${interval} months`;
337
+ if (rule.byMonthDay && rule.byMonthDay.length > 0) {
338
+ description += ` on day ${rule.byMonthDay.join(', ')}`;
339
+ }
340
+ break;
341
+ case 'YEARLY':
342
+ description = interval === 1 ? 'Yearly' : `Every ${interval} years`;
343
+ break;
344
+ }
345
+
346
+ if (rule.count) {
347
+ description += `, ${rule.count} times`;
348
+ } else if (rule.until) {
349
+ description += `, until ${rule.until.toLocaleDateString()}`;
350
+ }
351
+
352
+ return description;
353
+ }
354
+
355
+ /**
356
+ * Get day name from RRULE day code
357
+ * @param {string} dayCode - Day code (e.g., 'MO', '2TU')
358
+ * @returns {string} Day name
359
+ */
360
+ static getDayName(dayCode) {
361
+ const dayNames = {
362
+ 'SU': 'Sunday', 'MO': 'Monday', 'TU': 'Tuesday',
363
+ 'WE': 'Wednesday', 'TH': 'Thursday', 'FR': 'Friday',
364
+ 'SA': 'Saturday'
365
+ };
366
+
367
+ // Extract day code if it has a number prefix
368
+ const match = dayCode.match(/^(-?\d+)?([A-Z]{2})$/);
369
+ const code = match ? match[2] : dayCode;
370
+ const position = match && match[1] ? parseInt(match[1], 10) : null;
371
+
372
+ let name = dayNames[code] || dayCode;
373
+
374
+ if (position) {
375
+ const ordinals = ['', '1st', '2nd', '3rd', '4th', '5th'];
376
+ const ordinal = position === -1 ? 'Last' : (ordinals[position] || `${position}th`);
377
+ name = `${ordinal} ${name}`;
378
+ }
379
+
380
+ return name;
381
+ }
382
+ }