Files
Keith SmithandClaude Sonnet 5 c454423d4d Sync offline cache on every page load, not just every 2 minutes
Online-side mutations (e.g. deleting a tag) only update the server, not
IndexedDB, so a throttled background sync could leave the offline cache
stale for up to two minutes. Going offline in that window resurrected
deleted/stale data (e.g. a deleted tag reappearing). Removing the throttle
so every real page load pulls fresh state keeps the offline cache in
sync with whatever was just done online.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:36:49 -06:00

174 lines
7.0 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).
*/
// Bump this whenever a previously-untracked entity type starts being synced
// (e.g. tags/time_entries were added after tasks-only syncing already
// shipped). /api/sync/ only returns rows changed since a device's last sync
// token, so any device with an old token would otherwise never receive
// pre-existing rows of the newly-tracked type - they were never "changed
// since" a token issued before that type existed at all. Forces exactly one
// full resync (by clearing the stored token) per bump, per device.
const SYNC_FORMAT_VERSION = 2;
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 syncFormatVersion = await getMeta('sync_format_version');
const forceFullResync = (syncFormatVersion || 1) < SYNC_FORMAT_VERSION;
const lastSyncToken = forceFullResync ? null : 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());
await setMeta('sync_format_version', SYNC_FORMAT_VERSION);
// A forced full resync just backfilled previously-missed data (e.g. tags
// that existed before tag syncing shipped) - worth a refresh even if
// nothing local was dirty, since the current page may be missing it too.
return hadPendingChanges || forceFullResync;
}