diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 54ade8e..800335c 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -4,6 +4,7 @@ export default { pages: [ { name: "Overview", path: "/" }, { name: "Workstreams", path: "/workstreams" }, + { name: "Tasks", path: "/tasks" }, { name: "Decisions", path: "/decisions" }, { name: "Progress", path: "/progress" }, { diff --git a/dashboard/src/tasks.md b/dashboard/src/tasks.md new file mode 100644 index 0000000..418bc51 --- /dev/null +++ b/dashboard/src/tasks.md @@ -0,0 +1,260 @@ +--- +title: Tasks +--- + +```js +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; +``` + +```js +const taskState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const [rt, rw, rto] = await Promise.all([ + fetch(`${API}/tasks/?limit=500`), + fetch(`${API}/workstreams/`), + fetch(`${API}/topics/`), + ]); + ok = rt.ok && rw.ok && rto.ok; + if (ok) { + const [taskList, wsList, topicList] = await Promise.all([rt.json(), rw.json(), rto.json()]); + const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); + const wsMap = Object.fromEntries(wsList.map(w => [w.id, { + ...w, + domain: topicMap[w.topic_id]?.domain ?? "unknown", + }])); + data = taskList.map(t => ({ + ...t, + workstream_title: wsMap[t.workstream_id]?.title ?? "—", + domain: wsMap[t.workstream_id]?.domain ?? "unknown", + })); + } + } catch {} + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const data = taskState.data ?? []; +const _ok = taskState.ok ?? false; +const _ts = taskState.ts; +``` + +```js +import {MultiSelect} from "./components/multiselect.js"; + +const STATUSES = ["todo", "in_progress", "blocked", "done", "cancelled"]; +const PRIORITIES = ["critical", "high", "medium", "low"]; +const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; + +const _filtersForm = Inputs.form( + { + status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}), + priority: MultiSelect(PRIORITIES, {label: "Priority", placeholder: "All priorities"}), + domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}), + assignee: Inputs.text({placeholder: "Assignee…", style: "width:120px"}), + }, + { + template: ({status, priority, domain, assignee}) => html`
`, + } +); +``` + +```js +const filters = Generators.input(_filtersForm); +``` + +```js +const filtered = data.filter(t => + (filters.status.length === 0 || filters.status.includes(t.status)) && + (filters.priority.length === 0 || filters.priority.includes(t.priority)) && + (filters.domain.length === 0 || filters.domain.includes(t.domain)) && + (!filters.assignee || (t.assignee ?? "").toLowerCase().includes(filters.assignee.toLowerCase())) +); +``` + +# Tasks + +```js +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; + +// ── KPI sidebar card ───────────────────────────────────────────────────────── +const _open = data.filter(t => ["todo", "in_progress", "blocked"].includes(t.status)); +const _blocked = data.filter(t => t.status === "blocked"); +const _inProg = data.filter(t => t.status === "in_progress"); +const _done = data.filter(t => t.status === "done"); +const _total = data.filter(t => t.status !== "cancelled").length; +const _donePct = _total > 0 ? Math.round(_done.length / _total * 100) : 0; + +const _kpiBox = html`make api`}
+No tasks match the current filter.
` + : Plot.plot({ + marks: [ + Plot.barX(byStatus, {y: "status", x: "count", fill: d => STATUS_COLOR[d.status] ?? "#94a3b8", tip: true}), + Plot.ruleX([0]), + ], + marginLeft: 90, + width: 500, + }) +); +``` + +## Blocked Tasks + +```js +const _blockedInFilter = filtered.filter(t => t.status === "blocked"); + +if (_blockedInFilter.length === 0) { + display(html`No blocked tasks in current filter. ✓
`); +} else { + display(html`${filtered.length} tasks shown.
`); + +const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3}; +const STATUS_ORDER = {blocked: 0, in_progress: 1, todo: 2, done: 3, cancelled: 4}; + +const sorted = [...filtered].sort((a, b) => { + const sd = (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9); + if (sd !== 0) return sd; + return (PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9); +}); + +display(Inputs.table(sorted.map(t => ({ + Status: t.status, + Priority: t.priority, + Title: t.title, + Domain: t.domain, + Workstream: t.workstream_title, + Assignee: t.assignee ?? "—", + Due: t.due_date ?? "—", +})), {rows: 25})); +``` + +