Internal
Public Access
Fixes subtasks appearing flattened into the main task list (missing parent filter), and adds everything else needed for full offline functionality: tag chips/assignment/creation, full field editing (description, due time, recurrence presets), time tracking with a live-updating timer, and properly nested subtasks -- all queued locally and synced via the existing /api/sync/ protocol. The offline page now reuses the real dashboard's app-layout/header/ sidebar/detail-pane structure instead of a bespoke layout, with an amber-tinted header and banner so it's unmistakable which mode you're in. The detail panel mirrors _task_detail.html's fields and actions. Also fixes a real backend gap this feature depends on: apply_conflict_data() in sync/views.py silently no-op'd on time_entry conflicts. And computes duration_seconds client-side on merge, since TimeEntrySyncSerializer doesn't include it in the wire format at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
161 lines
5.9 KiB
JavaScript
161 lines
5.9 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 });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
const dirtyTags = await getDirtyTags();
|
|
const dirtyTimeEntries = await getDirtyTimeEntries();
|
|
|
|
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; // 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 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());
|
|
}
|
|
|
|
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();
|
|
}
|