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