@luckydye/calendar 1.1.0 → 1.1.2

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,609 @@
1
+ import type { CalendarStorage } from "./CalendarStorage.js";
2
+ import { rrulestr } from 'rrule';
3
+
4
+ export interface Attendee {
5
+ email: string;
6
+ name?: string;
7
+ role?: 'REQ-PARTICIPANT' | 'OPT-PARTICIPANT' | 'NON-PARTICIPANT' | 'CHAIR';
8
+ status?: 'NEEDS-ACTION' | 'ACCEPTED' | 'DECLINED' | 'TENTATIVE' | 'DELEGATED';
9
+ }
10
+
11
+ export interface Organizer {
12
+ email: string;
13
+ name?: string;
14
+ }
15
+
16
+ export interface NotificationConfig {
17
+ id: string;
18
+ triggerOffset: number;
19
+ enabled: boolean;
20
+ }
21
+
22
+ export const NOTIFICATION_PRESETS = [
23
+ { label: "At time of event", value: 0 },
24
+ { label: "5 minutes before", value: 5 },
25
+ { label: "15 minutes before", value: 15 },
26
+ { label: "30 minutes before", value: 30 },
27
+ { label: "1 hour before", value: 60 },
28
+ { label: "2 hours before", value: 120 },
29
+ { label: "1 day before", value: 1440 },
30
+ ];
31
+
32
+ export interface CalendarEvent {
33
+ id: string;
34
+ title: string;
35
+ start: Date;
36
+ end: Date;
37
+ color?: string;
38
+ calendar?: string;
39
+ calendarId?: string;
40
+ sourceId?: string;
41
+ description?: string;
42
+ location?: string;
43
+ url?: string;
44
+ rrule?: string;
45
+ readOnly?: boolean;
46
+ lastSynced?: Date;
47
+ organizer?: Organizer;
48
+ attendees?: Attendee[];
49
+ status?: 'TENTATIVE' | 'CONFIRMED' | 'CANCELLED';
50
+ reminders?: NotificationConfig[];
51
+ isAllDay?: boolean;
52
+ }
53
+
54
+ export interface WeekInfo {
55
+ weekNumber: number;
56
+ year: number;
57
+ days: Date[];
58
+ yOffset: number;
59
+ height: number;
60
+ }
61
+
62
+ // Compute event segments per week
63
+ export interface EventSegment {
64
+ event: CalendarEvent;
65
+ weekIndex: number;
66
+ week: WeekInfo;
67
+ startDayIndex: number; // 0-6 within week
68
+ endDayIndex: number; // 0-6 within week
69
+ isStart: boolean; // Is this the first segment of the event?
70
+ isEnd: boolean; // Is this the last segment of the event?
71
+ totalWeeks: number; // Total weeks this event spans
72
+ }
73
+
74
+ // Configuration for the sliding window buffer
75
+ const BUFFER_WEEKS = 12; // Weeks to keep as buffer outside viewport
76
+ const EXTEND_WEEKS = 12; // Weeks to add when extending
77
+ const MAX_WEEKS = 96; // Maximum weeks to keep in memory (trim beyond this)
78
+
79
+ export class CalendarInternal {
80
+ locale: string;
81
+
82
+ // Generate weeks for a smaller initial range (sliding window will extend as needed)
83
+ startDate = new Date();
84
+ endDate = CalendarInternal.addDays(new Date(), 26 * 7); // ~6 months forward
85
+
86
+ /**
87
+ * First day of the week (0 = Sunday, 1 = Monday, ..., 6 = Saturday).
88
+ * Defaults to locale-appropriate value.
89
+ */
90
+ weekStart: number = 0;
91
+
92
+ filter?: string = "";
93
+
94
+ getFilter() {
95
+ return this.filter;
96
+ }
97
+
98
+ calendarEvents: Map<string, CalendarEvent> = new Map();
99
+
100
+ selectedEvents: Set<CalendarEvent> = new Set();
101
+
102
+ enabledCalendars: Set<string> = new Set();
103
+
104
+ storage?: CalendarStorage;
105
+ initPromise: Promise<void>;
106
+
107
+ stream: ReadableStream<CalendarEvent[]>;
108
+
109
+ controller?: ReadableStreamDefaultController<CalendarEvent[]>;
110
+
111
+ constructor(config?: {
112
+ locale?: string;
113
+ weekStart?: number;
114
+ storage?: CalendarStorage;
115
+ }) {
116
+ this.locale = config?.locale || navigator.language;
117
+ this.weekStart = config?.weekStart || CalendarInternal.getWeekStartFromLocale(this.locale);
118
+
119
+ this.startDate = this.getStartOfWeek(CalendarInternal.addDays(new Date(), -26 * 7)); // ~6 months back
120
+
121
+ const eventSource: UnderlyingDefaultSource<CalendarEvent[]> = {
122
+ start: (controller) => {
123
+ this.controller = controller;
124
+ },
125
+ };
126
+
127
+ this.stream = new ReadableStream(eventSource);
128
+
129
+ this.storage = config?.storage;
130
+ this.initPromise = this.loadFromStorage();
131
+ }
132
+
133
+ /**
134
+ * Syncs multiple events to event storage on device.
135
+ * Performs differential sync: adds new events, updates existing ones,
136
+ * and removes events that belong to this source but are not in the fetched list.
137
+ *
138
+ * @param sourceName - The name of the calendar source (used to identify source ownership)
139
+ * @param syncTimestamp - The timestamp of this sync operation (used to identify stale events)
140
+ * @param events - The events fetched from the source
141
+ */
142
+ sync = (sourceName: string, syncTimestamp: Date, events: CalendarEvent[]): CalendarEvent[] => {
143
+ const updatedEvents: CalendarEvent[] = [];
144
+
145
+ // Add or update fetched events with the sync timestamp
146
+ for (const event of events) {
147
+ const updated = {
148
+ ...event,
149
+ lastSynced: syncTimestamp,
150
+ };
151
+ this.calendarEvents.set(event.id, updated);
152
+ updatedEvents.push(updated);
153
+ }
154
+
155
+ for (const [id, event] of this.calendarEvents.entries()) {
156
+ if (event.calendar === sourceName) {
157
+ if(!event.lastSynced || event.lastSynced < syncTimestamp) {
158
+ this.calendarEvents.delete(id);
159
+ }
160
+ }
161
+ }
162
+
163
+ this.sendEvents();
164
+
165
+ const syncedSources = new Map<string, Date>([[sourceName, syncTimestamp]]);
166
+ this.storage?.sync(updatedEvents, syncedSources).catch(error => {
167
+ console.error("Failed to persist events:", error);
168
+ });
169
+
170
+ return updatedEvents;
171
+ }
172
+
173
+ /**
174
+ * Loads events from persistent storage on initialization
175
+ */
176
+ async loadFromStorage(): Promise<CalendarEvent[]> {
177
+ const events = await this.storage?.loadEvents() || [];
178
+ for (const event of events) {
179
+ this.calendarEvents.set(event.id, event);
180
+ }
181
+ this.sendEvents();
182
+ return events;
183
+ }
184
+
185
+
186
+ setFilter(filter: string) {
187
+ this.filter = filter;
188
+ this.sendEvents();
189
+ }
190
+
191
+ setEnabledCalendars(calendarIds: string[]) {
192
+ this.enabledCalendars = new Set(calendarIds);
193
+ this.sendEvents();
194
+ }
195
+
196
+ expandRecurringEvent(event: CalendarEvent): CalendarEvent[] {
197
+ if (!event.rrule) {
198
+ return [event];
199
+ }
200
+
201
+ try {
202
+ const dtstart = event.start;
203
+ const year = dtstart.getUTCFullYear();
204
+ const month = String(dtstart.getUTCMonth() + 1).padStart(2, '0');
205
+ const day = String(dtstart.getUTCDate()).padStart(2, '0');
206
+ const hours = String(dtstart.getUTCHours()).padStart(2, '0');
207
+ const minutes = String(dtstart.getUTCMinutes()).padStart(2, '0');
208
+ const seconds = String(dtstart.getUTCSeconds()).padStart(2, '0');
209
+
210
+ const dtstartFormatted = `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
211
+ const rruleString = `DTSTART:${dtstartFormatted}\nRRULE:${event.rrule}`;
212
+
213
+ const rule = rrulestr(rruleString);
214
+
215
+ const twoYearsAgo = new Date();
216
+ twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2);
217
+ const twoYearsFromNow = new Date();
218
+ twoYearsFromNow.setFullYear(twoYearsFromNow.getFullYear() + 2);
219
+
220
+ const occurrences = rule.between(twoYearsAgo, twoYearsFromNow, true);
221
+
222
+ return occurrences.map((occurrenceStart) => {
223
+ const duration = event.end.getTime() - event.start.getTime();
224
+ const occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
225
+
226
+ return {
227
+ ...event,
228
+ id: `${event.id}-${occurrenceStart.getTime()}`,
229
+ start: occurrenceStart,
230
+ end: occurrenceEnd,
231
+ };
232
+ });
233
+ } catch (error) {
234
+ console.error(`Failed to parse RRULE for event ${event.title} (${event.id}):`, error);
235
+ console.error(` RRULE string: ${event.rrule}`);
236
+ return [event];
237
+ }
238
+ }
239
+
240
+ getFilteredEvents(filter?: string) {
241
+ const baseEvents = Array.from(this.calendarEvents.values());
242
+
243
+ // Filter by enabled calendars
244
+ // Note: enabledCalendars contains calendar IDs, not sourceIds
245
+ const enabledEvents = this.enabledCalendars.size > 0
246
+ ? baseEvents.filter(e => {
247
+ // For backwards compatibility: check both sourceId and calendarId
248
+ // CalDAV events: match via calendarId
249
+ // Other sources: match via sourceId
250
+ return (e.calendarId && this.enabledCalendars.has(e.calendarId)) ||
251
+ (e.sourceId && this.enabledCalendars.has(e.sourceId));
252
+ })
253
+ : baseEvents;
254
+
255
+ const expandedEvents: CalendarEvent[] = [];
256
+ for (const event of enabledEvents) {
257
+ const instances = this.expandRecurringEvent(event);
258
+ expandedEvents.push(...instances);
259
+ }
260
+
261
+ if (!filter) {
262
+ return expandedEvents;
263
+ }
264
+ return expandedEvents.filter(e => e.title.toLowerCase()
265
+ .includes(filter.toLowerCase())
266
+ );
267
+ }
268
+
269
+ sendEvents() {
270
+ const filteredEvents = this.getFilteredEvents(this.filter);
271
+ const inRangeEvents = filteredEvents.filter(event => {
272
+ // TODO: only send events within the date-range
273
+ return true;
274
+ });
275
+ this.controller?.enqueue(inRangeEvents);
276
+ }
277
+
278
+ /**
279
+ * Selects an event with the specified mode.
280
+ *
281
+ * @example
282
+ * // Replace current selection with this event
283
+ * internal.selectEvent(event, 'replace');
284
+ *
285
+ * @example
286
+ * // Add event to current selection (cmd/ctrl key behavior)
287
+ * internal.selectEvent(event, 'add');
288
+ *
289
+ * @example
290
+ * // Toggle event selection
291
+ * internal.selectEvent(event, 'toggle');
292
+ */
293
+ selectEvent(event: CalendarEvent, mode: 'replace' | 'add' | 'toggle' = 'replace') {
294
+ if (mode === 'replace') {
295
+ this.selectedEvents.clear();
296
+ this.selectedEvents.add(event);
297
+ } else if (mode === 'add') {
298
+ this.selectedEvents.add(event);
299
+ } else if (mode === 'toggle') {
300
+ if (this.selectedEvents.has(event)) {
301
+ this.selectedEvents.delete(event);
302
+ } else {
303
+ this.selectedEvents.add(event);
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Clears all selected events.
310
+ */
311
+ clearSelection() {
312
+ this.selectedEvents.clear();
313
+ }
314
+
315
+ /**
316
+ * Checks if an event is currently selected.
317
+ */
318
+ isEventSelected(event: CalendarEvent): boolean {
319
+ return this.selectedEvents.has(event);
320
+ }
321
+
322
+ /**
323
+ * Returns an array of all currently selected events.
324
+ *
325
+ * @example
326
+ * const selected = internal.getSelectedEvents();
327
+ * console.log(`${selected.length} events selected`);
328
+ */
329
+ getSelectedEvents(): CalendarEvent[] {
330
+ return Array.from(this.selectedEvents);
331
+ }
332
+
333
+ /**
334
+ * Returns all events that overlap with the given time range.
335
+ * Does not modify selection state.
336
+ *
337
+ * @example
338
+ * // Get all events in January 2024
339
+ * const start = new Date('2024-01-01');
340
+ * const end = new Date('2024-01-31T23:59:59');
341
+ * const events = internal.queryEvents(start, end);
342
+ *
343
+ * @example
344
+ * // Get events for the next week
345
+ * const now = new Date();
346
+ * const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
347
+ * const upcoming = internal.queryEvents(now, nextWeek);
348
+ */
349
+ queryEvents(start: Date, end: Date): CalendarEvent[] {
350
+ const startTime = start.getTime();
351
+ const endTime = end.getTime();
352
+ const result: CalendarEvent[] = [];
353
+
354
+ for (const event of this.calendarEvents.values()) {
355
+ const eventStartTime = event.start.getTime();
356
+ const eventEndTime = event.end.getTime();
357
+
358
+ // Check if event overlaps with time range
359
+ const overlaps = !(
360
+ eventEndTime < startTime ||
361
+ eventStartTime > endTime
362
+ );
363
+
364
+ if (overlaps) {
365
+ result.push(event);
366
+ }
367
+ }
368
+
369
+ return result;
370
+ }
371
+
372
+ /**
373
+ * Selects all events that overlap with the given time range.
374
+ *
375
+ * @example
376
+ * // Select all events in January 2024
377
+ * const start = new Date('2024-01-01');
378
+ * const end = new Date('2024-01-31T23:59:59');
379
+ * internal.selectEventsByTimeRange(start, end, 'replace');
380
+ *
381
+ * @example
382
+ * // Add events in a time range to current selection
383
+ * internal.selectEventsByTimeRange(start, end, 'add');
384
+ */
385
+ selectEventsByTimeRange(start: Date, end: Date, mode: 'replace' | 'add' = 'replace') {
386
+ if (mode === 'replace') {
387
+ this.selectedEvents.clear();
388
+ }
389
+
390
+ const startTime = start.getTime();
391
+ const endTime = end.getTime();
392
+
393
+ for (const event of this.calendarEvents.values()) {
394
+ const eventStartTime = event.start.getTime();
395
+ const eventEndTime = event.end.getTime();
396
+
397
+ // Check if event overlaps with time range
398
+ const overlaps = !(
399
+ eventEndTime < startTime ||
400
+ eventStartTime > endTime
401
+ );
402
+
403
+ if (overlaps) {
404
+ this.selectedEvents.add(event);
405
+ }
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Returns a minimal list of all events.
411
+ */
412
+ getAllEvents() {
413
+ throw new Error("not implemented");
414
+ }
415
+
416
+ /**
417
+ * Returns a stream of events in range
418
+ */
419
+ events() {
420
+ return this.stream;
421
+ }
422
+
423
+ generateWeeks() {
424
+ const startDate = this.startDate;
425
+ const endDate = this.endDate;
426
+
427
+ const weeks: WeekInfo[]= [];
428
+ let current = new Date(startDate);
429
+
430
+ while (current < endDate) {
431
+ const days: Date[] = [];
432
+ for (let i = 0; i < 7; i++) {
433
+ days.push(new Date(current));
434
+ current = CalendarInternal.addDays(current, 1);
435
+ }
436
+
437
+ const firstDay = days[0];
438
+ const thursday = days[3];
439
+ if (firstDay && thursday) {
440
+ weeks.push({
441
+ weekNumber: CalendarInternal.getWeekNumber(firstDay),
442
+ year: thursday.getFullYear(), // Use Thursday for year
443
+ days,
444
+ yOffset: 0,
445
+ height: 0,
446
+ });
447
+ }
448
+ }
449
+
450
+ return weeks;
451
+ }
452
+
453
+ // Extends the date range and returns new weeks added
454
+ extendRange(direction: "past" | "future"): WeekInfo[] {
455
+ const weeksToAdd = EXTEND_WEEKS;
456
+ const newWeeks: WeekInfo[] = [];
457
+
458
+ if (direction === "past") {
459
+ const newStartDate = CalendarInternal.addDays(this.startDate, -weeksToAdd * 7);
460
+ let current = new Date(newStartDate);
461
+
462
+ while (current < this.startDate) {
463
+ const days: Date[] = [];
464
+ for (let i = 0; i < 7; i++) {
465
+ days.push(new Date(current));
466
+ current = CalendarInternal.addDays(current, 1);
467
+ }
468
+
469
+ const firstDay = days[0];
470
+ const thursday = days[3];
471
+ if (firstDay && thursday) {
472
+ newWeeks.push({
473
+ weekNumber: CalendarInternal.getWeekNumber(firstDay),
474
+ year: thursday.getFullYear(),
475
+ days,
476
+ yOffset: 0,
477
+ height: 0,
478
+ });
479
+ }
480
+ }
481
+
482
+ this.startDate = newStartDate;
483
+ } else {
484
+ const newEndDate = CalendarInternal.addDays(this.endDate, weeksToAdd * 7);
485
+ let current = new Date(this.endDate);
486
+
487
+ while (current < newEndDate) {
488
+ const days: Date[] = [];
489
+ for (let i = 0; i < 7; i++) {
490
+ days.push(new Date(current));
491
+ current = CalendarInternal.addDays(current, 1);
492
+ }
493
+
494
+ const firstDay = days[0];
495
+ const thursday = days[3];
496
+ if (firstDay && thursday) {
497
+ newWeeks.push({
498
+ weekNumber: CalendarInternal.getWeekNumber(firstDay),
499
+ year: thursday.getFullYear(),
500
+ days,
501
+ yOffset: 0,
502
+ height: 0,
503
+ });
504
+ }
505
+ }
506
+
507
+ this.endDate = newEndDate;
508
+ }
509
+
510
+ return newWeeks;
511
+ }
512
+
513
+ getBufferWeeks(): number {
514
+ return BUFFER_WEEKS;
515
+ }
516
+
517
+ getMaxWeeks(): number {
518
+ return MAX_WEEKS;
519
+ }
520
+
521
+ trimRange(direction: "past" | "future", weeksToTrim: number): void {
522
+ if (direction === "past") {
523
+ this.startDate = CalendarInternal.addDays(this.startDate, -weeksToTrim * 7);
524
+ } else {
525
+ this.endDate = CalendarInternal.addDays(this.endDate, -weeksToTrim * 7);
526
+ }
527
+ }
528
+
529
+ // Resets the range to be centered around the target date
530
+ resetRangeAroundDate(targetDate: Date): WeekInfo[] {
531
+ // Set range to ~6 months before and after target
532
+ const target = new Date(targetDate);
533
+ target.setHours(0, 0, 0, 0);
534
+
535
+ this.startDate = this.getStartOfWeek(CalendarInternal.addDays(target, -26 * 7));
536
+ this.endDate = CalendarInternal.addDays(target, 26 * 7);
537
+
538
+ // Generate all weeks for the new range
539
+ return this.generateWeeks();
540
+ }
541
+
542
+ getWeekdayNames(): string[] {
543
+ const formatter = new Intl.DateTimeFormat(this.locale, { weekday: "short" });
544
+ const names: string[] = [];
545
+ for (let i = 0; i < 7; i++) {
546
+ const dayIndex = (this.weekStart + i) % 7;
547
+ // Jan 7, 2024 is a Sunday (0)
548
+ const date = new Date(2024, 0, 7 + dayIndex);
549
+ names.push(formatter.format(date));
550
+ }
551
+ return names;
552
+ }
553
+
554
+ getStartOfWeek(date: Date): Date {
555
+ const d = new Date(date);
556
+ const day = d.getDay();
557
+ const diff = (day - this.weekStart + 7) % 7;
558
+ d.setDate(d.getDate() - diff);
559
+ d.setHours(0, 0, 0, 0);
560
+ return d;
561
+ }
562
+
563
+ static getWeekNumber(date: Date): number {
564
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
565
+ const dayNum = d.getUTCDay() || 7;
566
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
567
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
568
+ return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
569
+ }
570
+
571
+ static isSameDay(a: Date, b: Date): boolean {
572
+ return (
573
+ a.getFullYear() === b.getFullYear() &&
574
+ a.getMonth() === b.getMonth() &&
575
+ a.getDate() === b.getDate()
576
+ );
577
+ }
578
+
579
+ static addDays(date: Date, days: number): Date {
580
+ const d = new Date(date);
581
+ d.setDate(d.getDate() + days);
582
+ return d;
583
+ }
584
+
585
+ // Returns timestamp for start of day using pure math (no Date object creation)
586
+ static startOfDayTime(date: Date | undefined): number {
587
+ if (!date) return 0;
588
+ const time = date.getTime();
589
+ const timezoneOffset = date.getTimezoneOffset() * 60000;
590
+ return time - ((time - timezoneOffset) % 86400000);
591
+ }
592
+
593
+ // Returns timestamp for end of day using pure math (no Date object creation)
594
+ static endOfDayTime(date: Date | undefined): number {
595
+ if (!date) return 0;
596
+ return CalendarInternal.startOfDayTime(date) + 86400000 - 1;
597
+ }
598
+
599
+ static getWeekStartFromLocale(locale: string) {
600
+ // Use Intl to determine locale's week start
601
+ // Most locales start on Monday (1), US/Canada start on Sunday (0)
602
+ const sundayLocales = ["en-US", "en-CA", "ja-JP", "ko-KR", "zh-TW"];
603
+ const lang = locale.split("-")[0];
604
+ if (sundayLocales.includes(locale) || lang === "he" || lang === "ar") {
605
+ return 0;
606
+ }
607
+ return 1;
608
+ }
609
+ }
@@ -0,0 +1,54 @@
1
+ import type { CalendarEvent } from "./CalendarInternal.js";
2
+
3
+ export interface SyncMetadata {
4
+ lastSync: Date;
5
+ }
6
+
7
+ /**
8
+ * Storage abstraction for persisting calendar events.
9
+ * Implementations can use IndexedDB, localStorage, remote APIs, etc.
10
+ */
11
+ export interface CalendarStorage {
12
+ /**
13
+ * Loads all events from storage.
14
+ * Called once on initialization.
15
+ */
16
+ loadEvents(): Promise<CalendarEvent[]>;
17
+
18
+ /**
19
+ * Queries events that overlap with the specified time range.
20
+ * More efficient than loading all events when only a subset is needed.
21
+ */
22
+ queryEvents(start: Date, end: Date): Promise<CalendarEvent[]>;
23
+
24
+ /**
25
+ * Performs differential sync: adds/updates events and removes stale ones.
26
+ * Stale events are those belonging to a synced source with lastSynced older than
27
+ * the latest lastSynced timestamp for that source in the provided events.
28
+ * Called after each sync operation.
29
+ * @param events - Events to save/update
30
+ * @param syncedSources - Map of source names to their latest sync timestamps (includes sources with 0 events)
31
+ */
32
+ sync(events: CalendarEvent[], syncedSources: Map<string, Date>): Promise<void>;
33
+
34
+ /**
35
+ * Clears all events from storage.
36
+ * For future reset functionality.
37
+ */
38
+ clear(): Promise<void>;
39
+
40
+ /**
41
+ * Gets sync metadata for a specific source.
42
+ */
43
+ getSyncMetadata(sourceId: string): Promise<SyncMetadata | undefined>;
44
+
45
+ /**
46
+ * Sets sync metadata for a specific source.
47
+ */
48
+ setSyncMetadata(sourceId: string, metadata: SyncMetadata): Promise<void>;
49
+
50
+ /**
51
+ * Upserts a single event (e.g. to persist locally-set reminders).
52
+ */
53
+ putEvent(event: CalendarEvent): Promise<void>;
54
+ }