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