Internal
Public Access
Lets users create, edit, and complete tasks with no network connection, queued locally in IndexedDB and replayed via the existing /api/sync/ protocol once back online -- the same protocol the native Android app already uses, with zero backend changes. The service worker now routes the dashboard's offline fallback to a small client-rendered task app instead of the generic "you're offline" page; every other route keeps the generic fallback. Conflicts (server row touched elsewhere since last sync) auto-resolve as "local wins" rather than surfacing a resolution UI. Scope is title/ status/priority/due-date only -- tags, subtasks, time tracking, and recurrence editing offline are out of scope for this phase. Also adds the first real test coverage for sync/views.py (previously untested): new-item creation, soft-delete-bypasses-conflict-check, conflict detection/discarding, and both resolve_conflict paths. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
122 lines
4.1 KiB
JavaScript
122 lines
4.1 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|