Dashboard decisions: list view with MultiSelect filters
- Replace Pending/Made tab + Inputs.table with MultiSelect filters and a proper list view - Filters: Type (pending/made), Status (open/escalated/resolved/superseded), title text search — all stable across polls (no data dependency) - Each decision renders as a compact card: left border coloured by status (blue=open, amber=escalated, green=resolved, gray=superseded), type and status badges, domain context, deadline (red+overdue warning if past), full title, description/rationale snippet, resolved-by attribution - Decisions sorted: escalated → open → resolved → superseded, then by deadline ascending within each group - Fetch now includes topics alongside decisions for domain name join - Escalation warning box and velocity chart retained Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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`<div class="live-bar">
|
||||
```
|
||||
|
||||
```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`<div class="filter-bar">
|
||||
${type}${status}
|
||||
<div class="filter-search">${search}</div>
|
||||
</div>`,
|
||||
}
|
||||
));
|
||||
```
|
||||
|
||||
```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`<p class="dim">No decisions match the current filter.</p>`);
|
||||
} else {
|
||||
display(html`<div class="dec-list">${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`<div class="dec-item" style="border-left-color:${border}">
|
||||
<div class="dec-item-header">
|
||||
<span class="dec-badge ${TYPE_CLASS[d.decision_type] ?? ''}">${d.decision_type}</span>
|
||||
<span class="dec-badge dec-badge-status dec-badge-${d.status}">
|
||||
${d.status === "escalated" ? "⚠ " : ""}${d.status}
|
||||
</span>
|
||||
${d.domain ? html`<span class="dec-context">${d.domain}</span>` : ""}
|
||||
${due ? html`<span class="dec-due ${overdue ? 'dec-overdue' : ''}">
|
||||
${overdue ? "⚠ overdue" : "due"} ${due}
|
||||
</span>` : ""}
|
||||
<span class="dec-date">${fmtDate(d.created_at)}</span>
|
||||
</div>
|
||||
<div class="dec-title">${d.title}</div>
|
||||
${snippet ? html`<div class="dec-snippet">${snippet}${snippet.length < (d.description || d.rationale || "").length ? "…" : ""}</div>` : ""}
|
||||
${d.decided_by ? html`<div class="dec-resolved-by">✓ ${d.decided_by}${decided ? " · " + decided : ""}</div>` : ""}
|
||||
${d.escalation_note ? html`<div class="dec-escalation-note">${d.escalation_note}</div>` : ""}
|
||||
</div>`;
|
||||
})}</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
if (tab === "Pending" && pending.filter(d => d.escalation_note).length > 0) {
|
||||
if (escalated.length > 0) {
|
||||
display(html`<div class="escalation-box">
|
||||
<strong>⚠️ Escalated decisions require human approval before any action is taken (constitution §4).</strong>
|
||||
<ul>${pending.filter(d => d.escalation_note).map(d =>
|
||||
html`<li><b>${d.title}</b>: ${d.escalation_note}</li>`)}</ul>
|
||||
<strong>⚠ ${escalated.length} escalated decision${escalated.length > 1 ? "s" : ""} require human approval before any action is taken (constitution §4).</strong>
|
||||
<ul>${escalated.map(d => html`<li><b>${d.title}</b>: ${d.escalation_note}</li>`)}</ul>
|
||||
</div>`);
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
|
||||
<style>
|
||||
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
|
||||
.dim { color: gray; font-style: italic; }
|
||||
/* Filter bar */
|
||||
.filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||
.filter-search { display: flex; align-items: center; }
|
||||
.filter-search input { height: 30px; font-size: 0.85rem; padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--theme-foreground-faint, #ccc); background: var(--theme-background, #fff); font-family: inherit; color: inherit; }
|
||||
/* Decision list */
|
||||
.dec-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.dec-item { border-left: 3px solid #ccc; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; }
|
||||
.dec-item-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.75rem; }
|
||||
.dec-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.badge-pending { background: #fef3c7; color: #92400e; }
|
||||
.badge-made { background: #e0e7ff; color: #3730a3; }
|
||||
.dec-badge-status { font-weight: 500; text-transform: capitalize; }
|
||||
.dec-badge-open { background: #dbeafe; color: #1e40af; }
|
||||
.dec-badge-escalated { background: #fef3c7; color: #92400e; }
|
||||
.dec-badge-resolved { background: #dcfce7; color: #166534; }
|
||||
.dec-badge-superseded { background: #f3f4f6; color: #6b7280; }
|
||||
.dec-context { color: var(--theme-foreground-muted); }
|
||||
.dec-due { color: steelblue; }
|
||||
.dec-overdue { color: #dc2626; font-weight: 600; }
|
||||
.dec-date { color: var(--theme-foreground-faint); margin-left: auto; }
|
||||
.dec-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.2rem; }
|
||||
.dec-snippet { font-size: 0.82rem; color: var(--theme-foreground-muted); line-height: 1.45; white-space: pre-wrap; }
|
||||
.dec-resolved-by { font-size: 0.78rem; color: #22c55e; margin-top: 0.3rem; }
|
||||
.dec-escalation-note { font-size: 0.78rem; color: #b45309; margin-top: 0.3rem; background: #fef3c7; border-radius: 4px; padding: 0.25rem 0.5rem; }
|
||||
/* Escalation warning */
|
||||
.escalation-box { background: #fff3cd; border: 2px solid orange; border-radius: 8px; padding: 1rem; margin-top: 1rem; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user