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>
This commit is contained in:
Keith Smith
2026-09-05 00:05:39 -06:00
co-authored by Claude Sonnet 5
parent ae9ab596bb
commit 3fbab2b831
3 changed files with 221 additions and 5 deletions
+176 -4
View File
@@ -13,8 +13,10 @@ const TAG_COLOR_PRESETS = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6'
let currentFilter = 'all'; let currentFilter = 'all';
let currentSort = 'due_date'; let currentSort = 'due_date';
let currentTagId = null;
let openTaskSyncId = null; let openTaskSyncId = null;
let timerDisplayInterval = null; let timerDisplayInterval = null;
let currentTagModalSyncId = null;
/* ============================================ /* ============================================
Date/format helpers Date/format helpers
@@ -64,6 +66,11 @@ function applyFilter(tasks, filter) {
} }
} }
function applyTagFilter(tasks, tagId) {
if (!tagId) return tasks;
return tasks.filter((t) => (t.tag_sync_ids || []).includes(tagId));
}
function applySort(tasks, sort) { function applySort(tasks, sort) {
const sorted = [...tasks]; const sorted = [...tasks];
const dueOrDefault = (t) => t.due_date || '9999-99-99'; const dueOrDefault = (t) => t.due_date || '9999-99-99';
@@ -217,7 +224,17 @@ async function renderOfflineTasks() {
// Only top-level tasks in the main list - subtasks appear inside their // Only top-level tasks in the main list - subtasks appear inside their
// parent's detail modal, matching the online dashboard. // parent's detail modal, matching the online dashboard.
const topLevel = allTasks.filter((t) => !t.is_deleted && !t.parent_sync_id); const topLevel = allTasks.filter((t) => !t.is_deleted && !t.parent_sync_id);
const visibleTasks = applySort(applyFilter(topLevel, currentFilter), currentSort); 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 = ''; listEl.innerHTML = '';
@@ -244,8 +261,150 @@ function setActiveFilterNav(filter) {
document.querySelectorAll('#offline-filter-nav .sidebar-item').forEach((item) => { document.querySelectorAll('#offline-filter-nav .sidebar-item').forEach((item) => {
item.classList.toggle('active', item.dataset.filter === filter); item.classList.toggle('active', item.dataset.filter === filter);
}); });
const titleEl = document.getElementById('offline-page-title'); }
if (titleEl) titleEl.textContent = FILTER_TITLES[filter] || 'All Tasks';
/* ============================================
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();
} }
/* ============================================ /* ============================================
@@ -569,7 +728,6 @@ document.addEventListener('DOMContentLoaded', () => {
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
currentFilter = item.dataset.filter; currentFilter = item.dataset.filter;
setActiveFilterNav(currentFilter);
renderOfflineTasks(); renderOfflineTasks();
}); });
}); });
@@ -582,6 +740,20 @@ document.addEventListener('DOMContentLoaded', () => {
renderOfflineTasks(); 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();
}
});
}); });
/* ============================================ /* ============================================
+44
View File
@@ -80,6 +80,17 @@
</a> </a>
</nav> </nav>
</div> </div>
<div class="sidebar-section">
<div class="sidebar-section-title">Tags</div>
<nav class="sidebar-nav" id="offline-tag-nav"></nav>
<button type="button" class="sidebar-add-btn" id="offline-add-tag-btn">
<svg style="width: 14px; height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
Add Tag
</button>
</div>
</aside> </aside>
<!-- Main Task Pane --> <!-- Main Task Pane -->
@@ -116,6 +127,39 @@
<div class="mobile-overlay" id="mobile-overlay" onclick="closeOfflineMobileMenus()"></div> <div class="mobile-overlay" id="mobile-overlay" onclick="closeOfflineMobileMenus()"></div>
</div> </div>
<!-- Tag Edit/Create Modal (outside app-layout for proper fixed positioning) -->
<div class="modal-backdrop" id="tag-modal-backdrop">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2 class="modal-title" id="tag-modal-title">New Tag</h2>
<button type="button" class="modal-close" id="tag-modal-close-btn">
<svg style="width: 20px; height: 20px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<form id="tag-edit-form">
<div class="form-group">
<label class="form-label" for="tag-modal-name">Name</label>
<input type="text" class="form-input" id="tag-modal-name" required placeholder="Tag name">
</div>
<div class="form-group">
<label class="form-label">Color</label>
<div class="color-presets" id="tag-modal-color-presets"></div>
<div class="color-custom-row">
<label class="color-custom-label">Custom:</label>
<input type="color" id="tag-modal-color" class="color-custom-input" value="#3b82f6">
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="tag-modal-cancel-btn">Cancel</button>
<button type="button" class="btn btn-danger" id="tag-modal-delete-btn" style="display: none;">Delete</button>
<button type="submit" class="btn btn-primary" id="tag-modal-submit-btn">Create</button>
</div>
</form>
</div>
</div>
<script src="{% static 'js/offline-db.js' %}"></script> <script src="{% static 'js/offline-db.js' %}"></script>
<script src="{% static 'js/offline-tasks.js' %}"></script> <script src="{% static 'js/offline-tasks.js' %}"></script>
</body> </body>
+1 -1
View File
@@ -1,4 +1,4 @@
{% load static %}const CACHE_NAME = 'keepitgoing-shell-v5'; {% load static %}const CACHE_NAME = 'keepitgoing-shell-v6';
const OFFLINE_URL = '{% url "offline" %}'; const OFFLINE_URL = '{% url "offline" %}';
const OFFLINE_TASKS_URL = '{% url "offline-tasks" %}'; const OFFLINE_TASKS_URL = '{% url "offline-tasks" %}';