generated from coulomb/repo-seed
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:
@@ -2,23 +2,62 @@
|
||||
title: Workstreams
|
||||
---
|
||||
|
||||
# Workstreams
|
||||
|
||||
```js
|
||||
const workstreams = await FileAttachment("data/workstreams.json").json();
|
||||
const data = Array.isArray(workstreams) ? workstreams : [];
|
||||
const API = "http://127.0.0.1:8000";
|
||||
const POLL = 15_000;
|
||||
```
|
||||
|
||||
```js
|
||||
const domainFilter = view(Inputs.select(
|
||||
["(all)", ...new Set(data.map(w => w.domain ?? "unknown"))],
|
||||
{ label: "Domain" }
|
||||
));
|
||||
const statusFilter = view(Inputs.select(
|
||||
["(all)", "active", "blocked", "completed", "archived"],
|
||||
{ label: "Status" }
|
||||
));
|
||||
const ownerFilter = view(Inputs.text({ label: "Owner contains" }));
|
||||
// Fetch workstreams + topics in parallel, join on topic_id → domain/title
|
||||
const wsState = (async function*() {
|
||||
while (true) {
|
||||
let data = [], ok = false;
|
||||
try {
|
||||
const [rw, rt] = await Promise.all([
|
||||
fetch(`${API}/workstreams/`),
|
||||
fetch(`${API}/topics/`),
|
||||
]);
|
||||
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
|
||||
@@ -28,40 +67,35 @@ const filtered = data.filter(w =>
|
||||
(!ownerFilter || (w.owner ?? "").toLowerCase().includes(ownerFilter.toLowerCase()))
|
||||
);
|
||||
|
||||
const STATUS_COLOR = {
|
||||
active: "green",
|
||||
blocked: "orange",
|
||||
completed: "blue",
|
||||
archived: "gray",
|
||||
};
|
||||
|
||||
display(Inputs.table(filtered.map(w => ({
|
||||
Title: w.title,
|
||||
Domain: w.domain,
|
||||
Status: w.status,
|
||||
Owner: w.owner ?? "—",
|
||||
"Due": w.due_date ?? "—",
|
||||
"Updated": new Date(w.updated_at).toLocaleDateString(),
|
||||
})), {
|
||||
rows: 20,
|
||||
}));
|
||||
Title: w.title,
|
||||
Domain: w.domain,
|
||||
Status: w.status,
|
||||
Owner: w.owner ?? "—",
|
||||
Due: w.due_date ?? "—",
|
||||
Updated: new Date(w.updated_at).toLocaleDateString(),
|
||||
})), {rows: 20}));
|
||||
```
|
||||
|
||||
## Status Distribution
|
||||
|
||||
```js
|
||||
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({
|
||||
title: "Workstream Status Distribution",
|
||||
marks: [
|
||||
Plot.barX(
|
||||
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.barX(byStatus, {y: "status", x: "count", fill: "status", tip: true}),
|
||||
Plot.ruleX([0]),
|
||||
],
|
||||
marginLeft: 80,
|
||||
width: 500,
|
||||
}));
|
||||
```
|
||||
|
||||
<style>
|
||||
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user