From 922d4d1bf24ce6310a00c3c95efe8b08e1aa4543 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Fri, 4 Sep 2026 23:01:47 -0600 Subject: [PATCH] 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 --- config/urls.py | 1 + static/js/app.js | 5 ++ static/js/offline-db.js | 73 +++++++++++++++++ static/js/offline-sync.js | 121 ++++++++++++++++++++++++++++ static/js/offline-tasks.js | 139 ++++++++++++++++++++++++++++++++ sync/tests.py | 150 ++++++++++++++++++++++++++++++++++- templates/base.html | 4 +- templates/offline_tasks.html | 43 ++++++++++ templates/sw.js | 13 ++- 9 files changed, 544 insertions(+), 5 deletions(-) create mode 100644 static/js/offline-db.js create mode 100644 static/js/offline-sync.js create mode 100644 static/js/offline-tasks.js create mode 100644 templates/offline_tasks.html diff --git a/config/urls.py b/config/urls.py index 574afd1..a1d062f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path('manifest.webmanifest', manifest_webmanifest, name='manifest'), path('sw.js', service_worker, name='service-worker'), path('offline/', TemplateView.as_view(template_name='offline.html'), name='offline'), + path('offline/tasks/', TemplateView.as_view(template_name='offline_tasks.html'), name='offline-tasks'), # Debug endpoint (remove in production) path('debug/user-agent/', debug_user_agent, name='debug-user-agent'), diff --git a/static/js/app.js b/static/js/app.js index d3956f8..5d87095 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -8,6 +8,11 @@ document.addEventListener('DOMContentLoaded', function() { initTaskSelection(); initTimerDisplays(); registerServiceWorker(); + maybeBackgroundSync(); +}); + +window.addEventListener('online', function() { + runBackgroundSync(); }); /* ============================================ diff --git a/static/js/offline-db.js b/static/js/offline-db.js new file mode 100644 index 0000000..609160f --- /dev/null +++ b/static/js/offline-db.js @@ -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); +} diff --git a/static/js/offline-sync.js b/static/js/offline-sync.js new file mode 100644 index 0000000..ed05df5 --- /dev/null +++ b/static/js/offline-sync.js @@ -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(); +} diff --git a/static/js/offline-tasks.js b/static/js/offline-tasks.js new file mode 100644 index 0000000..24b8f4e --- /dev/null +++ b/static/js/offline-tasks.js @@ -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 = ''; + } + 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 = ''; + } + }); + } +}); diff --git a/sync/tests.py b/sync/tests.py index 7ce503c..b2341f7 100644 --- a/sync/tests.py +++ b/sync/tests.py @@ -1,3 +1,149 @@ -from django.test import TestCase +import uuid +from datetime import timedelta -# Create your tests here. +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from tasks.models import Task +from sync.models import SyncLog, SyncConflict + +User = get_user_model() + + +class SyncEndpointTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username='synctestuser', + email='synctest@example.com', + password='testpass123', + ) + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_new_sync_id_creates_task(self): + new_sync_id = str(uuid.uuid4()) + response = self.client.post('/api/sync/', { + 'device_id': 'web-test-device', + 'last_sync_token': None, + 'changes': { + 'tasks': [{ + 'sync_id': new_sync_id, + 'title': 'Offline-created task', + 'status': 'pending', + 'priority': 'medium', + }], + 'tags': [], + 'time_entries': [], + }, + }, format='json') + + self.assertEqual(response.status_code, 200) + task = Task.objects.get(sync_id=new_sync_id) + self.assertEqual(task.title, 'Offline-created task') + self.assertEqual(task.user, self.user) + + def test_is_deleted_soft_deletes_regardless_of_conflict(self): + task = Task.objects.create(user=self.user, title='To be deleted') + + # Force a conflict window: server row touched after "last sync". + last_sync_at = timezone.now() - timedelta(minutes=5) + SyncLog.objects.create( + user=self.user, device_id='web-test-device', + sync_token='old-token', last_sync_at=last_sync_at, + ) + + response = self.client.post('/api/sync/', { + 'device_id': 'web-test-device', + 'last_sync_token': 'old-token', + 'changes': { + 'tasks': [{'sync_id': str(task.sync_id), 'is_deleted': True}], + 'tags': [], + 'time_entries': [], + }, + }, format='json') + + self.assertEqual(response.status_code, 200) + task.refresh_from_db() + self.assertTrue(task.is_deleted) + self.assertEqual(SyncConflict.objects.count(), 0) + + def test_conflict_created_and_client_change_discarded(self): + task = Task.objects.create(user=self.user, title='Original title') + + last_sync_at = timezone.now() - timedelta(minutes=5) + SyncLog.objects.create( + user=self.user, device_id='web-test-device', + sync_token='old-token', last_sync_at=last_sync_at, + ) + + # Server row modified after last_sync_at (updated_at auto-bumps on save). + task.title = 'Changed on server' + task.save() + + response = self.client.post('/api/sync/', { + 'device_id': 'web-test-device', + 'last_sync_token': 'old-token', + 'changes': { + 'tasks': [{ + 'sync_id': str(task.sync_id), + 'title': 'Changed offline', + 'status': 'pending', + }], + 'tags': [], + 'time_entries': [], + }, + }, format='json') + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data['conflicts']), 1) + self.assertEqual(data['conflicts'][0]['local_data']['sync_id'], str(task.sync_id)) + + task.refresh_from_db() + self.assertEqual(task.title, 'Changed on server') + + def test_resolve_conflict_local_applies_local_data(self): + task = Task.objects.create(user=self.user, title='Server title') + conflict = SyncConflict.objects.create( + user=self.user, + entity_type='task', + entity_id=task.id, + local_data={'sync_id': str(task.sync_id), 'title': 'Local title', 'status': 'pending'}, + server_data={'title': 'Server title'}, + ) + + response = self.client.post( + f'/api/sync/conflicts/{conflict.id}/resolve/', + {'resolution': 'local'}, + format='json', + ) + + self.assertEqual(response.status_code, 200) + task.refresh_from_db() + self.assertEqual(task.title, 'Local title') + conflict.refresh_from_db() + self.assertEqual(conflict.status, 'resolved_local') + + def test_resolve_conflict_server_is_noop(self): + task = Task.objects.create(user=self.user, title='Server title') + conflict = SyncConflict.objects.create( + user=self.user, + entity_type='task', + entity_id=task.id, + local_data={'sync_id': str(task.sync_id), 'title': 'Local title'}, + server_data={'title': 'Server title'}, + ) + + response = self.client.post( + f'/api/sync/conflicts/{conflict.id}/resolve/', + {'resolution': 'server'}, + format='json', + ) + + self.assertEqual(response.status_code, 200) + task.refresh_from_db() + self.assertEqual(task.title, 'Server title') + conflict.refresh_from_db() + self.assertEqual(conflict.status, 'resolved_server') diff --git a/templates/base.html b/templates/base.html index 999016e..cc9b779 100644 --- a/templates/base.html +++ b/templates/base.html @@ -147,7 +147,9 @@ {% endif %} {% endblock %} - + + + {% block extra_js %}{% endblock %} diff --git a/templates/offline_tasks.html b/templates/offline_tasks.html new file mode 100644 index 0000000..f5cf886 --- /dev/null +++ b/templates/offline_tasks.html @@ -0,0 +1,43 @@ +{% load static %} + + + + + Offline - KeepItGoing + + + + + +
+
+

