@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,32 +1,38 @@
1
- import type { CalendarEvent, Organizer, Attendee, NotificationConfig } from "./CalendarInternal.js";
1
+ import type {
2
+ Attendee,
3
+ CalendarEvent,
4
+ NotificationConfig,
5
+ Organizer,
6
+ } from "./CalendarInternal.js";
2
7
  import type { CalendarStorage, SyncMetadata } from "./CalendarStorage.js";
3
8
 
4
9
  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
- visualStyle?: 'heatmap';
10
+ id: string;
11
+ title: string;
12
+ start: string; // ISO date string
13
+ end: string; // ISO date string
14
+ color?: string;
15
+ calendar?: string;
16
+ calendarId?: string;
17
+ sourceId?: string;
18
+ description?: string;
19
+ location?: string;
20
+ url?: string;
21
+ rrule?: string;
22
+ readOnly?: boolean;
23
+ lastSynced?: string; // ISO date string
24
+ organizer?: Organizer;
25
+ attendees?: Attendee[];
26
+ status?: "TENTATIVE" | "CONFIRMED" | "CANCELLED";
27
+ isAllDay?: boolean;
28
+ reminders?: NotificationConfig[];
29
+ visualStyle?: "heatmap";
30
+ resourceUrl?: string;
25
31
  }
26
32
 
27
33
  interface SerializedMetadata {
28
- sourceId: string;
29
- lastSync: string; // ISO date string
34
+ sourceId: string;
35
+ lastSync: string; // ISO date string
30
36
  }
31
37
 
32
38
  /**
@@ -34,376 +40,419 @@ interface SerializedMetadata {
34
40
  * Uses differential sync to efficiently update events.
35
41
  */
