From 0a985c591b63ca9acb36d19e609b9deb58b7f487 Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sun, 17 May 2026 21:53:39 +0000 Subject: [PATCH] Refactor tasks to configurable entry types - Storage: nested object buckets per type, integrated next_id into meta, migration from old flat task:N key format, seeded default types (task/idea/note) - Server: /api/entry-types CRUD + /api/entries replacing /api/tasks, type validation, blocks deletion if entries exist - Frontend: dynamic nav per type, Entry_Type_Dialog, Entry_Dialog with type selector, type chip in All view, Manage Types view Co-Authored-By: Claude Sonnet 4.6 --- lib/storage.mjs | 147 +++++++++++-- public/app.mjs | 472 +++++++++++++++++++++++++++++++----------- public/index.html | 4 +- public/lib/api.mjs | 16 +- public/style.css | 95 +++++++++ public/templates.html | 46 +++- server.mjs | 94 ++++++--- 7 files changed, 699 insertions(+), 175 deletions(-) diff --git a/lib/storage.mjs b/lib/storage.mjs index 2faef73..2a69bcb 100644 --- a/lib/storage.mjs +++ b/lib/storage.mjs @@ -9,35 +9,144 @@ const store = new Simple_KeyValue_Store('./data/tasks.ndjson', { debounce_flush_timeout: 5000, }); -// --- Tasks --- +// --------------------------------------------------------------------------- +// Migration from old flat-key format (task:1, task:2, ...) +// --------------------------------------------------------------------------- -export function list_tasks() { - const result = []; +function migrate_if_needed() { + const old_keys = []; for (const [key] of store.data.entries()) { - if (key.startsWith('task:')) { - result.push(store.get(key)); - } + if (key.startsWith('task:')) { old_keys.push(key); } + } + if (!old_keys.length) { return; } + + console.log(`[storage] Migrating ${old_keys.length} old flat-key entries...`); + + const task_bucket = store.get('task') ?? {}; + let max_id = store.get('meta')?.next_id ?? 0; + + for (const key of old_keys) { + const entry = store.get(key); + task_bucket[entry.id] = { ...entry, type: 'task' }; + if (typeof entry.id === 'number' && entry.id > max_id) { max_id = entry.id; } + store.data.delete(key); + } + + store.set('task', task_bucket); + + // Absorb old ids.ndjson counter if present + try { + const ids_store = new Simple_KeyValue_Store('./data/ids.ndjson', { auto_load: true }); + const old_counter = ids_store.get('seq:task'); + if (old_counter && old_counter > max_id) { max_id = old_counter; } + } catch {} + + store.set('meta', { ...(store.get('meta') ?? {}), next_id: max_id }); + store.store(); + console.log(`[storage] Migration complete. next_id=${max_id}`); +} + +// --------------------------------------------------------------------------- +// Seed default entry types on a fresh store +// --------------------------------------------------------------------------- + +function seed_defaults() { + const now = Date.now(); + store.set('entry_types', { + task: { id: 'task', title: 'Task', description: 'A unit of work to be completed.', created_at: now, updated_at: now }, + idea: { id: 'idea', title: 'Idea', description: 'A thought, concept, or possibility to explore.', created_at: now, updated_at: now }, + note: { id: 'note', title: 'Note', description: 'A reference, observation, or piece of information.', created_at: now, updated_at: now }, + }); + if (!store.get('task')) { store.set('task', {}); } + if (!store.get('idea')) { store.set('idea', {}); } + if (!store.get('note')) { store.set('note', {}); } + store.store(); +} + +migrate_if_needed(); +if (!store.get('entry_types')) { seed_defaults(); } +if (!store.get('meta')) { store.set('meta', { next_id: 0 }); } + +// --------------------------------------------------------------------------- +// ID generation +// --------------------------------------------------------------------------- + +export function next_id() { + const meta = store.get('meta') ?? { next_id: 0 }; + meta.next_id = (meta.next_id ?? 0) + 1; + store.set('meta', meta); + return meta.next_id; +} + +// --------------------------------------------------------------------------- +// Entry types +// --------------------------------------------------------------------------- + +export function list_entry_types() { + return Object.values(store.get('entry_types') ?? {}); +} + +export function get_entry_type(id) { + return (store.get('entry_types') ?? {})[id] ?? null; +} + +export function set_entry_type(type) { + const types = store.get('entry_types') ?? {}; + types[type.id] = type; + store.set('entry_types', types); + if (!store.get(type.id)) { store.set(type.id, {}); } +} + +export function delete_entry_type(id) { + const types = store.get('entry_types') ?? {}; + if (!types[id]) { return false; } + delete types[id]; + store.set('entry_types', types); + return true; +} + +// --------------------------------------------------------------------------- +// Entries +// --------------------------------------------------------------------------- + +export function list_entries(type = null) { + const type_keys = type ? [type] : Object.keys(store.get('entry_types') ?? {}); + const result = []; + for (const t of type_keys) { + result.push(...Object.values(store.get(t) ?? {})); } return result.sort((a, b) => b.created_at - a.created_at); } -export function get_task(id) { - return store.get(`task:${id}`) ?? null; +export function get_entry(id) { + for (const t of Object.keys(store.get('entry_types') ?? {})) { + const entry = (store.get(t) ?? {})[id]; + if (entry) { return entry; } + } + return null; } -export function set_task(task) { - store.set(`task:${task.id}`, task); +export function set_entry(entry) { + const bucket = store.get(entry.type) ?? {}; + bucket[entry.id] = entry; + store.set(entry.type, bucket); } -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; } +export function delete_entry(id) { + for (const t of Object.keys(store.get('entry_types') ?? {})) { + const bucket = store.get(t); + if (bucket?.[id]) { + delete bucket[id]; + store.set(t, bucket); + return true; + } + } + return false; +} + +export function has_child_entries(parent_id) { + for (const t of Object.keys(store.get('entry_types') ?? {})) { + if (Object.values(store.get(t) ?? {}).some(e => e.parent_id === parent_id)) { return true; } } return false; } diff --git a/public/app.mjs b/public/app.mjs index e3bf0d6..448add5 100644 --- a/public/app.mjs +++ b/public/app.mjs @@ -7,7 +7,10 @@ import { qs, clone, set_text, show, hide } from './lib/dom.mjs'; class App_State { constructor() { - this.tasks = []; + this.entry_types = []; + this.entries = []; + this.active_type = null; // null = all types + this.active_view = 'entries'; // 'entries' | 'manage-types' this.filter_status = 'open'; this.filter_priority = ''; this.filter_tag = ''; @@ -46,54 +49,55 @@ function fill_markdown(el, text) { function build_children_map() { const map = new Map(); - for (const task of state.tasks) { - const pid = task.parent_id ?? null; + for (const entry of state.entries) { + const pid = entry.parent_id ?? null; if (!map.has(pid)) { map.set(pid, []); } - map.get(pid).push(task); + map.get(pid).push(entry); } return map; } -function get_descendants(task_id) { +function get_descendants(entry_id) { const result = new Set(); - const queue = state.tasks.filter(t => t.parent_id === task_id); + const queue = state.entries.filter(e => e.parent_id === entry_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); } + const e = queue.shift(); + result.add(e.id); + for (const child of state.entries.filter(c => c.parent_id === e.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; } +function entry_matches_filter(entry) { + if (state.active_type && entry.type !== state.active_type) { return false; } + if (state.filter_status && entry.status !== state.filter_status) { return false; } + if (state.filter_priority && entry.priority !== state.filter_priority) { return false; } + if (state.filter_tag && !entry.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; } + if (!entry.title.toLowerCase().includes(q) && !entry.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 node_or_descendants_match(entry, children_map) { + if (entry_matches_filter(entry)) { return true; } + return (children_map.get(entry.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 e of state.entries) { for (const tag of e.tags) { tags.add(tag); } } return [...tags].sort(); } // --------------------------------------------------------------------------- -// Task picker dialog +// Entry picker dialog // --------------------------------------------------------------------------- -class Task_Picker_Dialog { +class Entry_Picker_Dialog { constructor() { - const el = clone('t-task-picker'); + const el = clone('t-entry-picker'); document.body.appendChild(el); this.dialog = el; this.on_pick = null; @@ -109,18 +113,18 @@ class Task_Picker_Dialog { 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.filter(e => e.title.toLowerCase().includes(q) || String(e.id).includes(q)) : this._available; if (!filtered.length) { - list.textContent = 'No tasks.'; + list.textContent = 'No entries.'; return; } - for (const task of filtered) { + for (const entry of filtered) { const item = document.createElement('div'); item.className = 'picker-item'; - item.textContent = `#${task.id} ${task.title}`; + item.textContent = `#${entry.id} [${entry.type}] ${entry.title}`; item.addEventListener('click', () => { - if (this.on_pick) { this.on_pick(task.id); } + if (this.on_pick) { this.on_pick(entry.id); } this.dialog.close(); }); list.appendChild(item); @@ -128,7 +132,7 @@ class Task_Picker_Dialog { } open(excluded_ids, on_pick) { - this._available = state.tasks.filter(t => !excluded_ids.has(t.id)); + this._available = state.entries.filter(e => !excluded_ids.has(e.id)); this.on_pick = on_pick; const search = this.dialog.querySelector('.picker-search'); search.value = ''; @@ -137,19 +141,20 @@ class Task_Picker_Dialog { } } -let task_picker; +let entry_picker; // --------------------------------------------------------------------------- -// Task dialog +// Entry dialog // --------------------------------------------------------------------------- -class Task_Dialog { +class Entry_Dialog { constructor() { - const el = clone('t-task-dialog'); + const el = clone('t-entry-dialog'); document.body.appendChild(el); this.dialog = el; this.form = el.querySelector('form'); this.on_save = null; + this._editing_id = null; // Tab switching const tab_btns = el.querySelectorAll('.tab-btn'); @@ -166,19 +171,13 @@ 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); + if (current_id) { excluded.add(current_id); } + entry_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(); @@ -192,7 +191,7 @@ class Task_Dialog { 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); + const parent = state.entries.find(e => e.id === parent_id); hidden.value = parent_id; display.textContent = parent ? `#${parent.id} ${parent.title}` : `#${parent_id}`; show(clear_btn); @@ -223,97 +222,188 @@ class Task_Dialog { 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) : [], + type: fd.get('type'), + 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) { + // fixed_type: string — type is pre-set and shown as label; null — show select + open(title_text, initial = {}, fixed_type, on_save) { this._editing_id = initial.id ?? null; set_text(this.dialog, '.dialog-title', title_text); + + const type_display = this.dialog.querySelector('.type-display'); + const type_select = this.dialog.querySelector('.type-select'); + + // Always populate select options + type_select.innerHTML = ''; + for (const et of state.entry_types) { + const opt = document.createElement('option'); + opt.value = et.id; + opt.textContent = et.title; + type_select.appendChild(opt); + } + type_select.value = fixed_type ?? initial.type ?? (state.entry_types[0]?.id ?? ''); + + if (fixed_type) { + const et = state.entry_types.find(t => t.id === fixed_type); + type_display.textContent = et ? et.title : fixed_type; + show(type_display); + hide(type_select); + } else { + hide(type_display); + show(type_select); + } + 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'; } + for (const b of this.dialog.querySelectorAll('.tab-btn')) { b.classList.toggle('active', b.dataset.tab === 'edit'); } + for (const p of this.dialog.querySelectorAll('.tab-pane')) { p.hidden = p.dataset.tab !== 'edit'; } + this.on_save = on_save; this.dialog.showModal(); } } -let task_dialog; +let entry_dialog; + +// --------------------------------------------------------------------------- +// Entry type dialog +// --------------------------------------------------------------------------- + +class Entry_Type_Dialog { + constructor() { + const el = clone('t-entry-type-dialog'); + document.body.appendChild(el); + this.dialog = el; + this.form = el.querySelector('form'); + this.on_save = 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(); + }); + } + + _read_form() { + const fd = new FormData(this.form); + return { + id: fd.get('id')?.trim(), + title: fd.get('title').trim(), + description: fd.get('description').trim(), + }; + } + + open(initial, on_save) { + const is_new = !initial?.id; + set_text(this.dialog, '.dialog-title', is_new ? 'New Entry Type' : 'Edit Entry Type'); + + const id_label = this.dialog.querySelector('.id-label'); + const id_display = this.dialog.querySelector('.id-display-row'); + + if (is_new) { + show(id_label); + hide(id_display); + this.form.elements['id'].value = ''; + this.form.elements['id'].required = true; + } else { + hide(id_label); + this.dialog.querySelector('.id-display-value').textContent = initial.id; + show(id_display); + this.form.elements['id'].required = false; + } + + this.form.elements['title'].value = initial?.title ?? ''; + this.form.elements['description'].value = initial?.description ?? ''; + this.on_save = on_save; + this.dialog.showModal(); + } +} + +let entry_type_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; +function make_entry_row(entry, children_map) { + const children = children_map.get(entry.id) ?? []; + const row = clone('t-entry-row'); + row.dataset.priority = entry.priority; + row.dataset.status = entry.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.textContent = state.expanded.has(entry.id) ? '▼' : '▶'; expand_btn.addEventListener('click', () => { - if (state.expanded.has(task.id)) { state.expanded.delete(task.id); } - else { state.expanded.add(task.id); } + if (state.expanded.has(entry.id)) { state.expanded.delete(entry.id); } + else { state.expanded.add(entry.id); } render(); }); } - set_text(row, '.task-id', `#${task.id}`); + set_text(row, '.task-id', `#${entry.id}`); + + // Type chip — only shown in "All" view + const type_chip = row.querySelector('.entry-type-chip'); + if (!state.active_type) { + const et = state.entry_types.find(t => t.id === entry.type); + type_chip.textContent = et ? et.title : entry.type; + show(type_chip); + } const status_el = row.querySelector('.task-status'); - status_el.textContent = task.status; - status_el.dataset.val = task.status; + status_el.textContent = entry.status; + status_el.dataset.val = entry.status; const priority_el = row.querySelector('.task-priority'); - priority_el.textContent = task.priority; - priority_el.dataset.val = task.priority; + priority_el.textContent = entry.priority; + priority_el.dataset.val = entry.priority; - fill_markdown(row.querySelector('.task-title'), task.title); + fill_markdown(row.querySelector('.task-title'), entry.title); const body_el = row.querySelector('.task-body'); - if (task.body) { show(body_el); fill_markdown(body_el, task.body); } + if (entry.body) { show(body_el); fill_markdown(body_el, entry.body); } const tags_el = row.querySelector('.task-tags'); - for (const tag of task.tags) { + for (const tag of entry.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)); + row.querySelector('.btn-sub').addEventListener('click', () => open_add_dialog(entry.id, entry.type)); + row.querySelector('.btn-edit').addEventListener('click', () => open_edit_dialog(entry)); + row.querySelector('.btn-delete').addEventListener('click', () => confirm_delete(entry)); return row; } -function render_tree_node(task, children_map) { - if (!node_or_descendants_match(task, children_map)) { return null; } +function render_tree_node(entry, children_map) { + if (!node_or_descendants_match(entry, children_map)) { return null; } - const children = children_map.get(task.id) ?? []; + const children = children_map.get(entry.id) ?? []; const node = document.createElement('div'); node.className = 'task-node'; - node.appendChild(make_task_row(task, children_map)); + node.appendChild(make_entry_row(entry, children_map)); - if (children.length > 0 && state.expanded.has(task.id)) { + if (children.length > 0 && state.expanded.has(entry.id)) { const children_el = document.createElement('div'); children_el.className = 'task-children'; for (const child of children) { @@ -326,20 +416,40 @@ function render_tree_node(task, children_map) { 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); +// --------------------------------------------------------------------------- +// Nav +// --------------------------------------------------------------------------- + +function render_nav() { + const nav = document.getElementById('main-nav'); + nav.innerHTML = ''; + + const all_btn = document.createElement('button'); + all_btn.className = 'nav-btn' + (state.active_view === 'entries' && state.active_type === null ? ' active' : ''); + all_btn.textContent = 'All'; + all_btn.addEventListener('click', () => { state.active_view = 'entries'; state.active_type = null; render(); }); + nav.appendChild(all_btn); + + for (const et of state.entry_types) { + const btn = document.createElement('button'); + btn.className = 'nav-btn' + (state.active_view === 'entries' && state.active_type === et.id ? ' active' : ''); + btn.textContent = et.title; + btn.addEventListener('click', () => { state.active_view = 'entries'; state.active_type = et.id; render(); }); + nav.appendChild(btn); } - return result; + + const manage_btn = document.createElement('button'); + manage_btn.className = 'nav-btn' + (state.active_view === 'manage-types' ? ' active' : ''); + manage_btn.textContent = 'Manage Types'; + manage_btn.addEventListener('click', () => { state.active_view = 'manage-types'; render(); }); + nav.appendChild(manage_btn); } // --------------------------------------------------------------------------- -// Main render +// Entries view // --------------------------------------------------------------------------- -function render_tasks(container) { +function render_entries(container) { container.innerHTML = ''; const children_map = build_children_map(); @@ -348,7 +458,15 @@ function render_tasks(container) { // Toolbar const toolbar = document.createElement('div'); toolbar.className = 'toolbar'; - toolbar.innerHTML = '