Tasks (Offline)

+
+ +

+ You're offline. Changes you make here will sync automatically once you're back online. +

+ +
+ + +
+ + +
+
+ + + + + diff --git a/templates/sw.js b/templates/sw.js index 572174e..97acbd8 100644 --- a/templates/sw.js +++ b/templates/sw.js @@ -1,15 +1,19 @@ -{% load static %}const CACHE_NAME = 'keepitgoing-shell-v1'; +{% load static %}const CACHE_NAME = 'keepitgoing-shell-v2'; const OFFLINE_URL = '{% url "offline" %}'; +const OFFLINE_TASKS_URL = '{% url "offline-tasks" %}'; const PRECACHE_URLS = [ "{% static 'css/app.css' %}", "{% static 'js/app.js' %}", + "{% static 'js/offline-db.js' %}", + "{% static 'js/offline-tasks.js' %}", "{% static 'favicons/favicon.svg' %}", "{% static 'favicons/icon-192.png' %}", "{% static 'favicons/icon-512.png' %}", "{% static 'favicons/icon-512-maskable.png' %}", "{% static 'favicons/apple-touch-icon-180.png' %}", OFFLINE_URL, + OFFLINE_TASKS_URL, ]; self.addEventListener('install', (event) => { @@ -32,9 +36,14 @@ self.addEventListener('fetch', (event) => { const request = event.request; // Navigations: try the network first, fall back to the offline page. + // The dashboard route falls back to the offline-capable task app instead + // of the generic offline page, since it can still show/edit cached tasks. if (request.mode === 'navigate') { event.respondWith( - fetch(request).catch(() => caches.match(OFFLINE_URL)) + fetch(request).catch(() => { + const path = new URL(request.url).pathname; + return caches.match(path === '/' ? OFFLINE_TASKS_URL : OFFLINE_URL); + }) ); return; }