36
42
  export class IndexedDBStorage implements CalendarStorage {
37
- dbName = "calendar-events";
38
- storeName = "events";
39
- metadataStoreName = "metadata";
40
- version = 8;
41
-
42
- db?: IDBDatabase;
43
- openPromise?: Promise<IDBDatabase>;
44
-
45
- async recreateDatabase(): Promise<void> {
46
- if (this.db) {
47
- this.db.close();
48
- this.db = undefined;
49
- }
50
- this.openPromise = undefined;
51
-
52
- return new Promise((resolve, reject) => {
53
- const deleteRequest = indexedDB.deleteDatabase(this.dbName);
54
-
55
- deleteRequest.onerror = () => {
56
- reject(new Error(`Failed to delete database: ${deleteRequest.error?.message}`));
57
- };
58
-
59
- deleteRequest.onsuccess = () => {
60
- resolve();
61
- };
62
- });
63
- }
64
-
65
- async open(): Promise<IDBDatabase> {
66
- if (this.db) {
67
- return this.db;
68
- }
69
-
70
- if (this.openPromise) {
71
- return this.openPromise;
72
- }
73
-
74
- this.openPromise = new Promise((resolve, reject) => {
75
- const request = indexedDB.open(this.dbName, this.version);
76
-
77
- request.onerror = () => {
78
- this.openPromise = undefined;
79
- reject(new Error(`Failed to open IndexedDB: ${request.error?.message}`));
80
- };
81
-
82
- request.onsuccess = () => {
83
- this.db = request.result;
84
- this.openPromise = undefined;
85
- resolve(request.result);
86
- };
87
-
88
- request.onupgradeneeded = (event) => {
89
- const db = (event.target as IDBOpenDBRequest).result;
90
- const transaction = (event.target as IDBOpenDBRequest).transaction;
91
-
92
- if (!transaction) {
93
- throw new Error("Transaction not available during upgrade");
94
- }
95
-
96
- let store: IDBObjectStore;
97
-
98
- if (!db.objectStoreNames.contains(this.storeName)) {
99
- store = db.createObjectStore(this.storeName, { keyPath: "id" });
100
- store.createIndex("start", "start", { unique: false });
101
- store.createIndex("end", "end", { unique: false });
102
- store.createIndex("calendar", "calendar", { unique: false });
103
- store.createIndex("calendarSync", ["calendar", "lastSynced"], { unique: false });
104
- } else {
105
- store = transaction.objectStore(this.storeName);
106
- if (!store.indexNames.contains("calendar")) {
107
- store.createIndex("calendar", "calendar", { unique: false });
108
- }
109
- if (!store.indexNames.contains("calendarSync")) {
110
- store.createIndex("calendarSync", ["calendar", "lastSynced"], { unique: false });
111
- }
112
- }
113
-
114
- if (!db.objectStoreNames.contains(this.metadataStoreName)) {
115
- db.createObjectStore(this.metadataStoreName, { keyPath: "sourceId" });
116
- }
117
- };
118
- });
119
-
120
- return this.openPromise;
121
- }
122
-
123
- async loadEvents(): Promise<CalendarEvent[]> {
124
- const db = await this.open();
125
-
126
- return new Promise((resolve, reject) => {
127
- const transaction = db.transaction(this.storeName, "readonly");
128
- const store = transaction.objectStore(this.storeName);
129
- const request = store.getAll();
130
-
131
- request.onerror = () => {
132
- reject(new Error(`Failed to load events: ${request.error?.message}`));
133
- };
134
-
135
- request.onsuccess = () => {
136
- const serializedEvents = request.result as SerializedEvent[];
137
- const events = serializedEvents.map(e => this.deserializeEvent(e));
138
- resolve(events);
139
- };
140
- });
141
- }
142
-
143
- async queryEvents(start: Date, end: Date): Promise<CalendarEvent[]> {
144
- const db = await this.open();
145
-
146
- return new Promise((resolve, reject) => {
147
- const transaction = db.transaction(this.storeName, "readonly");
148
- const store = transaction.objectStore(this.storeName);
149
- const index = store.index("start");
150
-
151
- const startTime = start.toISOString();
152
- const endTime = end.toISOString();
153
-
154
- // Get all events that start at or before the query end time
155
- const range = IDBKeyRange.upperBound(endTime);
156
- const request = index.getAll(range);
157
-
158
- request.onerror = () => {
159
- reject(new Error(`Failed to query events: ${request.error?.message}`));
160
- };
161
-
162
- request.onsuccess = () => {
163
- const serializedEvents = request.result as SerializedEvent[];
164
-
165
- // Filter to only events that end at or after the query start time
166
- const overlappingEvents = serializedEvents.filter(e => e.end >= startTime);
167
-
168
- const events = overlappingEvents.map(e => this.deserializeEvent(e));
169
- resolve(events);
170
- };
171
- });
172
- }
173
-
174
- async sync(events: CalendarEvent[], syncedSources: Map<string, Date>): Promise<void> {
175
- const db = await this.open();
176
-
177
- return new Promise((resolve, reject) => {
178
- const transaction = db.transaction(this.storeName, "readwrite");
179
- const store = transaction.objectStore(this.storeName);
180
- const calendarIndex = store.index("calendar");
181
-
182
- // Upsert all new/updated events
183
- for (const event of events) {
184
- const serialized = this.serializeEvent(event);
185
- store.put(serialized);
186
- }
187
-
188
- // For each synced source (including those with 0 events), delete stale events using cursor
189
- for (const [calendar, syncTimestamp] of syncedSources.entries()) {
190
- const latestSyncStr = syncTimestamp.toISOString();
191
- const range = IDBKeyRange.only(calendar);
192
- const cursorRequest = calendarIndex.openCursor(range);
193
-
194
- cursorRequest.onsuccess = () => {
195
- const cursor = cursorRequest.result;
196
- if (cursor) {
197
- const storedEvent = cursor.value as SerializedEvent;
198
- // Delete if no lastSynced or if lastSynced is older than latest sync
199
- if (!storedEvent.lastSynced || storedEvent.lastSynced < latestSyncStr) {
200
- console.debug("sync deleted", storedEvent.id);
201
- store.delete(storedEvent.id);
202
- }
203
- cursor.continue();
204
- }
205
- };
206
-
207
- cursorRequest.onerror = () => {
208
- reject(new Error(`Failed to query calendar events: ${cursorRequest.error?.message}`));
209
- };
210
- }
211
-
212
- transaction.oncomplete = () => {
213
- resolve();
214
- };
215
-
216
- transaction.onerror = () => {
217
- reject(new Error(`Failed to save events: ${transaction.error?.message}`));
218
- };
219
- });
220
- }
221
-
222
- async clear(): Promise<void> {
223
- const db = await this.open();
224
-
225
- return new Promise((resolve, reject) => {
226
- const transaction = db.transaction(this.storeName, "readwrite");
227
- const store = transaction.objectStore(this.storeName);
228
- const request = store.clear();
229
-
230
- request.onerror = () => {
231
- reject(new Error(`Failed to clear storage: ${request.error?.message}`));
232
- };
233
-
234
- request.onsuccess = () => {
235
- resolve();
236
- };
237
- });
238
- }
239
-
240
- async getSyncMetadata(sourceId: string): Promise<SyncMetadata | undefined> {
241
- const db = await this.open();
242
-
243
- if (!db.objectStoreNames.contains(this.metadataStoreName)) {
244
- await this.recreateDatabase();
245
- return this.getSyncMetadata(sourceId);
246
- }
247
-
248
- return new Promise((resolve, reject) => {
249
- const transaction = db.transaction(this.metadataStoreName, "readonly");
250
- const store = transaction.objectStore(this.metadataStoreName);
251
- const request = store.get(sourceId);
252
-
253
- request.onerror = () => {
254
- reject(new Error(`Failed to get sync metadata: ${request.error?.message}`));
255
- };
256
-
257
- request.onsuccess = () => {
258
- const serialized = request.result as SerializedMetadata | undefined;
259
- if (!serialized) {
260
- resolve(undefined);
261
- return;
262
- }
263
-
264
- resolve({
265
- lastSync: new Date(serialized.lastSync),
266
- });
267
- };
268
- });
269
- }
270
-
271
- async setSyncMetadata(sourceId: string, metadata: SyncMetadata): Promise<void> {
272
- const db = await this.open();
273
-
274
- if (!db.objectStoreNames.contains(this.metadataStoreName)) {
275
- await this.recreateDatabase();
276
- return this.setSyncMetadata(sourceId, metadata);
277
- }
278
-
279
- return new Promise((resolve, reject) => {
280
- const transaction = db.transaction(this.metadataStoreName, "readwrite");
281
- const store = transaction.objectStore(this.metadataStoreName);
282
-
283
- const serialized: SerializedMetadata = {
284
- sourceId,
285
- lastSync: metadata.lastSync.toISOString(),
286
- };
287
-
288
- const request = store.put(serialized);
289
-
290
- request.onerror = () => {
291
- reject(new Error(`Failed to set sync metadata: ${request.error?.message}`));
292
- };
293
-
294
- request.onsuccess = () => {
295
- resolve();
296
- };
297
- });
298
- }
299
-
300
- serializeEvent(event: CalendarEvent): SerializedEvent {
301
- // For all-day events, store as local date string (YYYY-MM-DD) to preserve the date
302
- // For timed events, store as ISO string
303
- const start = event.isAllDay
304
- ? `${event.start.getFullYear()}-${String(event.start.getMonth() + 1).padStart(2, '0')}-${String(event.start.getDate()).padStart(2, '0')}`
305
- : event.start.toISOString();
306
- const end = event.isAllDay
307
- ? `${event.end.getFullYear()}-${String(event.end.getMonth() + 1).padStart(2, '0')}-${String(event.end.getDate()).padStart(2, '0')}`
308
- : event.end.toISOString();
309
-
310
- return {
311
- id: event.id,
312
- title: event.title,
313
- start,
314
- end,
315
- color: event.color,
316
- calendar: event.calendar,
317
- calendarId: event.calendarId,
318
- sourceId: event.sourceId,
319
- description: event.description,
320
- location: event.location,
321
- url: event.url,
322
- rrule: event.rrule,
323
- readOnly: event.readOnly,
324
- lastSynced: event.lastSynced?.toISOString(),
325
- organizer: event.organizer,
326
- attendees: event.attendees,
327
- status: event.status,
328
- isAllDay: event.isAllDay,
329
- reminders: event.reminders,
330
- visualStyle: event.visualStyle,
331
- };
332
- }
333
-
334
- deserializeEvent(serialized: SerializedEvent): CalendarEvent {
335
- // Parse dates - for all-day events, parse as local date to avoid UTC shifts
336
- let start: Date;
337
- let end: Date;
338
-
339
- if (serialized.isAllDay) {
340
- // Parse as local date (YYYY-MM-DD format)
341
- const startParts = serialized.start.split('-').map(Number);
342
- if (startParts.length === 3 && startParts.every(p => !Number.isNaN(p))) {
343
- const [year, month, day] = startParts as [number, number, number];
344
- start = new Date(year, month - 1, day);
345
- } else {
346
- start = new Date(serialized.start);
347
- }
348
- const endParts = serialized.end.split('-').map(Number);
349
- if (endParts.length === 3 && endParts.every(p => !Number.isNaN(p))) {
350
- const [year, month, day] = endParts as [number, number, number];
351
- end = new Date(year, month - 1, day);
352
- } else {
353
- end = new Date(serialized.end);
354
- }
355
- } else {
356
- start = new Date(serialized.start);
357
- end = new Date(serialized.end);
358
- }
359
-
360
- return {
361
- id: serialized.id,
362
- title: serialized.title,
363
- start,
364
- end,
365
- color: serialized.color,
366
- calendar: serialized.calendar,
367
- calendarId: serialized.calendarId,
368
- sourceId: serialized.sourceId,
369
- description: serialized.description,
370
- location: serialized.location,
371
- url: serialized.url,
372
- rrule: serialized.rrule,
373
- readOnly: serialized.readOnly,
374
- lastSynced: serialized.lastSynced ? new Date(serialized.lastSynced) : undefined,
375
- organizer: serialized.organizer,
376
- attendees: serialized.attendees,
377
- status: serialized.status,
378
- isAllDay: serialized.isAllDay,
379
- reminders: serialized.reminders,
380
- visualStyle: serialized.visualStyle,
381
- };
382
- }
383
-
384
- async putEvent(event: CalendarEvent): Promise<void> {
385
- const db = await this.open();
386
-
387
- return new Promise((resolve, reject) => {
388
- const transaction = db.transaction(this.storeName, "readwrite");
389
- const store = transaction.objectStore(this.storeName);
390
- store.put(this.serializeEvent(event));
391
-
392
- transaction.oncomplete = () => resolve();
393
- transaction.onerror = () => reject(transaction.error);
394
- });
395
- }
396
-
397
- async deleteEvent(id: string): Promise<void> {
398
- const db = await this.open();
399
-
400
- return new Promise((resolve, reject) => {
401
- const transaction = db.transaction(this.storeName, "readwrite");
402
- const store = transaction.objectStore(this.storeName);
403
- store.delete(id);
404
-
405
- transaction.oncomplete = () => resolve();
406
- transaction.onerror = () => reject(transaction.error);
407
- });
408
- }
43
+ dbName = "calendar-events";
44
+ storeName = "events";
45
+ metadataStoreName = "metadata";
46
+ version = 8;
47
+
48
+ db?: IDBDatabase;
49
+ openPromise?: Promise<IDBDatabase>;
50
+
51
+ async recreateDatabase(): Promise<void> {
52
+ if (this.db) {
53
+ this.db.close();
54
+ this.db = undefined;
55
+ }
56
+ this.openPromise = undefined;
57
+
58
+ return new Promise((resolve, reject) => {
59
+ const deleteRequest = indexedDB.deleteDatabase(this.dbName);
60
+
61
+ deleteRequest.onerror = () => {
62
+ reject(
63
+ new Error(
64
+ `Failed to delete database: ${deleteRequest.error?.message}`,
65
+ ),
66
+ );
67
+ };
68
+
69
+ deleteRequest.onsuccess = () => {
70
+ resolve();
71
+ };
72
+ });
73
+ }
74
+
75
+ async open(): Promise<IDBDatabase> {
76
+ if (this.db) {
77
+ return this.db;
78
+ }
79
+
80
+ if (this.openPromise) {
81
+ return this.openPromise;
82
+ }
83
+
84
+ this.openPromise = new Promise((resolve, reject) => {
85
+ const request = indexedDB.open(this.dbName, this.version);
86
+
87
+ request.onerror = () => {
88
+ this.openPromise = undefined;
89
+ reject(
90
+ new Error(`Failed to open IndexedDB: ${request.error?.message}`),
91
+ );
92
+ };
93
+
94
+ request.onsuccess = () => {
95
+ this.db = request.result;
96
+ this.openPromise = undefined;
97
+ resolve(request.result);
98
+ };
99
+
100
+ request.onupgradeneeded = (event) => {
101
+ const db = (event.target as IDBOpenDBRequest).result;
102
+ const transaction = (event.target as IDBOpenDBRequest).transaction;
103
+
104
+ if (!transaction) {
105
+ throw new Error("Transaction not available during upgrade");
106
+ }
107
+
108
+ let store: IDBObjectStore;
109
+
110
+ if (!db.objectStoreNames.contains(this.storeName)) {
111
+ store = db.createObjectStore(this.storeName, { keyPath: "id" });
112
+ store.createIndex("start", "start", { unique: false });
113
+ store.createIndex("end", "end", { unique: false });
114
+ store.createIndex("calendar", "calendar", { unique: false });
115
+ store.createIndex("calendarSync", ["calendar", "lastSynced"], {
116
+ unique: false,
117
+ });
118
+ } else {
119
+ store = transaction.objectStore(this.storeName);
120
+ if (!store.indexNames.contains("calendar")) {
121
+ store.createIndex("calendar", "calendar", { unique: false });
122
+ }
123
+ if (!store.indexNames.contains("calendarSync")) {
124
+ store.createIndex("calendarSync", ["calendar", "lastSynced"], {
125
+ unique: false,
126
+ });
127
+ }
128
+ }
129
+
130
+ if (!db.objectStoreNames.contains(this.metadataStoreName)) {
131
+ db.createObjectStore(this.metadataStoreName, { keyPath: "sourceId" });
132
+ }
133
+ };
134
+ });
135
+
136
+ return this.openPromise;
137
+ }
138
+
139
+ async loadEvents(): Promise<CalendarEvent[]> {
140
+ const db = await this.open();
141
+
142
+ return new Promise((resolve, reject) => {
143
+ const transaction = db.transaction(this.storeName, "readonly");
144
+ const store = transaction.objectStore(this.storeName);
145
+ const request = store.getAll();
146
+
147
+ request.onerror = () => {
148
+ reject(new Error(`Failed to load events: ${request.error?.message}`));
149
+ };
150
+
151
+ request.onsuccess = () => {
152
+ const serializedEvents = request.result as SerializedEvent[];
153
+ const events = serializedEvents.map((e) => this.deserializeEvent(e));
154
+ resolve(events);
155
+ };
156
+ });
157
+ }
158
+
159
+ async queryEvents(start: Date, end: Date): Promise<CalendarEvent[]> {
160
+ const db = await this.open();
161
+
162
+ return new Promise((resolve, reject) => {
163
+ const transaction = db.transaction(this.storeName, "readonly");
164
+ const store = transaction.objectStore(this.storeName);
165
+ const index = store.index("start");
166
+
167
+ const startTime = start.toISOString();
168
+ const endTime = end.toISOString();
169
+
170
+ // Get all events that start at or before the query end time
171
+ const range = IDBKeyRange.upperBound(endTime);
172
+ const request = index.getAll(range);
173
+
174
+ request.onerror = () => {
175
+ reject(new Error(`Failed to query events: ${request.error?.message}`));
176
+ };
177
+
178
+ request.onsuccess = () => {
179
+ const serializedEvents = request.result as SerializedEvent[];
180
+
181
+ // Filter to only events that end at or after the query start time
182
+ const overlappingEvents = serializedEvents.filter(
183
+ (e) => e.end >= startTime,
184
+ );
185
+
186
+ const events = overlappingEvents.map((e) => this.deserializeEvent(e));
187
+ resolve(events);
188
+ };
189
+ });
190
+ }
191
+
192
+ async sync(
193
+ events: CalendarEvent[],
194
+ syncedSources: Map<string, Date>,
195
+ ): Promise<void> {
196
+ const db = await this.open();
197
+
198
+ return new Promise((resolve, reject) => {
199
+ const transaction = db.transaction(this.storeName, "readwrite");
200
+ const store = transaction.objectStore(this.storeName);
201
+ const calendarIndex = store.index("calendar");
202
+
203
+ // Upsert all new/updated events
204
+ for (const event of events) {
205
+ const serialized = this.serializeEvent(event);
206
+ store.put(serialized);
207
+ }
208
+
209
+ // For each synced source (including those with 0 events), delete stale events using cursor
210
+ for (const [calendar, syncTimestamp] of syncedSources.entries()) {
211
+ const latestSyncStr = syncTimestamp.toISOString();
212
+ const range = IDBKeyRange.only(calendar);
213
+ const cursorRequest = calendarIndex.openCursor(range);
214
+
215
+ cursorRequest.onsuccess = () => {
216
+ const cursor = cursorRequest.result;
217
+ if (cursor) {
218
+ const storedEvent = cursor.value as SerializedEvent;
219
+ // Delete if no lastSynced or if lastSynced is older than latest sync
220
+ if (
221
+ !storedEvent.lastSynced ||
222
+ storedEvent.lastSynced < latestSyncStr
223
+ ) {
224
+ console.debug("sync deleted", storedEvent.id);
225
+ store.delete(storedEvent.id);
226
+ }
227
+ cursor.continue();
228
+ }
229
+ };
230
+
231
+ cursorRequest.onerror = () => {
232
+ reject(
233
+ new Error(
234
+ `Failed to query calendar events: ${cursorRequest.error?.message}`,
235
+ ),
236
+ );
237
+ };
238
+ }
239
+
240
+ transaction.oncomplete = () => {
241
+ resolve();
242
+ };
243
+
244
+ transaction.onerror = () => {
245
+ reject(
246
+ new Error(`Failed to save events: ${transaction.error?.message}`),
247
+ );
248
+ };
249
+ });
250
+ }
251
+
252
+ async clear(): Promise<void> {
253
+ const db = await this.open();
254
+
255
+ return new Promise((resolve, reject) => {
256
+ const transaction = db.transaction(this.storeName, "readwrite");
257
+ const store = transaction.objectStore(this.storeName);
258
+ const request = store.clear();
259
+
260
+ request.onerror = () => {
261
+ reject(new Error(`Failed to clear storage: ${request.error?.message}`));
262
+ };
263
+
264
+ request.onsuccess = () => {
265
+ resolve();
266
+ };
267
+ });
268
+ }
269
+
270
+ async getSyncMetadata(sourceId: string): Promise<SyncMetadata | undefined> {
271
+ const db = await this.open();
272
+
273
+ if (!db.objectStoreNames.contains(this.metadataStoreName)) {
274
+ await this.recreateDatabase();
275
+ return this.getSyncMetadata(sourceId);
276
+ }
277
+
278
+ return new Promise((resolve, reject) => {
279
+ const transaction = db.transaction(this.metadataStoreName, "readonly");
280
+ const store = transaction.objectStore(this.metadataStoreName);
281
+ const request = store.get(sourceId);
282
+
283
+ request.onerror = () => {
284
+ reject(
285
+ new Error(`Failed to get sync metadata: ${request.error?.message}`),
286
+ );
287
+ };
288
+
289
+ request.onsuccess = () => {
290
+ const serialized = request.result as SerializedMetadata | undefined;
291
+ if (!serialized) {
292
+ resolve(undefined);
293
+ return;
294
+ }
295
+
296
+ resolve({
297
+ lastSync: new Date(serialized.lastSync),
298
+ });
299
+ };
300
+ });
301
+ }
302
+
303
+ async setSyncMetadata(
304
+ sourceId: string,
305
+ metadata: SyncMetadata,
306
+ ): Promise<void> {
307
+ const db = await this.open();
308
+
309
+ if (!db.objectStoreNames.contains(this.metadataStoreName)) {
310
+ await this.recreateDatabase();
311
+ return this.setSyncMetadata(sourceId, metadata);
312
+ }
313
+
314
+ return new Promise((resolve, reject) => {
315
+ const transaction = db.transaction(this.metadataStoreName, "readwrite");
316
+ const store = transaction.objectStore(this.metadataStoreName);
317
+
318
+ const serialized: SerializedMetadata = {
319
+ sourceId,
320
+ lastSync: metadata.lastSync.toISOString(),
321
+ };
322
+
323
+ const request = store.put(serialized);
324
+
325
+ request.onerror = () => {
326
+ reject(
327
+ new Error(`Failed to set sync metadata: ${request.error?.message}`),
328
+ );
329
+ };
330
+
331
+ request.onsuccess = () => {
332
+ resolve();
333
+ };
334
+ });
335
+ }
336
+
337
+ serializeEvent(event: CalendarEvent): SerializedEvent {
338
+ // For all-day events, store as local date string (YYYY-MM-DD) to preserve the date
339
+ // For timed events, store as ISO string
340
+ const start = event.isAllDay
341
+ ? `${event.start.getFullYear()}-${String(
342
+ event.start.getMonth() + 1,
343
+ ).padStart(2, "0")}-${String(event.start.getDate()).padStart(2, "0")}`
344
+ : event.start.toISOString();
345
+ const end = event.isAllDay
346
+ ? `${event.end.getFullYear()}-${String(event.end.getMonth() + 1).padStart(
347
+ 2,
348
+ "0",
349
+ )}-${String(event.end.getDate()).padStart(2, "0")}`
350
+ : event.end.toISOString();
351
+
352
+ return {
353
+ id: event.id,
354
+ title: event.title,
355
+ start,
356
+ end,
357
+ color: event.color,
358
+ calendar: event.calendar,
359
+ calendarId: event.calendarId,
360
+ sourceId: event.sourceId,
361
+ description: event.description,
362
+ location: event.location,
363
+ url: event.url,
364
+ rrule: event.rrule,
365
+ readOnly: event.readOnly,
366
+ lastSynced: event.lastSynced?.toISOString(),
367
+ organizer: event.organizer,
368
+ attendees: event.attendees,
369
+ status: event.status,
370
+ isAllDay: event.isAllDay,
371
+ reminders: event.reminders,
372
+ visualStyle: event.visualStyle,
373
+ resourceUrl: event.resourceUrl,
374
+ };
375
+ }
376
+
377
+ deserializeEvent(serialized: SerializedEvent): CalendarEvent {
378
+ // Parse dates - for all-day events, parse as local date to avoid UTC shifts
379
+ let start: Date;
380
+ let end: Date;
381
+
382
+ if (serialized.isAllDay) {
383
+ // Parse as local date (YYYY-MM-DD format)
384
+ const startParts = serialized.start.split("-").map(Number);
385
+ if (
386
+ startParts.length === 3 &&
387
+ startParts.every((p) => !Number.isNaN(p))
388
+ ) {
389
+ const [year, month, day] = startParts as [number, number, number];
390
+ start = new Date(year, month - 1, day);
391
+ } else {
392
+ start = new Date(serialized.start);
393
+ }
394
+ const endParts = serialized.end.split("-").map(Number);
395
+ if (endParts.length === 3 && endParts.every((p) => !Number.isNaN(p))) {
396
+ const [year, month, day] = endParts as [number, number, number];
397
+ end = new Date(year, month - 1, day);
398
+ } else {
399
+ end = new Date(serialized.end);
400
+ }
401
+ } else {
402
+ start = new Date(serialized.start);
403
+ end = new Date(serialized.end);
404
+ }
405
+
406
+ return {
407
+ id: serialized.id,
408
+ title: serialized.title,
409
+ start,
410
+ end,
411
+ color: serialized.color,
412
+ calendar: serialized.calendar,
413
+ calendarId: serialized.calendarId,
414
+ sourceId: serialized.sourceId,
415
+ description: serialized.description,
416
+ location: serialized.location,
417
+ url: serialized.url,
418
+ rrule: serialized.rrule,
419
+ readOnly: serialized.readOnly,
420
+ lastSynced: serialized.lastSynced
421
+ ? new Date(serialized.lastSynced)
422
+ : undefined,
423
+ organizer: serialized.organizer,
424
+ attendees: serialized.attendees,
425
+ status: serialized.status,
426
+ isAllDay: serialized.isAllDay,
427
+ reminders: serialized.reminders,
428
+ visualStyle: serialized.visualStyle,
429
+ resourceUrl: serialized.resourceUrl,
430
+ };
431
+ }
432
+
433
+ async putEvent(event: CalendarEvent): Promise<void> {
434
+ const db = await this.open();
435
+
436
+ return new Promise((resolve, reject) => {
437
+ const transaction = db.transaction(this.storeName, "readwrite");
438
+ const store = transaction.objectStore(this.storeName);
439
+ store.put(this.serializeEvent(event));
440
+
441
+ transaction.oncomplete = () => resolve();
442
+ transaction.onerror = () => reject(transaction.error);
443
+ });
444
+ }
445
+
446
+ async deleteEvent(id: string): Promise<void> {
447
+ const db = await this.open();
448
+
449
+ return new Promise((resolve, reject) => {
450
+ const transaction = db.transaction(this.storeName, "readwrite");
451
+ const store = transaction.objectStore(this.storeName);
452
+ store.delete(id);
453
+
454
+ transaction.oncomplete = () => resolve();
455
+ transaction.onerror = () => reject(transaction.error);
456
+ });
457
+ }
409
458
  }