/** * KeepItGoing - Offline sync replay * Talks to /api/sync/ to pull server changes into IndexedDB and push any * locally-queued offline edits back up. Only ever runs from a real, * network-loaded page (never from the cached offline app). */ const BACKGROUND_SYNC_MIN_INTERVAL_MS = 2 * 60 * 1000; function getCsrfToken() { const el = document.querySelector('[name=csrfmiddlewaretoken]'); return el ? el.value : null; } function stripLocalFields(task) { const { _dirty, _pending_conflict_id, ...clean } = task; return clean; } async function runBackgroundSync() { const csrfToken = getCsrfToken(); if (!csrfToken) { return; // Not on an authenticated page (e.g. login/register). } const deviceId = await ensureDeviceId(); const lastSyncToken = await getMeta('last_sync_token'); const dirtyTasks = await getDirtyTasks(); let response; try { response = await fetch('/api/sync/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken, }, body: JSON.stringify({ device_id: deviceId, last_sync_token: lastSyncToken || null, changes: { tasks: dirtyTasks.map(stripLocalFields), tags: [], time_entries: [], }, }), }); } catch (err) { return; // Offline or network error - retry next time, nothing to clean up. } if (!response.ok) { return; // Includes 429 throttled - retry next time. } const data = await response.json(); const conflictedSyncIds = new Set( (data.conflicts || []) .map((c) => c.local_data && c.local_data.sync_id) .filter(Boolean) ); // Merge server changes first, skipping anything about to be re-asserted // by a conflict resolution below (otherwise this would clobber it). for (const serverTask of data.server_changes.tasks) { if (conflictedSyncIds.has(serverTask.sync_id)) { continue; } await putLocalTask({ ...serverTask, _dirty: false, _pending_conflict_id: null }); } // Clear dirty flags for rows that synced cleanly. for (const task of dirtyTasks) { if (!conflictedSyncIds.has(task.sync_id)) { await putLocalTask({ ...task, _dirty: false, _pending_conflict_id: null }); } } // Auto-resolve conflicts as "local wins" - this is a low-frequency-offline // client, so conflicts should be rare; a resolution UI is future work. for (const conflict of data.conflicts || []) { const syncId = conflict.local_data && conflict.local_data.sync_id; if (!syncId) { continue; } try { const resolveResponse = await fetch(`/api/sync/conflicts/${conflict.id}/resolve/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken, }, body: JSON.stringify({ resolution: 'local' }), }); if (!resolveResponse.ok) { throw new Error('resolve failed'); } const localTask = dirtyTasks.find((t) => t.sync_id === syncId); if (localTask) { await putLocalTask({ ...localTask, _dirty: false, _pending_conflict_id: null }); } } catch (err) { const localTask = dirtyTasks.find((t) => t.sync_id === syncId); if (localTask) { await putLocalTask({ ...localTask, _dirty: true, _pending_conflict_id: conflict.id }); } } } await setMeta('last_sync_token', data.sync_token); await setMeta('last_sync_at', new Date().toISOString()); } async function maybeBackgroundSync() { const lastSyncAt = await getMeta('last_sync_at'); if (lastSyncAt && Date.now() - new Date(lastSyncAt).getTime() < BACKGROUND_SYNC_MIN_INTERVAL_MS) { return; } runBackgroundSync(); }