Live dashboard: replace data loaders with client-side polling

CORS: add CORSMiddleware to FastAPI for localhost:3000 so browser fetch
works across ports without errors.

All four pages now use async generator cells that call the API directly
and re-yield every 15 s — no data loader cache, no manual cache clearing.

Each page shows a live status bar (● green/red · last updated time).
Offline state shows the `make api` hint inline.

index.md: add "Registered Projects" section — polls
  /progress/?event_type=milestone&limit=500 and filters for
  "Project registered with State Hub:" events; shows project name,
  domain, path, and registration timestamp.

workstreams.md: fix broken domain column — now fetches /workstreams/
  and /topics/ in parallel and joins on topic_id client-side.
  Previously the domain column showed "unknown" for all rows because
  WorkstreamRead schema doesn't include domain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 23:19:26 +01:00
parent 935d8a6b83
commit 34b1114a01
5 changed files with 329 additions and 161 deletions

View File

@@ -2,30 +2,70 @@
title: Decisions
---
# Decisions
```js
const decisions = await FileAttachment("data/decisions.json").json();
const data = Array.isArray(decisions) ? decisions : [];
const pending = data.filter(d => d.decision_type === "pending");
const made = data.filter(d => d.decision_type === "made");
const API = "http://127.0.0.1:8000";
const POLL = 15_000;
```
```js
const tab = view(Inputs.select(["Pending", "Made"], { label: "View" }));
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() : [];
} catch {}
yield {data, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```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");
```
# Decisions
```js
display(html`<div class="live-bar">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: `<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`);
```
```js
const tab = view(Inputs.select(["Pending", "Made"], {label: "View"}));
```
```js
const shown = tab === "Pending" ? pending : made;
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 }));
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}));
```
```js
if (tab === "Pending" && pending.filter(d => d.escalation_note).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>
</div>`);
}
```
## Resolution Velocity
@@ -34,37 +74,31 @@ display(Inputs.table(shown.map(d => ({
import * as Plot from "npm:@observablehq/plot";
const resolved = made.filter(d => d.decided_at);
const byMonth = resolved.reduce((acc, d) => {
const m = d.decided_at.slice(0, 7);
acc[m] = (acc[m] ?? 0) + 1;
return acc;
}, {});
const byMonth = Object.entries(
resolved.reduce((acc, d) => {
const m = d.decided_at.slice(0, 7);
acc[m] = (acc[m] ?? 0) + 1;
return acc;
}, {})
).map(([month, count]) => ({month, count}));
display(Plot.plot({
title: "Decisions Resolved per Month",
x: { label: "Month", tickRotate: -30 },
y: { label: "Count", grid: true },
marks: [
Plot.barY(
Object.entries(byMonth).map(([month, count]) => ({ month, count })),
{ x: "month", y: "count", fill: "steelblue", tip: true }
),
Plot.ruleY([0]),
],
marginBottom: 60,
width: 700,
}));
```
```js
if (tab === "Pending" && pending.filter(d => d.escalation_note).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>
</div>`);
}
display(byMonth.length === 0
? html`<p style="color:gray">No resolved decisions yet.</p>`
: Plot.plot({
title: "Decisions resolved per month",
x: {label: "Month", tickRotate: -30},
y: {label: "Count", grid: true},
marks: [
Plot.barY(byMonth, {x: "month", y: "count", fill: "steelblue", tip: true}),
Plot.ruleY([0]),
],
marginBottom: 60,
width: 700,
})
);
```
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
.escalation-box { background: #fff3cd; border: 2px solid orange; border-radius: 8px; padding: 1rem; margin-top: 1rem; }
</style>