From a6f227e9315bc122e112649145746c484ac2c0bf Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Fri, 4 Sep 2026 23:45:43 -0600 Subject: [PATCH] Bring offline mode to full parity with the online dashboard Fixes subtasks appearing flattened into the main task list (missing parent filter), and adds everything else needed for full offline functionality: tag chips/assignment/creation, full field editing (description, due time, recurrence presets), time tracking with a live-updating timer, and properly nested subtasks -- all queued locally and synced via the existing /api/sync/ protocol. The offline page now reuses the real dashboard's app-layout/header/ sidebar/detail-pane structure instead of a bespoke layout, with an amber-tinted header and banner so it's unmistakable which mode you're in. The detail panel mirrors _task_detail.html's fields and actions. Also fixes a real backend gap this feature depends on: apply_conflict_data() in sync/views.py silently no-op'd on time_entry conflicts. And computes duration_seconds client-side on merge, since TimeEntrySyncSerializer doesn't include it in the wire format at all. Co-Authored-By: Claude Sonnet 5 --- static/js/offline-db.js | 47 +++- static/js/offline-sync.js | 159 ++++++++----- static/js/offline-tasks.js | 450 ++++++++++++++++++++++++++++++++--- sync/tests.py | 26 +- sync/views.py | 18 ++ templates/offline_tasks.html | 121 +++++++--- templates/sw.js | 2 +- 7 files changed, 701 insertions(+), 122 deletions(-) diff --git a/static/js/offline-db.js b/static/js/offline-db.js index 609160f..8aa35e5 100644 --- a/static/js/offline-db.js +++ b/static/js/offline-db.js @@ -5,7 +5,7 @@ */ const OFFLINE_DB_NAME = 'keepitgoing-offline'; -const OFFLINE_DB_VERSION = 1; +const OFFLINE_DB_VERSION = 2; function openOfflineDB() { return new Promise((resolve, reject) => { @@ -19,6 +19,12 @@ function openOfflineDB() { if (!db.objectStoreNames.contains('meta')) { db.createObjectStore('meta', { keyPath: 'key' }); } + if (!db.objectStoreNames.contains('tags')) { + db.createObjectStore('tags', { keyPath: 'sync_id' }); + } + if (!db.objectStoreNames.contains('time_entries')) { + db.createObjectStore('time_entries', { keyPath: 'sync_id' }); + } }; request.onsuccess = () => resolve(request.result); @@ -71,3 +77,42 @@ async function getDirtyTasks() { const tasks = await getAllTasks(); return tasks.filter((t) => t._dirty === true); } + +async function getAllTags() { + const db = await openOfflineDB(); + const tx = db.transaction('tags', 'readonly'); + return promisifyRequest(tx.objectStore('tags').getAll()); +} + +async function putLocalTag(tag) { + const db = await openOfflineDB(); + const tx = db.transaction('tags', 'readwrite'); + await promisifyRequest(tx.objectStore('tags').put(tag)); +} + +async function getDirtyTags() { + const tags = await getAllTags(); + return tags.filter((t) => t._dirty === true); +} + +async function getAllTimeEntries() { + const db = await openOfflineDB(); + const tx = db.transaction('time_entries', 'readonly'); + return promisifyRequest(tx.objectStore('time_entries').getAll()); +} + +async function putLocalTimeEntry(entry) { + const db = await openOfflineDB(); + const tx = db.transaction('time_entries', 'readwrite'); + await promisifyRequest(tx.objectStore('time_entries').put(entry)); +} + +async function getDirtyTimeEntries() { + const entries = await getAllTimeEntries(); + return entries.filter((e) => e._dirty === true); +} + +async function getRunningTimeEntry() { + const entries = await getAllTimeEntries(); + return entries.find((e) => !e.is_deleted && !e.ended_at) || null; +} diff --git a/static/js/offline-sync.js b/static/js/offline-sync.js index ed05df5..05cb34e 100644 --- a/static/js/offline-sync.js +++ b/static/js/offline-sync.js @@ -12,74 +12,57 @@ function getCsrfToken() { return el ? el.value : null; } -function stripLocalFields(task) { - const { _dirty, _pending_conflict_id, ...clean } = task; +function stripLocalFields(record) { + const { _dirty, _pending_conflict_id, ...clean } = record; return clean; } -async function runBackgroundSync() { - const csrfToken = getCsrfToken(); - if (!csrfToken) { - return; // Not on an authenticated page (e.g. login/register). - } +// server_changes.time_entries[] uses a field named "task" holding the task's +// sync_id, but *creating* a new entry requires "task_sync_id" instead +// (sync/views.py process_time_entry_changes). Normalize to task_sync_id +// locally so the rest of the app only ever deals with one field name. +function timeEntryToWire(entry) { + return stripLocalFields(entry); +} - const deviceId = await ensureDeviceId(); - const lastSyncToken = await getMeta('last_sync_token'); - const dirtyTasks = await getDirtyTasks(); +function timeEntryFromWire(serverEntry) { + const { task, ...rest } = serverEntry; + // duration_seconds isn't part of the sync wire format at all (a + // pre-existing gap in TimeEntrySyncSerializer) - compute it locally so + // the offline app's time totals are correct regardless of where an + // entry came from. + const durationSeconds = rest.ended_at + ? Math.round((new Date(rest.ended_at) - new Date(rest.started_at)) / 1000) + : null; + return { ...rest, task_sync_id: task, duration_seconds: durationSeconds }; +} - 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) +// Shared merge-then-auto-resolve logic for one entity type's slice of a +// sync response: merge server rows (skipping anything about to be +// re-asserted by a conflict resolution), clear dirty flags for rows that +// synced cleanly, then resolve each conflict as "local wins". +async function mergeAndResolveEntity({ entityType, dirtyLocal, serverRows, conflicts, csrfToken, putLocal, fromWire }) { + const normalize = fromWire || ((r) => r); + const relevantConflicts = conflicts.filter((c) => c.entity_type === entityType); + const conflictedIds = new Set( + relevantConflicts.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)) { + for (const serverRow of serverRows) { + const normalized = normalize(serverRow); + if (conflictedIds.has(normalized.sync_id)) { continue; } - await putLocalTask({ ...serverTask, _dirty: false, _pending_conflict_id: null }); + await putLocal({ ...normalized, _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 }); + for (const row of dirtyLocal) { + if (!conflictedIds.has(row.sync_id)) { + await putLocal({ ...row, _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 || []) { + for (const conflict of relevantConflicts) { const syncId = conflict.local_data && conflict.local_data.sync_id; if (!syncId) { continue; @@ -96,17 +79,73 @@ async function runBackgroundSync() { 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 }); + const localRow = dirtyLocal.find((r) => r.sync_id === syncId); + if (localRow) { + await putLocal({ ...localRow, _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 }); + const localRow = dirtyLocal.find((r) => r.sync_id === syncId); + if (localRow) { + await putLocal({ ...localRow, _dirty: true, _pending_conflict_id: conflict.id }); } } } +} + +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(); + const dirtyTags = await getDirtyTags(); + const dirtyTimeEntries = await getDirtyTimeEntries(); + + 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: dirtyTags.map(stripLocalFields), + time_entries: dirtyTimeEntries.map(timeEntryToWire), + }, + }), + }); + } 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 conflicts = data.conflicts || []; + + await mergeAndResolveEntity({ + entityType: 'task', dirtyLocal: dirtyTasks, serverRows: data.server_changes.tasks, + conflicts, csrfToken, putLocal: putLocalTask, + }); + await mergeAndResolveEntity({ + entityType: 'tag', dirtyLocal: dirtyTags, serverRows: data.server_changes.tags, + conflicts, csrfToken, putLocal: putLocalTag, + }); + await mergeAndResolveEntity({ + entityType: 'time_entry', dirtyLocal: dirtyTimeEntries, serverRows: data.server_changes.time_entries, + conflicts, csrfToken, putLocal: putLocalTimeEntry, fromWire: timeEntryFromWire, + }); await setMeta('last_sync_token', data.sync_token); await setMeta('last_sync_at', new Date().toISOString()); diff --git a/static/js/offline-tasks.js b/static/js/offline-tasks.js index f0aed8c..52eac39 100644 --- a/static/js/offline-tasks.js +++ b/static/js/offline-tasks.js @@ -1,16 +1,24 @@ /** * 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, - * and mirrors the dashboard's default filter/sort behavior (tasks/views.py - * DashboardView) so offline mode isn't a degraded, unsorted, unfiltered view. + * Reuses .task-item/.task-checkbox/.priority-badge/.task-group-label/.modal-* + * classes from the real app's templates for visual consistency, and mirrors + * the dashboard's default filter/sort (tasks/views.py DashboardView) and the + * task-detail view's fields/actions (templates/tasks/_task_detail.html). */ const PRIORITY_ORDER = { urgent: 4, high: 3, medium: 2, low: 1 }; +const RECURRENCE_CHOICES = ['none', 'daily', 'weekly', 'biweekly', 'monthly', 'yearly']; +const TAG_COLOR_PRESETS = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']; let currentFilter = 'all'; let currentSort = 'due_date'; +let openTaskSyncId = null; +let timerDisplayInterval = null; + +/* ============================================ + Date/format helpers + ============================================ */ function todayLocalStr() { const d = new Date(); @@ -26,13 +34,23 @@ function formatDueDate(dateStr) { return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } +function formatDuration(totalSeconds) { + const seconds = Math.max(0, totalSeconds || 0); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; +} + +/* ============================================ + Filter/sort (mirrors DashboardView's defaults) + ============================================ */ + function applyFilter(tasks, filter) { const today = todayLocalStr(); if (filter === 'completed') { return tasks.filter((t) => t.status === 'completed'); } - // Every other filter excludes completed by default, matching the - // online dashboard's default behavior. const notCompleted = tasks.filter((t) => t.status !== 'completed'); switch (filter) { case 'today': @@ -69,7 +87,27 @@ function applySort(tasks, sort) { return sorted; } -function renderTaskItem(task) { +/* ============================================ + Task list rendering + ============================================ */ + +function renderTagChips(task, tagsBySyncId) { + const tagIds = task.tag_sync_ids || []; + if (tagIds.length === 0) return ''; + return tagIds + .map((id) => tagsBySyncId[id]) + .filter(Boolean) + .map((tag) => `${escapeHtml(tag.name)}`) + .join(''); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str == null ? '' : String(str); + return div.innerHTML; +} + +function renderTaskItem(task, tagsBySyncId) { const item = document.createElement('div'); item.className = `task-item priority-${task.priority}${task.status === 'completed' ? ' completed' : ''}`; @@ -80,7 +118,10 @@ function renderTaskItem(task) { if (task.status === 'completed') { toggleForm.innerHTML = ''; } - toggleForm.addEventListener('click', () => toggleTaskComplete(task.sync_id)); + toggleForm.addEventListener('click', (e) => { + e.stopPropagation(); + toggleTaskComplete(task.sync_id); + }); const content = document.createElement('div'); content.className = 'task-content'; @@ -90,10 +131,15 @@ function renderTaskItem(task) { title.textContent = task.title; content.appendChild(title); + const metaParts = []; if (task.due_date) { + metaParts.push(`📅 ${formatDueDate(task.due_date)}`); + } + const tagChips = renderTagChips(task, tagsBySyncId); + if (metaParts.length || tagChips) { const meta = document.createElement('div'); meta.className = 'task-meta'; - meta.textContent = `📅 ${formatDueDate(task.due_date)}`; + meta.innerHTML = metaParts.map((p) => `${p}`).join(' ') + tagChips; content.appendChild(meta); } @@ -105,6 +151,8 @@ function renderTaskItem(task) { item.appendChild(content); item.appendChild(priorityBadge); + item.addEventListener('click', () => openTaskDetail(task.sync_id)); + return item; } @@ -118,17 +166,20 @@ async function toggleTaskComplete(syncId) { task._dirty = true; await putLocalTask(task); renderOfflineTasks(); + if (openTaskSyncId === syncId) { + openTaskDetail(syncId); + } } -async function addOfflineTask(title) { +function blankTask(overrides) { const now = new Date().toISOString(); const syncId = crypto.randomUUID(); - const task = { + return { id: syncId, sync_id: syncId, parent: null, parent_sync_id: null, - title: title, + title: '', description: '', status: 'pending', priority: 'medium', @@ -145,8 +196,12 @@ async function addOfflineTask(title) { updated_at: now, _dirty: true, _pending_conflict_id: null, + ...overrides, }; - await putLocalTask(task); +} + +async function addOfflineTask(title) { + await putLocalTask(blankTask({ title })); renderOfflineTasks(); } @@ -155,19 +210,22 @@ async function renderOfflineTasks() { const emptyEl = document.getElementById('offline-empty-state'); if (!listEl) return; - const allTasks = await getAllTasks(); - const nonDeleted = allTasks.filter((t) => !t.is_deleted); - const visibleTasks = applySort(applyFilter(nonDeleted, currentFilter), currentSort); + const [allTasks, allTags] = await Promise.all([getAllTasks(), getAllTags()]); + const tagsBySyncId = {}; + allTags.forEach((t) => { if (!t.is_deleted) tagsBySyncId[t.sync_id] = t; }); + + // Only top-level tasks in the main list - subtasks appear inside their + // parent's detail modal, matching the online dashboard. + const topLevel = allTasks.filter((t) => !t.is_deleted && !t.parent_sync_id); + const visibleTasks = applySort(applyFilter(topLevel, currentFilter), currentSort); listEl.innerHTML = ''; if (visibleTasks.length === 0) { const lastSyncToken = await getMeta('last_sync_token'); - if (!lastSyncToken) { - emptyEl.textContent = "No offline data yet — connect once while online to sync your tasks."; - } else { - emptyEl.textContent = 'No tasks here.'; - } + emptyEl.textContent = lastSyncToken + ? 'No tasks here.' + : "No offline data yet — connect once while online to sync your tasks."; emptyEl.hidden = false; listEl.hidden = true; return; @@ -176,18 +234,321 @@ async function renderOfflineTasks() { emptyEl.hidden = true; listEl.hidden = false; for (const task of visibleTasks) { - listEl.appendChild(renderTaskItem(task)); + listEl.appendChild(renderTaskItem(task, tagsBySyncId)); } } -function setActiveFilterButton(filter) { - document.querySelectorAll('#offline-filter-tabs button').forEach((btn) => { - const isActive = btn.dataset.filter === filter; - btn.classList.toggle('btn-primary', isActive); - btn.classList.toggle('btn-secondary', !isActive); +const FILTER_TITLES = { all: 'All Tasks', today: 'Today', upcoming: 'Upcoming', overdue: 'Overdue', completed: 'Completed' }; + +function setActiveFilterNav(filter) { + document.querySelectorAll('#offline-filter-nav .sidebar-item').forEach((item) => { + item.classList.toggle('active', item.dataset.filter === filter); }); + const titleEl = document.getElementById('offline-page-title'); + if (titleEl) titleEl.textContent = FILTER_TITLES[filter] || 'All Tasks'; } +/* ============================================ + Task detail pane + (internal element ids keep the "modal-" prefix from an earlier design, + but this now renders into the real #detail-pane, matching the online + dashboard's slide-in panel instead of a popup modal.) + ============================================ */ + +function closeTaskDetail() { + document.getElementById('detail-pane').classList.add('hidden'); + document.getElementById('app').classList.add('detail-closed'); + closeOfflineMobileMenus(); + stopTimerDisplayInterval(); + openTaskSyncId = null; +} + +async function openTaskDetail(syncId) { + openTaskSyncId = syncId; + await renderTaskDetailPane(); + const detailPane = document.getElementById('detail-pane'); + detailPane.classList.remove('hidden'); + document.getElementById('app').classList.remove('detail-closed'); + if (window.innerWidth <= 1024) { + detailPane.classList.add('open'); + document.getElementById('mobile-overlay').classList.add('visible'); + } +} + +async function renderTaskDetailPane() { + const syncId = openTaskSyncId; + if (!syncId) return; + + const [allTasks, allTags, allTimeEntries] = await Promise.all([ + getAllTasks(), getAllTags(), getAllTimeEntries(), + ]); + const task = allTasks.find((t) => t.sync_id === syncId); + if (!task) { + closeTaskDetail(); + return; + } + + const tags = allTags.filter((t) => !t.is_deleted); + const subtasks = allTasks.filter((t) => !t.is_deleted && t.parent_sync_id === syncId); + const taskTimeEntries = allTimeEntries.filter((e) => !e.is_deleted && e.task_sync_id === syncId); + const totalSeconds = taskTimeEntries.reduce((sum, e) => sum + (e.duration_seconds || 0), 0); + const runningEntry = taskTimeEntries.find((e) => !e.ended_at); + + const body = document.getElementById('detail-pane'); + body.innerHTML = ` +
+

Task Details

+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ + + +
+
+ +
+ +
+
Subtasks
+ + +
+ +
+
+ Time Spent + ${formatDuration(totalSeconds)} +
+
+ ${runningEntry + ? `` + : `` + } +
+
+ `; + + wireModalEvents(task, totalSeconds); +} + +function wireModalEvents(task, baseTotalSeconds) { + document.getElementById('detail-close-btn').addEventListener('click', closeTaskDetail); + + document.getElementById('task-edit-form').addEventListener('submit', (e) => { + e.preventDefault(); + saveTaskModalEdits(task.sync_id); + }); + + document.getElementById('modal-add-tag-btn').addEventListener('click', () => { + addNewTagInline(task.sync_id); + }); + + document.getElementById('modal-subtask-form').addEventListener('submit', (e) => { + e.preventDefault(); + const input = document.getElementById('modal-subtask-title'); + const title = input.value.trim(); + if (title) { + addOfflineSubtask(task.sync_id, title); + } + }); + + document.querySelectorAll('.modal-subtask-toggle').forEach((btn) => { + btn.addEventListener('click', () => toggleTaskComplete(btn.dataset.syncId)); + }); + + const timerBtn = document.getElementById('modal-timer-btn'); + timerBtn.addEventListener('click', () => { + if (timerBtn.dataset.running === 'true') { + stopTimer(timerBtn.dataset.entrySyncId, task.sync_id); + } else { + startTimer(task.sync_id); + } + }); + + stopTimerDisplayInterval(); + if (timerBtn.dataset.running === 'true') { + const startedAt = new Date(timerBtn.dataset.started).getTime(); + timerDisplayInterval = setInterval(() => { + const liveSeconds = baseTotalSeconds + Math.floor((Date.now() - startedAt) / 1000); + const totalEl = document.getElementById('modal-time-total'); + if (totalEl) totalEl.textContent = formatDuration(liveSeconds); + }, 1000); + } +} + +function stopTimerDisplayInterval() { + if (timerDisplayInterval) { + clearInterval(timerDisplayInterval); + timerDisplayInterval = null; + } +} + +async function saveTaskModalEdits(syncId) { + const tasks = await getAllTasks(); + const task = tasks.find((t) => t.sync_id === syncId); + if (!task) return; + + task.title = document.getElementById('modal-title').value.trim() || task.title; + task.description = document.getElementById('modal-description').value; + task.status = document.getElementById('modal-status').value; + task.priority = document.getElementById('modal-priority').value; + task.due_date = document.getElementById('modal-due-date').value || null; + task.due_time = document.getElementById('modal-due-time').value || null; + task.recurrence = document.getElementById('modal-recurrence').value; + task.recurrence_end_date = document.getElementById('modal-recurrence-end').value || null; + task.tag_sync_ids = Array.from(document.querySelectorAll('.modal-tag-checkbox:checked')).map((cb) => cb.value); + task.updated_at = new Date().toISOString(); + task._dirty = true; + + await putLocalTask(task); + closeTaskDetail(); + renderOfflineTasks(); +} + +async function addNewTagInline(taskSyncId) { + const nameInput = document.getElementById('modal-new-tag-name'); + const colorInput = document.getElementById('modal-new-tag-color'); + const name = nameInput.value.trim(); + if (!name) return; + + const now = new Date().toISOString(); + const syncId = crypto.randomUUID(); + await putLocalTag({ + id: syncId, sync_id: syncId, name, description: '', color: colorInput.value, + icon: '', sort_order: 0, is_archived: false, is_deleted: false, + created_at: now, updated_at: now, _dirty: true, _pending_conflict_id: null, + }); + nameInput.value = ''; + await renderTaskDetailPane(); +} + +async function addOfflineSubtask(parentSyncId, title) { + const tasks = await getAllTasks(); + const parent = tasks.find((t) => t.sync_id === parentSyncId); + const subtask = blankTask({ + title, + parent: parentSyncId, + parent_sync_id: parentSyncId, + // Subtasks inherit the parent's tags, matching tasks/views.py subtask_create. + tag_sync_ids: parent ? [...(parent.tag_sync_ids || [])] : [], + }); + await putLocalTask(subtask); + await renderTaskDetailPane(); +} + +async function startTimer(taskSyncId) { + const running = await getRunningTimeEntry(); + if (running) { + // Only one timer can run per user at a time, matching web_timer_start. + running.ended_at = new Date().toISOString(); + running.duration_seconds = Math.round((new Date(running.ended_at) - new Date(running.started_at)) / 1000); + running._dirty = true; + await putLocalTimeEntry(running); + } + + const now = new Date().toISOString(); + const syncId = crypto.randomUUID(); + await putLocalTimeEntry({ + id: syncId, sync_id: syncId, task_sync_id: taskSyncId, started_at: now, ended_at: null, + duration_seconds: null, notes: '', is_deleted: false, created_at: now, updated_at: now, + _dirty: true, _pending_conflict_id: null, + }); + await renderTaskDetailPane(); +} + +async function stopTimer(entrySyncId) { + const entries = await getAllTimeEntries(); + const entry = entries.find((e) => e.sync_id === entrySyncId); + if (!entry) return; + + entry.ended_at = new Date().toISOString(); + entry.duration_seconds = Math.round((new Date(entry.ended_at) - new Date(entry.started_at)) / 1000); + entry.updated_at = entry.ended_at; + entry._dirty = true; + await putLocalTimeEntry(entry); + await renderTaskDetailPane(); +} + +/* ============================================ + Init + ============================================ */ + document.addEventListener('DOMContentLoaded', () => { renderOfflineTasks(); @@ -204,10 +565,11 @@ document.addEventListener('DOMContentLoaded', () => { }); } - document.querySelectorAll('#offline-filter-tabs button').forEach((btn) => { - btn.addEventListener('click', () => { - currentFilter = btn.dataset.filter; - setActiveFilterButton(currentFilter); + document.querySelectorAll('#offline-filter-nav .sidebar-item').forEach((item) => { + item.addEventListener('click', (e) => { + e.preventDefault(); + currentFilter = item.dataset.filter; + setActiveFilterNav(currentFilter); renderOfflineTasks(); }); }); @@ -221,3 +583,29 @@ document.addEventListener('DOMContentLoaded', () => { }); } }); + +/* ============================================ + Sidebar / mobile chrome + (app.js isn't loaded on this self-contained offline page, so these + mirror its toggleSidebar()/closeMobileMenus()/toggleTheme() directly.) + ============================================ */ + +function toggleOfflineSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('mobile-overlay'); + sidebar.classList.toggle('open'); + overlay.classList.toggle('visible', sidebar.classList.contains('open')); +} + +function closeOfflineMobileMenus() { + document.getElementById('sidebar').classList.remove('open'); + document.getElementById('detail-pane').classList.remove('open'); + document.getElementById('mobile-overlay').classList.remove('visible'); +} + +function toggleOfflineTheme() { + const current = document.documentElement.getAttribute('data-theme') || 'light'; + const next = current === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('theme', next); +} diff --git a/sync/tests.py b/sync/tests.py index b2341f7..b84382f 100644 --- a/sync/tests.py +++ b/sync/tests.py @@ -6,7 +6,7 @@ from django.test import TestCase from django.utils import timezone from rest_framework.test import APIClient -from tasks.models import Task +from tasks.models import Task, TimeEntry from sync.models import SyncLog, SyncConflict User = get_user_model() @@ -147,3 +147,27 @@ class SyncEndpointTests(TestCase): self.assertEqual(task.title, 'Server title') conflict.refresh_from_db() self.assertEqual(conflict.status, 'resolved_server') + + def test_resolve_conflict_local_applies_time_entry_data(self): + task = Task.objects.create(user=self.user, title='Timed task') + start = timezone.now() - timedelta(hours=1) + entry = TimeEntry.objects.create(user=self.user, task=task, started_at=start, notes='server notes') + conflict = SyncConflict.objects.create( + user=self.user, + entity_type='time_entry', + entity_id=entry.id, + local_data={'sync_id': str(entry.sync_id), 'notes': 'local notes'}, + server_data={'notes': 'server notes'}, + ) + + response = self.client.post( + f'/api/sync/conflicts/{conflict.id}/resolve/', + {'resolution': 'local'}, + format='json', + ) + + self.assertEqual(response.status_code, 200) + entry.refresh_from_db() + self.assertEqual(entry.notes, 'local notes') + conflict.refresh_from_db() + self.assertEqual(conflict.status, 'resolved_local') diff --git a/sync/views.py b/sync/views.py index cfdefff..74e026a 100644 --- a/sync/views.py +++ b/sync/views.py @@ -474,3 +474,21 @@ def apply_conflict_data(entity_type, entity_id, data): tag.save() except Tag.DoesNotExist: pass + elif entity_type == 'time_entry': + try: + entry = TimeEntry.objects.get(id=entity_id) + + started_at = data.get('started_at', entry.started_at) + if isinstance(started_at, str): + started_at = parse_datetime(started_at) + + ended_at = data.get('ended_at', entry.ended_at) + if ended_at and isinstance(ended_at, str): + ended_at = parse_datetime(ended_at) + + entry.started_at = started_at + entry.ended_at = ended_at + entry.notes = data.get('notes', entry.notes) + entry.save() + except TimeEntry.DoesNotExist: + pass diff --git a/templates/offline_tasks.html b/templates/offline_tasks.html index b09399b..6bea065 100644 --- a/templates/offline_tasks.html +++ b/templates/offline_tasks.html @@ -17,38 +17,103 @@ -
-
-

Tasks (Offline)

- -
+
+ +
+
+ + KeepItGoing + ⚠ Offline — changes sync automatically once you're back online +
+
+ +
+
-

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

+ + -
- - - - - -
+ +
+
+

All Tasks

+ +
-
- - -
+
+ + +
- -
+ +
+
+ + + + + +
diff --git a/templates/sw.js b/templates/sw.js index 1ada810..2fa17d4 100644 --- a/templates/sw.js +++ b/templates/sw.js @@ -1,4 +1,4 @@ -{% load static %}const CACHE_NAME = 'keepitgoing-shell-v3'; +{% load static %}const CACHE_NAME = 'keepitgoing-shell-v5'; const OFFLINE_URL = '{% url "offline" %}'; const OFFLINE_TASKS_URL = '{% url "offline-tasks" %}';