Files
thetool/public/mobile/shared/syncManager.js
2026-01-27 10:35:15 +01:00

415 lines
12 KiB
JavaScript

/**
* 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;