Add offline task CRUD, phase 1 (PWA)

Lets users create, edit, and complete tasks with no network connection,
queued locally in IndexedDB and replayed via the existing /api/sync/
protocol once back online -- the same protocol the native Android app
already uses, with zero backend changes. The service worker now routes
the dashboard's offline fallback to a small client-rendered task app
instead of the generic "you're offline" page; every other route keeps
the generic fallback.

Conflicts (server row touched elsewhere since last sync) auto-resolve
as "local wins" rather than surfacing a resolution UI. Scope is title/
status/priority/due-date only -- tags, subtasks, time tracking, and
recurrence editing offline are out of scope for this phase.

Also adds the first real test coverage for sync/views.py (previously
untested): new-item creation, soft-delete-bypasses-conflict-check,
conflict detection/discarding, and both resolve_conflict paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Keith Smith
2026-09-04 23:01:47 -06:00
co-authored by Claude Sonnet 5
parent 9ac1c41897
commit 922d4d1bf2
9 changed files with 544 additions and 5 deletions
+5
View File
@@ -8,6 +8,11 @@ document.addEventListener('DOMContentLoaded', function() {
initTaskSelection();
initTimerDisplays();
registerServiceWorker();
maybeBackgroundSync();
});
window.addEventListener('online', function() {
runBackgroundSync();
});
/* ============================================
+73
View File
@@ -0,0 +1,73 @@
/**
* KeepItGoing - Offline IndexedDB helpers
* Pure local storage access for offline task data. No network calls here —
* see offline-sync.js for the /api/sync/ replay logic.
*/
const OFFLINE_DB_NAME = 'keepitgoing-offline';
const OFFLINE_DB_VERSION = 1;
function openOfflineDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(OFFLINE_DB_NAME, OFFLINE_DB_VERSION);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('tasks')) {
db.createObjectStore('tasks', { keyPath: 'sync_id' });
}
if (!db.objectStoreNames.contains('meta')) {
db.createObjectStore('meta', { keyPath: 'key' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function promisifyRequest(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function getMeta(key) {
const db = await openOfflineDB();
const tx = db.transaction('meta', 'readonly');
const result = await promisifyRequest(tx.objectStore('meta').get(key));
return result ? result.value : undefined;
}
async function setMeta(key, value) {
const db = await openOfflineDB();
const tx = db.transaction('meta', 'readwrite');
await promisifyRequest(tx.objectStore('meta').put({ key, value }));
}
async function ensureDeviceId() {
let deviceId = await getMeta('device_id');
if (!deviceId) {
deviceId = 'web-' + crypto.randomUUID();
await setMeta('device_id', deviceId);
}
return deviceId;
}
async function getAllTasks() {
const db = await openOfflineDB();
const tx = db.transaction('tasks', 'readonly');
return promisifyRequest(tx.objectStore('tasks').getAll());
}
async function putLocalTask(task) {
const db = await openOfflineDB();
const tx = db.transaction('tasks', 'readwrite');
await promisifyRequest(tx.objectStore('tasks').put(task));
}
async function getDirtyTasks() {
const tasks = await getAllTasks();
return tasks.filter((t) => t._dirty === true);
}
+121
View File
@@ -0,0 +1,121 @@
/**
* 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(task) {
const { _dirty, _pending_conflict_id, ...clean } = task;
return clean;
}
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();
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)
);
// 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)) {
continue;
}
await putLocalTask({ ...serverTask, _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 });
}
}
// 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 || []) {
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 localTask = dirtyTasks.find((t) => t.sync_id === syncId);
if (localTask) {
await putLocalTask({ ...localTask, _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 });
}
}
}
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();
}
+139
View File
@@ -0,0 +1,139 @@
/**
* KeepItGoing - Offline mini task app
* Renders and edits tasks straight from IndexedDB while there's no network.
* Reuses the .task-item/.task-checkbox/.priority-badge classes from
* templates/tasks/_task_item.html for visual consistency with the real app.
*/
function formatDueDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function renderTaskItem(task) {
const item = document.createElement('div');
item.className = `task-item priority-${task.priority}${task.status === 'completed' ? ' completed' : ''}`;
const toggleForm = document.createElement('button');
toggleForm.type = 'button';
toggleForm.className = `task-checkbox${task.status === 'completed' ? ' checked' : ''}`;
toggleForm.setAttribute('aria-label', 'Toggle complete');
if (task.status === 'completed') {
toggleForm.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>';
}
toggleForm.addEventListener('click', () => toggleTaskComplete(task.sync_id));
const content = document.createElement('div');
content.className = 'task-content';
const title = document.createElement('div');
title.className = 'task-title';
title.textContent = task.title;
content.appendChild(title);
if (task.due_date) {
const meta = document.createElement('div');
meta.className = 'task-meta';
meta.textContent = `📅 ${formatDueDate(task.due_date)}`;
content.appendChild(meta);
}
const priorityBadge = document.createElement('span');
priorityBadge.className = `priority-badge ${task.priority}`;
priorityBadge.textContent = task.priority;
item.appendChild(toggleForm);
item.appendChild(content);
item.appendChild(priorityBadge);
return item;
}
async function toggleTaskComplete(syncId) {
const tasks = await getAllTasks();
const task = tasks.find((t) => t.sync_id === syncId);
if (!task) return;
task.status = task.status === 'completed' ? 'pending' : 'completed';
task.updated_at = new Date().toISOString();
task._dirty = true;
await putLocalTask(task);
renderOfflineTasks();
}
async function addOfflineTask(title) {
const now = new Date().toISOString();
const syncId = crypto.randomUUID();
const task = {
id: syncId,
sync_id: syncId,
parent: null,
parent_sync_id: null,
title: title,
description: '',
status: 'pending',
priority: 'medium',
due_date: null,
due_time: null,
reminder_at: null,
recurrence: 'none',
recurrence_rule: '',
recurrence_end_date: null,
tag_sync_ids: [],
sort_order: 0,
is_deleted: false,
created_at: now,
updated_at: now,
_dirty: true,
_pending_conflict_id: null,
};
await putLocalTask(task);
renderOfflineTasks();
}
async function renderOfflineTasks() {
const listEl = document.getElementById('offline-task-list');
const emptyEl = document.getElementById('offline-empty-state');
if (!listEl) return;
const allTasks = await getAllTasks();
const visibleTasks = allTasks
.filter((t) => !t.is_deleted)
.sort((a, b) => (a.due_date || '9999').localeCompare(b.due_date || '9999'));
listEl.innerHTML = '';
if (visibleTasks.length === 0) {
const lastSyncToken = await getMeta('last_sync_token');
emptyEl.textContent = lastSyncToken
? 'No tasks yet.'
: "No offline data yet — connect once while online to sync your tasks.";
emptyEl.hidden = false;
listEl.hidden = true;
return;
}
emptyEl.hidden = true;
listEl.hidden = false;
for (const task of visibleTasks) {
listEl.appendChild(renderTaskItem(task));
}
}
document.addEventListener('DOMContentLoaded', () => {
renderOfflineTasks();
const form = document.getElementById('offline-add-task-form');
if (form) {
form.addEventListener('submit', (event) => {
event.preventDefault();
const input = document.getElementById('offline-task-title');
const title = input.value.trim();
if (title) {
addOfflineTask(title);
input.value = '';
}
});
}
});