Implements offline-first calendar sync infrastructure

Adds IndexedDB and operation queue for robust offline synchronization
Introduces SyncManager to handle background data synchronization
Supports local event operations with automatic remote sync queuing

Enhances application reliability and user experience in low/no connectivity scenarios
This commit is contained in:
Janus C. H. Knudsen 2025-11-05 00:37:57 +01:00
parent 9c765b35ab
commit e7011526e3
20 changed files with 3822 additions and 57 deletions

View file

@ -0,0 +1,401 @@
import { ICalendarEvent } from '../types/CalendarTypes';
/**
* Operation for the sync queue
*/
export interface IQueueOperation {
id: string;
type: 'create' | 'update' | 'delete';
eventId: string;
data: Partial<ICalendarEvent> | ICalendarEvent;
timestamp: number;
retryCount: number;
}
/**
* IndexedDB Service for Calendar App
* Handles local storage of events and sync queue
*/
export class IndexedDBService {
private static readonly DB_NAME = 'CalendarDB';
private static readonly DB_VERSION = 1;
private static readonly EVENTS_STORE = 'events';
private static readonly QUEUE_STORE = 'operationQueue';
private static readonly SYNC_STATE_STORE = 'syncState';
private db: IDBDatabase | null = null;
/**
* Initialize and open the database
*/
async initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(IndexedDBService.DB_NAME, IndexedDBService.DB_VERSION);
request.onerror = () => {
reject(new Error(`Failed to open IndexedDB: ${request.error}`));
};
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create events store
if (!db.objectStoreNames.contains(IndexedDBService.EVENTS_STORE)) {
const eventsStore = db.createObjectStore(IndexedDBService.EVENTS_STORE, { keyPath: 'id' });
eventsStore.createIndex('start', 'start', { unique: false });
eventsStore.createIndex('end', 'end', { unique: false });
eventsStore.createIndex('syncStatus', 'syncStatus', { unique: false });
}
// Create operation queue store
if (!db.objectStoreNames.contains(IndexedDBService.QUEUE_STORE)) {
const queueStore = db.createObjectStore(IndexedDBService.QUEUE_STORE, { keyPath: 'id' });
queueStore.createIndex('timestamp', 'timestamp', { unique: false });
}
// Create sync state store
if (!db.objectStoreNames.contains(IndexedDBService.SYNC_STATE_STORE)) {
db.createObjectStore(IndexedDBService.SYNC_STATE_STORE, { keyPath: 'key' });
}
};
});
}
/**
* Ensure database is initialized
*/
private ensureDB(): IDBDatabase {
if (!this.db) {
throw new Error('IndexedDB not initialized. Call initialize() first.');
}
return this.db;
}
// ========================================
// Event CRUD Operations
// ========================================
/**
* Get a single event by ID
*/
async getEvent(id: string): Promise<ICalendarEvent | null> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.EVENTS_STORE], 'readonly');
const store = transaction.objectStore(IndexedDBService.EVENTS_STORE);
const request = store.get(id);
request.onsuccess = () => {
const event = request.result as ICalendarEvent | undefined;
resolve(event ? this.deserializeEvent(event) : null);
};
request.onerror = () => {
reject(new Error(`Failed to get event ${id}: ${request.error}`));
};
});
}
/**
* Get all events
*/
async getAllEvents(): Promise<ICalendarEvent[]> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.EVENTS_STORE], 'readonly');
const store = transaction.objectStore(IndexedDBService.EVENTS_STORE);
const request = store.getAll();
request.onsuccess = () => {
const events = request.result as ICalendarEvent[];
resolve(events.map(e => this.deserializeEvent(e)));
};
request.onerror = () => {
reject(new Error(`Failed to get all events: ${request.error}`));
};
});
}
/**
* Save an event (create or update)
*/
async saveEvent(event: ICalendarEvent): Promise<void> {
const db = this.ensureDB();
const serialized = this.serializeEvent(event);
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.EVENTS_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBService.EVENTS_STORE);
const request = store.put(serialized);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save event ${event.id}: ${request.error}`));
};
});
}
/**
* Delete an event
*/
async deleteEvent(id: string): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.EVENTS_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBService.EVENTS_STORE);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete event ${id}: ${request.error}`));
};
});
}
// ========================================
// Queue Operations
// ========================================
/**
* Add operation to queue
*/
async addToQueue(operation: Omit<IQueueOperation, 'id'>): Promise<void> {
const db = this.ensureDB();
const queueItem: IQueueOperation = {
...operation,
id: `${operation.type}-${operation.eventId}-${Date.now()}`
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.QUEUE_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBService.QUEUE_STORE);
const request = store.put(queueItem);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to add to queue: ${request.error}`));
};
});
}
/**
* Get all queue operations (sorted by timestamp)
*/
async getQueue(): Promise<IQueueOperation[]> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.QUEUE_STORE], 'readonly');
const store = transaction.objectStore(IndexedDBService.QUEUE_STORE);
const index = store.index('timestamp');
const request = index.getAll();
request.onsuccess = () => {
resolve(request.result as IQueueOperation[]);
};
request.onerror = () => {
reject(new Error(`Failed to get queue: ${request.error}`));
};
});
}
/**
* Remove operation from queue
*/
async removeFromQueue(id: string): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.QUEUE_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBService.QUEUE_STORE);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to remove from queue: ${request.error}`));
};
});
}
/**
* Clear entire queue
*/
async clearQueue(): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.QUEUE_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBService.QUEUE_STORE);
const request = store.clear();
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to clear queue: ${request.error}`));
};
});
}
// ========================================
// Sync State Operations
// ========================================
/**
* Save sync state value
*/
async setSyncState(key: string, value: any): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.SYNC_STATE_STORE], 'readwrite');
const store = transaction.objectStore(IndexedDBService.SYNC_STATE_STORE);
const request = store.put({ key, value });
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to set sync state ${key}: ${request.error}`));
};
});
}
/**
* Get sync state value
*/
async getSyncState(key: string): Promise<any | null> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([IndexedDBService.SYNC_STATE_STORE], 'readonly');
const store = transaction.objectStore(IndexedDBService.SYNC_STATE_STORE);
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.value : null);
};
request.onerror = () => {
reject(new Error(`Failed to get sync state ${key}: ${request.error}`));
};
});
}
// ========================================
// Serialization Helpers
// ========================================
/**
* Serialize event for IndexedDB storage (convert Dates to ISO strings)
*/
private serializeEvent(event: ICalendarEvent): any {
return {
...event,
start: event.start instanceof Date ? event.start.toISOString() : event.start,
end: event.end instanceof Date ? event.end.toISOString() : event.end
};
}
/**
* Deserialize event from IndexedDB (convert ISO strings to Dates)
*/
private deserializeEvent(event: any): ICalendarEvent {
return {
...event,
start: typeof event.start === 'string' ? new Date(event.start) : event.start,
end: typeof event.end === 'string' ? new Date(event.end) : event.end
};
}
/**
* Close database connection
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
}
}
/**
* Delete entire database (for testing/reset)
*/
static async deleteDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(IndexedDBService.DB_NAME);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete database: ${request.error}`));
};
});
}
/**
* Seed IndexedDB with mock data if empty
*/
async seedIfEmpty(mockDataUrl: string = 'data/mock-events.json'): Promise<void> {
try {
const existingEvents = await this.getAllEvents();
if (existingEvents.length > 0) {
console.log(`IndexedDB already has ${existingEvents.length} events - skipping seed`);
return;
}
console.log('IndexedDB is empty - seeding with mock data');
// Check if online to fetch mock data
if (!navigator.onLine) {
console.warn('Offline and IndexedDB empty - starting with no events');
return;
}
// Fetch mock events
const response = await fetch(mockDataUrl);
if (!response.ok) {
throw new Error(`Failed to fetch mock events: ${response.statusText}`);
}
const mockEvents = await response.json();
// Convert and save to IndexedDB
for (const event of mockEvents) {
const calendarEvent = {
...event,
start: new Date(event.start),
end: new Date(event.end),
allDay: event.allDay || false,
syncStatus: 'synced' as const
};
await this.saveEvent(calendarEvent);
}
console.log(`Seeded IndexedDB with ${mockEvents.length} mock events`);
} catch (error) {
console.error('Failed to seed IndexedDB:', error);
// Don't throw - allow app to start with empty calendar
}
}
}

View file

@ -0,0 +1,111 @@
import { IndexedDBService, IQueueOperation } from './IndexedDBService';
/**
* Operation Queue Manager
* Handles FIFO queue of pending sync operations
*/
export class OperationQueue {
private indexedDB: IndexedDBService;
constructor(indexedDB: IndexedDBService) {
this.indexedDB = indexedDB;
}
/**
* Add operation to the end of the queue
*/
async enqueue(operation: Omit<IQueueOperation, 'id'>): Promise<void> {
await this.indexedDB.addToQueue(operation);
}
/**
* Get the first operation from the queue (without removing it)
* Returns null if queue is empty
*/
async peek(): Promise<IQueueOperation | null> {
const queue = await this.indexedDB.getQueue();
return queue.length > 0 ? queue[0] : null;
}
/**
* Get all operations in the queue (sorted by timestamp FIFO)
*/
async getAll(): Promise<IQueueOperation[]> {
return await this.indexedDB.getQueue();
}
/**
* Remove a specific operation from the queue
*/
async remove(operationId: string): Promise<void> {
await this.indexedDB.removeFromQueue(operationId);
}
/**
* Remove the first operation from the queue and return it
* Returns null if queue is empty
*/
async dequeue(): Promise<IQueueOperation | null> {
const operation = await this.peek();
if (operation) {
await this.remove(operation.id);
}
return operation;
}
/**
* Clear all operations from the queue
*/
async clear(): Promise<void> {
await this.indexedDB.clearQueue();
}
/**
* Get the number of operations in the queue
*/
async size(): Promise<number> {
const queue = await this.getAll();
return queue.length;
}
/**
* Check if queue is empty
*/
async isEmpty(): Promise<boolean> {
const size = await this.size();
return size === 0;
}
/**
* Get operations for a specific event ID
*/
async getOperationsForEvent(eventId: string): Promise<IQueueOperation[]> {
const queue = await this.getAll();
return queue.filter(op => op.eventId === eventId);
}
/**
* Remove all operations for a specific event ID
*/
async removeOperationsForEvent(eventId: string): Promise<void> {
const operations = await this.getOperationsForEvent(eventId);
for (const op of operations) {
await this.remove(op.id);
}
}
/**
* Update retry count for an operation
*/
async incrementRetryCount(operationId: string): Promise<void> {
const queue = await this.getAll();
const operation = queue.find(op => op.id === operationId);
if (operation) {
operation.retryCount++;
// Re-add to queue with updated retry count
await this.remove(operationId);
await this.enqueue(operation);
}
}
}