Internal
Public Access
The real dashboard is server-rendered before any client JS runs, so the first page load after reconnecting always reflects the pre-sync database state -- the background sync then completes silently, but nothing told that already-rendered page to update. A task created offline would appear to vanish until the next navigation happened to load fresh data. runBackgroundSync() now reports whether it pushed pending changes, and both places that trigger it automatically reload the page when it did. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
171 lines
6.4 KiB
JavaScript
171 lines
6.4 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(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();
|
|
}
|
|
}
|