Tasks

'; + + const h2 = document.createElement('h2'); + if (state.active_type) { + const et = state.entry_types.find(t => t.id === state.active_type); + h2.textContent = et ? et.title + 's' : state.active_type; + } else { + h2.textContent = 'All Entries'; + } + toolbar.appendChild(h2); const mode_toggle = document.createElement('div'); mode_toggle.className = 'segmented-control'; @@ -362,10 +480,11 @@ function render_tasks(container) { } toolbar.appendChild(mode_toggle); + const et_active = state.entry_types.find(t => t.id === state.active_type); const add_btn = document.createElement('button'); add_btn.className = 'btn-primary'; - add_btn.textContent = '+ New task'; - add_btn.addEventListener('click', () => open_add_dialog(null)); + add_btn.textContent = et_active ? `+ New ${et_active.title.toLowerCase()}` : '+ New entry'; + add_btn.addEventListener('click', () => open_add_dialog(null, state.active_type)); toolbar.appendChild(add_btn); container.appendChild(toolbar); @@ -415,27 +534,27 @@ function render_tasks(container) { container.appendChild(filter_bar); - // Task list + // Entry 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); + const flat = state.entries.filter(e => entry_matches_filter(e)); if (!flat.length) { const empty = document.createElement('div'); empty.className = 'empty-state'; - empty.textContent = 'No tasks.'; + empty.textContent = 'No entries.'; list.appendChild(empty); } - for (const { task } of flat) { - list.appendChild(make_task_row(task, children_map)); + for (const entry of flat) { + list.appendChild(make_entry_row(entry, children_map)); } } else { - const visible = roots.filter(t => node_or_descendants_match(t, children_map)); + const visible = roots.filter(e => node_or_descendants_match(e, children_map)); if (!visible.length) { const empty = document.createElement('div'); empty.className = 'empty-state'; - empty.textContent = 'No tasks.'; + empty.textContent = 'No entries.'; list.appendChild(empty); } for (const root of visible) { @@ -447,19 +566,135 @@ function render_tasks(container) { container.appendChild(list); } +// --------------------------------------------------------------------------- +// Manage types view +// --------------------------------------------------------------------------- + +function render_manage_types(container) { + container.innerHTML = ''; + + const toolbar = document.createElement('div'); + toolbar.className = 'toolbar'; + const h2 = document.createElement('h2'); + h2.textContent = 'Entry Types'; + toolbar.appendChild(h2); + + const add_btn = document.createElement('button'); + add_btn.className = 'btn-primary'; + add_btn.textContent = '+ New Type'; + add_btn.addEventListener('click', () => { + entry_type_dialog.open(null, async (data) => { + try { + const { entry_type } = await api.create_entry_type(data); + state.entry_types.push(entry_type); + render(); + } catch (err) { alert(err.message); } + }); + }); + toolbar.appendChild(add_btn); + container.appendChild(toolbar); + + if (!state.entry_types.length) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.textContent = 'No entry types defined.'; + container.appendChild(empty); + return; + } + + const list = document.createElement('div'); + list.className = 'type-list'; + + for (const et of state.entry_types) { + const row = document.createElement('div'); + row.className = 'type-row'; + + const info = document.createElement('div'); + info.className = 'type-info'; + + const id_span = document.createElement('code'); + id_span.className = 'type-id'; + id_span.textContent = et.id; + info.appendChild(id_span); + + const title_span = document.createElement('span'); + title_span.className = 'type-title'; + title_span.textContent = et.title; + info.appendChild(title_span); + + if (et.description) { + const desc = document.createElement('span'); + desc.className = 'type-desc'; + desc.textContent = et.description; + info.appendChild(desc); + } + + row.appendChild(info); + + const actions = document.createElement('div'); + actions.className = 'task-actions'; + + const edit_btn = document.createElement('button'); + edit_btn.className = 'btn-edit'; + edit_btn.textContent = 'Edit'; + edit_btn.addEventListener('click', () => { + entry_type_dialog.open(et, async (data) => { + try { + const { entry_type } = await api.update_entry_type(et.id, data); + const idx = state.entry_types.findIndex(t => t.id === et.id); + if (idx !== -1) { state.entry_types[idx] = entry_type; } + render(); + } catch (err) { alert(err.message); } + }); + }); + actions.appendChild(edit_btn); + + const del_btn = document.createElement('button'); + del_btn.className = 'btn-delete'; + del_btn.textContent = 'Delete'; + del_btn.addEventListener('click', async () => { + if (!confirm(`Delete entry type "${et.title}" (id: ${et.id})?`)) { return; } + try { + await api.delete_entry_type(et.id); + state.entry_types = state.entry_types.filter(t => t.id !== et.id); + if (state.active_type === et.id) { state.active_type = null; } + render(); + } catch (err) { alert(err.message); } + }); + actions.appendChild(del_btn); + + row.appendChild(actions); + list.appendChild(row); + } + + container.appendChild(list); +} + +// --------------------------------------------------------------------------- +// Main render +// --------------------------------------------------------------------------- + function render() { - render_tasks(document.getElementById('main')); + render_nav(); + const main = document.getElementById('main'); + if (state.active_view === 'manage-types') { + render_manage_types(main); + } else { + render_entries(main); + } } // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- -function open_add_dialog(parent_id) { - task_dialog.open('New task', { parent_id }, async (data) => { +function open_add_dialog(parent_id, type_id) { + // If no type given and only one type exists, auto-select it + const resolved = type_id ?? (state.entry_types.length === 1 ? state.entry_types[0].id : null); + entry_dialog.open('New entry', { parent_id }, resolved, async (data) => { try { - const { task } = await api.create_task(data); - state.tasks.unshift(task); + const { entry } = await api.create_entry(data); + state.entries.unshift(entry); if (parent_id) { state.expanded.add(parent_id); } render(); } catch (err) { @@ -468,14 +703,14 @@ function open_add_dialog(parent_id) { }); } -function open_edit_dialog(task) { - task_dialog.open('Edit task', task, async (data) => { +function open_edit_dialog(entry) { + entry_dialog.open('Edit entry', entry, entry.type, 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); + const { entry: updated } = await api.update_entry(entry.id, data); + const idx = state.entries.findIndex(e => e.id === entry.id); + if (idx !== -1) { state.entries[idx] = updated; } + md_cache.delete(entry.title); + md_cache.delete(entry.body); render(); } catch (err) { alert(err.message); @@ -483,15 +718,15 @@ function open_edit_dialog(task) { }); } -function confirm_delete(task) { - const children_count = state.tasks.filter(t => t.parent_id === task.id).length; +function confirm_delete(entry) { + const children_count = state.entries.filter(e => e.parent_id === entry.id).length; if (children_count > 0) { - alert(`Cannot delete #${task.id}: it has ${children_count} subtask(s). Delete or reparent them first.`); + alert(`Cannot delete #${entry.id}: it has ${children_count} child entr${children_count === 1 ? 'y' : 'ies'}. 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); + if (!confirm(`Delete #${entry.id}: "${entry.title}"?`)) { return; } + api.delete_entry(entry.id).then(() => { + state.entries = state.entries.filter(e => e.id !== entry.id); render(); }).catch(err => alert(err.message)); } @@ -509,11 +744,16 @@ async function init() { document.body.appendChild(tmpl); } - task_picker = new Task_Picker_Dialog(); - task_dialog = new Task_Dialog(); + entry_picker = new Entry_Picker_Dialog(); + entry_dialog = new Entry_Dialog(); + entry_type_dialog = new Entry_Type_Dialog(); - const { tasks } = await api.get_tasks(); - state.tasks = tasks; + const [types_res, entries_res] = await Promise.all([ + api.get_entry_types(), + api.get_entries(), + ]); + state.entry_types = types_res.entry_types; + state.entries = entries_res.entries; render(); } diff --git a/public/index.html b/public/index.html index f0fea3c..53e5cbd 100644 --- a/public/index.html +++ b/public/index.html @@ -10,9 +10,7 @@

