/** * 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. */ const PRIORITY_ORDER = { urgent: 4, high: 3, medium: 2, low: 1 }; let currentFilter = 'all'; let currentSort = 'due_date'; function todayLocalStr() { const d = new Date(); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } 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 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': return notCompleted.filter((t) => t.due_date === today); case 'upcoming': return notCompleted.filter((t) => t.due_date && t.due_date > today); case 'overdue': return notCompleted.filter((t) => t.due_date && t.due_date < today && t.status !== 'cancelled'); default: return notCompleted; } } function applySort(tasks, sort) { const sorted = [...tasks]; const dueOrDefault = (t) => t.due_date || '9999-99-99'; const priorityOf = (t) => PRIORITY_ORDER[t.priority] || 0; switch (sort) { case 'due_date_desc': sorted.sort((a, b) => dueOrDefault(b).localeCompare(dueOrDefault(a)) || priorityOf(b) - priorityOf(a)); break; case 'priority': sorted.sort((a, b) => priorityOf(b) - priorityOf(a) || dueOrDefault(a).localeCompare(dueOrDefault(b))); break; case 'priority_low': sorted.sort((a, b) => priorityOf(a) - priorityOf(b) || dueOrDefault(a).localeCompare(dueOrDefault(b))); break; case 'due_date': default: sorted.sort((a, b) => dueOrDefault(a).localeCompare(dueOrDefault(b)) || priorityOf(b) - priorityOf(a)); break; } return sorted; } 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 = ''; } 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 nonDeleted = allTasks.filter((t) => !t.is_deleted); const visibleTasks = applySort(applyFilter(nonDeleted, 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.hidden = false; listEl.hidden = true; return; } emptyEl.hidden = true; listEl.hidden = false; for (const task of visibleTasks) { listEl.appendChild(renderTaskItem(task)); } } 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); }); } 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 = ''; } }); } document.querySelectorAll('#offline-filter-tabs button').forEach((btn) => { btn.addEventListener('click', () => { currentFilter = btn.dataset.filter; setActiveFilterButton(currentFilter); renderOfflineTasks(); }); }); const sortSelect = document.getElementById('offline-sort-select'); if (sortSelect) { sortSelect.value = currentSort; sortSelect.addEventListener('change', () => { currentSort = sortSelect.value; renderOfflineTasks(); }); } });