Bring offline mode to full parity with the online dashboard

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>
This commit is contained in:
Keith Smith
2026-09-04 23:45:43 -06:00
co-authored by Claude Sonnet 5
parent fc045a0c4b
commit a6f227e931
7 changed files with 701 additions and 122 deletions
+99 -60
View File
@@ -12,74 +12,57 @@ function getCsrfToken() {
return el ? el.value : null;
}
function stripLocalFields(task) {
const { _dirty, _pending_conflict_id, ...clean } = task;
function stripLocalFields(record) {
const { _dirty, _pending_conflict_id, ...clean } = record;
return clean;
}
async function runBackgroundSync() {
const csrfToken = getCsrfToken();
if (!csrfToken) {
return; // Not on an authenticated page (e.g. login/register).
}
// 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);
}
const deviceId = await ensureDeviceId();
const lastSyncToken = await getMeta('last_sync_token');
const dirtyTasks = await getDirtyTasks();
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 };
}
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)
// 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)
);
// 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)) {
for (const serverRow of serverRows) {
const normalized = normalize(serverRow);
if (conflictedIds.has(normalized.sync_id)) {
continue;
}
await putLocalTask({ ...serverTask, _dirty: false, _pending_conflict_id: null });
await putLocal({ ...normalized, _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 });
for (const row of dirtyLocal) {
if (!conflictedIds.has(row.sync_id)) {
await putLocal({ ...row, _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 || []) {
for (const conflict of relevantConflicts) {
const syncId = conflict.local_data && conflict.local_data.sync_id;
if (!syncId) {
continue;
@@ -96,17 +79,73 @@ async function runBackgroundSync() {
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 });
const localRow = dirtyLocal.find((r) => r.sync_id === syncId);
if (localRow) {
await putLocal({ ...localRow, _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 });
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());