/** * 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(record) { const { _dirty, _pending_conflict_id, ...clean } = record; return clean; } // server_changes.time_entries[] uses a field named "task" holding the task's // sync_id, but *creating* a new entry requires "task_sync_id" instead // (sync/views.py process_time_entry_changes). Normalize to task_sync_id // locally so the rest of the app only ever deals with one field name. function timeEntryToWire(entry) { return stripLocalFields(entry); } function timeEntryFromWire(serverEntry) { const { task, ...rest } = serverEntry; // duration_seconds isn't part of the sync wire format at all (a // pre-existing gap in TimeEntrySyncSerializer) - compute it locally so // the offline app's time totals are correct regardless of where an // entry came from. const durationSeconds = rest.ended_at ? Math.round((new Date(rest.ended_at) - new Date(rest.started_at)) / 1000) : null; return { ...rest, task_sync_id: task, duration_seconds: durationSeconds }; } // Shared merge-then-auto-resolve logic for one entity type's slice of a // sync response: merge server rows (skipping anything about to be // re-asserted by a conflict resolution), clear dirty flags for rows that // synced cleanly, then resolve each conflict as "local wins". async function mergeAndResolveEntity({ entityType, dirtyLocal, serverRows, conflicts, csrfToken, putLocal, fromWire }) { const normalize = fromWire || ((r) => r); const relevantConflicts = conflicts.filter((c) => c.entity_type === entityType); const conflictedIds = new Set( relevantConflicts.map((c) => c.local_data && c.local_data.sync_id).filter(Boolean) ); for (const serverRow of serverRows) { const normalized = normalize(serverRow); if (conflictedIds.has(normalized.sync_id)) { continue; } await putLocal({ ...normalized, _dirty: false, _pending_conflict_id: null }); } for (const row of dirtyLocal) { if (!conflictedIds.has(row.sync_id)) { await putLocal({ ...row, _dirty: false, _pending_conflict_id: null }); } } for (const conflict of relevantConflicts) { 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 localRow = dirtyLocal.find((r) => r.sync_id === syncId); if (localRow) { await putLocal({ ...localRow, _dirty: false, _pending_conflict_id: null }); } } catch (err) { const localRow = dirtyLocal.find((r) => r.sync_id === syncId); if (localRow) { await putLocal({ ...localRow, _dirty: true, _pending_conflict_id: conflict.id }); } } } } // Returns true if locally-queued offline changes were successfully pushed up // this call - meaning the current (server-rendered) page was rendered before // those changes existed and is now stale, so the caller should refresh it. // Returns false if there was nothing to push, or the sync didn't complete. async function runBackgroundSync() { const csrfToken = getCsrfToken(); if (!csrfToken) { return false; // Not on an authenticated page (e.g. login/register). } const deviceId = await ensureDeviceId(); const lastSyncToken = await getMeta('last_sync_token'); const dirtyTasks = await getDirtyTasks(); const dirtyTags = await getDirtyTags(); const dirtyTimeEntries = await getDirtyTimeEntries(); const hadPendingChanges = dirtyTasks.length + dirtyTags.length + dirtyTimeEntries.length > 0; 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: dirtyTags.map(stripLocalFields), time_entries: dirtyTimeEntries.map(timeEntryToWire), }, }), }); } catch (err) { return false; // Offline or network error - retry next time, nothing to clean up. } if (!response.ok) { return false; // Includes 429 throttled - retry next time. } const data = await response.json(); const conflicts = data.conflicts || []; await mergeAndResolveEntity({ entityType: 'task', dirtyLocal: dirtyTasks, serverRows: data.server_changes.tasks, conflicts, csrfToken, putLocal: putLocalTask, }); await mergeAndResolveEntity({ entityType: 'tag', dirtyLocal: dirtyTags, serverRows: data.server_changes.tags, conflicts, csrfToken, putLocal: putLocalTag, }); await mergeAndResolveEntity({ entityType: 'time_entry', dirtyLocal: dirtyTimeEntries, serverRows: data.server_changes.time_entries, conflicts, csrfToken, putLocal: putLocalTimeEntry, fromWire: timeEntryFromWire, }); await setMeta('last_sync_token', data.sync_token); await setMeta('last_sync_at', new Date().toISOString()); return hadPendingChanges; } async function maybeBackgroundSync() { const lastSyncAt = await getMeta('last_sync_at'); if (lastSyncAt && Date.now() - new Date(lastSyncAt).getTime() < BACKGROUND_SYNC_MIN_INTERVAL_MS) { return; } const pushedChanges = await runBackgroundSync(); if (pushedChanges) { window.location.reload(); } }