/** * KeepItGoing - Offline mini task app * Renders and edits tasks straight from IndexedDB while there's no network. * 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 currentTagId = null; let openTaskSyncId = null; let timerDisplayInterval = null; let currentTagModalSyncId = null; /* ============================================ Date/format helpers ============================================ */ 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 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'); } 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 applyTagFilter(tasks, tagId) { if (!tagId) return tasks; return tasks.filter((t) => (t.tag_sync_ids || []).includes(tagId)); } 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; } /* ============================================ 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 isTaskOverdue(task) { // Mirrors Task.is_overdue in tasks/models.py. if (!task.due_date || task.status === 'completed' || task.status === 'cancelled') return false; const today = todayLocalStr(); if (task.due_date < today) return true; if (task.due_date === today && task.due_time) { const now = new Date(); const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`; return task.due_time < nowStr; } return false; } function renderTaskItem(task, tagsBySyncId) { const overdue = isTaskOverdue(task); const item = document.createElement('div'); item.className = `task-item priority-${task.priority}${overdue ? ' overdue' : ''}${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', (e) => { e.stopPropagation(); 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); 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.innerHTML = metaParts.join(' ') + tagChips; 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); item.addEventListener('click', () => openTaskDetail(task.sync_id)); 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(); if (openTaskSyncId === syncId) { openTaskDetail(syncId); } } function blankTask(overrides) { const now = new Date().toISOString(); const syncId = crypto.randomUUID(); return { id: syncId, sync_id: syncId, parent: null, parent_sync_id: null, 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, ...overrides, }; } async function addOfflineTask(title) { await putLocalTask(blankTask({ title })); renderOfflineTasks(); } async function renderOfflineTasks() { const listEl = document.getElementById('offline-task-list'); const emptyEl = document.getElementById('offline-empty-state'); if (!listEl) return; 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 statusFiltered = applyFilter(topLevel, currentFilter); const visibleTasks = applySort(applyTagFilter(statusFiltered, currentTagId), currentSort); setActiveFilterNav(currentFilter); renderTagSidebarNav(allTags, topLevel); const titleEl = document.getElementById('offline-page-title'); if (titleEl) { const tagName = currentTagId && tagsBySyncId[currentTagId] ? tagsBySyncId[currentTagId].name : null; titleEl.textContent = (FILTER_TITLES[currentFilter] || 'All Tasks') + (tagName ? ` • ${tagName}` : ''); } listEl.innerHTML = ''; if (visibleTasks.length === 0) { const lastSyncToken = await getMeta('last_sync_token'); 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; } emptyEl.hidden = true; listEl.hidden = false; for (const task of visibleTasks) { listEl.appendChild(renderTaskItem(task, tagsBySyncId)); } } 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); }); } /* ============================================ Sidebar Tags section (filter-by-tag + manage) ============================================ */ function renderTagSidebarNav(allTags, topLevelTasks) { const nav = document.getElementById('offline-tag-nav'); if (!nav) return; const visibleTags = allTags.filter((t) => !t.is_deleted && !t.is_archived); // Counts match the sidebar Filters' "all" semantics: top-level, not completed. const countable = topLevelTasks.filter((t) => t.status !== 'completed'); nav.innerHTML = visibleTags.map((tag) => { const count = countable.filter((t) => (t.tag_sync_ids || []).includes(tag.sync_id)).length; return `
`; }).join('') + ` All Tags `; nav.querySelectorAll('.sidebar-group-item[data-tag-id]').forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault(); currentTagId = link.dataset.tagId; renderOfflineTasks(); }); }); nav.querySelectorAll('.sidebar-group-edit').forEach((btn) => { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const tag = visibleTags.find((t) => t.sync_id === btn.dataset.editTagId); if (tag) openTagModal(tag.sync_id, tag.name, tag.color); }); }); const allTagsLink = document.getElementById('offline-all-tags-link'); if (allTagsLink) { allTagsLink.addEventListener('click', (e) => { e.preventDefault(); currentTagId = null; renderOfflineTasks(); }); } } function renderTagColorPresets(selectedColor) { const container = document.getElementById('tag-modal-color-presets'); if (!container) return; container.innerHTML = TAG_COLOR_PRESETS.map((c) => ` `).join(''); container.querySelectorAll('.color-preset').forEach((btn) => { btn.addEventListener('click', () => { document.getElementById('tag-modal-color').value = btn.dataset.color; container.querySelectorAll('.color-preset').forEach((b) => b.classList.toggle('selected', b === btn)); }); }); } function openTagModal(tagSyncId = null, name = '', color = '#3b82f6') { currentTagModalSyncId = tagSyncId; document.getElementById('tag-modal-title').textContent = tagSyncId ? 'Edit Tag' : 'New Tag'; document.getElementById('tag-modal-name').value = name; document.getElementById('tag-modal-color').value = color; document.getElementById('tag-modal-submit-btn').textContent = tagSyncId ? 'Save' : 'Create'; document.getElementById('tag-modal-delete-btn').style.display = tagSyncId ? 'inline-flex' : 'none'; renderTagColorPresets(color); document.getElementById('tag-modal-backdrop').classList.add('open'); document.getElementById('tag-modal-name').focus(); } function closeTagModal() { document.getElementById('tag-modal-backdrop').classList.remove('open'); currentTagModalSyncId = null; } async function saveTagModal() { const name = document.getElementById('tag-modal-name').value.trim(); if (!name) return; const color = document.getElementById('tag-modal-color').value; const now = new Date().toISOString(); if (currentTagModalSyncId) { const tags = await getAllTags(); const tag = tags.find((t) => t.sync_id === currentTagModalSyncId); if (tag) { tag.name = name; tag.color = color; tag.updated_at = now; tag._dirty = true; await putLocalTag(tag); } } else { const syncId = crypto.randomUUID(); await putLocalTag({ id: syncId, sync_id: syncId, name, description: '', color, icon: '', sort_order: 0, is_archived: false, is_deleted: false, created_at: now, updated_at: now, _dirty: true, _pending_conflict_id: null, }); } closeTagModal(); renderOfflineTasks(); if (openTaskSyncId) { renderTaskDetailPane(); } } async function deleteCurrentTag() { if (!currentTagModalSyncId) return; if (!confirm('Are you sure you want to delete this tag?\n\nTasks with this tag will not be deleted.')) { return; } const tags = await getAllTags(); const tag = tags.find((t) => t.sync_id === currentTagModalSyncId); if (tag) { tag.is_deleted = true; tag.updated_at = new Date().toISOString(); tag._dirty = true; await putLocalTag(tag); } if (currentTagId === currentTagModalSyncId) { currentTagId = null; } closeTagModal(); renderOfflineTasks(); } /* ============================================ 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 = `