/** * 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 = `

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(); 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-nav .sidebar-item').forEach((item) => { item.addEventListener('click', (e) => { e.preventDefault(); currentFilter = item.dataset.filter; renderOfflineTasks(); }); }); const sortSelect = document.getElementById('offline-sort-select'); if (sortSelect) { sortSelect.value = currentSort; sortSelect.addEventListener('change', () => { currentSort = sortSelect.value; renderOfflineTasks(); }); } document.getElementById('offline-add-tag-btn').addEventListener('click', () => openTagModal()); document.getElementById('tag-modal-close-btn').addEventListener('click', closeTagModal); document.getElementById('tag-modal-cancel-btn').addEventListener('click', closeTagModal); document.getElementById('tag-modal-delete-btn').addEventListener('click', deleteCurrentTag); document.getElementById('tag-edit-form').addEventListener('submit', (e) => { e.preventDefault(); saveTagModal(); }); document.getElementById('tag-modal-backdrop').addEventListener('click', (e) => { if (e.target.id === 'tag-modal-backdrop') { closeTagModal(); } }); }); /* ============================================ 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); }