@forcecalendar/core 0.2.0 → 0.3.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.
@@ -0,0 +1,918 @@
1
+ /**
2
+ * Event class - represents a calendar event with timezone support
3
+ * Pure JavaScript, no DOM dependencies
4
+ * Locker Service compatible
5
+ */
6
+
7
+ import { TimezoneManager } from '../timezone/TimezoneManager.js';
8
+
9
+ export class Event {
10
+ /**
11
+ * Normalize event data
12
+ * @param {import('../../types.js').EventData} data - Raw event data
13
+ * @returns {import('../../types.js').EventData} Normalized event data
14
+ */
15
+ static normalize(data) {
16
+ const normalized = { ...data };
17
+
18
+ // Ensure dates are Date objects
19
+ if (normalized.start && !(normalized.start instanceof Date)) {
20
+ normalized.start = new Date(normalized.start);
21
+ }
22
+ if (normalized.end && !(normalized.end instanceof Date)) {
23
+ normalized.end = new Date(normalized.end);
24
+ }
25
+
26
+ // If no end date, set it to start date
27
+ if (!normalized.end) {
28
+ normalized.end = normalized.start ? new Date(normalized.start) : null;
29
+ }
30
+
31
+ // For all-day events, normalize times to midnight
32
+ if (normalized.allDay && normalized.start) {
33
+ normalized.start.setHours(0, 0, 0, 0);
34
+ if (normalized.end) {
35
+ normalized.end.setHours(23, 59, 59, 999);
36
+ }
37
+ }
38
+
39
+ // Normalize string fields
40
+ normalized.id = String(normalized.id || '').trim();
41
+ normalized.title = String(normalized.title || '').trim();
42
+ normalized.description = String(normalized.description || '').trim();
43
+ normalized.location = String(normalized.location || '').trim();
44
+
45
+ // Normalize arrays
46
+ normalized.attendees = Array.isArray(normalized.attendees) ? normalized.attendees : [];
47
+ normalized.reminders = Array.isArray(normalized.reminders) ? normalized.reminders : [];
48
+
49
+ // Handle both 'category' (singular) and 'categories' (plural)
50
+ if (data.category && !data.categories) {
51
+ // If single category is provided, convert to array
52
+ normalized.categories = [data.category];
53
+ } else if (normalized.categories) {
54
+ normalized.categories = Array.isArray(normalized.categories) ? normalized.categories : [];
55
+ } else {
56
+ normalized.categories = [];
57
+ }
58
+
59
+ normalized.attachments = Array.isArray(normalized.attachments) ? normalized.attachments : [];
60
+
61
+ // Normalize status and visibility
62
+ const validStatuses = ['confirmed', 'tentative', 'cancelled'];
63
+ if (!validStatuses.includes(normalized.status)) {
64
+ normalized.status = 'confirmed';
65
+ }
66
+
67
+ const validVisibilities = ['public', 'private', 'confidential'];
68
+ if (!validVisibilities.includes(normalized.visibility)) {
69
+ normalized.visibility = 'public';
70
+ }
71
+
72
+ // Normalize colors
73
+ if (normalized.color && !normalized.backgroundColor) {
74
+ normalized.backgroundColor = normalized.color;
75
+ }
76
+ if (normalized.color && !normalized.borderColor) {
77
+ normalized.borderColor = normalized.color;
78
+ }
79
+
80
+ return normalized;
81
+ }
82
+
83
+ /**
84
+ * Validate event data
85
+ * @param {import('../../types.js').EventData} data - Normalized event data
86
+ * @throws {Error} If validation fails
87
+ */
88
+ static validate(data) {
89
+ // Required fields
90
+ if (!data.id) {
91
+ throw new Error('Event must have an id');
92
+ }
93
+ if (!data.title) {
94
+ throw new Error('Event must have a title');
95
+ }
96
+ if (!data.start) {
97
+ throw new Error('Event must have a start date');
98
+ }
99
+
100
+ // Validate dates
101
+ if (!(data.start instanceof Date) || isNaN(data.start.getTime())) {
102
+ throw new Error('Invalid start date');
103
+ }
104
+ if (data.end && (!(data.end instanceof Date) || isNaN(data.end.getTime()))) {
105
+ throw new Error('Invalid end date');
106
+ }
107
+
108
+ // Validate date order
109
+ if (data.end && data.start && data.end < data.start) {
110
+ throw new Error('Event end time cannot be before start time');
111
+ }
112
+
113
+ // Validate recurrence
114
+ if (data.recurring && !data.recurrenceRule) {
115
+ throw new Error('Recurring events must have a recurrence rule');
116
+ }
117
+
118
+ // Validate attendees
119
+ if (data.attendees && data.attendees.length > 0) {
120
+ data.attendees.forEach((attendee, index) => {
121
+ if (!attendee.email || !attendee.name) {
122
+ throw new Error(`Attendee at index ${index} must have email and name`);
123
+ }
124
+ // Validate email format
125
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
126
+ if (!emailRegex.test(attendee.email)) {
127
+ throw new Error(`Invalid email for attendee: ${attendee.email}`);
128
+ }
129
+ });
130
+ }
131
+
132
+ // Validate reminders
133
+ if (data.reminders && data.reminders.length > 0) {
134
+ data.reminders.forEach((reminder, index) => {
135
+ if (!reminder.method || reminder.minutesBefore == null) {
136
+ throw new Error(`Reminder at index ${index} must have method and minutesBefore`);
137
+ }
138
+ if (reminder.minutesBefore < 0) {
139
+ throw new Error('Reminder minutesBefore must be non-negative');
140
+ }
141
+ });
142
+ }
143
+
144
+ // Validate timezone if provided
145
+ if (data.timeZone) {
146
+ try {
147
+ // Test if timezone is valid by trying to use it
148
+ new Intl.DateTimeFormat('en-US', { timeZone: data.timeZone });
149
+ } catch (e) {
150
+ throw new Error(`Invalid timezone: ${data.timeZone}`);
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Create a new Event instance
157
+ * @param {import('../../types.js').EventData} eventData - Event data object
158
+ * @throws {Error} If required fields are missing or invalid
159
+ */
160
+ constructor({
161
+ id,
162
+ title,
163
+ start,
164
+ end,
165
+ allDay = false,
166
+ description = '',
167
+ location = '',
168
+ color = null,
169
+ backgroundColor = null,
170
+ borderColor = null,
171
+ textColor = null,
172
+ recurring = false,
173
+ recurrenceRule = null,
174
+ timeZone = null,
175
+ endTimeZone = null,
176
+ status = 'confirmed',
177
+ visibility = 'public',
178
+ organizer = null,
179
+ attendees = [],
180
+ reminders = [],
181
+ category, // Support singular category (no default)
182
+ categories, // Support plural categories (no default)
183
+ attachments = [],
184
+ conferenceData = null,
185
+ metadata = {},
186
+ ...rest // Capture any extra properties
187
+ }) {
188
+ // Normalize and validate input
189
+ const normalized = Event.normalize({
190
+ id,
191
+ title,
192
+ start,
193
+ end,
194
+ allDay,
195
+ description,
196
+ location,
197
+ color,
198
+ backgroundColor,
199
+ borderColor,
200
+ textColor,
201
+ recurring,
202
+ recurrenceRule,
203
+ timeZone,
204
+ endTimeZone,
205
+ status,
206
+ visibility,
207
+ organizer,
208
+ attendees,
209
+ reminders,
210
+ category, // Pass category to normalize
211
+ categories, // Pass categories to normalize
212
+ attachments,
213
+ conferenceData,
214
+ metadata,
215
+ ...rest // Pass any extra properties
216
+ });
217
+
218
+ // Validate normalized data
219
+ Event.validate(normalized);
220
+
221
+ this.id = normalized.id;
222
+ this.title = normalized.title;
223
+
224
+ // Initialize timezone manager
225
+ this._timezoneManager = new TimezoneManager();
226
+
227
+ // Timezone handling
228
+ // Store the timezone the event was created in (wall-clock time)
229
+ this.timeZone = normalized.timeZone || this._timezoneManager.getSystemTimezone();
230
+ this.endTimeZone = normalized.endTimeZone || this.timeZone; // Different end timezone for flights etc.
231
+
232
+ // Store dates as provided (wall-clock time in event timezone)
233
+ this.start = normalized.start;
234
+ this.end = normalized.end;
235
+
236
+ // Store UTC versions for efficient querying and comparison
237
+ this.startUTC = this._timezoneManager.toUTC(this.start, this.timeZone);
238
+ this.endUTC = this._timezoneManager.toUTC(this.end, this.endTimeZone);
239
+
240
+ this.allDay = normalized.allDay;
241
+ this.description = normalized.description;
242
+ this.location = normalized.location;
243
+
244
+ // Styling
245
+ this.color = normalized.color;
246
+ this.backgroundColor = normalized.backgroundColor;
247
+ this.borderColor = normalized.borderColor;
248
+ this.textColor = normalized.textColor;
249
+
250
+ // Recurrence
251
+ this.recurring = normalized.recurring;
252
+ this.recurrenceRule = normalized.recurrenceRule;
253
+
254
+ // Store original timezone from system if not provided
255
+ this._originalTimeZone = normalized.timeZone || null;
256
+
257
+ // Event status and visibility
258
+ this.status = normalized.status;
259
+ this.visibility = normalized.visibility;
260
+
261
+ // People
262
+ this.organizer = normalized.organizer;
263
+ this.attendees = [...normalized.attendees];
264
+
265
+ // Reminders
266
+ this.reminders = [...normalized.reminders];
267
+
268
+ // Categories/Tags
269
+ this.categories = normalized.categories ? [...normalized.categories] : [];
270
+
271
+ // Attachments
272
+ this.attachments = [...normalized.attachments];
273
+
274
+ // Conference/Virtual meeting
275
+ this.conferenceData = normalized.conferenceData;
276
+
277
+ // Custom metadata for extensibility
278
+ this.metadata = { ...normalized.metadata };
279
+
280
+ // Computed properties cache
281
+ this._cache = {};
282
+
283
+ // Validate complex properties
284
+ this._validateAttendees();
285
+ this._validateReminders();
286
+ }
287
+
288
+ /**
289
+ * Get event duration in milliseconds
290
+ * @returns {number} Duration in milliseconds
291
+ */
292
+ get duration() {
293
+ if (!this._cache.duration) {
294
+ // Use UTC times for accurate duration calculation
295
+ this._cache.duration = this.endUTC.getTime() - this.startUTC.getTime();
296
+ }
297
+ return this._cache.duration;
298
+ }
299
+
300
+ /**
301
+ * Get start date in a specific timezone
302
+ * @param {string} timezone - Target timezone
303
+ * @returns {Date} Start date in specified timezone
304
+ */
305
+ getStartInTimezone(timezone) {
306
+ if (timezone === this.timeZone) {
307
+ return new Date(this.start);
308
+ }
309
+ return this._timezoneManager.fromUTC(this.startUTC, timezone);
310
+ }
311
+
312
+ /**
313
+ * Get end date in a specific timezone
314
+ * @param {string} timezone - Target timezone
315
+ * @returns {Date} End date in specified timezone
316
+ */
317
+ getEndInTimezone(timezone) {
318
+ if (timezone === this.endTimeZone) {
319
+ return new Date(this.end);
320
+ }
321
+ return this._timezoneManager.fromUTC(this.endUTC, timezone);
322
+ }
323
+
324
+ /**
325
+ * Update event times preserving the timezone
326
+ * @param {Date} start - New start date
327
+ * @param {Date} end - New end date
328
+ * @param {string} [timezone] - Timezone for the new dates
329
+ */
330
+ updateTimes(start, end, timezone) {
331
+ const tz = timezone || this.timeZone;
332
+
333
+ this.start = start instanceof Date ? start : new Date(start);
334
+ this.end = end instanceof Date ? end : new Date(end);
335
+
336
+ if (timezone) {
337
+ this.timeZone = timezone;
338
+ this.endTimeZone = timezone;
339
+ }
340
+
341
+ // Update UTC versions
342
+ this.startUTC = this._timezoneManager.toUTC(this.start, this.timeZone);
343
+ this.endUTC = this._timezoneManager.toUTC(this.end, this.endTimeZone);
344
+
345
+ // Clear cache
346
+ this._cache = {};
347
+
348
+ // Validate
349
+ if (this.endUTC < this.startUTC) {
350
+ throw new Error('Event end time cannot be before start time');
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Get event duration in minutes
356
+ * @returns {number} Duration in minutes
357
+ */
358
+ get durationMinutes() {
359
+ return Math.floor(this.duration / (1000 * 60));
360
+ }
361
+
362
+ /**
363
+ * Get event duration in hours
364
+ * @returns {number} Duration in hours
365
+ */
366
+ get durationHours() {
367
+ return this.duration / (1000 * 60 * 60);
368
+ }
369
+
370
+ /**
371
+ * Check if this is a multi-day event
372
+ * @returns {boolean} True if event spans multiple days
373
+ */
374
+ get isMultiDay() {
375
+ if (!this._cache.hasOwnProperty('isMultiDay')) {
376
+ const startDay = this.start.toDateString();
377
+ const endDay = this.end.toDateString();
378
+ this._cache.isMultiDay = startDay !== endDay;
379
+ }
380
+ return this._cache.isMultiDay;
381
+ }
382
+
383
+ /**
384
+ * Check if event is recurring
385
+ * @returns {boolean} True if event is recurring
386
+ */
387
+ isRecurring() {
388
+ return this.recurring && this.recurrenceRule !== null;
389
+ }
390
+
391
+ /**
392
+ * Check if event occurs on a specific date
393
+ * @param {Date|string} date - The date to check
394
+ * @returns {boolean} True if event occurs on the given date
395
+ */
396
+ occursOn(date) {
397
+ if (!(date instanceof Date)) {
398
+ date = new Date(date);
399
+ }
400
+
401
+ const dateString = date.toDateString();
402
+ const startString = this.start.toDateString();
403
+ const endString = this.end.toDateString();
404
+
405
+ // For all-day events, check if date falls within range
406
+ if (this.allDay) {
407
+ return date >= new Date(startString) && date <= new Date(endString);
408
+ }
409
+
410
+ // For timed events, check if any part of the event occurs on this date
411
+ if (this.isMultiDay) {
412
+ // Multi-day event: check if date is within range
413
+ const dayStart = new Date(dateString);
414
+ const dayEnd = new Date(dateString);
415
+ dayEnd.setHours(23, 59, 59, 999);
416
+
417
+ return this.start <= dayEnd && this.end >= dayStart;
418
+ } else {
419
+ // Single day event: check if it's on the same day
420
+ return startString === dateString;
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Check if this event overlaps with another event
426
+ * @param {Event|{start: Date, end: Date}} otherEvent - The other event or time range to check
427
+ * @returns {boolean} True if events overlap
428
+ * @throws {Error} If otherEvent is not an Event instance or doesn't have start/end
429
+ */
430
+ overlaps(otherEvent) {
431
+ if (otherEvent instanceof Event) {
432
+ // Events don't overlap if one ends before the other starts
433
+ return !(this.end <= otherEvent.start || this.start >= otherEvent.end);
434
+ } else if (otherEvent && otherEvent.start && otherEvent.end) {
435
+ // Allow checking against time ranges
436
+ return !(this.end <= otherEvent.start || this.start >= otherEvent.end);
437
+ } else {
438
+ throw new Error('Parameter must be an Event instance or have start/end properties');
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Check if event contains a specific datetime
444
+ * @param {Date|string} datetime - The datetime to check
445
+ * @returns {boolean} True if the datetime falls within the event
446
+ */
447
+ contains(datetime) {
448
+ if (!(datetime instanceof Date)) {
449
+ datetime = new Date(datetime);
450
+ }
451
+ return datetime >= this.start && datetime <= this.end;
452
+ }
453
+
454
+ /**
455
+ * Clone the event with optional updates
456
+ * @param {Partial<import('../../types.js').EventData>} [updates={}] - Properties to update in the clone
457
+ * @returns {Event} New Event instance with updated properties
458
+ */
459
+ clone(updates = {}) {
460
+ return new Event({
461
+ id: this.id,
462
+ title: this.title,
463
+ start: new Date(this.start),
464
+ end: new Date(this.end),
465
+ allDay: this.allDay,
466
+ description: this.description,
467
+ location: this.location,
468
+ color: this.color,
469
+ backgroundColor: this.backgroundColor,
470
+ borderColor: this.borderColor,
471
+ textColor: this.textColor,
472
+ recurring: this.recurring,
473
+ recurrenceRule: this.recurrenceRule,
474
+ timeZone: this.timeZone,
475
+ status: this.status,
476
+ visibility: this.visibility,
477
+ organizer: this.organizer ? { ...this.organizer } : null,
478
+ attendees: this.attendees.map(a => ({ ...a })),
479
+ reminders: this.reminders.map(r => ({ ...r })),
480
+ categories: [...this.categories],
481
+ attachments: this.attachments.map(a => ({ ...a })),
482
+ conferenceData: this.conferenceData ? { ...this.conferenceData } : null,
483
+ metadata: { ...this.metadata },
484
+ ...updates
485
+ });
486
+ }
487
+
488
+ /**
489
+ * Convert event to plain object
490
+ * @returns {import('../../types.js').EventData} Plain object representation of the event
491
+ */
492
+ toObject() {
493
+ return {
494
+ id: this.id,
495
+ title: this.title,
496
+ start: this.start.toISOString(),
497
+ end: this.end.toISOString(),
498
+ allDay: this.allDay,
499
+ description: this.description,
500
+ location: this.location,
501
+ color: this.color,
502
+ backgroundColor: this.backgroundColor,
503
+ borderColor: this.borderColor,
504
+ textColor: this.textColor,
505
+ recurring: this.recurring,
506
+ recurrenceRule: this.recurrenceRule,
507
+ timeZone: this.timeZone,
508
+ status: this.status,
509
+ visibility: this.visibility,
510
+ organizer: this.organizer,
511
+ attendees: this.attendees,
512
+ reminders: this.reminders,
513
+ categories: this.categories,
514
+ attachments: this.attachments,
515
+ conferenceData: this.conferenceData,
516
+ metadata: { ...this.metadata }
517
+ };
518
+ }
519
+
520
+ /**
521
+ * Create Event from plain object
522
+ * @param {import('../../types.js').EventData} obj - Plain object with event properties
523
+ * @returns {Event} New Event instance
524
+ */
525
+ static fromObject(obj) {
526
+ return new Event(obj);
527
+ }
528
+
529
+ /**
530
+ * Compare events for equality
531
+ * @param {Event} other - The other event
532
+ * @returns {boolean} True if events are equal
533
+ */
534
+ equals(other) {
535
+ if (!(other instanceof Event)) return false;
536
+
537
+ return (
538
+ this.id === other.id &&
539
+ this.title === other.title &&
540
+ this.start.getTime() === other.start.getTime() &&
541
+ this.end.getTime() === other.end.getTime() &&
542
+ this.allDay === other.allDay &&
543
+ this.description === other.description &&
544
+ this.location === other.location &&
545
+ this.recurring === other.recurring &&
546
+ this.recurrenceRule === other.recurrenceRule &&
547
+ this.status === other.status
548
+ );
549
+ }
550
+
551
+ // ============ Attendee Management Methods ============
552
+
553
+ /**
554
+ * Add an attendee to the event
555
+ * @param {import('../../types.js').Attendee} attendee - Attendee to add
556
+ * @returns {boolean} True if attendee was added, false if already exists
557
+ */
558
+ addAttendee(attendee) {
559
+ if (!attendee || !attendee.email) {
560
+ throw new Error('Attendee must have an email');
561
+ }
562
+
563
+ // Check if attendee already exists
564
+ if (this.hasAttendee(attendee.email)) {
565
+ return false;
566
+ }
567
+
568
+ // Generate ID if not provided
569
+ if (!attendee.id) {
570
+ attendee.id = `attendee_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
571
+ }
572
+
573
+ // Set defaults
574
+ attendee.responseStatus = attendee.responseStatus || 'needs-action';
575
+ attendee.role = attendee.role || 'required';
576
+
577
+ this.attendees.push(attendee);
578
+ return true;
579
+ }
580
+
581
+ /**
582
+ * Remove an attendee from the event
583
+ * @param {string} emailOrId - Email or ID of the attendee to remove
584
+ * @returns {boolean} True if attendee was removed
585
+ */
586
+ removeAttendee(emailOrId) {
587
+ const index = this.attendees.findIndex(
588
+ a => a.email === emailOrId || a.id === emailOrId
589
+ );
590
+
591
+ if (index !== -1) {
592
+ this.attendees.splice(index, 1);
593
+ return true;
594
+ }
595
+ return false;
596
+ }
597
+
598
+ /**
599
+ * Update an attendee's response status
600
+ * @param {string} email - Attendee's email
601
+ * @param {import('../../types.js').AttendeeResponseStatus} responseStatus - New response status
602
+ * @returns {boolean} True if attendee was updated
603
+ */
604
+ updateAttendeeResponse(email, responseStatus) {
605
+ const attendee = this.getAttendee(email);
606
+ if (attendee) {
607
+ attendee.responseStatus = responseStatus;
608
+ attendee.responseTime = new Date();
609
+ return true;
610
+ }
611
+ return false;
612
+ }
613
+
614
+ /**
615
+ * Get an attendee by email
616
+ * @param {string} email - Attendee's email
617
+ * @returns {import('../../types.js').Attendee|null} The attendee or null
618
+ */
619
+ getAttendee(email) {
620
+ return this.attendees.find(a => a.email === email) || null;
621
+ }
622
+
623
+ /**
624
+ * Check if an attendee exists
625
+ * @param {string} email - Attendee's email
626
+ * @returns {boolean} True if attendee exists
627
+ */
628
+ hasAttendee(email) {
629
+ return this.attendees.some(a => a.email === email);
630
+ }
631
+
632
+ /**
633
+ * Get attendees by response status
634
+ * @param {import('../../types.js').AttendeeResponseStatus} status - Response status to filter by
635
+ * @returns {import('../../types.js').Attendee[]} Filtered attendees
636
+ */
637
+ getAttendeesByStatus(status) {
638
+ return this.attendees.filter(a => a.responseStatus === status);
639
+ }
640
+
641
+ /**
642
+ * Get count of attendees by response status
643
+ * @returns {Object.<string, number>} Count by status
644
+ */
645
+ getAttendeeCounts() {
646
+ return this.attendees.reduce((counts, attendee) => {
647
+ const status = attendee.responseStatus || 'needs-action';
648
+ counts[status] = (counts[status] || 0) + 1;
649
+ return counts;
650
+ }, {});
651
+ }
652
+
653
+ // ============ Reminder Management Methods ============
654
+
655
+ /**
656
+ * Add a reminder to the event
657
+ * @param {import('../../types.js').Reminder} reminder - Reminder to add
658
+ * @returns {boolean} True if reminder was added
659
+ */
660
+ addReminder(reminder) {
661
+ if (!reminder || typeof reminder.minutesBefore !== 'number') {
662
+ throw new Error('Reminder must have minutesBefore property');
663
+ }
664
+
665
+ // Generate ID if not provided
666
+ if (!reminder.id) {
667
+ reminder.id = `reminder_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
668
+ }
669
+
670
+ // Set defaults
671
+ reminder.method = reminder.method || 'popup';
672
+ reminder.enabled = reminder.enabled !== false;
673
+
674
+ // Check for duplicate
675
+ const duplicate = this.reminders.some(
676
+ r => r.method === reminder.method && r.minutesBefore === reminder.minutesBefore
677
+ );
678
+
679
+ if (duplicate) {
680
+ return false;
681
+ }
682
+
683
+ this.reminders.push(reminder);
684
+ return true;
685
+ }
686
+
687
+ /**
688
+ * Remove a reminder from the event
689
+ * @param {string} reminderId - ID of the reminder to remove
690
+ * @returns {boolean} True if reminder was removed
691
+ */
692
+ removeReminder(reminderId) {
693
+ const index = this.reminders.findIndex(r => r.id === reminderId);
694
+ if (index !== -1) {
695
+ this.reminders.splice(index, 1);
696
+ return true;
697
+ }
698
+ return false;
699
+ }
700
+
701
+ /**
702
+ * Get active reminders
703
+ * @returns {import('../../types.js').Reminder[]} Active reminders
704
+ */
705
+ getActiveReminders() {
706
+ return this.reminders.filter(r => r.enabled !== false);
707
+ }
708
+
709
+ /**
710
+ * Get reminder trigger times
711
+ * @returns {Date[]} Array of dates when reminders should trigger
712
+ */
713
+ getReminderTriggerTimes() {
714
+ return this.getActiveReminders().map(reminder => {
715
+ const triggerTime = new Date(this.start);
716
+ triggerTime.setMinutes(triggerTime.getMinutes() - reminder.minutesBefore);
717
+ return triggerTime;
718
+ });
719
+ }
720
+
721
+ // ============ Category Management Methods ============
722
+
723
+ /**
724
+ * Add a category to the event
725
+ * @param {string} category - Category to add
726
+ * @returns {boolean} True if category was added
727
+ */
728
+ addCategory(category) {
729
+ if (!category || typeof category !== 'string') {
730
+ throw new Error('Category must be a non-empty string');
731
+ }
732
+
733
+ const normalizedCategory = category.trim().toLowerCase();
734
+ if (!this.hasCategory(normalizedCategory)) {
735
+ this.categories.push(normalizedCategory);
736
+ return true;
737
+ }
738
+ return false;
739
+ }
740
+
741
+ /**
742
+ * Remove a category from the event
743
+ * @param {string} category - Category to remove
744
+ * @returns {boolean} True if category was removed
745
+ */
746
+ removeCategory(category) {
747
+ const normalizedCategory = category.trim().toLowerCase();
748
+ const index = this.categories.findIndex(
749
+ c => c.toLowerCase() === normalizedCategory
750
+ );
751
+
752
+ if (index !== -1) {
753
+ this.categories.splice(index, 1);
754
+ return true;
755
+ }
756
+ return false;
757
+ }
758
+
759
+ /**
760
+ * Get primary category (first in array) for backward compatibility
761
+ * @returns {string|null} Primary category or null
762
+ */
763
+ get category() {
764
+ return this.categories && this.categories.length > 0 ? this.categories[0] : null;
765
+ }
766
+
767
+ /**
768
+ * Check if event has a specific category
769
+ * @param {string} category - Category to check
770
+ * @returns {boolean} True if event has the category
771
+ */
772
+ hasCategory(category) {
773
+ const normalizedCategory = category.trim().toLowerCase();
774
+ return this.categories.some(c => c.toLowerCase() === normalizedCategory);
775
+ }
776
+
777
+ /**
778
+ * Check if event has any of the specified categories
779
+ * @param {string[]} categories - Categories to check
780
+ * @returns {boolean} True if event has any of the categories
781
+ */
782
+ hasAnyCategory(categories) {
783
+ return categories.some(category => this.hasCategory(category));
784
+ }
785
+
786
+ /**
787
+ * Check if event has all of the specified categories
788
+ * @param {string[]} categories - Categories to check
789
+ * @returns {boolean} True if event has all of the categories
790
+ */
791
+ hasAllCategories(categories) {
792
+ return categories.every(category => this.hasCategory(category));
793
+ }
794
+
795
+ // ============ Validation Methods ============
796
+
797
+ /**
798
+ * Validate attendees
799
+ * @private
800
+ * @throws {Error} If attendees are invalid
801
+ */
802
+ _validateAttendees() {
803
+ for (const attendee of this.attendees) {
804
+ if (!attendee.email) {
805
+ throw new Error('All attendees must have an email address');
806
+ }
807
+ if (!attendee.name) {
808
+ attendee.name = attendee.email; // Use email as fallback name
809
+ }
810
+ if (!this._isValidEmail(attendee.email)) {
811
+ throw new Error(`Invalid attendee email: ${attendee.email}`);
812
+ }
813
+ }
814
+ }
815
+
816
+ /**
817
+ * Validate reminders
818
+ * @private
819
+ * @throws {Error} If reminders are invalid
820
+ */
821
+ _validateReminders() {
822
+ for (const reminder of this.reminders) {
823
+ if (typeof reminder.minutesBefore !== 'number' || reminder.minutesBefore < 0) {
824
+ throw new Error('Reminder minutesBefore must be a positive number');
825
+ }
826
+
827
+ const validMethods = ['email', 'popup', 'sms'];
828
+ if (!validMethods.includes(reminder.method)) {
829
+ throw new Error(`Invalid reminder method: ${reminder.method}`);
830
+ }
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Validate email address
836
+ * @private
837
+ * @param {string} email - Email to validate
838
+ * @returns {boolean} True if email is valid
839
+ */
840
+ _isValidEmail(email) {
841
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
842
+ return emailRegex.test(email);
843
+ }
844
+
845
+ // ============ Enhanced Getters ============
846
+
847
+ /**
848
+ * Check if the event is cancelled
849
+ * @returns {boolean} True if event is cancelled
850
+ */
851
+ get isCancelled() {
852
+ return this.status === 'cancelled';
853
+ }
854
+
855
+ /**
856
+ * Check if the event is tentative
857
+ * @returns {boolean} True if event is tentative
858
+ */
859
+ get isTentative() {
860
+ return this.status === 'tentative';
861
+ }
862
+
863
+ /**
864
+ * Check if the event is confirmed
865
+ * @returns {boolean} True if event is confirmed
866
+ */
867
+ get isConfirmed() {
868
+ return this.status === 'confirmed';
869
+ }
870
+
871
+ /**
872
+ * Check if the event is private
873
+ * @returns {boolean} True if event is private
874
+ */
875
+ get isPrivate() {
876
+ return this.visibility === 'private';
877
+ }
878
+
879
+ /**
880
+ * Check if the event is public
881
+ * @returns {boolean} True if event is public
882
+ */
883
+ get isPublic() {
884
+ return this.visibility === 'public';
885
+ }
886
+
887
+ /**
888
+ * Check if the event has attendees
889
+ * @returns {boolean} True if event has attendees
890
+ */
891
+ get hasAttendees() {
892
+ return this.attendees.length > 0;
893
+ }
894
+
895
+ /**
896
+ * Check if the event has reminders
897
+ * @returns {boolean} True if event has reminders
898
+ */
899
+ get hasReminders() {
900
+ return this.reminders.length > 0;
901
+ }
902
+
903
+ /**
904
+ * Check if the event is a meeting (has attendees or conference data)
905
+ * @returns {boolean} True if event is a meeting
906
+ */
907
+ get isMeeting() {
908
+ return this.hasAttendees || this.conferenceData !== null;
909
+ }
910
+
911
+ /**
912
+ * Check if the event is virtual (has conference data)
913
+ * @returns {boolean} True if event is virtual
914
+ */
915
+ get isVirtual() {
916
+ return this.conferenceData !== null;
917
+ }
918
+ }