@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,715 @@
1
+ import { EventStore } from '../events/EventStore.js';
2
+ import { Event } from '../events/Event.js';
3
+ import { StateManager } from '../state/StateManager.js';
4
+ import { DateUtils } from './DateUtils.js';
5
+ import { TimezoneManager } from '../timezone/TimezoneManager.js';
6
+
7
+ /**
8
+ * Calendar - Main calendar class with full timezone support
9
+ * Pure JavaScript, no DOM dependencies
10
+ * Framework agnostic, Locker Service compatible
11
+ */
12
+ export class Calendar {
13
+ /**
14
+ * Create a new Calendar instance
15
+ * @param {import('../../types.js').CalendarConfig} [config={}] - Configuration options
16
+ */
17
+ constructor(config = {}) {
18
+ // Initialize timezone manager first
19
+ this.timezoneManager = new TimezoneManager();
20
+
21
+ // Initialize configuration
22
+ this.config = {
23
+ view: 'month',
24
+ date: new Date(),
25
+ weekStartsOn: 0, // 0 = Sunday
26
+ locale: 'en-US',
27
+ timeZone: config.timeZone || this.timezoneManager.getSystemTimezone(),
28
+ showWeekNumbers: false,
29
+ showWeekends: true,
30
+ fixedWeekCount: true,
31
+ businessHours: {
32
+ start: '09:00',
33
+ end: '17:00'
34
+ },
35
+ ...config
36
+ };
37
+
38
+ // Initialize core components with timezone support
39
+ this.eventStore = new EventStore({ timezone: this.config.timeZone });
40
+ this.state = new StateManager({
41
+ view: this.config.view,
42
+ currentDate: this.config.date,
43
+ weekStartsOn: this.config.weekStartsOn,
44
+ locale: this.config.locale,
45
+ timeZone: this.config.timeZone,
46
+ showWeekNumbers: this.config.showWeekNumbers,
47
+ showWeekends: this.config.showWeekends,
48
+ fixedWeekCount: this.config.fixedWeekCount,
49
+ businessHours: this.config.businessHours
50
+ });
51
+
52
+ // Event emitter for calendar events
53
+ this.listeners = new Map();
54
+
55
+ // Plugins
56
+ this.plugins = new Set();
57
+
58
+ // View instances (lazy loaded)
59
+ this.views = new Map();
60
+
61
+ // Set up internal listeners
62
+ this._setupInternalListeners();
63
+
64
+ // Load initial events if provided
65
+ if (config.events) {
66
+ this.setEvents(config.events);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Set the calendar view
72
+ * @param {import('../../types.js').ViewType} viewType - The view type ('month', 'week', 'day', 'list')
73
+ * @param {Date} [date=null] - Optional date to navigate to
74
+ */
75
+ setView(viewType, date = null) {
76
+ this.state.setView(viewType);
77
+
78
+ if (date) {
79
+ this.state.setCurrentDate(date);
80
+ }
81
+
82
+ this._emit('viewChange', {
83
+ view: viewType,
84
+ date: date || this.state.get('currentDate')
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Get the current view type
90
+ * @returns {import('../../types.js').ViewType} The current view type
91
+ */
92
+ getView() {
93
+ return this.state.get('view');
94
+ }
95
+
96
+ /**
97
+ * Navigate to the next period
98
+ */
99
+ next() {
100
+ this.state.navigateNext();
101
+ this._emit('navigate', {
102
+ direction: 'next',
103
+ date: this.state.get('currentDate'),
104
+ view: this.state.get('view')
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Navigate to the previous period
110
+ */
111
+ previous() {
112
+ this.state.navigatePrevious();
113
+ this._emit('navigate', {
114
+ direction: 'previous',
115
+ date: this.state.get('currentDate'),
116
+ view: this.state.get('view')
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Navigate to today
122
+ */
123
+ today() {
124
+ this.state.navigateToday();
125
+ this._emit('navigate', {
126
+ direction: 'today',
127
+ date: this.state.get('currentDate'),
128
+ view: this.state.get('view')
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Navigate to a specific date
134
+ * @param {Date} date - The date to navigate to
135
+ */
136
+ goToDate(date) {
137
+ this.state.setCurrentDate(date);
138
+ this._emit('navigate', {
139
+ direction: 'goto',
140
+ date: date,
141
+ view: this.state.get('view')
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Get the current date
147
+ * @returns {Date}
148
+ */
149
+ getCurrentDate() {
150
+ return new Date(this.state.get('currentDate'));
151
+ }
152
+
153
+ /**
154
+ * Add an event
155
+ * @param {import('../events/Event.js').Event|import('../../types.js').EventData} eventData - Event data or Event instance
156
+ * @returns {import('../events/Event.js').Event} The added event
157
+ */
158
+ addEvent(eventData) {
159
+ // If eventData is not an Event instance and doesn't have a timezone, use calendar's timezone
160
+ if (!(eventData instanceof Event) && !eventData.timeZone) {
161
+ eventData = { ...eventData, timeZone: this.config.timeZone };
162
+ }
163
+
164
+ const event = this.eventStore.addEvent(eventData);
165
+
166
+ this._emit('eventAdd', { event });
167
+
168
+ return event;
169
+ }
170
+
171
+ /**
172
+ * Update an event
173
+ * @param {string} eventId - The event ID
174
+ * @param {Object} updates - Properties to update
175
+ * @returns {Event} The updated event
176
+ */
177
+ updateEvent(eventId, updates) {
178
+ const oldEvent = this.eventStore.getEvent(eventId);
179
+ const event = this.eventStore.updateEvent(eventId, updates);
180
+
181
+ this._emit('eventUpdate', { event, oldEvent });
182
+
183
+ return event;
184
+ }
185
+
186
+ /**
187
+ * Remove an event
188
+ * @param {string} eventId - The event ID
189
+ * @returns {boolean} True if removed
190
+ */
191
+ removeEvent(eventId) {
192
+ const event = this.eventStore.getEvent(eventId);
193
+ const removed = this.eventStore.removeEvent(eventId);
194
+
195
+ if (removed) {
196
+ this._emit('eventRemove', { event });
197
+ }
198
+
199
+ return removed;
200
+ }
201
+
202
+ /**
203
+ * Get an event by ID
204
+ * @param {string} eventId - The event ID
205
+ * @returns {Event|null}
206
+ */
207
+ getEvent(eventId) {
208
+ return this.eventStore.getEvent(eventId);
209
+ }
210
+
211
+ /**
212
+ * Get all events
213
+ * @returns {Event[]}
214
+ */
215
+ getEvents() {
216
+ return this.eventStore.getAllEvents();
217
+ }
218
+
219
+ /**
220
+ * Set all events (replaces existing)
221
+ * @param {Event[]} events - Array of events
222
+ */
223
+ setEvents(events) {
224
+ this.eventStore.loadEvents(events);
225
+ this._emit('eventsSet', { events: this.getEvents() });
226
+ }
227
+
228
+ /**
229
+ * Query events with filters
230
+ * @param {Object} filters - Query filters
231
+ * @returns {Event[]}
232
+ */
233
+ queryEvents(filters) {
234
+ return this.eventStore.queryEvents(filters);
235
+ }
236
+
237
+ /**
238
+ * Get events for a specific date
239
+ * @param {Date} date - The date
240
+ * @param {string} [timezone] - Timezone for the query (defaults to calendar timezone)
241
+ * @returns {Event[]}
242
+ */
243
+ getEventsForDate(date, timezone = null) {
244
+ return this.eventStore.getEventsForDate(date, timezone || this.config.timeZone);
245
+ }
246
+
247
+ /**
248
+ * Get events in a date range
249
+ * @param {Date} start - Start date
250
+ * @param {Date} end - End date
251
+ * @param {string} [timezone] - Timezone for the query (defaults to calendar timezone)
252
+ * @returns {Event[]}
253
+ */
254
+ getEventsInRange(start, end, timezone = null) {
255
+ return this.eventStore.getEventsInRange(start, end, true, timezone || this.config.timeZone);
256
+ }
257
+
258
+ /**
259
+ * Set the calendar's timezone
260
+ * @param {string} timezone - IANA timezone identifier
261
+ */
262
+ setTimezone(timezone) {
263
+ const parsedTimezone = this.timezoneManager.parseTimezone(timezone);
264
+ const previousTimezone = this.config.timeZone;
265
+
266
+ this.config.timeZone = parsedTimezone;
267
+ this.eventStore.defaultTimezone = parsedTimezone;
268
+ this.state.setState({ timeZone: parsedTimezone });
269
+
270
+ this._emit('timezoneChange', {
271
+ timezone: parsedTimezone,
272
+ previousTimezone: previousTimezone
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Get the current timezone
278
+ * @returns {string} Current timezone
279
+ */
280
+ getTimezone() {
281
+ return this.config.timeZone;
282
+ }
283
+
284
+ /**
285
+ * Convert a date from one timezone to another
286
+ * @param {Date} date - Date to convert
287
+ * @param {string} fromTimezone - Source timezone
288
+ * @param {string} toTimezone - Target timezone
289
+ * @returns {Date} Converted date
290
+ */
291
+ convertTimezone(date, fromTimezone, toTimezone) {
292
+ return this.timezoneManager.convertTimezone(date, fromTimezone, toTimezone);
293
+ }
294
+
295
+ /**
296
+ * Convert a date to the calendar's timezone
297
+ * @param {Date} date - Date to convert
298
+ * @param {string} fromTimezone - Source timezone
299
+ * @returns {Date} Date in calendar timezone
300
+ */
301
+ toCalendarTimezone(date, fromTimezone) {
302
+ return this.timezoneManager.convertTimezone(date, fromTimezone, this.config.timeZone);
303
+ }
304
+
305
+ /**
306
+ * Convert a date from the calendar's timezone
307
+ * @param {Date} date - Date in calendar timezone
308
+ * @param {string} toTimezone - Target timezone
309
+ * @returns {Date} Converted date
310
+ */
311
+ fromCalendarTimezone(date, toTimezone) {
312
+ return this.timezoneManager.convertTimezone(date, this.config.timeZone, toTimezone);
313
+ }
314
+
315
+ /**
316
+ * Format a date in a specific timezone
317
+ * @param {Date} date - Date to format
318
+ * @param {string} [timezone] - Timezone for formatting (defaults to calendar timezone)
319
+ * @param {Object} [options] - Formatting options
320
+ * @returns {string} Formatted date string
321
+ */
322
+ formatInTimezone(date, timezone = null, options = {}) {
323
+ return this.timezoneManager.formatInTimezone(
324
+ date,
325
+ timezone || this.config.timeZone,
326
+ options
327
+ );
328
+ }
329
+
330
+ /**
331
+ * Get list of common timezones with offsets
332
+ * @returns {Array<{value: string, label: string, offset: string}>} Timezone list
333
+ */
334
+ getTimezones() {
335
+ return this.timezoneManager.getCommonTimezones();
336
+ }
337
+
338
+ /**
339
+ * Get overlapping event groups for a date
340
+ * @param {Date} date - The date to check
341
+ * @param {boolean} timedOnly - Only include timed events
342
+ * @returns {Array<Event[]>} Array of event groups that overlap
343
+ */
344
+ getOverlapGroups(date, timedOnly = true) {
345
+ return this.eventStore.getOverlapGroups(date, timedOnly);
346
+ }
347
+
348
+ /**
349
+ * Calculate event positions for rendering
350
+ * @param {Event[]} events - Array of overlapping events
351
+ * @returns {Map<string, {column: number, totalColumns: number}>} Position data
352
+ */
353
+ calculateEventPositions(events) {
354
+ return this.eventStore.calculateEventPositions(events);
355
+ }
356
+
357
+ /**
358
+ * Get the current view's data
359
+ * @returns {import('../../types.js').MonthViewData|import('../../types.js').WeekViewData|import('../../types.js').DayViewData|import('../../types.js').ListViewData|null} View-specific data
360
+ */
361
+ getViewData() {
362
+ const view = this.state.get('view');
363
+ const currentDate = this.state.get('currentDate');
364
+
365
+ switch (view) {
366
+ case 'month':
367
+ return this._getMonthViewData(currentDate);
368
+ case 'week':
369
+ return this._getWeekViewData(currentDate);
370
+ case 'day':
371
+ return this._getDayViewData(currentDate);
372
+ case 'list':
373
+ return this._getListViewData(currentDate);
374
+ default:
375
+ return null;
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Get month view data
381
+ * @private
382
+ */
383
+ _getMonthViewData(date) {
384
+ const year = date.getFullYear();
385
+ const month = date.getMonth();
386
+ const weekStartsOn = this.state.get('weekStartsOn');
387
+ const fixedWeekCount = this.state.get('fixedWeekCount');
388
+
389
+ // Get the first day of the month
390
+ const firstDay = new Date(year, month, 1);
391
+
392
+ // Get the last day of the month
393
+ const lastDay = new Date(year, month + 1, 0);
394
+
395
+ // Calculate the start date (beginning of the week containing the first day)
396
+ const startDate = DateUtils.startOfWeek(firstDay, weekStartsOn);
397
+
398
+ // Calculate weeks
399
+ const weeks = [];
400
+ let currentDate = new Date(startDate);
401
+
402
+ // Generate weeks
403
+ const maxWeeks = fixedWeekCount ? 6 : Math.ceil((lastDay.getDate() + DateUtils.getDayOfWeek(firstDay, weekStartsOn)) / 7);
404
+
405
+ for (let weekIndex = 0; weekIndex < maxWeeks; weekIndex++) {
406
+ const week = {
407
+ weekNumber: DateUtils.getWeekNumber(currentDate),
408
+ days: []
409
+ };
410
+
411
+ for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
412
+ const dayDate = new Date(currentDate);
413
+ const isCurrentMonth = dayDate.getMonth() === month;
414
+ const isToday = DateUtils.isToday(dayDate);
415
+ const isWeekend = dayDate.getDay() === 0 || dayDate.getDay() === 6;
416
+
417
+ week.days.push({
418
+ date: dayDate,
419
+ dayOfMonth: dayDate.getDate(),
420
+ isCurrentMonth,
421
+ isToday,
422
+ isWeekend,
423
+ events: this.getEventsForDate(dayDate)
424
+ });
425
+
426
+ // Use DateUtils.addDays to handle month boundaries correctly
427
+ currentDate = DateUtils.addDays(currentDate, 1);
428
+ }
429
+
430
+ weeks.push(week);
431
+ }
432
+
433
+ return {
434
+ type: 'month',
435
+ year,
436
+ month,
437
+ monthName: DateUtils.getMonthName(date, this.state.get('locale')),
438
+ weeks,
439
+ startDate,
440
+ endDate: new Date(currentDate.getTime() - 1) // Last moment of the view
441
+ };
442
+ }
443
+
444
+ /**
445
+ * Get week view data
446
+ * @private
447
+ */
448
+ _getWeekViewData(date) {
449
+ const weekStartsOn = this.state.get('weekStartsOn');
450
+ const startDate = DateUtils.startOfWeek(date, weekStartsOn);
451
+ const endDate = DateUtils.endOfWeek(date, weekStartsOn);
452
+
453
+ const days = [];
454
+ const currentDate = new Date(startDate);
455
+
456
+ for (let i = 0; i < 7; i++) {
457
+ const dayDate = new Date(currentDate);
458
+ days.push({
459
+ date: dayDate,
460
+ dayOfMonth: dayDate.getDate(),
461
+ dayOfWeek: dayDate.getDay(),
462
+ dayName: DateUtils.getDayName(dayDate, this.state.get('locale')),
463
+ isToday: DateUtils.isToday(dayDate),
464
+ isWeekend: dayDate.getDay() === 0 || dayDate.getDay() === 6,
465
+ events: this.getEventsForDate(dayDate),
466
+ // Add overlap groups for positioning overlapping events
467
+ overlapGroups: this.eventStore.getOverlapGroups(dayDate, true),
468
+ getEventPositions: (events) => this.eventStore.calculateEventPositions(events)
469
+ });
470
+ // Move to next day
471
+ currentDate.setDate(currentDate.getDate() + 1);
472
+ }
473
+
474
+ return {
475
+ type: 'week',
476
+ weekNumber: DateUtils.getWeekNumber(startDate),
477
+ startDate,
478
+ endDate,
479
+ days
480
+ };
481
+ }
482
+
483
+ /**
484
+ * Get day view data
485
+ * @private
486
+ */
487
+ _getDayViewData(date) {
488
+ const events = this.getEventsForDate(date);
489
+
490
+ // Separate all-day and timed events
491
+ const allDayEvents = events.filter(e => e.allDay);
492
+ const timedEvents = events.filter(e => !e.allDay);
493
+
494
+ // Create hourly slots for timed events
495
+ const hours = [];
496
+ for (let hour = 0; hour < 24; hour++) {
497
+ const hourDate = new Date(date);
498
+ hourDate.setHours(hour, 0, 0, 0);
499
+ const hourEnd = new Date(date);
500
+ hourEnd.setHours(hour + 1, 0, 0, 0);
501
+
502
+ hours.push({
503
+ hour,
504
+ time: DateUtils.formatTime(hourDate, this.state.get('locale')),
505
+ events: timedEvents.filter(event => {
506
+ // Check if event occurs during this hour (not just starts)
507
+ // Event occurs in this hour if it overlaps with the hour slot
508
+ return event.start < hourEnd && event.end > hourDate;
509
+ })
510
+ });
511
+ }
512
+
513
+ return {
514
+ type: 'day',
515
+ date,
516
+ dayName: DateUtils.getDayName(date, this.state.get('locale')),
517
+ isToday: DateUtils.isToday(date),
518
+ allDayEvents,
519
+ hours
520
+ };
521
+ }
522
+
523
+ /**
524
+ * Get list view data
525
+ * @private
526
+ */
527
+ _getListViewData(date) {
528
+ // Get events for the next 30 days
529
+ const startDate = new Date(date);
530
+ startDate.setHours(0, 0, 0, 0);
531
+
532
+ const endDate = new Date(startDate);
533
+ endDate.setDate(endDate.getDate() + 30);
534
+
535
+ const events = this.getEventsInRange(startDate, endDate);
536
+
537
+ // Group events by day
538
+ const groupedEvents = new Map();
539
+
540
+ events.forEach(event => {
541
+ const dateKey = event.start.toDateString();
542
+ if (!groupedEvents.has(dateKey)) {
543
+ groupedEvents.set(dateKey, {
544
+ date: new Date(event.start),
545
+ events: []
546
+ });
547
+ }
548
+ groupedEvents.get(dateKey).events.push(event);
549
+ });
550
+
551
+ // Convert to sorted array
552
+ const days = Array.from(groupedEvents.values())
553
+ .sort((a, b) => a.date - b.date)
554
+ .map(day => ({
555
+ ...day,
556
+ dayName: DateUtils.getDayName(day.date, this.state.get('locale')),
557
+ isToday: DateUtils.isToday(day.date)
558
+ }));
559
+
560
+ return {
561
+ type: 'list',
562
+ startDate,
563
+ endDate,
564
+ days,
565
+ totalEvents: events.length
566
+ };
567
+ }
568
+
569
+ /**
570
+ * Select an event
571
+ * @param {string} eventId - Event ID to select
572
+ */
573
+ selectEvent(eventId) {
574
+ const event = this.getEvent(eventId);
575
+ if (event) {
576
+ this.state.selectEvent(eventId);
577
+ this._emit('eventSelect', { event });
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Clear event selection
583
+ */
584
+ clearEventSelection() {
585
+ const eventId = this.state.get('selectedEventId');
586
+ this.state.clearEventSelection();
587
+
588
+ if (eventId) {
589
+ this._emit('eventDeselect', { eventId });
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Select a date
595
+ * @param {Date} date - Date to select
596
+ */
597
+ selectDate(date) {
598
+ this.state.selectDate(date);
599
+ this._emit('dateSelect', { date });
600
+ }
601
+
602
+ /**
603
+ * Clear date selection
604
+ */
605
+ clearDateSelection() {
606
+ const date = this.state.get('selectedDate');
607
+ this.state.clearDateSelection();
608
+
609
+ if (date) {
610
+ this._emit('dateDeselect', { date });
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Subscribe to calendar events
616
+ * @param {string} eventName - Event name
617
+ * @param {Function} callback - Callback function
618
+ * @returns {Function} Unsubscribe function
619
+ */
620
+ on(eventName, callback) {
621
+ if (!this.listeners.has(eventName)) {
622
+ this.listeners.set(eventName, new Set());
623
+ }
624
+ this.listeners.get(eventName).add(callback);
625
+
626
+ return () => this.off(eventName, callback);
627
+ }
628
+
629
+ /**
630
+ * Unsubscribe from calendar events
631
+ * @param {string} eventName - Event name
632
+ * @param {Function} callback - Callback function
633
+ */
634
+ off(eventName, callback) {
635
+ const callbacks = this.listeners.get(eventName);
636
+ if (callbacks) {
637
+ callbacks.delete(callback);
638
+ if (callbacks.size === 0) {
639
+ this.listeners.delete(eventName);
640
+ }
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Emit an event
646
+ * @private
647
+ */
648
+ _emit(eventName, data) {
649
+ const callbacks = this.listeners.get(eventName);
650
+ if (callbacks) {
651
+ callbacks.forEach(callback => {
652
+ try {
653
+ callback(data);
654
+ } catch (error) {
655
+ console.error(`Error in event listener for "${eventName}":`, error);
656
+ }
657
+ });
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Set up internal listeners
663
+ * @private
664
+ */
665
+ _setupInternalListeners() {
666
+ // Listen to state changes
667
+ this.state.subscribe((newState, oldState) => {
668
+ this._emit('stateChange', { newState, oldState });
669
+ });
670
+
671
+ // Listen to event store changes
672
+ this.eventStore.subscribe((change) => {
673
+ this._emit('eventStoreChange', change);
674
+ });
675
+ }
676
+
677
+ /**
678
+ * Install a plugin
679
+ * @param {Object} plugin - Plugin object with install method
680
+ */
681
+ use(plugin) {
682
+ if (this.plugins.has(plugin)) {
683
+ console.warn('Plugin already installed');
684
+ return;
685
+ }
686
+
687
+ if (typeof plugin.install === 'function') {
688
+ plugin.install(this);
689
+ this.plugins.add(plugin);
690
+ } else {
691
+ throw new Error('Plugin must have an install method');
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Destroy the calendar and clean up
697
+ */
698
+ destroy() {
699
+ // Clear all listeners
700
+ this.listeners.clear();
701
+
702
+ // Clear stores
703
+ this.eventStore.clear();
704
+
705
+ // Clear plugins
706
+ this.plugins.forEach(plugin => {
707
+ if (typeof plugin.uninstall === 'function') {
708
+ plugin.uninstall(this);
709
+ }
710
+ });
711
+ this.plugins.clear();
712
+
713
+ this._emit('destroy');
714
+ }
715
+ }// Test workflow