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

276
src/workers/SyncManager.ts Normal file
View file

@ -0,0 +1,276 @@
import { IEventBus } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
import { OperationQueue } from '../storage/OperationQueue';
import { IQueueOperation } from '../storage/IndexedDBService';
import { IndexedDBService } from '../storage/IndexedDBService';
import { ApiEventRepository } from '../repositories/ApiEventRepository';
/**
* SyncManager - Background sync worker
* Processes operation queue and syncs with API when online
*
* Features:
* - Monitors online/offline status
* - Processes queue with FIFO order
* - Exponential backoff retry logic
* - Updates syncStatus in IndexedDB after successful sync
* - Emits sync events for UI feedback
*/
export class SyncManager {
private eventBus: IEventBus;
private queue: OperationQueue;
private indexedDB: IndexedDBService;
private apiRepository: ApiEventRepository;
private isOnline: boolean = navigator.onLine;
private isSyncing: boolean = false;
private syncInterval: number = 5000; // 5 seconds
private maxRetries: number = 5;
private intervalId: number | null = null;
constructor(
eventBus: IEventBus,
queue: OperationQueue,
indexedDB: IndexedDBService,
apiRepository: ApiEventRepository
) {
this.eventBus = eventBus;
this.queue = queue;
this.indexedDB = indexedDB;
this.apiRepository = apiRepository;
this.setupNetworkListeners();
}
/**
* Setup online/offline event listeners
*/
private setupNetworkListeners(): void {
window.addEventListener('online', () => {
this.isOnline = true;
this.eventBus.emit(CoreEvents.OFFLINE_MODE_CHANGED, {
isOnline: true
});
console.log('SyncManager: Network online - starting sync');
this.startSync();
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.eventBus.emit(CoreEvents.OFFLINE_MODE_CHANGED, {
isOnline: false
});
console.log('SyncManager: Network offline - pausing sync');
this.stopSync();
});
}
/**
* Start background sync worker
*/
public startSync(): void {
if (this.intervalId) {
return; // Already running
}
console.log('SyncManager: Starting background sync');
// Process immediately
this.processQueue();
// Then poll every syncInterval
this.intervalId = window.setInterval(() => {
this.processQueue();
}, this.syncInterval);
}
/**
* Stop background sync worker
*/
public stopSync(): void {
if (this.intervalId) {
window.clearInterval(this.intervalId);
this.intervalId = null;
console.log('SyncManager: Stopped background sync');
}
}
/**
* Process operation queue
* Sends pending operations to API
*/
private async processQueue(): Promise<void> {
// Don't sync if offline
if (!this.isOnline) {
return;
}
// Don't start new sync if already syncing
if (this.isSyncing) {
return;
}
// Check if queue is empty
if (await this.queue.isEmpty()) {
return;
}
this.isSyncing = true;
try {
const operations = await this.queue.getAll();
this.eventBus.emit(CoreEvents.SYNC_STARTED, {
operationCount: operations.length
});
// Process operations one by one (FIFO)
for (const operation of operations) {
await this.processOperation(operation);
}
this.eventBus.emit(CoreEvents.SYNC_COMPLETED, {
operationCount: operations.length
});
} catch (error) {
console.error('SyncManager: Queue processing error:', error);
this.eventBus.emit(CoreEvents.SYNC_FAILED, {
error: error instanceof Error ? error.message : 'Unknown error'
});
} finally {
this.isSyncing = false;
}
}
/**
* Process a single operation
*/
private async processOperation(operation: IQueueOperation): Promise<void> {
// Check if max retries exceeded
if (operation.retryCount >= this.maxRetries) {
console.error(`SyncManager: Max retries exceeded for operation ${operation.id}`, operation);
await this.queue.remove(operation.id);
await this.markEventAsError(operation.eventId);
return;
}
try {
// Send to API based on operation type
switch (operation.type) {
case 'create':
await this.apiRepository.sendCreate(operation.data as any);
break;
case 'update':
await this.apiRepository.sendUpdate(operation.eventId, operation.data);
break;
case 'delete':
await this.apiRepository.sendDelete(operation.eventId);
break;
default:
console.error(`SyncManager: Unknown operation type ${operation.type}`);
await this.queue.remove(operation.id);
return;
}
// Success - remove from queue and mark as synced
await this.queue.remove(operation.id);
await this.markEventAsSynced(operation.eventId);
console.log(`SyncManager: Successfully synced operation ${operation.id}`);
} catch (error) {
console.error(`SyncManager: Failed to sync operation ${operation.id}:`, error);
// Increment retry count
await this.queue.incrementRetryCount(operation.id);
// Calculate backoff delay
const backoffDelay = this.calculateBackoff(operation.retryCount + 1);
this.eventBus.emit(CoreEvents.SYNC_RETRY, {
operationId: operation.id,
retryCount: operation.retryCount + 1,
nextRetryIn: backoffDelay
});
}
}
/**
* Mark event as synced in IndexedDB
*/
private async markEventAsSynced(eventId: string): Promise<void> {
try {
const event = await this.indexedDB.getEvent(eventId);
if (event) {
event.syncStatus = 'synced';
await this.indexedDB.saveEvent(event);
}
} catch (error) {
console.error(`SyncManager: Failed to mark event ${eventId} as synced:`, error);
}
}
/**
* Mark event as error in IndexedDB
*/
private async markEventAsError(eventId: string): Promise<void> {
try {
const event = await this.indexedDB.getEvent(eventId);
if (event) {
event.syncStatus = 'error';
await this.indexedDB.saveEvent(event);
}
} catch (error) {
console.error(`SyncManager: Failed to mark event ${eventId} as error:`, error);
}
}
/**
* Calculate exponential backoff delay
* @param retryCount Current retry count
* @returns Delay in milliseconds
*/
private calculateBackoff(retryCount: number): number {
// Exponential backoff: 2^retryCount * 1000ms
// Retry 1: 2s, Retry 2: 4s, Retry 3: 8s, Retry 4: 16s, Retry 5: 32s
const baseDelay = 1000;
const exponentialDelay = Math.pow(2, retryCount) * baseDelay;
const maxDelay = 60000; // Max 1 minute
return Math.min(exponentialDelay, maxDelay);
}
/**
* Manually trigger sync (for testing or manual sync button)
*/
public async triggerManualSync(): Promise<void> {
console.log('SyncManager: Manual sync triggered');
await this.processQueue();
}
/**
* Get current sync status
*/
public getSyncStatus(): {
isOnline: boolean;
isSyncing: boolean;
isRunning: boolean;
} {
return {
isOnline: this.isOnline,
isSyncing: this.isSyncing,
isRunning: this.intervalId !== null
};
}
/**
* Cleanup - stop sync and remove listeners
*/
public destroy(): void {
this.stopSync();
// Note: We don't remove window event listeners as they're global
}
}