Internal
Public Access
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:
co-authored by
Claude Sonnet 5
parent
fc045a0c4b
commit
a6f227e931
+99
-60
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user