Task Inventory

- +
diff --git a/public/lib/api.mjs b/public/lib/api.mjs index deff753..15b6dc0 100644 --- a/public/lib/api.mjs +++ b/public/lib/api.mjs @@ -10,8 +10,14 @@ async function req(method, path, body) { return data; } -export const get_tasks = () => req('GET', '/api/tasks'); -export const create_task = (body) => req('POST', '/api/tasks', body); -export const update_task = (id, body) => req('PUT', `/api/tasks/${id}`, body); -export const delete_task = (id) => req('DELETE', `/api/tasks/${id}`); -export const render_markdown = (text) => req('POST', '/api/render-markdown', { text }); +export const get_entry_types = () => req('GET', '/api/entry-types'); +export const create_entry_type = (body) => req('POST', '/api/entry-types', body); +export const update_entry_type = (id, body) => req('PUT', `/api/entry-types/${id}`, body); +export const delete_entry_type = (id) => req('DELETE', `/api/entry-types/${id}`); + +export const get_entries = (type) => req('GET', type ? `/api/entries?type=${type}` : '/api/entries'); +export const create_entry = (body) => req('POST', '/api/entries', body); +export const update_entry = (id, body) => req('PUT', `/api/entries/${id}`, body); +export const delete_entry = (id) => req('DELETE', `/api/entries/${id}`); + +export const render_markdown = (text) => req('POST', '/api/render-markdown', { text }); diff --git a/public/style.css b/public/style.css index c36c602..b9f0ee9 100644 --- a/public/style.css +++ b/public/style.css @@ -397,3 +397,98 @@ dialog input:focus, dialog textarea:focus, dialog select:focus { } .preview-content.loading { color: #555; font-style: italic; } + +/* Entry type chip (shown in All view) */ +.entry-type-chip { + font-size: 10px; + padding: 0.1rem 0.35rem; + background: #1e2a3a; + border: 1px solid #2a4060; + border-radius: 3px; + color: #5588cc; + margin-bottom: 0.2rem; + display: inline-block; + width: fit-content; +} + +/* Type section in entry dialog */ +.type-section { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + font-size: 12px; +} + +.type-section-label { color: #999; min-width: 3rem; } + +.type-section .type-display { + color: #ccc; + font-size: 13px; +} + +.type-section .type-select { + padding: 0.3rem 0.5rem; + background: #1a1a1a; + border: 1px solid #444; + border-radius: 4px; + color: #e0e0e0; + font-size: 13px; +} + +/* Manage types view */ +.type-list { + display: flex; + flex-direction: column; + gap: 1px; +} + +.type-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + background: #242424; + border-radius: 4px; +} + +.type-info { + flex: 1; + display: flex; + align-items: baseline; + gap: 0.75rem; + flex-wrap: wrap; +} + +.type-id { + font-family: monospace; + font-size: 12px; + color: #777; + background: #1a1a1a; + padding: 0.1rem 0.35rem; + border-radius: 3px; +} + +.type-title { + font-weight: 500; + font-size: 14px; +} + +.type-desc { + font-size: 12px; + color: #888; +} + +/* Entry type dialog */ +.entry-type-dialog { width: 420px; max-width: 95vw; } + +.id-display-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + font-size: 12px; +} + +.id-display-label { color: #999; min-width: 3rem; } +.id-display-value { font-size: 13px; color: #ccc; } diff --git a/public/templates.html b/public/templates.html index c05451f..a266d02 100644 --- a/public/templates.html +++ b/public/templates.html @@ -1,11 +1,12 @@ - -