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}
+
${search}
+
`,
+ }
+));
```
```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.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).
-
${pending.filter(d => d.escalation_note).map(d =>
- html`- ${d.title}: ${d.escalation_note}
`)}
+
⚠ ${escalated.length} escalated decision${escalated.length > 1 ? "s" : ""} require human approval before any action is taken (constitution §4).
+
${escalated.map(d => html`- ${d.title}: ${d.escalation_note}
`)}
`);
}
```
@@ -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