import { networkManager } from '../hooks/NetworkManager' import { apiClient } from './ApiClient' import { commandQueue, CommandType } from './CommandQueue' import { ArchiveChore, CreateChore, DeleteChore, DeleteChoreHistory, MarkChoreComplete, PauseChore, SaveChore, SkipChore, StartChore, UnArchiveChore, UpdateChoreHistory, UpdateDueDate, } from './Fetcher' import { syncOfflineImages } from './ImageCache' import { offlineDB } from './OfflineDB' import { isOfflineFeatureEnabled } from './OfflineFeatureToggle' class SyncEngine { constructor() { this.isSyncing = false this.listeners = [] } // Register listener for sync state changes onSyncStateChange(callback) { this.listeners.push(callback) return () => { this.listeners = this.listeners.filter(l => l !== callback) } } _notify(state) { this.listeners.forEach(cb => cb(state)) } // Main sync entry point — returns true if sync succeeded, false otherwise async sync() { if (!isOfflineFeatureEnabled()) return false if (this.isSyncing) return false this.isSyncing = true this._notify({ syncing: true, error: null }) try { await commandQueue.resetSyncing() // Step 1: Compact the queue (merge consecutive updates) await commandQueue.compact() // Step 2: Replay pending commands await this._replayCommands() // Step 3: Delta sync from server await this._deltaSync() // Sync succeeded — server is reachable (only sync success restores online status) networkManager.setServerReachable() this._notify({ syncing: false, lastSync: Date.now() }) // Reconcile the offline image store against the full cached chore list // (prefetch referenced images, evict ones no longer referenced). // Fire-and-forget: image downloads must not block or fail the sync. offlineDB .getChores(true) .then(chores => syncOfflineImages(chores || [])) .catch(() => {}) return true } catch (err) { await commandQueue.resetSyncing() console.error('Sync failed:', err) this._notify({ syncing: false, error: err.message }) return false } finally { this.isSyncing = false } } async _replayCommands() { const commands = await commandQueue.getPending() for (const cmd of commands) { if (!networkManager.isOnline) break await commandQueue.markSyncing(cmd.id) try { await this._executeCommand(cmd) await commandQueue.markDone(cmd.id) } catch (err) { const status = err.status || err.statusCode if (status === 409) { // Conflict - mark for user attention but continue with other commands await commandQueue.markFailed( cmd.id, 'Conflict: modified by another user', ) } else if (status === 404) { // Entity no longer exists - discard command await commandQueue.markDone(cmd.id) } else { // Transient network/server error - reset to pending so it retries await commandQueue.resetPending(cmd.id) throw err } } } } async _executeCommand(cmd) { let response switch (cmd.commandType) { case CommandType.CREATE_CHORE: response = await CreateChore(cmd.payload) break case CommandType.UPDATE_CHORE: response = await SaveChore(cmd.payload) break case CommandType.COMPLETE_CHORE: { const { id, body, completedDate, performer } = cmd.payload response = await MarkChoreComplete( id, body || {}, completedDate || null, performer || null, ) break } case CommandType.SKIP_CHORE: response = await SkipChore(cmd.payload.id || cmd.entityId) break case CommandType.START_CHORE: response = await StartChore(cmd.payload.id || cmd.entityId) break case CommandType.PAUSE_CHORE: response = await PauseChore(cmd.payload.id || cmd.entityId) break case CommandType.DELETE_CHORE: response = await DeleteChore(cmd.payload.id || cmd.entityId) break case CommandType.UPDATE_CHORE_HISTORY: { const { choreId, historyId, historyData } = cmd.payload response = await UpdateChoreHistory(choreId, historyId, historyData) break } case CommandType.DELETE_CHORE_HISTORY: { const { choreId, historyId } = cmd.payload response = await DeleteChoreHistory(choreId, historyId) break } case CommandType.RESCHEDULE_CHORE: { const { id, dueDate } = cmd.payload response = await UpdateDueDate(id, dueDate) break } case CommandType.ARCHIVE_CHORE: response = await ArchiveChore(cmd.payload.id || cmd.entityId) break case CommandType.UNARCHIVE_CHORE: response = await UnArchiveChore(cmd.payload.id || cmd.entityId) break default: console.warn('Unknown command type:', cmd.commandType) return } // Check if the response indicates an error and throw so the caller can handle it if (response && typeof response.ok !== 'undefined' && !response.ok) { const err = new Error(`API error: ${response.status}`) err.status = response.status throw err } } async _deltaSync() { const cursor = (await offlineDB.getSyncCursor()) || -1 let hasMore = true let currentCursor = cursor while (hasMore && networkManager.deviceOnline) { // Use apiClient.get which handles auth and returns a fetch Response const response = await apiClient.get( `/sync/changes?since=${currentCursor}`, ) if (!response || !response.ok) { const error = new Error( response ? `Delta sync failed: ${response.status}` : 'Delta sync failed: no response from server', ) error.status = response?.status throw error } const data = await response.json() // Upsert changed chores first const changedChores = data.changes?.chores ?? [] if (changedChores.length > 0) { await offlineDB.saveChores(changedChores) } // Upsert changed history entries (also clears any pending entries for the same chore IDs) const changedHistory = data.changes?.choreHistories ?? [] if (changedHistory.length > 0) { await offlineDB.saveHistory(changedHistory) } // Hard-delete removed IDs after inserts (safe if the same ID somehow appears in both) const deletedIds = data.deletions?.chores ?? [] if (deletedIds.length > 0) { await offlineDB.deleteChores(deletedIds) } const deletedHistoryIds = data.deletions?.choreHistories ?? [] if (deletedHistoryIds.length > 0) { await offlineDB.deleteHistory(deletedHistoryIds) } // Always advance the cursor, even when there are no changes if (data.cursor) { currentCursor = data.cursor } hasMore = !!data.hasMore } await offlineDB.setSyncCursor(currentCursor) await offlineDB.setLastSyncTime(Date.now()) } // Cache current chores (call after a successful online fetch) async cacheChores(chores) { if (!isOfflineFeatureEnabled()) return if (!chores || chores.length === 0) return await offlineDB.saveChores(chores) // Fire-and-forget: keep the offline image store in step with the data. // Reconcile against the *full* cached list — the passed list may exclude // archived chores, and eviction must only run against everything we have. offlineDB .getChores(true) .then(all => syncOfflineImages(all || [])) .catch(() => {}) } } export const syncEngine = new SyncEngine()