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:
@@ -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;
|
||||
}
|
||||
|
||||
328
public/app.mjs
328
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 = '<h2>Tasks</h2>';
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- Task list item -->
|
||||
<template id="t-task-row">
|
||||
<div class="task-row">
|
||||
<button class="btn-expand" hidden></button>
|
||||
<span class="task-id"></span>
|
||||
<span class="task-status"></span>
|
||||
<span class="task-priority"></span>
|
||||
@@ -10,6 +11,7 @@
|
||||
</div>
|
||||
<span class="task-tags"></span>
|
||||
<div class="task-actions">
|
||||
<button class="btn-sub">+ Sub</button>
|
||||
<button class="btn-edit">Edit</button>
|
||||
<button class="btn-delete">Delete</button>
|
||||
</div>
|
||||
@@ -24,6 +26,13 @@
|
||||
<label>Title
|
||||
<input name="title" type="text" required autocomplete="off">
|
||||
</label>
|
||||
<div class="parent-section">
|
||||
<span class="parent-section-label">Parent</span>
|
||||
<input type="hidden" name="parent_id">
|
||||
<span class="parent-display">—</span>
|
||||
<button type="button" class="btn-set-parent">Set parent…</button>
|
||||
<button type="button" class="btn-clear-parent" hidden>× Remove</button>
|
||||
</div>
|
||||
<div class="field-with-tabs">
|
||||
<div class="tab-bar">
|
||||
<button type="button" class="tab-btn active" data-tab="edit">Edit</button>
|
||||
@@ -62,3 +71,15 @@
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<!-- Task picker dialog -->
|
||||
<template id="t-task-picker">
|
||||
<dialog class="task-picker-dialog">
|
||||
<h2>Make subtask of…</h2>
|
||||
<input type="text" class="picker-search" placeholder="Search tasks…" autocomplete="off">
|
||||
<div class="picker-list"></div>
|
||||
<div class="dialog-buttons">
|
||||
<button type="button" class="btn-cancel">Cancel</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
14
server.mjs
14
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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user