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 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 18:55:14 +00:00
parent 444b51794a
commit b7ee5006d2
5 changed files with 381 additions and 77 deletions

View File

@@ -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);
});