Internal
Public Access
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:
co-authored by
Claude Sonnet 5
parent
9ac1c41897
commit
922d4d1bf2
@@ -23,6 +23,7 @@ urlpatterns = [
|
|||||||
path('manifest.webmanifest', manifest_webmanifest, name='manifest'),
|
path('manifest.webmanifest', manifest_webmanifest, name='manifest'),
|
||||||
path('sw.js', service_worker, name='service-worker'),
|
path('sw.js', service_worker, name='service-worker'),
|
||||||
path('offline/', TemplateView.as_view(template_name='offline.html'), name='offline'),
|
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)
|
# Debug endpoint (remove in production)
|
||||||
path('debug/user-agent/', debug_user_agent, name='debug-user-agent'),
|
path('debug/user-agent/', debug_user_agent, name='debug-user-agent'),
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
initTaskSelection();
|
initTaskSelection();
|
||||||
initTimerDisplays();
|
initTimerDisplays();
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
|
maybeBackgroundSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('online', function() {
|
||||||
|
runBackgroundSync();
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
+148
-2
@@ -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')
|
||||||
|
|||||||
+3
-1
@@ -147,7 +147,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
<script src="{% static 'js/app.js' %}?v=6"></script>
|
<script src="{% static 'js/offline-db.js' %}?v=7"></script>
|
||||||
|
<script src="{% static 'js/offline-sync.js' %}?v=7"></script>
|
||||||
|
<script src="{% static 'js/app.js' %}?v=7"></script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% load static %}<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Offline - KeepItGoing</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{% static 'favicons/favicon.svg' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'css/app.css' %}">
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var saved = localStorage.getItem('theme');
|
||||||
|
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
if (saved === 'dark' || (!saved && systemDark)) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="max-width: 640px; margin: 0 auto; padding: var(--space-lg);">
|
||||||
|
<div class="task-pane-header">
|
||||||
|
<h1 class="task-pane-title">Tasks (Offline)</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted" style="margin-bottom: var(--space-lg);">
|
||||||
|
You're offline. Changes you make here will sync automatically once you're back online.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="offline-add-task-form" class="quick-add">
|
||||||
|
<input type="text" id="offline-task-title" class="quick-add-input" placeholder="Add a new task..." required>
|
||||||
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="offline-empty-state" class="empty-state" hidden>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
<div class="task-list" id="offline-task-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static 'js/offline-db.js' %}"></script>
|
||||||
|
<script src="{% static 'js/offline-tasks.js' %}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+11
-2
@@ -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_URL = '{% url "offline" %}';
|
||||||
|
const OFFLINE_TASKS_URL = '{% url "offline-tasks" %}';
|
||||||
|
|
||||||
const PRECACHE_URLS = [
|
const PRECACHE_URLS = [
|
||||||
"{% static 'css/app.css' %}",
|
"{% static 'css/app.css' %}",
|
||||||
"{% static 'js/app.js' %}",
|
"{% static 'js/app.js' %}",
|
||||||
|
"{% static 'js/offline-db.js' %}",
|
||||||
|
"{% static 'js/offline-tasks.js' %}",
|
||||||
"{% static 'favicons/favicon.svg' %}",
|
"{% static 'favicons/favicon.svg' %}",
|
||||||
"{% static 'favicons/icon-192.png' %}",
|
"{% static 'favicons/icon-192.png' %}",
|
||||||
"{% static 'favicons/icon-512.png' %}",
|
"{% static 'favicons/icon-512.png' %}",
|
||||||
"{% static 'favicons/icon-512-maskable.png' %}",
|
"{% static 'favicons/icon-512-maskable.png' %}",
|
||||||
"{% static 'favicons/apple-touch-icon-180.png' %}",
|
"{% static 'favicons/apple-touch-icon-180.png' %}",
|
||||||
OFFLINE_URL,
|
OFFLINE_URL,
|
||||||
|
OFFLINE_TASKS_URL,
|
||||||
];
|
];
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
@@ -32,9 +36,14 @@ self.addEventListener('fetch', (event) => {
|
|||||||
const request = event.request;
|
const request = event.request;
|
||||||
|
|
||||||
// Navigations: try the network first, fall back to the offline page.
|
// 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') {
|
if (request.mode === 'navigate') {
|
||||||
event.respondWith(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user