Files
KeepItGoingServer/static/js/offline-tasks.js
T
Keith SmithandClaude Sonnet 5 3fbab2b831 Add sidebar Tags section to offline mode
Offline mode had tag chips and per-task tag assignment, but was
missing the dashboard sidebar's Tags section entirely: filtering the
task list by tag, per-tag task counts, and tag create/rename/delete.
Ports that section over (reusing the same tag modal pattern as the
online create/edit flow), combinable with the existing status filters
the same way it works online.

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

784 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 renderTaskItem(task, tagsBySyncId) {
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', (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.map((p) => `<span>${p}</span>`).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);
}