Files
task-inventory/public/app.mjs
mikael-lovqvists-claude-agent fa2b5bda18 Improve mode toggle and flat mode row layout
- Segmented control (Tree | Flat) replaces the toggle button
- Expand arrow hidden in flat mode
- Arrow listener only attached in tree mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 19:10:05 +00:00

527 lines
17 KiB
JavaScript

import * as api from './lib/api.mjs';
import { qs, clone, set_text, show, hide } from './lib/dom.mjs';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
class App_State {
constructor() {
this.tasks = [];
this.filter_status = 'open';
this.filter_priority = '';
this.filter_tag = '';
this.search = '';
this.flat_mode = false;
this.expanded = new Set();
}
}
const state = new App_State();
// ---------------------------------------------------------------------------
// Markdown
// ---------------------------------------------------------------------------
const md_cache = new Map();
async function render_markdown(text) {
if (md_cache.has(text)) { return md_cache.get(text); }
const { html } = await api.render_markdown(text);
md_cache.set(text, html);
return html;
}
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 = '';
render_markdown(text).then(html => { el.innerHTML = html; });
}
// ---------------------------------------------------------------------------
// Tree helpers
// ---------------------------------------------------------------------------
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); } }
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
// ---------------------------------------------------------------------------
class Task_Dialog {
constructor() {
const el = clone('t-task-dialog');
document.body.appendChild(el);
this.dialog = el;
this.form = el.querySelector('form');
this.on_save = null;
// Tab switching
const tab_btns = el.querySelectorAll('.tab-btn');
const tab_panes = el.querySelectorAll('.tab-pane');
for (const btn of tab_btns) {
btn.addEventListener('click', () => {
const target = btn.dataset.tab;
for (const b of tab_btns) { b.classList.toggle('active', b.dataset.tab === target); }
for (const p of tab_panes) { p.hidden = p.dataset.tab !== target; }
if (target === 'preview') { this._load_preview(); }
});
}
// 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();
if (this.on_save) { this.on_save(this._read_form()); }
el.close();
});
}
_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');
if (!text.trim()) { preview.innerHTML = '<em style="color:#555">Nothing to preview.</em>'; return; }
preview.classList.add('loading');
preview.textContent = 'Loading…';
render_markdown(text).then(html => {
preview.classList.remove('loading');
preview.innerHTML = html;
}).catch(err => {
preview.classList.remove('loading');
preview.textContent = 'Error: ' + err.message;
});
}
_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');
for (const b of tab_btns) { b.classList.toggle('active', b.dataset.tab === 'edit'); }
for (const p of tab_panes) { p.hidden = p.dataset.tab !== 'edit'; }
this.on_save = on_save;
this.dialog.showModal();
}
}
let task_dialog;
// ---------------------------------------------------------------------------
// 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 — hidden in flat mode
const expand_btn = row.querySelector('.btn-expand');
if (!state.flat_mode && 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 mode_toggle = document.createElement('div');
mode_toggle.className = 'segmented-control';
for (const [val, label] of [[false, 'Tree'], [true, 'Flat']]) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = label;
btn.classList.toggle('active', state.flat_mode === val);
btn.addEventListener('click', () => { state.flat_mode = val; render(); });
mode_toggle.appendChild(btn);
}
toolbar.appendChild(mode_toggle);
const add_btn = document.createElement('button');
add_btn.className = 'btn-primary';
add_btn.textContent = '+ New task';
add_btn.addEventListener('click', () => open_add_dialog(null));
toolbar.appendChild(add_btn);
container.appendChild(toolbar);
// Filter bar
const filter_bar = document.createElement('div');
filter_bar.className = 'filter-bar';
const status_sel = document.createElement('select');
for (const [val, label] of [['', 'All statuses'], ['open', 'Open'], ['deferred', 'Deferred'], ['done', 'Done'], ['cancelled', 'Cancelled']]) {
const opt = document.createElement('option');
opt.value = val; opt.textContent = label;
if (val === state.filter_status) { opt.selected = true; }
status_sel.appendChild(opt);
}
status_sel.addEventListener('change', () => { state.filter_status = status_sel.value; render(); });
filter_bar.appendChild(status_sel);
const priority_sel = document.createElement('select');
for (const [val, label] of [['', 'All priorities'], ['high', 'High'], ['normal', 'Normal'], ['low', 'Low']]) {
const opt = document.createElement('option');
opt.value = val; opt.textContent = label;
if (val === state.filter_priority) { opt.selected = true; }
priority_sel.appendChild(opt);
}
priority_sel.addEventListener('change', () => { state.filter_priority = priority_sel.value; render(); });
filter_bar.appendChild(priority_sel);
const tag_sel = document.createElement('select');
const all_opt = document.createElement('option');
all_opt.value = ''; all_opt.textContent = 'All tags';
tag_sel.appendChild(all_opt);
for (const tag of all_tags()) {
const opt = document.createElement('option');
opt.value = tag; opt.textContent = tag;
if (tag === state.filter_tag) { opt.selected = true; }
tag_sel.appendChild(opt);
}
tag_sel.addEventListener('change', () => { state.filter_tag = tag_sel.value; render(); });
filter_bar.appendChild(tag_sel);
const search_input = document.createElement('input');
search_input.type = 'text';
search_input.placeholder = 'Search…';
search_input.value = state.search;
search_input.addEventListener('input', () => { state.search = search_input.value; render(); });
filter_bar.appendChild(search_input);
container.appendChild(filter_bar);
// Task list
const list = document.createElement('div');
list.className = 'task-list' + (state.flat_mode ? ' flat-mode' : '');
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);
}
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); }
}
}
container.appendChild(list);
}
function render() {
render_tasks(document.getElementById('main'));
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
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);
}
});
}
function open_edit_dialog(task) {
task_dialog.open('Edit task', task, async (data) => {
try {
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; }
md_cache.delete(task.title);
md_cache.delete(task.body);
render();
} catch (err) {
alert(err.message);
}
});
}
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);
render();
}).catch(err => alert(err.message));
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
async function init() {
const tmpl_res = await fetch('/templates.html');
const tmpl_html = await tmpl_res.text();
const tmpl_container = document.createElement('div');
tmpl_container.innerHTML = tmpl_html;
for (const tmpl of tmpl_container.querySelectorAll('template')) {
document.body.appendChild(tmpl);
}
task_picker = new Task_Picker_Dialog();
task_dialog = new Task_Dialog();
const { tasks } = await api.get_tasks();
state.tasks = tasks;
render();
}
init().catch(err => {
console.error('Init failed:', err);
document.getElementById('main').textContent = 'Failed to load: ' + err.message;
});