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 <noreply@anthropic.com>
This commit is contained in:
147
lib/storage.mjs
147
lib/storage.mjs
@@ -9,35 +9,144 @@ const store = new Simple_KeyValue_Store('./data/tasks.ndjson', {
|
|||||||
debounce_flush_timeout: 5000,
|
debounce_flush_timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Tasks ---
|
// ---------------------------------------------------------------------------
|
||||||
|
// Migration from old flat-key format (task:1, task:2, ...)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function list_tasks() {
|
function migrate_if_needed() {
|
||||||
const result = [];
|
const old_keys = [];
|
||||||
for (const [key] of store.data.entries()) {
|
for (const [key] of store.data.entries()) {
|
||||||
if (key.startsWith('task:')) {
|
if (key.startsWith('task:')) { old_keys.push(key); }
|
||||||
result.push(store.get(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);
|
return result.sort((a, b) => b.created_at - a.created_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get_task(id) {
|
export function get_entry(id) {
|
||||||
return store.get(`task:${id}`) ?? null;
|
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) {
|
export function set_entry(entry) {
|
||||||
store.set(`task:${task.id}`, task);
|
const bucket = store.get(entry.type) ?? {};
|
||||||
|
bucket[entry.id] = entry;
|
||||||
|
store.set(entry.type, bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function delete_task(id) {
|
export function delete_entry(id) {
|
||||||
return store.delete(`task:${id}`);
|
for (const t of Object.keys(store.get('entry_types') ?? {})) {
|
||||||
}
|
const bucket = store.get(t);
|
||||||
|
if (bucket?.[id]) {
|
||||||
export function has_child_tasks(parent_id) {
|
delete bucket[id];
|
||||||
for (const [key] of store.data.entries()) {
|
store.set(t, bucket);
|
||||||
if (!key.startsWith('task:')) { continue; }
|
return true;
|
||||||
const task = store.get(key);
|
}
|
||||||
if (task.parent_id === parent_id) { 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
472
public/app.mjs
472
public/app.mjs
@@ -7,7 +7,10 @@ import { qs, clone, set_text, show, hide } from './lib/dom.mjs';
|
|||||||
|
|
||||||
class App_State {
|
class App_State {
|
||||||
constructor() {
|
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_status = 'open';
|
||||||
this.filter_priority = '';
|
this.filter_priority = '';
|
||||||
this.filter_tag = '';
|
this.filter_tag = '';
|
||||||
@@ -46,54 +49,55 @@ function fill_markdown(el, text) {
|
|||||||
|
|
||||||
function build_children_map() {
|
function build_children_map() {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const task of state.tasks) {
|
for (const entry of state.entries) {
|
||||||
const pid = task.parent_id ?? null;
|
const pid = entry.parent_id ?? null;
|
||||||
if (!map.has(pid)) { map.set(pid, []); }
|
if (!map.has(pid)) { map.set(pid, []); }
|
||||||
map.get(pid).push(task);
|
map.get(pid).push(entry);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
function get_descendants(task_id) {
|
function get_descendants(entry_id) {
|
||||||
const result = new Set();
|
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) {
|
while (queue.length) {
|
||||||
const t = queue.shift();
|
const e = queue.shift();
|
||||||
result.add(t.id);
|
result.add(e.id);
|
||||||
for (const child of state.tasks.filter(c => c.parent_id === t.id)) { queue.push(child); }
|
for (const child of state.entries.filter(c => c.parent_id === e.id)) { queue.push(child); }
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function task_matches_filter(task) {
|
function entry_matches_filter(entry) {
|
||||||
if (state.filter_status && task.status !== state.filter_status) { return false; }
|
if (state.active_type && entry.type !== state.active_type) { return false; }
|
||||||
if (state.filter_priority && task.priority !== state.filter_priority) { return false; }
|
if (state.filter_status && entry.status !== state.filter_status) { return false; }
|
||||||
if (state.filter_tag && !task.tags.includes(state.filter_tag)) { 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) {
|
if (state.search) {
|
||||||
const q = state.search.toLowerCase();
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function node_or_descendants_match(task, children_map) {
|
function node_or_descendants_match(entry, children_map) {
|
||||||
if (task_matches_filter(task)) { return true; }
|
if (entry_matches_filter(entry)) { return true; }
|
||||||
return (children_map.get(task.id) ?? []).some(c => node_or_descendants_match(c, children_map));
|
return (children_map.get(entry.id) ?? []).some(c => node_or_descendants_match(c, children_map));
|
||||||
}
|
}
|
||||||
|
|
||||||
function all_tags() {
|
function all_tags() {
|
||||||
const tags = new Set();
|
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();
|
return [...tags].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Task picker dialog
|
// Entry picker dialog
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class Task_Picker_Dialog {
|
class Entry_Picker_Dialog {
|
||||||
constructor() {
|
constructor() {
|
||||||
const el = clone('t-task-picker');
|
const el = clone('t-entry-picker');
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
this.dialog = el;
|
this.dialog = el;
|
||||||
this.on_pick = null;
|
this.on_pick = null;
|
||||||
@@ -109,18 +113,18 @@ class Task_Picker_Dialog {
|
|||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
const filtered = q
|
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;
|
: this._available;
|
||||||
if (!filtered.length) {
|
if (!filtered.length) {
|
||||||
list.textContent = 'No tasks.';
|
list.textContent = 'No entries.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const task of filtered) {
|
for (const entry of filtered) {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'picker-item';
|
item.className = 'picker-item';
|
||||||
item.textContent = `#${task.id} ${task.title}`;
|
item.textContent = `#${entry.id} [${entry.type}] ${entry.title}`;
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
if (this.on_pick) { this.on_pick(task.id); }
|
if (this.on_pick) { this.on_pick(entry.id); }
|
||||||
this.dialog.close();
|
this.dialog.close();
|
||||||
});
|
});
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
@@ -128,7 +132,7 @@ class Task_Picker_Dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
open(excluded_ids, on_pick) {
|
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;
|
this.on_pick = on_pick;
|
||||||
const search = this.dialog.querySelector('.picker-search');
|
const search = this.dialog.querySelector('.picker-search');
|
||||||
search.value = '';
|
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() {
|
constructor() {
|
||||||
const el = clone('t-task-dialog');
|
const el = clone('t-entry-dialog');
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
this.dialog = el;
|
this.dialog = el;
|
||||||
this.form = el.querySelector('form');
|
this.form = el.querySelector('form');
|
||||||
this.on_save = null;
|
this.on_save = null;
|
||||||
|
this._editing_id = null;
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
const tab_btns = el.querySelectorAll('.tab-btn');
|
const tab_btns = el.querySelectorAll('.tab-btn');
|
||||||
@@ -166,19 +171,13 @@ class Task_Dialog {
|
|||||||
// Parent controls
|
// Parent controls
|
||||||
el.querySelector('.btn-set-parent').addEventListener('click', () => {
|
el.querySelector('.btn-set-parent').addEventListener('click', () => {
|
||||||
const current_id = Number(this.form.elements['parent_id'].value) || null;
|
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 self_id = this._editing_id;
|
||||||
const excluded = self_id ? new Set([self_id, ...get_descendants(self_id)]) : new Set();
|
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
|
if (current_id) { excluded.add(current_id); }
|
||||||
task_picker.open(excluded, (picked_id) => {
|
entry_picker.open(excluded, (picked_id) => { this._set_parent(picked_id); });
|
||||||
this._set_parent(picked_id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
el.querySelector('.btn-clear-parent').addEventListener('click', () => {
|
|
||||||
this._set_parent(null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
el.querySelector('.btn-clear-parent').addEventListener('click', () => { this._set_parent(null); });
|
||||||
el.querySelector('.btn-cancel').addEventListener('click', () => el.close());
|
el.querySelector('.btn-cancel').addEventListener('click', () => el.close());
|
||||||
this.form.addEventListener('submit', (e) => {
|
this.form.addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -192,7 +191,7 @@ class Task_Dialog {
|
|||||||
const display = this.dialog.querySelector('.parent-display');
|
const display = this.dialog.querySelector('.parent-display');
|
||||||
const clear_btn = this.dialog.querySelector('.btn-clear-parent');
|
const clear_btn = this.dialog.querySelector('.btn-clear-parent');
|
||||||
if (parent_id) {
|
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;
|
hidden.value = parent_id;
|
||||||
display.textContent = parent ? `#${parent.id} ${parent.title}` : `#${parent_id}`;
|
display.textContent = parent ? `#${parent.id} ${parent.title}` : `#${parent_id}`;
|
||||||
show(clear_btn);
|
show(clear_btn);
|
||||||
@@ -223,97 +222,188 @@ class Task_Dialog {
|
|||||||
const tags_raw = fd.get('tags').trim();
|
const tags_raw = fd.get('tags').trim();
|
||||||
const parent_id_raw = fd.get('parent_id');
|
const parent_id_raw = fd.get('parent_id');
|
||||||
return {
|
return {
|
||||||
title: fd.get('title').trim(),
|
type: fd.get('type'),
|
||||||
body: fd.get('body').trim(),
|
title: fd.get('title').trim(),
|
||||||
status: fd.get('status'),
|
body: fd.get('body').trim(),
|
||||||
priority: fd.get('priority'),
|
status: fd.get('status'),
|
||||||
tags: tags_raw ? tags_raw.split(',').map(s => s.trim()).filter(Boolean) : [],
|
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,
|
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;
|
this._editing_id = initial.id ?? null;
|
||||||
set_text(this.dialog, '.dialog-title', title_text);
|
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['title'].value = initial.title ?? '';
|
||||||
this.form.elements['body'].value = initial.body ?? '';
|
this.form.elements['body'].value = initial.body ?? '';
|
||||||
this.form.elements['status'].value = initial.status ?? 'open';
|
this.form.elements['status'].value = initial.status ?? 'open';
|
||||||
this.form.elements['priority'].value = initial.priority ?? 'normal';
|
this.form.elements['priority'].value = initial.priority ?? 'normal';
|
||||||
this.form.elements['tags'].value = (initial.tags ?? []).join(', ');
|
this.form.elements['tags'].value = (initial.tags ?? []).join(', ');
|
||||||
this._set_parent(initial.parent_id ?? null);
|
this._set_parent(initial.parent_id ?? null);
|
||||||
|
|
||||||
// Always open on edit tab
|
// Always open on edit tab
|
||||||
const tab_btns = this.dialog.querySelectorAll('.tab-btn');
|
for (const b of this.dialog.querySelectorAll('.tab-btn')) { b.classList.toggle('active', b.dataset.tab === 'edit'); }
|
||||||
const tab_panes = this.dialog.querySelectorAll('.tab-pane');
|
for (const p of this.dialog.querySelectorAll('.tab-pane')) { p.hidden = p.dataset.tab !== 'edit'; }
|
||||||
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.on_save = on_save;
|
||||||
this.dialog.showModal();
|
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
|
// Render helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function make_task_row(task, children_map) {
|
function make_entry_row(entry, children_map) {
|
||||||
const children = children_map.get(task.id) ?? [];
|
const children = children_map.get(entry.id) ?? [];
|
||||||
const row = clone('t-task-row');
|
const row = clone('t-entry-row');
|
||||||
row.dataset.priority = task.priority;
|
row.dataset.priority = entry.priority;
|
||||||
row.dataset.status = task.status;
|
row.dataset.status = entry.status;
|
||||||
|
|
||||||
// Expand button — hidden in flat mode
|
// Expand button — hidden in flat mode
|
||||||
const expand_btn = row.querySelector('.btn-expand');
|
const expand_btn = row.querySelector('.btn-expand');
|
||||||
if (!state.flat_mode && children.length > 0) {
|
if (!state.flat_mode && children.length > 0) {
|
||||||
show(expand_btn);
|
show(expand_btn);
|
||||||
expand_btn.textContent = state.expanded.has(task.id) ? '▼' : '▶';
|
expand_btn.textContent = state.expanded.has(entry.id) ? '▼' : '▶';
|
||||||
expand_btn.addEventListener('click', () => {
|
expand_btn.addEventListener('click', () => {
|
||||||
if (state.expanded.has(task.id)) { state.expanded.delete(task.id); }
|
if (state.expanded.has(entry.id)) { state.expanded.delete(entry.id); }
|
||||||
else { state.expanded.add(task.id); }
|
else { state.expanded.add(entry.id); }
|
||||||
render();
|
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');
|
const status_el = row.querySelector('.task-status');
|
||||||
status_el.textContent = task.status;
|
status_el.textContent = entry.status;
|
||||||
status_el.dataset.val = task.status;
|
status_el.dataset.val = entry.status;
|
||||||
|
|
||||||
const priority_el = row.querySelector('.task-priority');
|
const priority_el = row.querySelector('.task-priority');
|
||||||
priority_el.textContent = task.priority;
|
priority_el.textContent = entry.priority;
|
||||||
priority_el.dataset.val = task.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');
|
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');
|
const tags_el = row.querySelector('.task-tags');
|
||||||
for (const tag of task.tags) {
|
for (const tag of entry.tags) {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'tag';
|
span.className = 'tag';
|
||||||
span.textContent = tag;
|
span.textContent = tag;
|
||||||
tags_el.appendChild(span);
|
tags_el.appendChild(span);
|
||||||
}
|
}
|
||||||
|
|
||||||
row.querySelector('.btn-sub').addEventListener('click', () => open_add_dialog(task.id));
|
row.querySelector('.btn-sub').addEventListener('click', () => open_add_dialog(entry.id, entry.type));
|
||||||
row.querySelector('.btn-edit').addEventListener('click', () => open_edit_dialog(task));
|
row.querySelector('.btn-edit').addEventListener('click', () => open_edit_dialog(entry));
|
||||||
row.querySelector('.btn-delete').addEventListener('click', () => confirm_delete(task));
|
row.querySelector('.btn-delete').addEventListener('click', () => confirm_delete(entry));
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render_tree_node(task, children_map) {
|
function render_tree_node(entry, children_map) {
|
||||||
if (!node_or_descendants_match(task, children_map)) { return null; }
|
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');
|
const node = document.createElement('div');
|
||||||
node.className = 'task-node';
|
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');
|
const children_el = document.createElement('div');
|
||||||
children_el.className = 'task-children';
|
children_el.className = 'task-children';
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
@@ -326,20 +416,40 @@ function render_tree_node(task, children_map) {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collect_flat(tasks, children_map, depth = 0, result = []) {
|
// ---------------------------------------------------------------------------
|
||||||
for (const task of tasks) {
|
// Nav
|
||||||
if (task_matches_filter(task)) { result.push({ task, depth }); }
|
// ---------------------------------------------------------------------------
|
||||||
const children = children_map.get(task.id) ?? [];
|
|
||||||
collect_flat(children, children_map, depth + 1, result);
|
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 = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
const children_map = build_children_map();
|
const children_map = build_children_map();
|
||||||
@@ -348,7 +458,15 @@ function render_tasks(container) {
|
|||||||
// Toolbar
|
// Toolbar
|
||||||
const toolbar = document.createElement('div');
|
const toolbar = document.createElement('div');
|
||||||
toolbar.className = 'toolbar';
|
toolbar.className = 'toolbar';
|
||||||
toolbar.innerHTML = '<h2>Tasks</h2>';
|
|
||||||
|
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');
|
const mode_toggle = document.createElement('div');
|
||||||
mode_toggle.className = 'segmented-control';
|
mode_toggle.className = 'segmented-control';
|
||||||
@@ -362,10 +480,11 @@ function render_tasks(container) {
|
|||||||
}
|
}
|
||||||
toolbar.appendChild(mode_toggle);
|
toolbar.appendChild(mode_toggle);
|
||||||
|
|
||||||
|
const et_active = state.entry_types.find(t => t.id === state.active_type);
|
||||||
const add_btn = document.createElement('button');
|
const add_btn = document.createElement('button');
|
||||||
add_btn.className = 'btn-primary';
|
add_btn.className = 'btn-primary';
|
||||||
add_btn.textContent = '+ New task';
|
add_btn.textContent = et_active ? `+ New ${et_active.title.toLowerCase()}` : '+ New entry';
|
||||||
add_btn.addEventListener('click', () => open_add_dialog(null));
|
add_btn.addEventListener('click', () => open_add_dialog(null, state.active_type));
|
||||||
toolbar.appendChild(add_btn);
|
toolbar.appendChild(add_btn);
|
||||||
container.appendChild(toolbar);
|
container.appendChild(toolbar);
|
||||||
|
|
||||||
@@ -415,27 +534,27 @@ function render_tasks(container) {
|
|||||||
|
|
||||||
container.appendChild(filter_bar);
|
container.appendChild(filter_bar);
|
||||||
|
|
||||||
// Task list
|
// Entry list
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'task-list' + (state.flat_mode ? ' flat-mode' : '');
|
list.className = 'task-list' + (state.flat_mode ? ' flat-mode' : '');
|
||||||
|
|
||||||
if (state.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) {
|
if (!flat.length) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'empty-state';
|
empty.className = 'empty-state';
|
||||||
empty.textContent = 'No tasks.';
|
empty.textContent = 'No entries.';
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
}
|
}
|
||||||
for (const { task } of flat) {
|
for (const entry of flat) {
|
||||||
list.appendChild(make_task_row(task, children_map));
|
list.appendChild(make_entry_row(entry, children_map));
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
if (!visible.length) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'empty-state';
|
empty.className = 'empty-state';
|
||||||
empty.textContent = 'No tasks.';
|
empty.textContent = 'No entries.';
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
}
|
}
|
||||||
for (const root of visible) {
|
for (const root of visible) {
|
||||||
@@ -447,19 +566,135 @@ function render_tasks(container) {
|
|||||||
container.appendChild(list);
|
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() {
|
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
|
// Actions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function open_add_dialog(parent_id) {
|
function open_add_dialog(parent_id, type_id) {
|
||||||
task_dialog.open('New task', { parent_id }, async (data) => {
|
// 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 {
|
try {
|
||||||
const { task } = await api.create_task(data);
|
const { entry } = await api.create_entry(data);
|
||||||
state.tasks.unshift(task);
|
state.entries.unshift(entry);
|
||||||
if (parent_id) { state.expanded.add(parent_id); }
|
if (parent_id) { state.expanded.add(parent_id); }
|
||||||
render();
|
render();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -468,14 +703,14 @@ function open_add_dialog(parent_id) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function open_edit_dialog(task) {
|
function open_edit_dialog(entry) {
|
||||||
task_dialog.open('Edit task', task, async (data) => {
|
entry_dialog.open('Edit entry', entry, entry.type, async (data) => {
|
||||||
try {
|
try {
|
||||||
const { task: updated } = await api.update_task(task.id, data);
|
const { entry: updated } = await api.update_entry(entry.id, data);
|
||||||
const idx = state.tasks.findIndex(t => t.id === task.id);
|
const idx = state.entries.findIndex(e => e.id === entry.id);
|
||||||
if (idx !== -1) { state.tasks[idx] = updated; }
|
if (idx !== -1) { state.entries[idx] = updated; }
|
||||||
md_cache.delete(task.title);
|
md_cache.delete(entry.title);
|
||||||
md_cache.delete(task.body);
|
md_cache.delete(entry.body);
|
||||||
render();
|
render();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
@@ -483,15 +718,15 @@ function open_edit_dialog(task) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirm_delete(task) {
|
function confirm_delete(entry) {
|
||||||
const children_count = state.tasks.filter(t => t.parent_id === task.id).length;
|
const children_count = state.entries.filter(e => e.parent_id === entry.id).length;
|
||||||
if (children_count > 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm(`Delete task #${task.id}: "${task.title}"?`)) { return; }
|
if (!confirm(`Delete #${entry.id}: "${entry.title}"?`)) { return; }
|
||||||
api.delete_task(task.id).then(() => {
|
api.delete_entry(entry.id).then(() => {
|
||||||
state.tasks = state.tasks.filter(t => t.id !== task.id);
|
state.entries = state.entries.filter(e => e.id !== entry.id);
|
||||||
render();
|
render();
|
||||||
}).catch(err => alert(err.message));
|
}).catch(err => alert(err.message));
|
||||||
}
|
}
|
||||||
@@ -509,11 +744,16 @@ async function init() {
|
|||||||
document.body.appendChild(tmpl);
|
document.body.appendChild(tmpl);
|
||||||
}
|
}
|
||||||
|
|
||||||
task_picker = new Task_Picker_Dialog();
|
entry_picker = new Entry_Picker_Dialog();
|
||||||
task_dialog = new Task_Dialog();
|
entry_dialog = new Entry_Dialog();
|
||||||
|
entry_type_dialog = new Entry_Type_Dialog();
|
||||||
|
|
||||||
const { tasks } = await api.get_tasks();
|
const [types_res, entries_res] = await Promise.all([
|
||||||
state.tasks = tasks;
|
api.get_entry_types(),
|
||||||
|
api.get_entries(),
|
||||||
|
]);
|
||||||
|
state.entry_types = types_res.entry_types;
|
||||||
|
state.entries = entries_res.entries;
|
||||||
|
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>Task Inventory</h1>
|
<h1>Task Inventory</h1>
|
||||||
<nav>
|
<nav id="main-nav"></nav>
|
||||||
<button data-nav="tasks" class="nav-btn active">Tasks</button>
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
<main id="main"></main>
|
<main id="main"></main>
|
||||||
<script type="module" src="/app.mjs"></script>
|
<script type="module" src="/app.mjs"></script>
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ async function req(method, path, body) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const get_tasks = () => req('GET', '/api/tasks');
|
export const get_entry_types = () => req('GET', '/api/entry-types');
|
||||||
export const create_task = (body) => req('POST', '/api/tasks', body);
|
export const create_entry_type = (body) => req('POST', '/api/entry-types', body);
|
||||||
export const update_task = (id, body) => req('PUT', `/api/tasks/${id}`, body);
|
export const update_entry_type = (id, body) => req('PUT', `/api/entry-types/${id}`, body);
|
||||||
export const delete_task = (id) => req('DELETE', `/api/tasks/${id}`);
|
export const delete_entry_type = (id) => req('DELETE', `/api/entry-types/${id}`);
|
||||||
export const render_markdown = (text) => req('POST', '/api/render-markdown', { text });
|
|
||||||
|
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 });
|
||||||
|
|||||||
@@ -397,3 +397,98 @@ dialog input:focus, dialog textarea:focus, dialog select:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-content.loading { color: #555; font-style: italic; }
|
.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; }
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<!-- Task list item -->
|
<!-- Entry list item -->
|
||||||
<template id="t-task-row">
|
<template id="t-entry-row">
|
||||||
<div class="task-row">
|
<div class="task-row">
|
||||||
<button class="btn-expand" hidden></button>
|
<button class="btn-expand" hidden></button>
|
||||||
<span class="task-id"></span>
|
<span class="task-id"></span>
|
||||||
<span class="task-status"></span>
|
<span class="task-status"></span>
|
||||||
<span class="task-priority"></span>
|
<span class="task-priority"></span>
|
||||||
<div class="task-main">
|
<div class="task-main">
|
||||||
|
<div class="entry-type-chip" hidden></div>
|
||||||
<div class="task-title markup"></div>
|
<div class="task-title markup"></div>
|
||||||
<div class="task-body markup" hidden></div>
|
<div class="task-body markup" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -18,11 +19,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Task form dialog -->
|
<!-- Entry form dialog -->
|
||||||
<template id="t-task-dialog">
|
<template id="t-entry-dialog">
|
||||||
<dialog class="task-dialog">
|
<dialog class="task-dialog">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<h2 class="dialog-title"></h2>
|
<h2 class="dialog-title"></h2>
|
||||||
|
<div class="type-section">
|
||||||
|
<span class="type-section-label">Type</span>
|
||||||
|
<span class="type-display" hidden></span>
|
||||||
|
<select name="type" class="type-select" hidden></select>
|
||||||
|
</div>
|
||||||
<label>Title
|
<label>Title
|
||||||
<input name="title" type="text" required autocomplete="off">
|
<input name="title" type="text" required autocomplete="off">
|
||||||
</label>
|
</label>
|
||||||
@@ -72,14 +78,40 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Task picker dialog -->
|
<!-- Entry picker dialog -->
|
||||||
<template id="t-task-picker">
|
<template id="t-entry-picker">
|
||||||
<dialog class="task-picker-dialog">
|
<dialog class="task-picker-dialog">
|
||||||
<h2>Make subtask of…</h2>
|
<h2>Make subtask of…</h2>
|
||||||
<input type="text" class="picker-search" placeholder="Search tasks…" autocomplete="off">
|
<input type="text" class="picker-search" placeholder="Search entries…" autocomplete="off">
|
||||||
<div class="picker-list"></div>
|
<div class="picker-list"></div>
|
||||||
<div class="dialog-buttons">
|
<div class="dialog-buttons">
|
||||||
<button type="button" class="btn-cancel">Cancel</button>
|
<button type="button" class="btn-cancel">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Entry type form dialog -->
|
||||||
|
<template id="t-entry-type-dialog">
|
||||||
|
<dialog class="entry-type-dialog">
|
||||||
|
<form method="dialog">
|
||||||
|
<h2 class="dialog-title"></h2>
|
||||||
|
<label class="id-label">ID
|
||||||
|
<input name="id" type="text" pattern="[a-z][a-z0-9_]*" autocomplete="off" placeholder="e.g. bug_report">
|
||||||
|
</label>
|
||||||
|
<div class="id-display-row" hidden>
|
||||||
|
<span class="id-display-label">ID</span>
|
||||||
|
<code class="id-display-value"></code>
|
||||||
|
</div>
|
||||||
|
<label>Title
|
||||||
|
<input name="title" type="text" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label>Description
|
||||||
|
<textarea name="description" rows="3"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button type="submit" class="btn-save">Save</button>
|
||||||
|
<button type="button" class="btn-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|||||||
94
server.mjs
94
server.mjs
@@ -2,8 +2,11 @@ process.on('unhandledRejection', (reason) => { console.error('[unhandledRejectio
|
|||||||
process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err); process.exit(1); });
|
process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err); process.exit(1); });
|
||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { next_id } from './lib/ids.mjs';
|
import {
|
||||||
import { list_tasks, get_task, set_task, delete_task, has_child_tasks } from './lib/storage.mjs';
|
next_id,
|
||||||
|
list_entry_types, get_entry_type, set_entry_type, delete_entry_type,
|
||||||
|
list_entries, get_entry, set_entry, delete_entry, has_child_entries,
|
||||||
|
} from './lib/storage.mjs';
|
||||||
import { get_config } from './lib/config.mjs';
|
import { get_config } from './lib/config.mjs';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -42,20 +45,61 @@ app.post('/api/render-markdown', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tasks
|
// Entry types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
app.get('/api/tasks', (req, res) => {
|
app.get('/api/entry-types', (req, res) => {
|
||||||
ok(res, { tasks: list_tasks() });
|
ok(res, { entry_types: list_entry_types() });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/tasks', (req, res) => {
|
app.post('/api/entry-types', (req, res) => {
|
||||||
const { title, body = '', status = 'open', priority = 'normal', tags = [], parent_id = null } = req.body;
|
const { id, title, description = '' } = req.body;
|
||||||
if (!title?.trim()) { return fail(res, 'title is required'); }
|
if (!id?.trim()) { return fail(res, 'id is required'); }
|
||||||
if (parent_id !== null && !get_task(Number(parent_id))) { return fail(res, 'parent task not found', 404); }
|
if (!/^[a-z][a-z0-9_]*$/.test(id)) { return fail(res, 'id must be lowercase alphanumeric/underscore, starting with a letter'); }
|
||||||
|
if (get_entry_type(id)) { return fail(res, 'entry type already exists'); }
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const task = {
|
const type = { id, title: title?.trim() || id, description: description.trim(), created_at: now, updated_at: now };
|
||||||
id: next_id('task'),
|
set_entry_type(type);
|
||||||
|
ok(res, { entry_type: type });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/entry-types/:id', (req, res) => {
|
||||||
|
const existing = get_entry_type(req.params.id);
|
||||||
|
if (!existing) { return fail(res, 'not found', 404); }
|
||||||
|
const { title, description } = req.body;
|
||||||
|
const updated = { ...existing, updated_at: Date.now() };
|
||||||
|
if (title !== undefined) { updated.title = title.trim(); }
|
||||||
|
if (description !== undefined) { updated.description = description.trim(); }
|
||||||
|
set_entry_type(updated);
|
||||||
|
ok(res, { entry_type: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/entry-types/:id', (req, res) => {
|
||||||
|
if (!get_entry_type(req.params.id)) { return fail(res, 'not found', 404); }
|
||||||
|
if (list_entries(req.params.id).length) { return fail(res, 'entry type still has entries — delete or retype them first'); }
|
||||||
|
delete_entry_type(req.params.id);
|
||||||
|
ok(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get('/api/entries', (req, res) => {
|
||||||
|
const type = req.query.type || null;
|
||||||
|
ok(res, { entries: list_entries(type) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/entries', (req, res) => {
|
||||||
|
const { type, title, body = '', status = 'open', priority = 'normal', tags = [], parent_id = null } = req.body;
|
||||||
|
if (!type) { return fail(res, 'type is required'); }
|
||||||
|
if (!get_entry_type(type)) { return fail(res, 'unknown entry type', 400); }
|
||||||
|
if (!title?.trim()) { return fail(res, 'title is required'); }
|
||||||
|
if (parent_id !== null && !get_entry(Number(parent_id))) { return fail(res, 'parent entry not found', 404); }
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = {
|
||||||
|
id: next_id(),
|
||||||
|
type,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
body: body.trim(),
|
body: body.trim(),
|
||||||
status,
|
status,
|
||||||
@@ -65,18 +109,18 @@ app.post('/api/tasks', (req, res) => {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
set_task(task);
|
set_entry(entry);
|
||||||
ok(res, { task });
|
ok(res, { entry });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/tasks/:id', (req, res) => {
|
app.get('/api/entries/:id', (req, res) => {
|
||||||
const task = get_task(Number(req.params.id));
|
const entry = get_entry(Number(req.params.id));
|
||||||
if (!task) { return fail(res, 'not found', 404); }
|
if (!entry) { return fail(res, 'not found', 404); }
|
||||||
ok(res, { task });
|
ok(res, { entry });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/tasks/:id', (req, res) => {
|
app.put('/api/entries/:id', (req, res) => {
|
||||||
const existing = get_task(Number(req.params.id));
|
const existing = get_entry(Number(req.params.id));
|
||||||
if (!existing) { return fail(res, 'not found', 404); }
|
if (!existing) { return fail(res, 'not found', 404); }
|
||||||
const { title, body, status, priority, tags, parent_id } = req.body;
|
const { title, body, status, priority, tags, parent_id } = req.body;
|
||||||
const updated = { ...existing, updated_at: Date.now() };
|
const updated = { ...existing, updated_at: Date.now() };
|
||||||
@@ -86,15 +130,15 @@ app.put('/api/tasks/:id', (req, res) => {
|
|||||||
if (priority !== undefined) { updated.priority = priority; }
|
if (priority !== undefined) { updated.priority = priority; }
|
||||||
if (tags !== undefined && Array.isArray(tags)) { updated.tags = tags; }
|
if (tags !== undefined && Array.isArray(tags)) { updated.tags = tags; }
|
||||||
if (parent_id !== undefined) { updated.parent_id = parent_id !== null ? Number(parent_id) : null; }
|
if (parent_id !== undefined) { updated.parent_id = parent_id !== null ? Number(parent_id) : null; }
|
||||||
set_task(updated);
|
set_entry(updated);
|
||||||
ok(res, { task: updated });
|
ok(res, { entry: updated });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/tasks/:id', (req, res) => {
|
app.delete('/api/entries/:id', (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (!get_task(id)) { return fail(res, 'not found', 404); }
|
if (!get_entry(id)) { return fail(res, 'not found', 404); }
|
||||||
if (has_child_tasks(id)) { return fail(res, 'task has subtasks — delete or reparent them first'); }
|
if (has_child_entries(id)) { return fail(res, 'entry has children — delete or reparent them first'); }
|
||||||
delete_task(id);
|
delete_entry(id);
|
||||||
ok(res);
|
ok(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user