From b7ee5006d2d81e4f429212df467b3ba9d5ab2cfb Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sun, 17 May 2026 18:55:14 +0000 Subject: [PATCH] Add hierarchical tasks with tree and flat mode - parent_id on tasks; roots shown by default, others expandable - Expand/collapse per node with expand/collapse button - Sub button creates subtask directly from the row - Make subtask of button in edit dialog with searchable picker - Remove parent clears the parent link in the dialog - Flat mode toggle shows all tasks indented by depth - Tree filter shows nodes whose descendants match, not only direct matches - Deletion blocked if task has subtasks (client + server) Co-Authored-By: Claude Sonnet 4.6 --- lib/storage.mjs | 9 ++ public/app.mjs | 328 ++++++++++++++++++++++++++++++++---------- public/style.css | 86 ++++++++++- public/templates.html | 21 +++ server.mjs | 14 +- 5 files changed, 381 insertions(+), 77 deletions(-) diff --git a/lib/storage.mjs b/lib/storage.mjs index dbe0f36..2faef73 100644 --- a/lib/storage.mjs +++ b/lib/storage.mjs @@ -32,3 +32,12 @@ export function set_task(task) { export function delete_task(id) { return store.delete(`task:${id}`); } + +export function has_child_tasks(parent_id) { + for (const [key] of store.data.entries()) { + if (!key.startsWith('task:')) { continue; } + const task = store.get(key); + if (task.parent_id === parent_id) { return true; } + } + return false; +} diff --git a/public/app.mjs b/public/app.mjs index 38d3bda..5b32367 100644 --- a/public/app.mjs +++ b/public/app.mjs @@ -12,6 +12,8 @@ class App_State { this.filter_priority = ''; this.filter_tag = ''; this.search = ''; + this.flat_mode = false; + this.expanded = new Set(); } } @@ -33,39 +35,110 @@ async function render_markdown(text) { function fill_markdown(el, text) { if (!text) { el.innerHTML = ''; return; } const cached = md_cache.get(text); - if (cached !== undefined) { - el.innerHTML = cached; - return; - } - el.textContent = text; // placeholder while loading + if (cached !== undefined) { el.innerHTML = cached; return; } + el.textContent = ''; render_markdown(text).then(html => { el.innerHTML = html; }); } // --------------------------------------------------------------------------- -// Helpers +// Tree helpers // --------------------------------------------------------------------------- -function filtered_tasks() { - return state.tasks.filter(t => { - if (state.filter_status && t.status !== state.filter_status) { return false; } - if (state.filter_priority && t.priority !== state.filter_priority) { return false; } - if (state.filter_tag && !t.tags.includes(state.filter_tag)) { return false; } - if (state.search) { - const q = state.search.toLowerCase(); - if (!t.title.toLowerCase().includes(q) && !t.body.toLowerCase().includes(q)) { return false; } - } - return true; - }); +function build_children_map() { + const map = new Map(); + for (const task of state.tasks) { + const pid = task.parent_id ?? null; + if (!map.has(pid)) { map.set(pid, []); } + map.get(pid).push(task); + } + return map; +} + +function get_descendants(task_id) { + const result = new Set(); + const queue = state.tasks.filter(t => t.parent_id === task_id); + while (queue.length) { + const t = queue.shift(); + result.add(t.id); + for (const child of state.tasks.filter(c => c.parent_id === t.id)) { queue.push(child); } + } + return result; +} + +function task_matches_filter(task) { + if (state.filter_status && task.status !== state.filter_status) { return false; } + if (state.filter_priority && task.priority !== state.filter_priority) { return false; } + if (state.filter_tag && !task.tags.includes(state.filter_tag)) { return false; } + if (state.search) { + const q = state.search.toLowerCase(); + if (!task.title.toLowerCase().includes(q) && !task.body.toLowerCase().includes(q)) { return false; } + } + return true; +} + +function node_or_descendants_match(task, children_map) { + if (task_matches_filter(task)) { return true; } + return (children_map.get(task.id) ?? []).some(c => node_or_descendants_match(c, children_map)); } function all_tags() { const tags = new Set(); - for (const t of state.tasks) { - for (const tag of t.tags) { tags.add(tag); } - } + for (const t of state.tasks) { for (const tag of t.tags) { tags.add(tag); } } return [...tags].sort(); } +// --------------------------------------------------------------------------- +// Task picker dialog +// --------------------------------------------------------------------------- + +class Task_Picker_Dialog { + constructor() { + const el = clone('t-task-picker'); + document.body.appendChild(el); + this.dialog = el; + this.on_pick = null; + this._available = []; + + const search = el.querySelector('.picker-search'); + search.addEventListener('input', () => this._render_list(search.value)); + el.querySelector('.btn-cancel').addEventListener('click', () => el.close()); + } + + _render_list(query) { + const list = this.dialog.querySelector('.picker-list'); + list.innerHTML = ''; + const q = query.toLowerCase(); + const filtered = q + ? this._available.filter(t => t.title.toLowerCase().includes(q) || String(t.id).includes(q)) + : this._available; + if (!filtered.length) { + list.textContent = 'No tasks.'; + return; + } + for (const task of filtered) { + const item = document.createElement('div'); + item.className = 'picker-item'; + item.textContent = `#${task.id} ${task.title}`; + item.addEventListener('click', () => { + if (this.on_pick) { this.on_pick(task.id); } + this.dialog.close(); + }); + list.appendChild(item); + } + } + + open(excluded_ids, on_pick) { + this._available = state.tasks.filter(t => !excluded_ids.has(t.id)); + this.on_pick = on_pick; + const search = this.dialog.querySelector('.picker-search'); + search.value = ''; + this._render_list(''); + this.dialog.showModal(); + } +} + +let task_picker; + // --------------------------------------------------------------------------- // Task dialog // --------------------------------------------------------------------------- @@ -90,6 +163,22 @@ class Task_Dialog { }); } + // Parent controls + el.querySelector('.btn-set-parent').addEventListener('click', () => { + const current_id = Number(this.form.elements['parent_id'].value) || null; + // Exclude self (if editing) and its descendants + const self_id = this._editing_id; + const excluded = self_id ? new Set([self_id, ...get_descendants(self_id)]) : new Set(); + if (current_id) { excluded.add(current_id); } // avoid re-selecting same + task_picker.open(excluded, (picked_id) => { + this._set_parent(picked_id); + }); + }); + + el.querySelector('.btn-clear-parent').addEventListener('click', () => { + this._set_parent(null); + }); + el.querySelector('.btn-cancel').addEventListener('click', () => el.close()); this.form.addEventListener('submit', (e) => { e.preventDefault(); @@ -98,6 +187,22 @@ class Task_Dialog { }); } + _set_parent(parent_id) { + const hidden = this.form.elements['parent_id']; + const display = this.dialog.querySelector('.parent-display'); + const clear_btn = this.dialog.querySelector('.btn-clear-parent'); + if (parent_id) { + const parent = state.tasks.find(t => t.id === parent_id); + hidden.value = parent_id; + display.textContent = parent ? `#${parent.id} ${parent.title}` : `#${parent_id}`; + show(clear_btn); + } else { + hidden.value = ''; + display.textContent = '—'; + hide(clear_btn); + } + } + _load_preview() { const text = this.form.elements['body'].value; const preview = this.dialog.querySelector('.preview-content'); @@ -116,22 +221,26 @@ class Task_Dialog { _read_form() { const fd = new FormData(this.form); const tags_raw = fd.get('tags').trim(); + const parent_id_raw = fd.get('parent_id'); return { title: fd.get('title').trim(), body: fd.get('body').trim(), status: fd.get('status'), priority: fd.get('priority'), tags: tags_raw ? tags_raw.split(',').map(s => s.trim()).filter(Boolean) : [], + parent_id: parent_id_raw ? Number(parent_id_raw) : null, }; } open(title_text, initial = {}, on_save) { + this._editing_id = initial.id ?? null; set_text(this.dialog, '.dialog-title', title_text); this.form.elements['title'].value = initial.title ?? ''; this.form.elements['body'].value = initial.body ?? ''; this.form.elements['status'].value = initial.status ?? 'open'; this.form.elements['priority'].value = initial.priority ?? 'normal'; this.form.elements['tags'].value = (initial.tags ?? []).join(', '); + this._set_parent(initial.parent_id ?? null); // Always open on edit tab const tab_btns = this.dialog.querySelectorAll('.tab-btn'); const tab_panes = this.dialog.querySelectorAll('.tab-pane'); @@ -145,20 +254,111 @@ class Task_Dialog { let task_dialog; // --------------------------------------------------------------------------- -// Render +// Render helpers +// --------------------------------------------------------------------------- + +function make_task_row(task, children_map) { + const children = children_map.get(task.id) ?? []; + const row = clone('t-task-row'); + row.dataset.priority = task.priority; + row.dataset.status = task.status; + + // Expand button + const expand_btn = row.querySelector('.btn-expand'); + if (children.length > 0) { + show(expand_btn); + expand_btn.textContent = state.expanded.has(task.id) ? '▼' : '▶'; + expand_btn.addEventListener('click', () => { + if (state.expanded.has(task.id)) { state.expanded.delete(task.id); } + else { state.expanded.add(task.id); } + render(); + }); + } + + set_text(row, '.task-id', `#${task.id}`); + + const status_el = row.querySelector('.task-status'); + status_el.textContent = task.status; + status_el.dataset.val = task.status; + + const priority_el = row.querySelector('.task-priority'); + priority_el.textContent = task.priority; + priority_el.dataset.val = task.priority; + + fill_markdown(row.querySelector('.task-title'), task.title); + + const body_el = row.querySelector('.task-body'); + if (task.body) { show(body_el); fill_markdown(body_el, task.body); } + + const tags_el = row.querySelector('.task-tags'); + for (const tag of task.tags) { + const span = document.createElement('span'); + span.className = 'tag'; + span.textContent = tag; + tags_el.appendChild(span); + } + + row.querySelector('.btn-sub').addEventListener('click', () => open_add_dialog(task.id)); + row.querySelector('.btn-edit').addEventListener('click', () => open_edit_dialog(task)); + row.querySelector('.btn-delete').addEventListener('click', () => confirm_delete(task)); + + return row; +} + +function render_tree_node(task, children_map) { + if (!node_or_descendants_match(task, children_map)) { return null; } + + const children = children_map.get(task.id) ?? []; + const node = document.createElement('div'); + node.className = 'task-node'; + node.appendChild(make_task_row(task, children_map)); + + if (children.length > 0 && state.expanded.has(task.id)) { + const children_el = document.createElement('div'); + children_el.className = 'task-children'; + for (const child of children) { + const child_node = render_tree_node(child, children_map); + if (child_node) { children_el.appendChild(child_node); } + } + node.appendChild(children_el); + } + + return node; +} + +function collect_flat(tasks, children_map, depth = 0, result = []) { + for (const task of tasks) { + if (task_matches_filter(task)) { result.push({ task, depth }); } + const children = children_map.get(task.id) ?? []; + collect_flat(children, children_map, depth + 1, result); + } + return result; +} + +// --------------------------------------------------------------------------- +// Main render // --------------------------------------------------------------------------- function render_tasks(container) { container.innerHTML = ''; + const children_map = build_children_map(); + const roots = children_map.get(null) ?? []; + // Toolbar const toolbar = document.createElement('div'); toolbar.className = 'toolbar'; toolbar.innerHTML = '

Tasks

'; + + const flat_btn = document.createElement('button'); + flat_btn.textContent = state.flat_mode ? 'Tree mode' : 'Flat mode'; + flat_btn.addEventListener('click', () => { state.flat_mode = !state.flat_mode; render(); }); + toolbar.appendChild(flat_btn); + const add_btn = document.createElement('button'); add_btn.className = 'btn-primary'; add_btn.textContent = '+ New task'; - add_btn.addEventListener('click', () => open_add_dialog()); + add_btn.addEventListener('click', () => open_add_dialog(null)); toolbar.appendChild(add_btn); container.appendChild(toolbar); @@ -209,72 +409,53 @@ function render_tasks(container) { container.appendChild(filter_bar); // Task list - const tasks = filtered_tasks(); const list = document.createElement('div'); - list.className = 'task-list'; + list.className = 'task-list' + (state.flat_mode ? ' flat-mode' : ''); - if (tasks.length === 0) { - const empty = document.createElement('div'); - empty.className = 'empty-state'; - empty.textContent = 'No tasks.'; - list.appendChild(empty); - } - - for (const task of tasks) { - const row = clone('t-task-row'); - row.dataset.priority = task.priority; - row.dataset.status = task.status; - - set_text(row, '.task-id', `#${task.id}`); - - const status_el = row.querySelector('.task-status'); - status_el.textContent = task.status; - status_el.dataset.val = task.status; - - const priority_el = row.querySelector('.task-priority'); - priority_el.textContent = task.priority; - priority_el.dataset.val = task.priority; - - const title_el = row.querySelector('.task-title'); - fill_markdown(title_el, task.title); - - const body_el = row.querySelector('.task-body'); - if (task.body) { - show(body_el); - fill_markdown(body_el, task.body); + if (state.flat_mode) { + const flat = collect_flat(roots, children_map); + if (!flat.length) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.textContent = 'No tasks.'; + list.appendChild(empty); } - - const tags_el = row.querySelector('.task-tags'); - for (const tag of task.tags) { - const span = document.createElement('span'); - span.className = 'tag'; - span.textContent = tag; - tags_el.appendChild(span); + for (const { task, depth } of flat) { + const row = make_task_row(task, children_map); + if (depth > 0) { row.style.setProperty('--depth', depth); } + list.appendChild(row); + } + } else { + const visible = roots.filter(t => node_or_descendants_match(t, children_map)); + if (!visible.length) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.textContent = 'No tasks.'; + list.appendChild(empty); + } + for (const root of visible) { + const node = render_tree_node(root, children_map); + if (node) { list.appendChild(node); } } - - row.querySelector('.btn-edit').addEventListener('click', () => open_edit_dialog(task)); - row.querySelector('.btn-delete').addEventListener('click', () => confirm_delete(task)); - - list.appendChild(row); } container.appendChild(list); } function render() { - const main = document.getElementById('main'); - render_tasks(main); + render_tasks(document.getElementById('main')); } // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- -function open_add_dialog() { - task_dialog.open('New task', {}, async (data) => { +function open_add_dialog(parent_id) { + task_dialog.open('New task', { parent_id }, async (data) => { try { const { task } = await api.create_task(data); state.tasks.unshift(task); + if (parent_id) { state.expanded.add(parent_id); } render(); } catch (err) { alert(err.message); @@ -288,7 +469,6 @@ function open_edit_dialog(task) { const { task: updated } = await api.update_task(task.id, data); const idx = state.tasks.findIndex(t => t.id === task.id); if (idx !== -1) { state.tasks[idx] = updated; } - // Invalidate cache for changed fields md_cache.delete(task.title); md_cache.delete(task.body); render(); @@ -299,6 +479,11 @@ function open_edit_dialog(task) { } function confirm_delete(task) { + const children_count = state.tasks.filter(t => t.parent_id === task.id).length; + if (children_count > 0) { + alert(`Cannot delete #${task.id}: it has ${children_count} subtask(s). Delete or reparent them first.`); + return; + } if (!confirm(`Delete task #${task.id}: "${task.title}"?`)) { return; } api.delete_task(task.id).then(() => { state.tasks = state.tasks.filter(t => t.id !== task.id); @@ -311,7 +496,6 @@ function confirm_delete(task) { // --------------------------------------------------------------------------- async function init() { - // Inject templates const tmpl_res = await fetch('/templates.html'); const tmpl_html = await tmpl_res.text(); const tmpl_container = document.createElement('div'); @@ -320,9 +504,9 @@ async function init() { document.body.appendChild(tmpl); } + task_picker = new Task_Picker_Dialog(); task_dialog = new Task_Dialog(); - // Load data const { tasks } = await api.get_tasks(); state.tasks = tasks; diff --git a/public/style.css b/public/style.css index 2a21631..ab01252 100644 --- a/public/style.css +++ b/public/style.css @@ -72,10 +72,22 @@ main { padding: 1.25rem; } /* Task list */ .task-list { display: flex; flex-direction: column; gap: 1px; } +.task-node + .task-node { margin-top: 1px; } + +.task-node { display: flex; flex-direction: column; } + +.task-children { + margin-left: 1rem; + padding-left: 0.75rem; + border-left: 1px solid #2d2d2d; + display: flex; + flex-direction: column; + gap: 1px; +} .task-row { display: grid; - grid-template-columns: 2.5rem 5rem 4.5rem 1fr auto auto; + grid-template-columns: 1.25rem 2.5rem 5rem 4.5rem 1fr auto auto; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; @@ -84,6 +96,10 @@ main { padding: 1.25rem; } border-left: 3px solid transparent; } +.task-list.flat-mode .task-row { + padding-left: calc(0.75rem + var(--depth, 0) * 1.5rem); +} + .task-row[data-priority='high'] { border-left-color: #e05555; } .task-row[data-priority='normal'] { border-left-color: #5588e0; } .task-row[data-priority='low'] { border-left-color: #555; } @@ -124,6 +140,23 @@ main { padding: 1.25rem; } .task-actions { display: flex; gap: 0.4rem; } +.btn-expand { + width: 1.25rem; + height: 1.25rem; + padding: 0; + font-size: 9px; + background: transparent; + color: #555; + border: none; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; +} +.btn-expand:hover { color: #aaa; background: #333; } + +.btn-sub { font-size: 12px; padding: 0.2rem 0.5rem; color: #6a9; } + /* Buttons */ button { cursor: pointer; @@ -198,6 +231,57 @@ dialog input:focus, dialog textarea:focus, dialog select:focus { margin-top: 1rem; } +/* Parent section in dialog */ +.parent-section { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + font-size: 12px; +} + +.parent-section-label { color: #999; min-width: 3rem; } +.parent-display { color: #ccc; flex: 1; } + +/* Task picker dialog */ +.task-picker-dialog { + width: 420px; + max-width: 95vw; +} + +.task-picker-dialog h2 { margin-bottom: 0.75rem; font-size: 1rem; } + +.picker-search { + width: 100%; + padding: 0.4rem 0.6rem; + background: #1a1a1a; + border: 1px solid #444; + border-radius: 4px; + color: #e0e0e0; + font-size: 13px; + margin-bottom: 0.5rem; +} + +.picker-search:focus { outline: none; border-color: #5588e0; } + +.picker-list { + max-height: 280px; + overflow-y: auto; + border: 1px solid #333; + border-radius: 4px; + margin-bottom: 0.75rem; +} + +.picker-item { + padding: 0.4rem 0.6rem; + cursor: pointer; + font-size: 13px; + border-bottom: 1px solid #2a2a2a; +} + +.picker-item:last-child { border-bottom: none; } +.picker-item:hover { background: #2e2e2e; color: #fff; } + .empty-state { color: #555; padding: 2rem; diff --git a/public/templates.html b/public/templates.html index f0a6a8d..c05451f 100644 --- a/public/templates.html +++ b/public/templates.html @@ -1,6 +1,7 @@ + + + diff --git a/server.mjs b/server.mjs index d59030d..a2d23b3 100644 --- a/server.mjs +++ b/server.mjs @@ -3,7 +3,7 @@ process.on('uncaughtException', (err) => { console.error('[uncaughtException import express from 'express'; import { next_id } from './lib/ids.mjs'; -import { list_tasks, get_task, set_task, delete_task } from './lib/storage.mjs'; +import { list_tasks, get_task, set_task, delete_task, has_child_tasks } from './lib/storage.mjs'; import { get_config } from './lib/config.mjs'; const app = express(); @@ -50,8 +50,9 @@ app.get('/api/tasks', (req, res) => { }); app.post('/api/tasks', (req, res) => { - const { title, body = '', status = 'open', priority = 'normal', tags = [] } = req.body; + const { title, body = '', status = 'open', priority = 'normal', tags = [], parent_id = null } = req.body; if (!title?.trim()) { return fail(res, 'title is required'); } + if (parent_id !== null && !get_task(Number(parent_id))) { return fail(res, 'parent task not found', 404); } const now = Date.now(); const task = { id: next_id('task'), @@ -60,6 +61,7 @@ app.post('/api/tasks', (req, res) => { status, priority, tags: Array.isArray(tags) ? tags : [], + parent_id: parent_id !== null ? Number(parent_id) : null, created_at: now, updated_at: now, }; @@ -76,19 +78,23 @@ app.get('/api/tasks/:id', (req, res) => { app.put('/api/tasks/:id', (req, res) => { const existing = get_task(Number(req.params.id)); if (!existing) { return fail(res, 'not found', 404); } - const { title, body, status, priority, tags } = req.body; + const { title, body, status, priority, tags, parent_id } = req.body; const updated = { ...existing, updated_at: Date.now() }; if (title !== undefined) { updated.title = title.trim(); } if (body !== undefined) { updated.body = body.trim(); } if (status !== undefined) { updated.status = status; } if (priority !== undefined) { updated.priority = priority; } if (tags !== undefined && Array.isArray(tags)) { updated.tags = tags; } + if (parent_id !== undefined) { updated.parent_id = parent_id !== null ? Number(parent_id) : null; } set_task(updated); ok(res, { task: updated }); }); app.delete('/api/tasks/:id', (req, res) => { - if (!delete_task(Number(req.params.id))) { return fail(res, 'not found', 404); } + const id = Number(req.params.id); + if (!get_task(id)) { return fail(res, 'not found', 404); } + if (has_child_tasks(id)) { return fail(res, 'task has subtasks — delete or reparent them first'); } + delete_task(id); ok(res); });