Files
KeepItGoingServer/static/js/offline-tasks.js
T
Keith SmithandClaude Sonnet 5 bde7be454b Show overdue styling on tasks in offline mode
renderTaskItem() never applied the "overdue" CSS class at all, so
overdue tasks in offline mode looked like any other task instead of
getting the red highlight/due-date treatment they get online. Adds
isTaskOverdue(), mirroring Task.is_overdue, applied the same way
_task_item.html does it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:15:22 -06:00

791 lines
33 KiB
JavaScript

/**
* 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) => `<span class="task-group-label" style="background-color: ${tag.color}; color: white; font-weight: 600;">${escapeHtml(tag.name)}</span>`)
.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;
return task.due_date < todayLocalStr();
}
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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>';
}
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(`<span class="task-due${overdue ? ' overdue' : ''}">📅 ${formatDueDate(task.due_date)}</span>`);
}
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 `
<div class="sidebar-group-row">
<a href="#" class="sidebar-item sidebar-group-item ${currentTagId === tag.sync_id ? 'active' : ''}" data-tag-id="${tag.sync_id}">
<span class="group-color-dot" style="background-color: ${tag.color}"></span>
${escapeHtml(tag.name)}
${count ? `<span class="sidebar-item-count">${count}</span>` : ''}
</a>
<button type="button" class="sidebar-group-edit" title="Edit tag" data-edit-tag-id="${tag.sync_id}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
</div>
`;
}).join('') + `
<a href="#" class="sidebar-item sidebar-group-item ${!currentTagId ? 'active' : ''}" id="offline-all-tags-link">
<span class="group-color-dot" style="background-color: var(--text-muted)"></span>
All Tags
</a>
`;
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) => `
<button type="button" class="color-preset ${c.toLowerCase() === selectedColor.toLowerCase() ? 'selected' : ''}" data-color="${c}" style="background-color: ${c}"></button>
`).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 = `
<div class="detail-header">
<h2 style="font-size: var(--font-lg); font-weight: 600;">Task Details</h2>
<button class="detail-close" id="detail-close-btn" aria-label="Close detail panel">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<form id="task-edit-form">
<div class="form-group">
<label class="form-label" for="modal-title">Title</label>
<input type="text" class="form-input" id="modal-title" required value="${escapeHtml(task.title)}">
</div>
<div class="form-group">
<label class="form-label" for="modal-description">Description</label>
<textarea class="form-textarea" id="modal-description" rows="3">${escapeHtml(task.description)}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="modal-status">Status</label>
<select class="form-select" id="modal-status">
<option value="pending" ${task.status === 'pending' ? 'selected' : ''}>Pending</option>
<option value="in_progress" ${task.status === 'in_progress' ? 'selected' : ''}>In Progress</option>
<option value="completed" ${task.status === 'completed' ? 'selected' : ''}>Completed</option>
<option value="cancelled" ${task.status === 'cancelled' ? 'selected' : ''}>Cancelled</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="modal-priority">Priority</label>
<select class="form-select" id="modal-priority">
<option value="low" ${task.priority === 'low' ? 'selected' : ''}>Low</option>
<option value="medium" ${task.priority === 'medium' ? 'selected' : ''}>Medium</option>
<option value="high" ${task.priority === 'high' ? 'selected' : ''}>High</option>
<option value="urgent" ${task.priority === 'urgent' ? 'selected' : ''}>Urgent</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="modal-due-date">Due Date</label>
<input type="date" class="form-input" id="modal-due-date" value="${task.due_date || ''}">
</div>
<div class="form-group">
<label class="form-label" for="modal-due-time">Due Time</label>
<input type="time" class="form-input" id="modal-due-time" value="${task.due_time || ''}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="modal-recurrence">Recurrence</label>
<select class="form-select" id="modal-recurrence">
${RECURRENCE_CHOICES.map((r) => `<option value="${r}" ${task.recurrence === r ? 'selected' : ''}>${r === 'none' ? 'None' : r.charAt(0).toUpperCase() + r.slice(1)}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label" for="modal-recurrence-end">Ends</label>
<input type="date" class="form-input" id="modal-recurrence-end" value="${task.recurrence_end_date || ''}">
</div>
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<div id="modal-tag-checkboxes" style="display: flex; flex-wrap: wrap; gap: var(--space-sm); margin-bottom: var(--space-sm);">
${tags.map((tag) => `
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="modal-tag-checkbox" value="${tag.sync_id}" ${(task.tag_sync_ids || []).includes(tag.sync_id) ? 'checked' : ''}>
<span style="color: ${tag.color}">${escapeHtml(tag.name)}</span>
</label>
`).join('') || '<span class="text-muted">No tags yet.</span>'}
</div>
<div style="display: flex; gap: var(--space-sm);">
<input type="text" id="modal-new-tag-name" class="form-input" placeholder="New tag name" style="flex: 1;">
<input type="color" id="modal-new-tag-color" value="${TAG_COLOR_PRESETS[0]}" style="width: 40px; padding: 2px;">
<button type="button" class="btn btn-secondary btn-sm" id="modal-add-tag-btn">+ Tag</button>
</div>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Save</button>
</form>
<div style="margin-top: var(--space-lg); background: var(--bg); border-radius: var(--radius-sm); padding: var(--space-md);">
<div style="font-size: var(--font-sm); font-weight: 500; color: var(--text-secondary); margin-bottom: var(--space-sm);">Subtasks</div>
<form id="modal-subtask-form" style="display: flex; gap: var(--space-sm); margin-bottom: var(--space-sm);">
<input type="text" id="modal-subtask-title" class="form-input" placeholder="Add a subtask..." style="flex: 1; padding: var(--space-xs) var(--space-sm); font-size: var(--font-sm);" required>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
<div id="modal-subtask-list" style="display: flex; flex-direction: column; gap: var(--space-xs);">
${subtasks.length === 0 ? '<div style="font-size: var(--font-sm); color: var(--text-muted);">No subtasks</div>' : subtasks.map((st) => `
<div style="display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-xs); border-radius: var(--radius-xs); background: var(--surface);">
<button type="button" class="task-checkbox modal-subtask-toggle ${st.status === 'completed' ? 'checked' : ''}" data-sync-id="${st.sync_id}" style="width: 16px; height: 16px; font-size: 10px;">${st.status === 'completed' ? '&#10003;' : ''}</button>
<span style="font-size: var(--font-sm); ${st.status === 'completed' ? 'text-decoration: line-through; color: var(--text-muted);' : ''}">${escapeHtml(st.title)}</span>
</div>
`).join('')}
</div>
</div>
<div class="time-tracker" style="margin-top: var(--space-lg);">
<div class="time-tracker-header">
<span class="time-tracker-label">Time Spent</span>
<span class="time-tracker-total" id="modal-time-total">${formatDuration(totalSeconds)}</span>
</div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);">
${runningEntry
? `<button type="button" class="btn btn-danger btn-full btn-sm" id="modal-timer-btn" data-running="true" data-entry-sync-id="${runningEntry.sync_id}" data-started="${runningEntry.started_at}">Stop Timer</button>`
: `<button type="button" class="btn btn-secondary btn-full btn-sm" id="modal-timer-btn" data-running="false">Start Timer</button>`
}
</div>
</div>
`;
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);
}