import { IEventBus, EntityType, ISync } from '../types/CalendarTypes'; import { CoreEvents } from '../constants/CoreEvents'; import { OperationQueue, IQueueOperation } from '../storage/OperationQueue'; import { IApiRepository } from '../repositories/IApiRepository'; import { IEntityService } from '../storage/IEntityService'; /** * SyncManager - Background sync worker * Processes operation queue and syncs with API when online * * GENERIC ARCHITECTURE: * - Handles all entity types (Event, Booking, Customer, Resource) * - Routes operations based on IQueueOperation.dataEntity.typename * - Uses IApiRepository pattern for type-safe API calls * - Uses IEntityService polymorphism for sync status management * * POLYMORFI DESIGN: * - Services implement IEntityService interface * - SyncManager uses Array.find() for service lookup (simple, only 4 entities) * - Services encapsulate sync status manipulation (markAsSynced, markAsError) * - SyncManager does NOT manipulate entity.syncStatus directly * - Open/Closed Principle: Adding new entity requires only DI registration * * 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 repositories: Map>; private entityServices: IEntityService[]; 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, apiRepositories: IApiRepository[], entityServices: IEntityService[] ) { this.eventBus = eventBus; this.queue = queue; this.entityServices = entityServices; // Build map: EntityType → IApiRepository this.repositories = new Map( apiRepositories.map(repo => [repo.entityType, repo]) ); this.setupNetworkListeners(); this.startSync(); console.log(`SyncManager initialized with ${apiRepositories.length} entity repositories and ${entityServices.length} entity services`); } /** * 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 { // 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 * Generic - routes to correct API repository based on entity type */ private async processOperation(operation: IQueueOperation): Promise { // 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.markEntityAsError(operation.dataEntity.typename, operation.entityId); return; } // Get the appropriate API repository for this entity type const repository = this.repositories.get(operation.dataEntity.typename); if (!repository) { console.error(`SyncManager: No repository found for entity type ${operation.dataEntity.typename}`); await this.queue.remove(operation.id); return; } try { // Send to API based on operation type switch (operation.type) { case 'create': await repository.sendCreate(operation.dataEntity.data); break; case 'update': await repository.sendUpdate(operation.entityId, operation.dataEntity.data); break; case 'delete': await repository.sendDelete(operation.entityId); 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.markEntityAsSynced(operation.dataEntity.typename, operation.entityId); console.log(`SyncManager: Successfully synced ${operation.dataEntity.typename} 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 entity as synced in IndexedDB * Uses polymorphism - delegates to IEntityService.markAsSynced() */ private async markEntityAsSynced(entityType: EntityType, entityId: string): Promise { try { const service = this.entityServices.find(s => s.entityType === entityType); if (!service) { console.error(`SyncManager: No service found for entity type ${entityType}`); return; } await service.markAsSynced(entityId); } catch (error) { console.error(`SyncManager: Failed to mark ${entityType} ${entityId} as synced:`, error); } } /** * Mark entity as error in IndexedDB * Uses polymorphism - delegates to IEntityService.markAsError() */ private async markEntityAsError(entityType: EntityType, entityId: string): Promise { try { const service = this.entityServices.find(s => s.entityType === entityType); if (!service) { console.error(`SyncManager: No service found for entity type ${entityType}`); return; } await service.markAsError(entityId); } catch (error) { console.error(`SyncManager: Failed to mark ${entityType} ${entityId} 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 { 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 } }