Internal
Public Access
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>
140 lines
4.4 KiB
JavaScript
140 lines
4.4 KiB
JavaScript
/**
|
|
* 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 = '';
|
|
}
|
|
});
|
|
}
|
|
});
|