@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,546 @@
1
+ /**
2
+ * StateManager - Central state management for the calendar
3
+ * Implements an immutable state pattern with change notifications
4
+ */
5
+ export class StateManager {
6
+ /**
7
+ * Create a new StateManager instance
8
+ * @param {Partial<import('../../types.js').CalendarState>} [initialState={}] - Initial state values
9
+ */
10
+ constructor(initialState = {}) {
11
+ this.state = {
12
+ // Current view configuration
13
+ view: 'month', // 'month', 'week', 'day', 'list'
14
+ currentDate: new Date(),
15
+
16
+ // UI state
17
+ selectedEventId: null,
18
+ selectedDate: null,
19
+ hoveredEventId: null,
20
+ hoveredDate: null,
21
+
22
+ // Display options
23
+ weekStartsOn: 0, // 0 = Sunday, 1 = Monday, etc.
24
+ showWeekNumbers: false,
25
+ showWeekends: true,
26
+ fixedWeekCount: true, // Always show 6 weeks in month view
27
+
28
+ // Time configuration
29
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
30
+ locale: 'en-US',
31
+ hourFormat: '12h', // '12h' or '24h'
32
+
33
+ // Business hours (for week/day views)
34
+ businessHours: {
35
+ start: '09:00',
36
+ end: '17:00'
37
+ },
38
+
39
+ // Filters
40
+ filters: {
41
+ searchTerm: '',
42
+ categories: [],
43
+ showAllDay: true,
44
+ showTimed: true
45
+ },
46
+
47
+ // Interaction flags
48
+ isDragging: false,
49
+ isResizing: false,
50
+ isCreating: false,
51
+
52
+ // Loading states
53
+ isLoading: false,
54
+ loadingMessage: '',
55
+
56
+ // Error state
57
+ error: null,
58
+
59
+ // Custom metadata
60
+ metadata: {},
61
+
62
+ // Apply initial state overrides
63
+ ...initialState
64
+ };
65
+
66
+ // Observers for state changes
67
+ this.listeners = new Map();
68
+ this.globalListeners = new Set();
69
+
70
+ // History for undo/redo (optional)
71
+ this.history = [];
72
+ this.historyIndex = -1;
73
+ this.maxHistorySize = 50;
74
+ }
75
+
76
+ /**
77
+ * Get the current state
78
+ * @returns {import('../../types.js').CalendarState} Current state (frozen)
79
+ */
80
+ getState() {
81
+ return Object.freeze({ ...this.state });
82
+ }
83
+
84
+ /**
85
+ * Get a specific state value
86
+ * @param {keyof import('../../types.js').CalendarState} key - The state key
87
+ * @returns {any} The state value
88
+ */
89
+ get(key) {
90
+ return this.state[key];
91
+ }
92
+
93
+ /**
94
+ * Update state with partial updates
95
+ * @param {Object|Function} updates - Object with updates or updater function
96
+ */
97
+ setState(updates) {
98
+ const oldState = this.state;
99
+
100
+ // Support function updater pattern
101
+ if (typeof updates === 'function') {
102
+ updates = updates(oldState);
103
+ }
104
+
105
+ // Create new state with updates
106
+ const newState = {
107
+ ...oldState,
108
+ ...updates,
109
+ // Preserve nested objects
110
+ filters: updates.filters ? { ...oldState.filters, ...updates.filters } : oldState.filters,
111
+ businessHours: updates.businessHours ? { ...oldState.businessHours, ...updates.businessHours } : oldState.businessHours,
112
+ metadata: updates.metadata ? { ...oldState.metadata, ...updates.metadata } : oldState.metadata
113
+ };
114
+
115
+ // Check if state actually changed
116
+ if (this._hasChanged(oldState, newState)) {
117
+ this.state = newState;
118
+
119
+ // Add to history (store the new state)
120
+ this._addToHistory(newState);
121
+
122
+ // Notify listeners
123
+ this._notifyListeners(oldState, newState);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Set the current view
129
+ * @param {string} view - The view type
130
+ */
131
+ setView(view) {
132
+ const validViews = ['month', 'week', 'day', 'list'];
133
+ if (!validViews.includes(view)) {
134
+ throw new Error(`Invalid view: ${view}. Must be one of: ${validViews.join(', ')}`);
135
+ }
136
+ this.setState({ view });
137
+ }
138
+
139
+ /**
140
+ * Set the current date
141
+ * @param {Date} date - The date to set
142
+ */
143
+ setCurrentDate(date) {
144
+ if (!(date instanceof Date)) {
145
+ date = new Date(date);
146
+ }
147
+ if (isNaN(date.getTime())) {
148
+ throw new Error('Invalid date');
149
+ }
150
+ this.setState({ currentDate: date });
151
+ }
152
+
153
+ /**
154
+ * Navigate to the next period (month/week/day based on view)
155
+ */
156
+ navigateNext() {
157
+ const { view, currentDate } = this.state;
158
+ const newDate = new Date(currentDate);
159
+
160
+ switch (view) {
161
+ case 'month':
162
+ newDate.setMonth(newDate.getMonth() + 1);
163
+ break;
164
+ case 'week':
165
+ newDate.setDate(newDate.getDate() + 7);
166
+ break;
167
+ case 'day':
168
+ newDate.setDate(newDate.getDate() + 1);
169
+ break;
170
+ }
171
+
172
+ this.setCurrentDate(newDate);
173
+ }
174
+
175
+ /**
176
+ * Navigate to the previous period
177
+ */
178
+ navigatePrevious() {
179
+ const { view, currentDate } = this.state;
180
+ const newDate = new Date(currentDate);
181
+
182
+ switch (view) {
183
+ case 'month':
184
+ newDate.setMonth(newDate.getMonth() - 1);
185
+ break;
186
+ case 'week':
187
+ newDate.setDate(newDate.getDate() - 7);
188
+ break;
189
+ case 'day':
190
+ newDate.setDate(newDate.getDate() - 1);
191
+ break;
192
+ }
193
+
194
+ this.setCurrentDate(newDate);
195
+ }
196
+
197
+ /**
198
+ * Navigate to today
199
+ */
200
+ navigateToday() {
201
+ this.setCurrentDate(new Date());
202
+ }
203
+
204
+ /**
205
+ * Select an event
206
+ * @param {string} eventId - The event ID to select
207
+ */
208
+ selectEvent(eventId) {
209
+ this.setState({ selectedEventId: eventId });
210
+ }
211
+
212
+ /**
213
+ * Clear event selection
214
+ */
215
+ clearEventSelection() {
216
+ this.setState({ selectedEventId: null });
217
+ }
218
+
219
+ /**
220
+ * Select a date
221
+ * @param {Date} date - The date to select
222
+ */
223
+ selectDate(date) {
224
+ if (!(date instanceof Date)) {
225
+ date = new Date(date);
226
+ }
227
+ this.setState({ selectedDate: date });
228
+ }
229
+
230
+ /**
231
+ * Clear date selection
232
+ */
233
+ clearDateSelection() {
234
+ this.setState({ selectedDate: null });
235
+ }
236
+
237
+ /**
238
+ * Set loading state
239
+ * @param {boolean} isLoading - Loading state
240
+ * @param {string} message - Optional loading message
241
+ */
242
+ setLoading(isLoading, message = '') {
243
+ this.setState({
244
+ isLoading,
245
+ loadingMessage: message
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Set error state
251
+ * @param {Error|string|null} error - The error
252
+ */
253
+ setError(error) {
254
+ this.setState({
255
+ error: error ? (error instanceof Error ? error.message : error) : null
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Update filters
261
+ * @param {Object} filters - Filter updates
262
+ */
263
+ updateFilters(filters) {
264
+ this.setState({
265
+ filters: {
266
+ ...this.state.filters,
267
+ ...filters
268
+ }
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Subscribe to all state changes
274
+ * @param {Function} callback - Callback function
275
+ * @returns {Function} Unsubscribe function
276
+ */
277
+ subscribe(callback) {
278
+ this.globalListeners.add(callback);
279
+
280
+ return () => {
281
+ this.globalListeners.delete(callback);
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Subscribe to specific state key changes
287
+ * @param {string|string[]} keys - State key(s) to watch
288
+ * @param {Function} callback - Callback function
289
+ * @returns {Function} Unsubscribe function
290
+ */
291
+ watch(keys, callback) {
292
+ const keyArray = Array.isArray(keys) ? keys : [keys];
293
+
294
+ keyArray.forEach(key => {
295
+ if (!this.listeners.has(key)) {
296
+ this.listeners.set(key, new Set());
297
+ }
298
+ this.listeners.get(key).add(callback);
299
+ });
300
+
301
+ return () => {
302
+ keyArray.forEach(key => {
303
+ const callbacks = this.listeners.get(key);
304
+ if (callbacks) {
305
+ callbacks.delete(callback);
306
+ if (callbacks.size === 0) {
307
+ this.listeners.delete(key);
308
+ }
309
+ }
310
+ });
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Check if undo is available
316
+ * @returns {boolean} True if undo is available
317
+ */
318
+ canUndo() {
319
+ return this.historyIndex > 0;
320
+ }
321
+
322
+ /**
323
+ * Check if redo is available
324
+ * @returns {boolean} True if redo is available
325
+ */
326
+ canRedo() {
327
+ return this.historyIndex < this.history.length - 1;
328
+ }
329
+
330
+ /**
331
+ * Get the number of undo operations available
332
+ * @returns {number} Number of undo operations
333
+ */
334
+ getUndoCount() {
335
+ return this.historyIndex;
336
+ }
337
+
338
+ /**
339
+ * Get the number of redo operations available
340
+ * @returns {number} Number of redo operations
341
+ */
342
+ getRedoCount() {
343
+ return this.history.length - 1 - this.historyIndex;
344
+ }
345
+
346
+ /**
347
+ * Undo the last state change
348
+ * @returns {boolean} True if undo was performed
349
+ */
350
+ undo() {
351
+ if (!this.canUndo()) {
352
+ return false;
353
+ }
354
+
355
+ this.historyIndex--;
356
+ const previousState = this.history[this.historyIndex];
357
+ const currentState = this.state;
358
+
359
+ // Update state without adding to history
360
+ this.state = { ...previousState };
361
+
362
+ // Notify listeners
363
+ this._notifyListeners(currentState, this.state);
364
+
365
+ return true;
366
+ }
367
+
368
+ /**
369
+ * Redo the next state change
370
+ * @returns {boolean} True if redo was performed
371
+ */
372
+ redo() {
373
+ if (!this.canRedo()) {
374
+ return false;
375
+ }
376
+
377
+ this.historyIndex++;
378
+ const nextState = this.history[this.historyIndex];
379
+ const currentState = this.state;
380
+
381
+ // Update state without adding to history
382
+ this.state = { ...nextState };
383
+
384
+ // Notify listeners
385
+ this._notifyListeners(currentState, this.state);
386
+
387
+ return true;
388
+ }
389
+
390
+ /**
391
+ * Reset state to initial values
392
+ */
393
+ reset() {
394
+ const initialState = this.history[0] || {};
395
+ this.setState(initialState);
396
+ this.history = [initialState];
397
+ this.historyIndex = 0;
398
+ }
399
+
400
+ /**
401
+ * Check if state has changed
402
+ * @private
403
+ */
404
+ _hasChanged(oldState, newState) {
405
+ return !this._deepEqual(oldState, newState);
406
+ }
407
+
408
+ /**
409
+ * Deep equality check optimized for state comparison
410
+ * @private
411
+ * @param {*} a - First value
412
+ * @param {*} b - Second value
413
+ * @param {Set} seen - Track circular references
414
+ * @returns {boolean} True if values are deeply equal
415
+ */
416
+ _deepEqual(a, b, seen = new Set()) {
417
+ // Same reference
418
+ if (a === b) return true;
419
+
420
+ // Different types or null/undefined
421
+ if (a == null || b == null) return a === b;
422
+ if (typeof a !== typeof b) return false;
423
+
424
+ // Primitives
425
+ if (typeof a !== 'object') return a === b;
426
+
427
+ // Check for circular references
428
+ if (seen.has(a) || seen.has(b)) return false;
429
+ seen.add(a);
430
+ seen.add(b);
431
+
432
+ // Arrays
433
+ if (Array.isArray(a)) {
434
+ if (!Array.isArray(b) || a.length !== b.length) {
435
+ seen.delete(a);
436
+ seen.delete(b);
437
+ return false;
438
+ }
439
+
440
+ for (let i = 0; i < a.length; i++) {
441
+ if (!this._deepEqual(a[i], b[i], seen)) {
442
+ seen.delete(a);
443
+ seen.delete(b);
444
+ return false;
445
+ }
446
+ }
447
+
448
+ seen.delete(a);
449
+ seen.delete(b);
450
+ return true;
451
+ }
452
+
453
+ // Dates
454
+ if (a instanceof Date && b instanceof Date) {
455
+ const result = a.getTime() === b.getTime();
456
+ seen.delete(a);
457
+ seen.delete(b);
458
+ return result;
459
+ }
460
+
461
+ // Objects
462
+ const aKeys = Object.keys(a);
463
+ const bKeys = Object.keys(b);
464
+
465
+ if (aKeys.length !== bKeys.length) {
466
+ seen.delete(a);
467
+ seen.delete(b);
468
+ return false;
469
+ }
470
+
471
+ // Sort keys for consistent comparison
472
+ aKeys.sort();
473
+ bKeys.sort();
474
+
475
+ // Compare keys
476
+ for (let i = 0; i < aKeys.length; i++) {
477
+ if (aKeys[i] !== bKeys[i]) {
478
+ seen.delete(a);
479
+ seen.delete(b);
480
+ return false;
481
+ }
482
+ }
483
+
484
+ // Compare values
485
+ for (const key of aKeys) {
486
+ if (!this._deepEqual(a[key], b[key], seen)) {
487
+ seen.delete(a);
488
+ seen.delete(b);
489
+ return false;
490
+ }
491
+ }
492
+
493
+ seen.delete(a);
494
+ seen.delete(b);
495
+ return true;
496
+ }
497
+
498
+ /**
499
+ * Add state to history
500
+ * @private
501
+ */
502
+ _addToHistory(state) {
503
+ // Remove any future history if we're not at the end
504
+ if (this.historyIndex < this.history.length - 1) {
505
+ this.history = this.history.slice(0, this.historyIndex + 1);
506
+ }
507
+
508
+ // Add new state
509
+ this.history.push({ ...state });
510
+ this.historyIndex++;
511
+
512
+ // Limit history size
513
+ if (this.history.length > this.maxHistorySize) {
514
+ this.history.shift();
515
+ this.historyIndex--;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Notify listeners of state changes
521
+ * @private
522
+ */
523
+ _notifyListeners(oldState, newState) {
524
+ // Notify global listeners
525
+ for (const callback of this.globalListeners) {
526
+ try {
527
+ callback(newState, oldState);
528
+ } catch (error) {
529
+ console.error('Error in state listener:', error);
530
+ }
531
+ }
532
+
533
+ // Notify specific key listeners
534
+ for (const [key, callbacks] of this.listeners) {
535
+ if (oldState[key] !== newState[key]) {
536
+ for (const callback of callbacks) {
537
+ try {
538
+ callback(newState[key], oldState[key], newState, oldState);
539
+ } catch (error) {
540
+ console.error(`Error in state listener for key "${key}":`, error);
541
+ }
542
+ }
543
+ }
544
+ }
545
+ }
546
+ }