/** * Sync Manager - iOS-compatible sync orchestration * * Background Sync API is NOT supported on iOS Safari. * This manager uses visibility-based and online-event sync as primary strategy. */ import { db, getPendingCount as getDbPendingCount } from './db.js'; import { isOfflineModeEnabled, isAutoSyncEnabled, updateLastSyncTimestamp } from './offlineSettings.js'; import syncQueue from './syncQueue.js'; import photoQueue from './photoQueue.js'; import workorderService from './workorderOfflineService.js'; const API_BASE = '/MobileApp/Workorder/Workorder'; // Sync state const syncState = { isSyncing: false, lastSyncAttempt: null, lastSyncSuccess: null, syncError: null, listeners: new Set() }; // Minimum time between auto-syncs (prevent rapid fire) const MIN_SYNC_INTERVAL = 30000; // 30 seconds /** * Notify listeners of state change */ function notifyListeners(event, data) { for (const listener of syncState.listeners) { try { listener(event, data); } catch (error) { console.error('[SyncManager] Listener error:', error); } } } /** * Subscribe to sync events */ export function subscribe(listener) { syncState.listeners.add(listener); return () => syncState.listeners.delete(listener); } /** * Get current sync state */ export function getSyncState() { return { ...syncState }; } /** * Check if sync is currently running */ export function isSyncing() { return syncState.isSyncing; } /** * Initialize sync manager * Sets up event listeners for iOS-compatible sync */ export function initSyncManager() { // Primary iOS strategy: sync when app becomes visible document.addEventListener('visibilitychange', handleVisibilityChange); // Sync when network reconnects window.addEventListener('online', handleOnline); // Also handle page show (for bfcache restoration) window.addEventListener('pageshow', handlePageShow); // Initial sync check on load window.addEventListener('load', () => { if (navigator.onLine && isOfflineModeEnabled()) { // Delay initial sync slightly to let app stabilize setTimeout(() => maybeTriggerSync('load'), 2000); } }); // Try to register for Background Sync (Chrome/Edge only) registerBackgroundSync().catch(() => { console.log('[SyncManager] Background Sync not available, using fallback'); }); console.log('[SyncManager] Initialized'); } /** * Handle visibility change */ function handleVisibilityChange() { if (document.visibilityState === 'visible' && navigator.onLine) { maybeTriggerSync('visibility'); } } /** * Handle online event */ function handleOnline() { maybeTriggerSync('online'); } /** * Handle pageshow event */ function handlePageShow(event) { if (event.persisted && navigator.onLine) { // Page was restored from bfcache maybeTriggerSync('pageshow'); } } /** * Maybe trigger sync (with throttling) */ async function maybeTriggerSync(trigger) { if (!isOfflineModeEnabled() || !isAutoSyncEnabled()) { return; } if (syncState.isSyncing) { console.log('[SyncManager] Sync already in progress, skipping'); return; } // Throttle syncs const now = Date.now(); if (syncState.lastSyncAttempt && (now - syncState.lastSyncAttempt) < MIN_SYNC_INTERVAL) { console.log('[SyncManager] Sync throttled, too recent'); return; } // Check if there's anything to sync const pendingOps = await syncQueue.getPendingCount(); const pendingPhotos = await photoQueue.getPendingCount(); if (pendingOps === 0 && pendingPhotos === 0) { console.log('[SyncManager] No pending changes to sync'); return; } console.log(`[SyncManager] Auto-sync triggered by ${trigger} (${pendingOps} ops, ${pendingPhotos} photos)`); await performSync(); } /** * Register for Background Sync API (Chrome/Edge only) */ async function registerBackgroundSync() { if ('serviceWorker' in navigator && 'sync' in window.SyncManager?.prototype) { const registration = await navigator.serviceWorker.ready; if ('sync' in registration) { await registration.sync.register('workorder-sync'); console.log('[SyncManager] Background Sync registered'); } } } /** * Perform sync - main sync logic */ export async function performSync(options = {}) { if (syncState.isSyncing && !options.force) { return { success: false, error: 'Sync already in progress' }; } if (!navigator.onLine) { return { success: false, error: 'Offline' }; } syncState.isSyncing = true; syncState.lastSyncAttempt = Date.now(); syncState.syncError = null; notifyListeners('sync-start', {}); const results = { operations: { success: 0, failed: 0, total: 0 }, photos: { success: 0, failed: 0, total: 0 }, conflicts: [], reassigned: [] }; try { // 1. Process pending operations const pendingOps = await syncQueue.getPending(); results.operations.total = pendingOps.length; for (const op of pendingOps) { notifyListeners('sync-progress', { phase: 'operations', current: results.operations.success + results.operations.failed + 1, total: results.operations.total }); const opResult = await processOperation(op); if (opResult.success) { results.operations.success++; } else { results.operations.failed++; if (opResult.conflict) { results.conflicts.push({ operation: op, serverData: opResult.serverData }); } } } // 2. Upload pending photos const pendingPhotos = await photoQueue.getPending(); results.photos.total = pendingPhotos.length; for (const photo of pendingPhotos) { notifyListeners('sync-progress', { phase: 'photos', current: results.photos.success + results.photos.failed + 1, total: results.photos.total, fileName: photo.fileName }); const photoResult = await photoQueue.upload(photo.localId); if (photoResult.success) { results.photos.success++; } else { results.photos.failed++; } } // 3. Check for reassigned workorders (if enabled) if (options.checkReassigned !== false) { const serverWorkorderIds = await fetchServerWorkorderIds(); if (serverWorkorderIds) { results.reassigned = await workorderService.checkReassignedWorkorders(serverWorkorderIds); } } // 4. Cleanup old completed items await syncQueue.cleanup(); await photoQueue.cleanup(); // Update last sync time updateLastSyncTimestamp(); syncState.lastSyncSuccess = Date.now(); console.log('[SyncManager] Sync completed:', results); notifyListeners('sync-complete', results); return { success: true, results }; } catch (error) { console.error('[SyncManager] Sync error:', error); syncState.syncError = error.message; notifyListeners('sync-error', { error: error.message }); return { success: false, error: error.message }; } finally { syncState.isSyncing = false; } } /** * Process single operation from queue */ async function processOperation(op) { await syncQueue.markProcessing(op.localId); try { const result = await executeOperation(op); if (result.success) { await syncQueue.markCompleted(op.localId, result); return { success: true }; } if (result.conflict) { // Conflict detected - don't retry automatically await syncQueue.markFailed(op.localId, 'Konflikt erkannt', false); return { success: false, conflict: true, serverData: result.serverData }; } // Check if retriable const retriable = syncQueue.isRetriableError(result._status, result.error); await syncQueue.markFailed(op.localId, result.error, retriable); return { success: false, error: result.error }; } catch (error) { await syncQueue.markFailed(op.localId, error.message, true); return { success: false, error: error.message }; } } /** * Execute operation via API */ async function executeOperation(op) { const endpoint = getOperationEndpoint(op.operation); if (!endpoint) { return { success: false, error: `Unknown operation: ${op.operation}` }; } const response = await fetch(`${API_BASE}/${endpoint}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': op.operationId, 'X-Client-Timestamp': String(op.clientTimestamp), 'X-Device-Id': op.deviceId }, body: JSON.stringify(op.payload) }); const data = await response.json(); return { ...data, _status: response.status }; } /** * Map operation type to API endpoint */ function getOperationEndpoint(operation) { const endpoints = { 'addJournal': 'addJournal', 'updateNotes': 'updateAdditionalInfo', 'scheduleAppointment': 'scheduleAppointment', 'requestIntervention': 'requestIntervention', 'updateCableData': 'updateWorkorderData', 'completeWorkorder': 'completeWorkorder' }; return endpoints[operation]; } /** * Fetch server workorder IDs for reassignment check */ async function fetchServerWorkorderIds() { try { const response = await fetch(`${API_BASE}/getWorkorderIds`, { credentials: 'include' }); if (response.ok) { const data = await response.json(); return data.success ? data.ids : null; } } catch (error) { console.warn('[SyncManager] Failed to fetch workorder IDs:', error); } return null; } /** * Manual sync trigger */ export async function triggerManualSync() { console.log('[SyncManager] Manual sync triggered'); return performSync({ force: true }); } /** * Get pending changes count */ export async function getPendingCount() { const ops = await syncQueue.getPendingCount(); const photos = await photoQueue.getPendingCount(); return ops + photos; } /** * Get detailed pending summary */ export async function getPendingSummary() { const ops = await syncQueue.getPendingCount(); const photos = await photoQueue.getPendingCount(); const failedOps = await syncQueue.getFailed(); return { operations: ops, photos: photos, total: ops + photos, failed: failedOps.length, hasFailures: failedOps.length > 0 }; } /** * Cleanup and stop sync manager */ export function destroySyncManager() { document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('online', handleOnline); window.removeEventListener('pageshow', handlePageShow); syncState.listeners.clear(); console.log('[SyncManager] Destroyed'); } // Export for service worker communication export const SyncManager = { init: initSyncManager, destroy: destroySyncManager, sync: performSync, triggerSync: triggerManualSync, subscribe, getState: getSyncState, isSyncing, getPendingCount, getPendingSummary }; export default SyncManager;