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" %}';