Internal
Public Access
The offline task app was showing every cached task including completed ones, with no sorting -- unlike the online dashboard, which hides completed tasks by default and sorts by due date. Ports the same filter tabs (All/Today/Upcoming/Overdue/Completed) and sort options (due date asc/desc, priority high/low) from tasks/views.py's DashboardView so offline mode isn't a degraded view. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
224 lines
7.4 KiB
JavaScript
224 lines
7.4 KiB
JavaScript
/**
|
|
* 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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>';
|
|
}
|
|
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();
|
|
});
|
|
}
|
|
});
|