All Tasks
+ +Loading...
-Loading...
+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 = ` +
- You're offline. Changes you make here will sync automatically once you're back online. -
+ + -Loading...
-Loading...
+