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 %}