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

@@ -1,6 +1,7 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from api.database import engine from api.database import engine
from api.routers import decisions, progress, state, tasks, topics, workstreams from api.routers import decisions, progress, state, tasks, topics, workstreams
@@ -19,6 +20,13 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_methods=["GET", "POST", "PATCH"],
allow_headers=["Content-Type"],
)
app.include_router(topics.router) app.include_router(topics.router)
app.include_router(workstreams.router) app.include_router(workstreams.router)
app.include_router(tasks.router) app.include_router(tasks.router)

View File

@@ -2,30 +2,70 @@
title: Decisions title: Decisions
--- ---
# Decisions
```js ```js
const decisions = await FileAttachment("data/decisions.json").json(); const API = "http://127.0.0.1:8000";
const data = Array.isArray(decisions) ? decisions : []; const POLL = 15_000;
const pending = data.filter(d => d.decision_type === "pending");
const made = data.filter(d => d.decision_type === "made");
``` ```
```js ```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 ```js
const shown = tab === "Pending" ? pending : made; const shown = tab === "Pending" ? pending : made;
display(Inputs.table(shown.map(d => ({ display(Inputs.table(shown.map(d => ({
Title: d.title, Title: d.title,
Status: d.status + (d.escalation_note ? " ⚠️" : ""), Status: d.status + (d.escalation_note ? " ⚠️" : ""),
Decided_by: d.decided_by ?? "—", Decided_by: d.decided_by ?? "—",
Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—", Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—",
Rationale: (d.rationale ?? "").slice(0, 80), Rationale: (d.rationale ?? "").slice(0, 80),
Updated: new Date(d.updated_at).toLocaleDateString(), Updated: new Date(d.updated_at).toLocaleDateString(),
})), { rows: 30 })); })), {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 ## Resolution Velocity
@@ -34,37 +74,31 @@ display(Inputs.table(shown.map(d => ({
import * as Plot from "npm:@observablehq/plot"; import * as Plot from "npm:@observablehq/plot";
const resolved = made.filter(d => d.decided_at); const resolved = made.filter(d => d.decided_at);
const byMonth = resolved.reduce((acc, d) => { const byMonth = Object.entries(
const m = d.decided_at.slice(0, 7); resolved.reduce((acc, d) => {
acc[m] = (acc[m] ?? 0) + 1; const m = d.decided_at.slice(0, 7);
return acc; acc[m] = (acc[m] ?? 0) + 1;
}, {}); return acc;
}, {})
).map(([month, count]) => ({month, count}));
display(Plot.plot({ display(byMonth.length === 0
title: "Decisions Resolved per Month", ? html`<p style="color:gray">No resolved decisions yet.</p>`
x: { label: "Month", tickRotate: -30 }, : Plot.plot({
y: { label: "Count", grid: true }, title: "Decisions resolved per month",
marks: [ x: {label: "Month", tickRotate: -30},
Plot.barY( y: {label: "Count", grid: true},
Object.entries(byMonth).map(([month, count]) => ({ month, count })), marks: [
{ x: "month", y: "count", fill: "steelblue", tip: true } Plot.barY(byMonth, {x: "month", y: "count", fill: "steelblue", tip: true}),
), Plot.ruleY([0]),
Plot.ruleY([0]), ],
], marginBottom: 60,
marginBottom: 60, width: 700,
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>`);
}
``` ```
<style> <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; } .escalation-box { background: #fff3cd; border: 2px solid orange; border-radius: 8px; padding: 1rem; margin-top: 1rem; }
</style> </style>

View File

@@ -2,67 +2,131 @@
title: Overview title: Overview
--- ---
# Custodian State Hub
```js ```js
const summary = await FileAttachment("data/summary.json").json(); const API = "http://127.0.0.1:8000";
const totals = summary.totals ?? {}; const POLL = 15_000;
const ws = totals.workstreams ?? {};
const tasks = totals.tasks ?? {};
const decisions = totals.decisions ?? {};
const topics = totals.topics ?? {};
``` ```
```js ```js
if (summary.error) display(html`<div class="warning">⚠️ API unreachable: ${summary.error}. Run <code>make api</code>.</div>`); // Live polling — yields {data, ok, ts} every POLL ms
const summaryState = (async function*() {
while (true) {
let data, ok = false;
try {
const r = await fetch(`${API}/state/summary`);
ok = r.ok;
data = ok ? await r.json() : {error: `HTTP ${r.status}`};
} catch (e) {
data = {error: "API unreachable"};
}
yield {data, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
const summary = summaryState.data ?? {};
const _ok = summaryState.ok ?? false;
const _ts = summaryState.ts;
const totals = summary.totals ?? {};
const ws = totals.workstreams ?? {};
const tasks = totals.tasks ?? {};
const decisions = totals.decisions ?? {};
```
```js
// Registered projects — milestone events tagged with registration
const regsState = (async function*() {
while (true) {
let rows = [];
try {
const r = await fetch(`${API}/progress/?event_type=milestone&limit=500`);
if (r.ok) {
const all = await r.json();
rows = all.filter(e => e.summary?.startsWith("Project registered with State Hub:"));
}
} catch {}
yield rows;
await new Promise(res => setTimeout(res, POLL));
}
})();
```
# Custodian State Hub
```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>cd ~/the-custodian/state-hub && make api</code></span>`}
</div>`);
```
```js
if (summary.error) display(html`<div class="warning">⚠️ ${summary.error}</div>`);
``` ```
## Status ## Status
```js ```js
display(html`<div class="grid grid-cols-4" style="gap:1rem; margin-bottom:1.5rem;"> display(html`<div class="grid grid-cols-4" style="gap:1rem;margin-bottom:1.5rem">
<div class="card"> <div class="card">
<h3>Active Workstreams</h3> <h3>Active Workstreams</h3>
<p class="big-number">${ws.active ?? 0}</p> <p class="big-num">${ws.active ?? 0}</p>
<small>${ws.blocked ?? 0} blocked</small> <small>${ws.blocked ?? 0} blocked</small>
</div> </div>
<div class="card ${(decisions.open + decisions.escalated) > 0 ? 'warn' : ''}"> <div class="card ${(decisions.open + decisions.escalated) > 0 ? 'warn' : ''}">
<h3>Blocking Decisions</h3> <h3>Blocking Decisions</h3>
<p class="big-number">${(decisions.open ?? 0) + (decisions.escalated ?? 0)}</p> <p class="big-num">${(decisions.open ?? 0) + (decisions.escalated ?? 0)}</p>
<small>${decisions.escalated ?? 0} escalated</small> <small>${decisions.escalated ?? 0} escalated</small>
</div> </div>
<div class="card ${(tasks.blocked ?? 0) > 0 ? 'warn' : ''}"> <div class="card ${(tasks.blocked ?? 0) > 0 ? 'warn' : ''}">
<h3>Blocked Tasks</h3> <h3>Blocked Tasks</h3>
<p class="big-number">${tasks.blocked ?? 0}</p> <p class="big-num">${tasks.blocked ?? 0}</p>
<small>of ${tasks.total ?? 0} total</small> <small>of ${tasks.total ?? 0} total</small>
</div> </div>
<div class="card"> <div class="card">
<h3>Progress Events Today</h3> <h3>Events Today</h3>
<p class="big-number">${(summary.recent_progress ?? []).filter(e => e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}</p> <p class="big-num">${(summary.recent_progress ?? []).filter(e =>
e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}</p>
<small>last 20 shown below</small> <small>last 20 shown below</small>
</div> </div>
</div>`); </div>`);
``` ```
## Tasks by Domain ## Registered Projects
```js
const regs = regsState ?? [];
if (regs.length === 0) {
display(html`<p style="color:gray">No projects registered yet. Run <code>custodian register-project</code> inside a repo.</p>`);
} else {
display(Inputs.table(regs.map(e => ({
Project: e.detail?.project_path?.split("/").at(-1) ?? "—",
Domain: e.detail?.domain ?? "—",
Path: e.detail?.project_path ?? "—",
Registered: new Date(e.created_at).toLocaleString(),
})), {maxWidth: 900}));
}
```
## Open Workstreams by Domain
```js ```js
import * as Plot from "npm:@observablehq/plot"; import * as Plot from "npm:@observablehq/plot";
const tasksByDomain = []; const wsData = (summary.topics ?? []).map(t => ({
for (const topic of (summary.topics ?? [])) { domain: t.domain,
for (const ws of (topic.workstreams ?? [])) { count: (t.workstreams ?? []).length,
// workstream stubs don't include tasks in summary — show per-topic WS count as proxy }));
}
tasksByDomain.push({ domain: topic.domain, status: topic.status, count: (topic.workstreams ?? []).length });
}
display(Plot.plot({ display(Plot.plot({
title: "Open Workstreams by Domain", x: {label: "Domain"},
x: { label: "Domain" }, y: {label: "Open workstreams", grid: true},
y: { label: "Count", grid: true },
marks: [ marks: [
Plot.barY(tasksByDomain, { x: "domain", y: "count", fill: "domain", tip: true }), Plot.barY(wsData, {x: "domain", y: "count", fill: "domain", tip: true}),
Plot.ruleY([0]), Plot.ruleY([0]),
], ],
marginBottom: 80, marginBottom: 80,
@@ -78,9 +142,9 @@ if (blocking.length === 0) {
display(html`<p style="color:green">✓ No blocking decisions.</p>`); display(html`<p style="color:green">✓ No blocking decisions.</p>`);
} else { } else {
display(Inputs.table(blocking.map(d => ({ display(Inputs.table(blocking.map(d => ({
Title: d.title, Title: d.title,
Status: d.status, Status: d.status,
Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—", Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—",
Escalated: d.escalation_note ? "⚠️" : "", Escalated: d.escalation_note ? "⚠️" : "",
})))); }))));
} }
@@ -89,16 +153,15 @@ if (blocking.length === 0) {
## Decisions Due Within 7 Days ## Decisions Due Within 7 Days
```js ```js
const now = new Date(); const in7 = new Date(Date.now() + 7*24*60*60*1000);
const in7 = new Date(now.getTime() + 7*24*60*60*1000);
const due = (summary.blocking_decisions ?? []).filter(d => d.deadline && new Date(d.deadline) <= in7); const due = (summary.blocking_decisions ?? []).filter(d => d.deadline && new Date(d.deadline) <= in7);
if (due.length === 0) { if (due.length === 0) {
display(html`<p>No decisions due in next 7 days.</p>`); display(html`<p>No decisions due in next 7 days.</p>`);
} else { } else {
display(Inputs.table(due.map(d => ({ display(Inputs.table(due.map(d => ({
Title: d.title, Title: d.title,
Deadline: new Date(d.deadline).toLocaleString(), Deadline: new Date(d.deadline).toLocaleString(),
Status: d.status, Status: d.status,
})))); }))));
} }
``` ```
@@ -107,16 +170,17 @@ if (due.length === 0) {
```js ```js
display(Inputs.table((summary.recent_progress ?? []).map(e => ({ display(Inputs.table((summary.recent_progress ?? []).map(e => ({
Time: new Date(e.created_at).toLocaleString(), Time: new Date(e.created_at).toLocaleString(),
Type: e.event_type, Type: e.event_type,
Author: e.author ?? "—", Author: e.author ?? "—",
Summary: e.summary, Summary: e.summary,
})), { maxWidth: 900 })); })), {maxWidth: 900}));
``` ```
<style> <style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; } .card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
.card.warn { border: 2px solid orange; } .card.warn { border: 2px solid orange; }
.big-number { font-size: 2.5rem; font-weight: bold; margin: 0.25rem 0; } .big-num { font-size: 2.5rem; font-weight: bold; margin: 0.25rem 0; }
.warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.75rem; } .warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.75rem; }
</style> </style>

View File

@@ -2,42 +2,69 @@
title: Progress title: Progress
--- ---
```js
const API = "http://127.0.0.1:8000";
const POLL = 15_000;
```
```js
const progState = (async function*() {
while (true) {
let data = [], ok = false;
try {
const r = await fetch(`${API}/progress/?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 = progState.data ?? [];
const _ok = progState.ok ?? false;
const _ts = progState.ts;
```
# Progress Log # Progress Log
*Append-only per constitution §5 — no deletions.* *Append-only per constitution §5 — no deletions.*
```js ```js
const events = await FileAttachment("data/progress.json").json(); display(html`<div class="live-bar">
const data = Array.isArray(events) ? events : []; <span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()} · ${data.length} events total`
: `<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`);
``` ```
```js ```js
const authorFilter = view(Inputs.select( const authorOpts = ["(all)", ...new Set(data.map(e => e.author ?? "unknown"))].sort();
["(all)", ...new Set(data.map(e => e.author ?? "unknown"))], const typeOpts = ["(all)", ...new Set(data.map(e => e.event_type))].sort();
{ label: "Author" }
)); const authorFilter = view(Inputs.select(authorOpts, {label: "Author"}));
const typeFilter = view(Inputs.select( const typeFilter = view(Inputs.select(typeOpts, {label: "Event type"}));
["(all)", ...new Set(data.map(e => e.event_type))], const sinceFilter = view(Inputs.date({label: "Since"}));
{ label: "Event type" }
));
const sinceFilter = view(Inputs.date({ label: "Since" }));
``` ```
```js ```js
const filtered = data.filter(e => const filtered = data.filter(e =>
(authorFilter === "(all)" || (e.author ?? "unknown") === authorFilter) && (authorFilter === "(all)" || (e.author ?? "unknown") === authorFilter) &&
(typeFilter === "(all)" || e.event_type === typeFilter) && (typeFilter === "(all)" || e.event_type === typeFilter) &&
(!sinceFilter || new Date(e.created_at) >= sinceFilter) (!sinceFilter || new Date(e.created_at) >= sinceFilter)
); );
display(html`<p><strong>${filtered.length}</strong> events shown (append-only, no deletions).</p>`); display(html`<p><strong>${filtered.length}</strong> events shown (append-only, no deletions).</p>`);
display(Inputs.table(filtered.map(e => ({ display(Inputs.table(filtered.map(e => ({
Time: new Date(e.created_at).toLocaleString(), Time: new Date(e.created_at).toLocaleString(),
Type: e.event_type, Type: e.event_type,
Author: e.author ?? "—", Author: e.author ?? "—",
Summary: e.summary, Summary: e.summary,
})), { rows: 50 })); })), {rows: 50}));
``` ```
## Event Volume (Last 30 Days) ## Event Volume (Last 30 Days)
@@ -45,33 +72,34 @@ display(Inputs.table(filtered.map(e => ({
```js ```js
import * as Plot from "npm:@observablehq/plot"; import * as Plot from "npm:@observablehq/plot";
const cutoff = new Date(); const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
cutoff.setDate(cutoff.getDate() - 30); const byDay = Object.entries(
data
.filter(e => new Date(e.created_at) >= cutoff)
.reduce((acc, e) => {
const day = e.created_at.slice(0, 10);
acc[day] = (acc[day] ?? 0) + 1;
return acc;
}, {})
).sort().map(([day, count]) => ({day, count}));
const byDay = data display(byDay.length === 0
.filter(e => new Date(e.created_at) >= cutoff) ? html`<p style="color:gray">No events in the last 30 days.</p>`
.reduce((acc, e) => { : Plot.plot({
const day = e.created_at.slice(0, 10); title: "Progress events per day (30-day window)",
acc[day] = (acc[day] ?? 0) + 1; x: {label: "Date", tickRotate: -30},
return acc; y: {label: "Events", grid: true},
}, {}); marks: [
Plot.areaY(byDay, {x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3}),
display(Plot.plot({ Plot.lineY(byDay, {x: "day", y: "count", stroke: "steelblue"}),
title: "Progress Events per Day (30-day window)", Plot.ruleY([0]),
x: { label: "Date", tickRotate: -30 }, ],
y: { label: "Events", grid: true }, marginBottom: 60,
marks: [ width: 750,
Plot.areaY( })
Object.entries(byDay).sort().map(([day, count]) => ({ day, count })), );
{ x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3 }
),
Plot.lineY(
Object.entries(byDay).sort().map(([day, count]) => ({ day, count })),
{ x: "day", y: "count", stroke: "steelblue" }
),
Plot.ruleY([0]),
],
marginBottom: 60,
width: 750,
}));
``` ```
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
</style>

View File

@@ -2,23 +2,62 @@
title: Workstreams title: Workstreams
--- ---
# Workstreams
```js ```js
const workstreams = await FileAttachment("data/workstreams.json").json(); const API = "http://127.0.0.1:8000";
const data = Array.isArray(workstreams) ? workstreams : []; const POLL = 15_000;
``` ```
```js ```js
const domainFilter = view(Inputs.select( // Fetch workstreams + topics in parallel, join on topic_id → domain/title
["(all)", ...new Set(data.map(w => w.domain ?? "unknown"))], const wsState = (async function*() {
{ label: "Domain" } while (true) {
)); let data = [], ok = false;
const statusFilter = view(Inputs.select( try {
["(all)", "active", "blocked", "completed", "archived"], const [rw, rt] = await Promise.all([
{ label: "Status" } fetch(`${API}/workstreams/`),
)); fetch(`${API}/topics/`),
const ownerFilter = view(Inputs.text({ label: "Owner contains" })); ]);
ok = rw.ok && rt.ok;
if (ok) {
const [wsList, topicList] = await Promise.all([rw.json(), rt.json()]);
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
data = wsList.map(w => ({
...w,
domain: topicMap[w.topic_id]?.domain ?? "unknown",
topic_title: topicMap[w.topic_id]?.title ?? "—",
}));
}
} catch {}
yield {data, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
const data = wsState.data ?? [];
const _ok = wsState.ok ?? false;
const _ts = wsState.ts;
```
# Workstreams
```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 domainOpts = ["(all)", ...new Set(data.map(w => w.domain))].sort();
const statusOpts = ["(all)", "active", "blocked", "completed", "archived"];
const domainFilter = view(Inputs.select(domainOpts, {label: "Domain"}));
const statusFilter = view(Inputs.select(statusOpts, {label: "Status"}));
const ownerFilter = view(Inputs.text({label: "Owner contains"}));
``` ```
```js ```js
@@ -28,40 +67,35 @@ const filtered = data.filter(w =>
(!ownerFilter || (w.owner ?? "").toLowerCase().includes(ownerFilter.toLowerCase())) (!ownerFilter || (w.owner ?? "").toLowerCase().includes(ownerFilter.toLowerCase()))
); );
const STATUS_COLOR = {
active: "green",
blocked: "orange",
completed: "blue",
archived: "gray",
};
display(Inputs.table(filtered.map(w => ({ display(Inputs.table(filtered.map(w => ({
Title: w.title, Title: w.title,
Domain: w.domain, Domain: w.domain,
Status: w.status, Status: w.status,
Owner: w.owner ?? "—", Owner: w.owner ?? "—",
"Due": w.due_date ?? "—", Due: w.due_date ?? "—",
"Updated": new Date(w.updated_at).toLocaleDateString(), Updated: new Date(w.updated_at).toLocaleDateString(),
})), { })), {rows: 20}));
rows: 20,
}));
``` ```
## Status Distribution
```js ```js
import * as Plot from "npm:@observablehq/plot"; import * as Plot from "npm:@observablehq/plot";
const byStatus = Object.entries(
filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {})
).map(([status, count]) => ({status, count}));
display(Plot.plot({ display(Plot.plot({
title: "Workstream Status Distribution",
marks: [ marks: [
Plot.barX( Plot.barX(byStatus, {y: "status", x: "count", fill: "status", tip: true}),
Object.entries(
filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {})
).map(([status, count]) => ({ status, count })),
{ y: "status", x: "count", fill: "status", tip: true }
),
Plot.ruleX([0]), Plot.ruleX([0]),
], ],
marginLeft: 80, marginLeft: 80,
width: 500, width: 500,
})); }));
``` ```
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
</style>