diff --git a/state-hub/dashboard/src/decisions.md b/state-hub/dashboard/src/decisions.md index 82b462d..4b56a4a 100644 --- a/state-hub/dashboard/src/decisions.md +++ b/state-hub/dashboard/src/decisions.md @@ -8,13 +8,34 @@ const POLL = 15_000; ``` ```js +// Fetch decisions + topics (for domain context) in parallel const decState = (async function*() { while (true) { let data = [], ok = false; try { - const r = await fetch(`${API}/decisions/?limit=500`); - ok = r.ok; - data = ok ? await r.json() : []; + const [rd, rt] = await Promise.all([ + fetch(`${API}/decisions/?limit=500`), + fetch(`${API}/topics/`), + ]); + ok = rd.ok && rt.ok; + if (ok) { + const [decisions, topics] = await Promise.all([rd.json(), rt.json()]); + const topicMap = Object.fromEntries(topics.map(t => [t.id, t])); + data = decisions + .map(d => ({ + ...d, + domain: topicMap[d.topic_id]?.domain ?? null, + topic_title: topicMap[d.topic_id]?.title ?? null, + })) + .sort((a, b) => { + // escalated first, then open, then resolved/superseded; within group by deadline asc + const rank = {escalated: 0, open: 1, resolved: 2, superseded: 3}; + const dr = (rank[a.status] ?? 9) - (rank[b.status] ?? 9); + if (dr !== 0) return dr; + if (a.deadline && b.deadline) return a.deadline.localeCompare(b.deadline); + return a.deadline ? -1 : b.deadline ? 1 : 0; + }); + } } catch {} yield {data, ok, ts: new Date()}; await new Promise(res => setTimeout(res, POLL)); @@ -23,11 +44,9 @@ const decState = (async function*() { ``` ```js -const data = decState.data ?? []; -const _ok = decState.ok ?? false; -const _ts = decState.ts; -const pending = data.filter(d => d.decision_type === "pending"); -const made = data.filter(d => d.decision_type === "made"); +const data = decState.data ?? []; +const _ok = decState.ok ?? false; +const _ts = decState.ts; ``` # Decisions @@ -42,28 +61,78 @@ display(html`
``` ```js -const tab = view(Inputs.select(["Pending", "Made"], {label: "View"})); +import {MultiSelect} from "./components/multiselect.js"; + +const filters = view(Inputs.form( + { + type: MultiSelect(["pending", "made"], {label: "Type", placeholder: "All types"}), + status: MultiSelect(["open", "escalated", "resolved", "superseded"], {label: "Status", placeholder: "All statuses"}), + search: Inputs.text({placeholder: "Search title…", style: "width:160px"}), + }, + { + template: ({type, status, search}) => html`
+ ${type}${status} + +
`, + } +)); ``` ```js -const shown = tab === "Pending" ? pending : made; +const filtered = data.filter(d => + (filters.type.length === 0 || filters.type.includes(d.decision_type)) && + (filters.status.length === 0 || filters.status.includes(d.status)) && + (!filters.search || d.title.toLowerCase().includes(filters.search.toLowerCase())) +); -display(Inputs.table(shown.map(d => ({ - Title: d.title, - Status: d.status + (d.escalation_note ? " ⚠️" : ""), - Decided_by: d.decided_by ?? "—", - Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—", - Rationale: (d.rationale ?? "").slice(0, 80), - Updated: new Date(d.updated_at).toLocaleDateString(), -})), {rows: 30})); +const escalated = filtered.filter(d => d.escalation_note); + +const STATUS_BORDER = {open: "steelblue", escalated: "#f59e0b", resolved: "#22c55e", superseded: "#aaa"}; +const TYPE_CLASS = {pending: "badge-pending", made: "badge-made"}; + +function fmtDate(iso) { + return iso ? new Date(iso).toLocaleDateString(undefined, {day: "numeric", month: "short", year: "numeric"}) : null; +} +function isOverdue(iso) { + return iso && new Date(iso) < new Date(); +} + +if (filtered.length === 0) { + display(html`

No decisions match the current filter.

`); +} else { + display(html`
${filtered.map(d => { + const border = STATUS_BORDER[d.status] ?? "#ccc"; + const snippet = (d.description || d.rationale || "").slice(0, 200); + const due = fmtDate(d.deadline); + const decided = fmtDate(d.decided_at); + const overdue = isOverdue(d.deadline); + + return html`
+
+ ${d.decision_type} + + ${d.status === "escalated" ? "⚠ " : ""}${d.status} + + ${d.domain ? html`${d.domain}` : ""} + ${due ? html` + ${overdue ? "⚠ overdue" : "due"} ${due} + ` : ""} + ${fmtDate(d.created_at)} +
+
${d.title}
+ ${snippet ? html`
${snippet}${snippet.length < (d.description || d.rationale || "").length ? "…" : ""}
` : ""} + ${d.decided_by ? html`
✓ ${d.decided_by}${decided ? " · " + decided : ""}
` : ""} + ${d.escalation_note ? html`
${d.escalation_note}
` : ""} +
`; + })}
`); +} ``` ```js -if (tab === "Pending" && pending.filter(d => d.escalation_note).length > 0) { +if (escalated.length > 0) { display(html`
- ⚠️ Escalated decisions require human approval before any action is taken (constitution §4). - + ⚠ ${escalated.length} escalated decision${escalated.length > 1 ? "s" : ""} require human approval before any action is taken (constitution §4). +
`); } ``` @@ -73,7 +142,7 @@ if (tab === "Pending" && pending.filter(d => d.escalation_note).length > 0) { ```js import * as Plot from "npm:@observablehq/plot"; -const resolved = made.filter(d => d.decided_at); +const resolved = data.filter(d => d.decided_at); const byMonth = Object.entries( resolved.reduce((acc, d) => { const m = d.decided_at.slice(0, 7); @@ -100,5 +169,31 @@ display(byMonth.length === 0