@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,393 @@
1
+ import type { CalendarEvent, Organizer, Attendee, NotificationConfig } from "./CalendarInternal.js";
2
+ import type { CalendarStorage, SyncMetadata } from "./CalendarStorage.js";
3
+
4
+ interface SerializedEvent {
5
+ id: string;
6
+ title: string;
7
+ start: string; // ISO date string
8
+ end: string; // ISO date string
9
+ color?: string;
10
+ calendar?: string;
11
+ calendarId?: string;
12
+ sourceId?: string;
13
+ description?: string;
14
+ location?: string;
15
+ url?: string;
16
+ rrule?: string;
17
+ readOnly?: boolean;
18
+ lastSynced?: string; // ISO date string
19
+ organizer?: Organizer;
20
+ attendees?: Attendee[];
21
+ status?: 'TENTATIVE' | 'CONFIRMED' | 'CANCELLED';
22
+ isAllDay?: boolean;
23
+ reminders?: NotificationConfig[];
24
+ }
25
+
26
+ interface SerializedMetadata {
27
+ sourceId: string;
28
+ lastSync: string; // ISO date string
29
+ }
30
+
31
+ /**
32
+ * IndexedDB storage implementation for calendar events.
33
+ * Uses differential sync to efficiently update events.
34
+ */
35
+ export class IndexedDBStorage implements CalendarStorage {
36
+ dbName = "calendar-events";
37
+ storeName = "events";
38
+ metadataStoreName = "metadata";
39
+ version = 8;
40
+
41
+ db?: IDBDatabase;
42
+ openPromise?: Promise<IDBDatabase>;
43
+
44
+ async recreateDatabase(): Promise<void> {
45
+ if (this.db) {
46
+ this.db.close();
47
+ this.db = undefined;
48
+ }
49
+ this.openPromise = undefined;
50
+
51
+ return new Promise((resolve, reject) => {
52
+ const deleteRequest = indexedDB.deleteDatabase(this.dbName);
53
+
54
+ deleteRequest.onerror = () => {
55
+ reject(new Error(`Failed to delete database: ${deleteRequest.error?.message}`));
56
+ };
57
+
58
+ deleteRequest.onsuccess = () => {
59
+ resolve();
60
+ };
61
+ });
62
+ }
63
+
64
+ async open(): Promise<IDBDatabase> {
65
+ if (this.db) {
66
+ return this.db;
67
+ }
68
+
69
+ if (this.openPromise) {
70
+ return this.openPromise;
71
+ }
72
+
73
+ this.openPromise = new Promise((resolve, reject) => {
74
+ const request = indexedDB.open(this.dbName, this.version);
75
+
76
+ request.onerror = () => {
77
+ this.openPromise = undefined;
78
+ reject(new Error(`Failed to open IndexedDB: ${request.error?.message}`));
79
+ };
80
+
81
+ request.onsuccess = () => {
82
+ this.db = request.result;
83
+ this.openPromise = undefined;
84
+ resolve(request.result);
85
+ };
86
+
87
+ request.onupgradeneeded = (event) => {
88
+ const db = (event.target as IDBOpenDBRequest).result;
89
+ const transaction = (event.target as IDBOpenDBRequest).transaction;
90
+
91
+ if (!transaction) {
92
+ throw new Error("Transaction not available during upgrade");
93
+ }
94
+
95
+ let store: IDBObjectStore;
96
+
97
+ if (!db.objectStoreNames.contains(this.storeName)) {
98
+ store = db.createObjectStore(this.storeName, { keyPath: "id" });
99
+ store.createIndex("start", "start", { unique: false });
100
+ store.createIndex("end", "end", { unique: false });
101
+ store.createIndex("calendar", "calendar", { unique: false });
102
+ store.createIndex("calendarSync", ["calendar", "lastSynced"], { unique: false });
103
+ } else {
104
+ store = transaction.objectStore(this.storeName);
105
+ if (!store.indexNames.contains("calendar")) {
106
+ store.createIndex("calendar", "calendar", { unique: false });
107
+ }
108
+ if (!store.indexNames.contains("calendarSync")) {
109
+ store.createIndex("calendarSync", ["calendar", "lastSynced"], { unique: false });
110
+ }
111
+ }
112
+
113
+ if (!db.objectStoreNames.contains(this.metadataStoreName)) {
114
+ db.createObjectStore(this.metadataStoreName, { keyPath: "sourceId" });
115
+ }
116
+ };
117
+ });
118
+
119
+ return this.openPromise;
120
+ }
121
+
122
+ async loadEvents(): Promise<CalendarEvent[]> {
123
+ const db = await this.open();
124
+
125
+ return new Promise((resolve, reject) => {
126
+ const transaction = db.transaction(this.storeName, "readonly");
127
+ const store = transaction.objectStore(this.storeName);
128
+ const request = store.getAll();
129
+
130
+ request.onerror = () => {
131
+ reject(new Error(`Failed to load events: ${request.error?.message}`));
132
+ };
133
+
134
+ request.onsuccess = () => {
135
+ const serializedEvents = request.result as SerializedEvent[];
136
+ const events = serializedEvents.map(e => this.deserializeEvent(e));
137
+ resolve(events);
138
+ };
139
+ });
140
+ }
141
+
142
+ async queryEvents(start: Date, end: Date): Promise<CalendarEvent[]> {
143
+ const db = await this.open();
144
+
145
+ return new Promise((resolve, reject) => {
146
+ const transaction = db.transaction(this.storeName, "readonly");
147
+ const store = transaction.objectStore(this.storeName);
148
+ const index = store.index("start");
149
+
150
+ const startTime = start.toISOString();
151
+ const endTime = end.toISOString();
152
+
153
+ // Get all events that start at or before the query end time
154
+ const range = IDBKeyRange.upperBound(endTime);
155
+ const request = index.getAll(range);
156
+
157
+ request.onerror = () => {
158
+ reject(new Error(`Failed to query events: ${request.error?.message}`));
159
+ };
160
+
161
+ request.onsuccess = () => {
162
+ const serializedEvents = request.result as SerializedEvent[];
163
+
164
+ // Filter to only events that end at or after the query start time
165
+ const overlappingEvents = serializedEvents.filter(e => e.end >= startTime);
166
+
167
+ const events = overlappingEvents.map(e => this.deserializeEvent(e));
168
+ resolve(events);
169
+ };
170
+ });
171
+ }
172
+
173
+ async sync(events: CalendarEvent[], syncedSources: Map<string, Date>): Promise<void> {
174
+ const db = await this.open();
175
+
176
+ return new Promise((resolve, reject) => {
177
+ const transaction = db.transaction(this.storeName, "readwrite");
178
+ const store = transaction.objectStore(this.storeName);
179
+ const calendarIndex = store.index("calendar");
180
+
181
+ // Upsert all new/updated events
182
+ for (const event of events) {
183
+ const serialized = this.serializeEvent(event);
184
+ store.put(serialized);
185
+ }
186
+
187
+ // For each synced source (including those with 0 events), delete stale events using cursor
188
+ for (const [calendar, syncTimestamp] of syncedSources.entries()) {
189
+ const latestSyncStr = syncTimestamp.toISOString();
190
+ const range = IDBKeyRange.only(calendar);
191
+ const cursorRequest = calendarIndex.openCursor(range);
192
+
193
+ cursorRequest.onsuccess = () => {
194
+ const cursor = cursorRequest.result;
195
+ if (cursor) {
196
+ const storedEvent = cursor.value as SerializedEvent;
197
+ // Delete if no lastSynced or if lastSynced is older than latest sync
198
+ if (!storedEvent.lastSynced || storedEvent.lastSynced < latestSyncStr) {
199
+ console.debug("sync deleted", storedEvent.id);
200
+ store.delete(storedEvent.id);
201
+ }
202
+ cursor.continue();
203
+ }
204
+ };
205
+
206
+ cursorRequest.onerror = () => {
207
+ reject(new Error(`Failed to query calendar events: ${cursorRequest.error?.message}`));
208
+ };
209
+ }
210
+
211
+ transaction.oncomplete = () => {
212
+ resolve();
213
+ };
214
+
215
+ transaction.onerror = () => {
216
+ reject(new Error(`Failed to save events: ${transaction.error?.message}`));
217
+ };
218
+ });
219
+ }
220
+
221
+ async clear(): Promise<void> {
222
+ const db = await this.open();
223
+
224
+ return new Promise((resolve, reject) => {
225
+ const transaction = db.transaction(this.storeName, "readwrite");
226
+ const store = transaction.objectStore(this.storeName);
227
+ const request = store.clear();
228
+
229
+ request.onerror = () => {
230
+ reject(new Error(`Failed to clear storage: ${request.error?.message}`));
231
+ };
232
+
233
+ request.onsuccess = () => {
234
+ resolve();
235
+ };
236
+ });
237
+ }
238
+
239
+ async getSyncMetadata(sourceId: string): Promise<SyncMetadata | undefined> {
240
+ const db = await this.open();
241
+
242
+ if (!db.objectStoreNames.contains(this.metadataStoreName)) {
243
+ await this.recreateDatabase();
244
+ return this.getSyncMetadata(sourceId);
245
+ }
246
+
247
+ return new Promise((resolve, reject) => {
248
+ const transaction = db.transaction(this.metadataStoreName, "readonly");
249
+ const store = transaction.objectStore(this.metadataStoreName);
250
+ const request = store.get(sourceId);
251
+
252
+ request.onerror = () => {
253
+ reject(new Error(`Failed to get sync metadata: ${request.error?.message}`));
254
+ };
255
+
256
+ request.onsuccess = () => {
257
+ const serialized = request.result as SerializedMetadata | undefined;
258
+ if (!serialized) {
259
+ resolve(undefined);
260
+ return;
261
+ }
262
+
263
+ resolve({
264
+ lastSync: new Date(serialized.lastSync),
265
+ });
266
+ };
267
+ });
268
+ }
269
+
270
+ async setSyncMetadata(sourceId: string, metadata: SyncMetadata): Promise<void> {
271
+ const db = await this.open();
272
+
273
+ if (!db.objectStoreNames.contains(this.metadataStoreName)) {
274
+ await this.recreateDatabase();
275
+ return this.setSyncMetadata(sourceId, metadata);
276
+ }
277
+
278
+ return new Promise((resolve, reject) => {
279
+ const transaction = db.transaction(this.metadataStoreName, "readwrite");
280
+ const store = transaction.objectStore(this.metadataStoreName);
281
+
282
+ const serialized: SerializedMetadata = {
283
+ sourceId,
284
+ lastSync: metadata.lastSync.toISOString(),
285
+ };
286
+
287
+ const request = store.put(serialized);
288
+
289
+ request.onerror = () => {
290
+ reject(new Error(`Failed to set sync metadata: ${request.error?.message}`));
291
+ };
292
+
293
+ request.onsuccess = () => {
294
+ resolve();
295
+ };
296
+ });
297
+ }
298
+
299
+ serializeEvent(event: CalendarEvent): SerializedEvent {
300
+ // For all-day events, store as local date string (YYYY-MM-DD) to preserve the date
301
+ // For timed events, store as ISO string
302
+ const start = event.isAllDay
303
+ ? `${event.start.getFullYear()}-${String(event.start.getMonth() + 1).padStart(2, '0')}-${String(event.start.getDate()).padStart(2, '0')}`
304
+ : event.start.toISOString();
305
+ const end = event.isAllDay
306
+ ? `${event.end.getFullYear()}-${String(event.end.getMonth() + 1).padStart(2, '0')}-${String(event.end.getDate()).padStart(2, '0')}`
307
+ : event.end.toISOString();
308
+
309
+ return {
310
+ id: event.id,
311
+ title: event.title,
312
+ start,
313
+ end,
314
+ color: event.color,
315
+ calendar: event.calendar,
316
+ calendarId: event.calendarId,
317
+ sourceId: event.sourceId,
318
+ description: event.description,
319
+ location: event.location,
320
+ url: event.url,
321
+ rrule: event.rrule,
322
+ readOnly: event.readOnly,
323
+ lastSynced: event.lastSynced?.toISOString(),
324
+ organizer: event.organizer,
325
+ attendees: event.attendees,
326
+ status: event.status,
327
+ isAllDay: event.isAllDay,
328
+ reminders: event.reminders,
329
+ };
330
+ }
331
+
332
+ deserializeEvent(serialized: SerializedEvent): CalendarEvent {
333
+ // Parse dates - for all-day events, parse as local date to avoid UTC shifts
334
+ let start: Date;
335
+ let end: Date;
336
+
337
+ if (serialized.isAllDay) {
338
+ // Parse as local date (YYYY-MM-DD format)
339
+ const startParts = serialized.start.split('-').map(Number);
340
+ if (startParts.length === 3 && startParts.every(p => !Number.isNaN(p))) {
341
+ const [year, month, day] = startParts as [number, number, number];
342
+ start = new Date(year, month - 1, day);
343
+ } else {
344
+ start = new Date(serialized.start);
345
+ }
346
+ const endParts = serialized.end.split('-').map(Number);
347
+ if (endParts.length === 3 && endParts.every(p => !Number.isNaN(p))) {
348
+ const [year, month, day] = endParts as [number, number, number];
349
+ end = new Date(year, month - 1, day);
350
+ } else {
351
+ end = new Date(serialized.end);
352
+ }
353
+ } else {
354
+ start = new Date(serialized.start);
355
+ end = new Date(serialized.end);
356
+ }
357
+
358
+ return {
359
+ id: serialized.id,
360
+ title: serialized.title,
361
+ start,
362
+ end,
363
+ color: serialized.color,
364
+ calendar: serialized.calendar,
365
+ calendarId: serialized.calendarId,
366
+ sourceId: serialized.sourceId,
367
+ description: serialized.description,
368
+ location: serialized.location,
369
+ url: serialized.url,
370
+ rrule: serialized.rrule,
371
+ readOnly: serialized.readOnly,
372
+ lastSynced: serialized.lastSynced ? new Date(serialized.lastSynced) : undefined,
373
+ organizer: serialized.organizer,
374
+ attendees: serialized.attendees,
375
+ status: serialized.status,
376
+ isAllDay: serialized.isAllDay,
377
+ reminders: serialized.reminders,
378
+ };
379
+ }
380
+
381
+ async putEvent(event: CalendarEvent): Promise<void> {
382
+ const db = await this.open();
383
+
384
+ return new Promise((resolve, reject) => {
385
+ const transaction = db.transaction(this.storeName, "readwrite");
386
+ const store = transaction.objectStore(this.storeName);
387
+ store.put(this.serializeEvent(event));
388
+
389
+ transaction.oncomplete = () => resolve();
390
+ transaction.onerror = () => reject(transaction.error);
391
+ });
392
+ }
393
+ }
@@ -0,0 +1,237 @@
1
+ import type { CalendarEvent } from "./CalendarInternal.js";
2
+ import type { CalendarSource, CalendarCredentials } from "./CalendarIntegration.js";
3
+
4
+ /**
5
+ * Inhouse Booking System credentials.
6
+ */
7
+ export interface InhouseCredentials extends CalendarCredentials {
8
+ /**
9
+ * Session cookie for authentication (e.g., "sessionid=abc123; csrftoken=xyz789")
10
+ */
11
+ sessionCookie: string;
12
+ /**
13
+ * Employee ID to filter bookings for
14
+ */
15
+ employeeId: string;
16
+ /**
17
+ * Unit ID (optional, for filtering by unit/location)
18
+ */
19
+ unitId?: string;
20
+ }
21
+
22
+ /**
23
+ * Inhouse Booking System API response structure.
24
+ */
25
+ interface InhouseBooking {
26
+ id: string | number;
27
+ date: string;
28
+ timeslots: number;
29
+ description: string;
30
+ employee_id: number;
31
+ closing_id?: number;
32
+ project_id?: number;
33
+ owner_id?: number;
34
+ updated_user_id?: number;
35
+ home_office?: number;
36
+ out_of_office?: number;
37
+ optional?: number;
38
+ deadline?: number;
39
+ created_at?: string;
40
+ updated_at?: string;
41
+ deleted_at?: string | null;
42
+ }
43
+
44
+ /**
45
+ * Inhouse Booking System source for syncing with internal booking API.
46
+ *
47
+ * @example
48
+ * const source = new InhouseBookingSource(
49
+ * 'inhouse-1',
50
+ * 'My Bookings',
51
+ * '#4285F4',
52
+ * {
53
+ * sessionCookie: 'sessionid=abc123',
54
+ * employeeId: '589',
55
+ * unitId: '3'
56
+ * }
57
+ * );
58
+ *
59
+ * const events = await source.fetchEvents();
60
+ */
61
+ export class InhouseBookingSource implements CalendarSource {
62
+ readonly type = 'inhouse';
63
+ credentials: InhouseCredentials;
64
+ enabled: boolean;
65
+
66
+ constructor(
67
+ public id: string,
68
+ public name: string,
69
+ public color: string,
70
+ credentials: InhouseCredentials,
71
+ enabled = true
72
+ ) {
73
+ this.credentials = credentials;
74
+ this.enabled = enabled;
75
+ }
76
+
77
+ /**
78
+ * Make an authenticated request to the Inhouse Booking API via the proxy.
79
+ * The proxy will inject the session cookie.
80
+ */
81
+ private async apiRequest<T>(endpoint: string): Promise<T> {
82
+ const url = `/bookings${endpoint}`;
83
+
84
+ const response = await fetch(url, {
85
+ headers: {
86
+ 'X-Session-Cookie': this.credentials.sessionCookie,
87
+ },
88
+ });
89
+
90
+ if (!response.ok) {
91
+ if (response.status === 401) {
92
+ throw new Error('Inhouse Booking System authentication failed. Please check your session cookie.');
93
+ }
94
+ throw new Error(`Inhouse Booking API error: ${response.status} ${response.statusText}`);
95
+ }
96
+
97
+ return response.json() as Promise<T>;
98
+ }
99
+
100
+ /**
101
+ * Convert an Inhouse booking to internal CalendarEvent format.
102
+ * Bookings use date + timeslots (each quarter = 2 hours in an 8-hour work day).
103
+ * Bookings are ordered by their position in the day (first booking = starts at 09:00).
104
+ */
105
+ private mapBookingToEvent(
106
+ booking: InhouseBooking,
107
+ startTime: Date
108
+ ): CalendarEvent | null {
109
+ if (!booking.date || !booking.timeslots) return null;
110
+
111
+ // Each quarter = 2 hours (8-hour day / 4 quarters)
112
+ const quarterHours = 2;
113
+ const durationHours = booking.timeslots * quarterHours;
114
+ const endTime = new Date(startTime.getTime() + durationHours * 60 * 60 * 1000);
115
+
116
+ if (Number.isNaN(startTime.getTime()) || Number.isNaN(endTime.getTime())) return null;
117
+
118
+ const title = booking.description || 'Booking';
119
+
120
+ return {
121
+ id: String(booking.id),
122
+ title: title,
123
+ start: startTime,
124
+ end: endTime,
125
+ color: this.color,
126
+ calendar: this.name,
127
+ calendarId: this.id,
128
+ sourceId: this.id,
129
+ description: `${booking.timeslots} quarter${booking.timeslots > 1 ? 's' : ''} (${durationHours}h)`,
130
+ readOnly: true,
131
+ status: booking.optional === 1 ? 'TENTATIVE' : undefined,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Process bookings and calculate their timestamps based on order.
137
+ * Bookings are stacked chronologically - first booking starts at 09:00.
138
+ */
139
+ private processBookings(bookings: InhouseBooking[]): CalendarEvent[] {
140
+ // Group bookings by date
141
+ const bookingsByDate = new Map<string, InhouseBooking[]>();
142
+
143
+ for (const booking of bookings) {
144
+ if (!booking.date) continue;
145
+ const list = bookingsByDate.get(booking.date) || [];
146
+ list.push(booking);
147
+ bookingsByDate.set(booking.date, list);
148
+ }
149
+
150
+ const events: CalendarEvent[] = [];
151
+
152
+ for (const [dateStr, dateBookings] of bookingsByDate) {
153
+ // Parse date (format: "2025-03-07")
154
+ const [year, month, day] = dateStr.split('-').map(Number);
155
+ if (!year || !month || !day) continue;
156
+
157
+ // Start of work day: 09:00
158
+ let currentTime = new Date(year, month - 1, day, 9, 0);
159
+
160
+ // Process bookings in order (API returns them in chronological order)
161
+ for (const booking of dateBookings) {
162
+ const event = this.mapBookingToEvent(booking, currentTime);
163
+ if (event) {
164
+ events.push(event);
165
+ // Advance current time for next booking
166
+ currentTime = event.end;
167
+ }
168
+ }
169
+ }
170
+
171
+ return events;
172
+ }
173
+
174
+ /**
175
+ * Fetch bookings from the Inhouse Booking System.
176
+ * Fetches bookings from 1 year ago to 1 year in the future.
177
+ */
178
+ async fetchEvents(): Promise<CalendarEvent[]> {
179
+ if (!this.enabled) return [];
180
+
181
+ const now = new Date();
182
+ const oneYearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
183
+ const oneYearFuture = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
184
+
185
+ const formatDate = (date: Date): string => date.toISOString().split('T')[0];
186
+
187
+ const dateFilter = {
188
+ '>=': formatDate(oneYearAgo),
189
+ '<=': formatDate(oneYearFuture),
190
+ };
191
+
192
+ const employeeFilter = {
193
+ '=': this.credentials.employeeId,
194
+ };
195
+
196
+ const params = new URLSearchParams({
197
+ date: JSON.stringify(dateFilter),
198
+ employee_id: JSON.stringify(employeeFilter),
199
+ });
200
+
201
+ if (this.credentials.unitId) {
202
+ params.set('unit_id', this.credentials.unitId);
203
+ }
204
+
205
+ const bookings = await this.apiRequest<InhouseBooking[]>(`?${params}`);
206
+
207
+ if (!Array.isArray(bookings)) return [];
208
+
209
+ return this.processBookings(bookings);
210
+ }
211
+
212
+ /**
213
+ * Test the connection to the Inhouse Booking System.
214
+ * Returns true if the credentials are valid.
215
+ */
216
+ async testConnection(): Promise<boolean> {
217
+ try {
218
+ const now = new Date();
219
+ const dateFilter = {
220
+ '>=': now.toISOString().split('T')[0],
221
+ '<=': now.toISOString().split('T')[0],
222
+ };
223
+ const employeeFilter = { '=': this.credentials.employeeId };
224
+
225
+ const params = new URLSearchParams({
226
+ date: JSON.stringify(dateFilter),
227
+ employee_id: JSON.stringify(employeeFilter),
228
+ limit: '1',
229
+ });
230
+
231
+ await this.apiRequest<InhouseBooking[]>(`?${params}`);
232
+ return true;
233
+ } catch (error) {
234
+ return false;
235
+ }
236
+ }
237
+ }