Internal
Public Access
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>
791 lines
33 KiB
JavaScript
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' ? '✓' : ''}</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);
|
|
}
|