Files
state-hub/dashboard/src/workstreams.md
tegwick 34b1114a01 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>
2026-02-24 23:19:26 +01:00

2.7 KiB

title
title
Workstreams
const API = "http://127.0.0.1:8000";
const POLL = 15_000;
// 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));
  }
})();
const data = wsState.data ?? [];
const _ok  = wsState.ok  ?? false;
const _ts  = wsState.ts;

Workstreams

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>`);
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"}));
const filtered = data.filter(w =>
  (domainFilter === "(all)" || w.domain === domainFilter) &&
  (statusFilter === "(all)" || w.status === statusFilter) &&
  (!ownerFilter || (w.owner ?? "").toLowerCase().includes(ownerFilter.toLowerCase()))
);

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}));

Status Distribution

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({
  marks: [
    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>