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:
@@ -1,6 +1,7 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api.database import engine
|
||||
from api.routers import decisions, progress, state, tasks, topics, workstreams
|
||||
@@ -19,6 +20,13 @@ app = FastAPI(
|
||||
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(workstreams.router)
|
||||
app.include_router(tasks.router)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,67 +2,131 @@
|
||||
title: Overview
|
||||
---
|
||||
|
||||
# Custodian State Hub
|
||||
|
||||
```js
|
||||
const summary = await FileAttachment("data/summary.json").json();
|
||||
const totals = summary.totals ?? {};
|
||||
const ws = totals.workstreams ?? {};
|
||||
const tasks = totals.tasks ?? {};
|
||||
const decisions = totals.decisions ?? {};
|
||||
const topics = totals.topics ?? {};
|
||||
const API = "http://127.0.0.1:8000";
|
||||
const POLL = 15_000;
|
||||
```
|
||||
|
||||
```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
|
||||
|
||||
```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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="card ${(decisions.open + decisions.escalated) > 0 ? 'warn' : ''}">
|
||||
<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>
|
||||
</div>
|
||||
<div class="card ${(tasks.blocked ?? 0) > 0 ? 'warn' : ''}">
|
||||
<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>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Progress Events Today</h3>
|
||||
<p class="big-number">${(summary.recent_progress ?? []).filter(e => e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}</p>
|
||||
<h3>Events Today</h3>
|
||||
<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>
|
||||
</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
|
||||
import * as Plot from "npm:@observablehq/plot";
|
||||
|
||||
const tasksByDomain = [];
|
||||
for (const topic of (summary.topics ?? [])) {
|
||||
for (const ws of (topic.workstreams ?? [])) {
|
||||
// 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 });
|
||||
}
|
||||
const wsData = (summary.topics ?? []).map(t => ({
|
||||
domain: t.domain,
|
||||
count: (t.workstreams ?? []).length,
|
||||
}));
|
||||
|
||||
display(Plot.plot({
|
||||
title: "Open Workstreams by Domain",
|
||||
x: { label: "Domain" },
|
||||
y: { label: "Count", grid: true },
|
||||
x: {label: "Domain"},
|
||||
y: {label: "Open workstreams", grid: true},
|
||||
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]),
|
||||
],
|
||||
marginBottom: 80,
|
||||
@@ -78,9 +142,9 @@ if (blocking.length === 0) {
|
||||
display(html`<p style="color:green">✓ No blocking decisions.</p>`);
|
||||
} else {
|
||||
display(Inputs.table(blocking.map(d => ({
|
||||
Title: d.title,
|
||||
Status: d.status,
|
||||
Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—",
|
||||
Title: d.title,
|
||||
Status: d.status,
|
||||
Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—",
|
||||
Escalated: d.escalation_note ? "⚠️" : "",
|
||||
}))));
|
||||
}
|
||||
@@ -89,16 +153,15 @@ if (blocking.length === 0) {
|
||||
## Decisions Due Within 7 Days
|
||||
|
||||
```js
|
||||
const now = new Date();
|
||||
const in7 = new Date(now.getTime() + 7*24*60*60*1000);
|
||||
const in7 = new Date(Date.now() + 7*24*60*60*1000);
|
||||
const due = (summary.blocking_decisions ?? []).filter(d => d.deadline && new Date(d.deadline) <= in7);
|
||||
if (due.length === 0) {
|
||||
display(html`<p>No decisions due in next 7 days.</p>`);
|
||||
} else {
|
||||
display(Inputs.table(due.map(d => ({
|
||||
Title: d.title,
|
||||
Title: d.title,
|
||||
Deadline: new Date(d.deadline).toLocaleString(),
|
||||
Status: d.status,
|
||||
Status: d.status,
|
||||
}))));
|
||||
}
|
||||
```
|
||||
@@ -107,16 +170,17 @@ if (due.length === 0) {
|
||||
|
||||
```js
|
||||
display(Inputs.table((summary.recent_progress ?? []).map(e => ({
|
||||
Time: new Date(e.created_at).toLocaleString(),
|
||||
Type: e.event_type,
|
||||
Author: e.author ?? "—",
|
||||
Time: new Date(e.created_at).toLocaleString(),
|
||||
Type: e.event_type,
|
||||
Author: e.author ?? "—",
|
||||
Summary: e.summary,
|
||||
})), { maxWidth: 900 }));
|
||||
})), {maxWidth: 900}));
|
||||
```
|
||||
|
||||
<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.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; }
|
||||
</style>
|
||||
|
||||
@@ -2,42 +2,69 @@
|
||||
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
|
||||
|
||||
*Append-only per constitution §5 — no deletions.*
|
||||
|
||||
```js
|
||||
const events = await FileAttachment("data/progress.json").json();
|
||||
const data = Array.isArray(events) ? events : [];
|
||||
display(html`<div class="live-bar">
|
||||
<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
|
||||
const authorFilter = view(Inputs.select(
|
||||
["(all)", ...new Set(data.map(e => e.author ?? "unknown"))],
|
||||
{ label: "Author" }
|
||||
));
|
||||
const typeFilter = view(Inputs.select(
|
||||
["(all)", ...new Set(data.map(e => e.event_type))],
|
||||
{ label: "Event type" }
|
||||
));
|
||||
const sinceFilter = view(Inputs.date({ label: "Since" }));
|
||||
const authorOpts = ["(all)", ...new Set(data.map(e => e.author ?? "unknown"))].sort();
|
||||
const typeOpts = ["(all)", ...new Set(data.map(e => e.event_type))].sort();
|
||||
|
||||
const authorFilter = view(Inputs.select(authorOpts, {label: "Author"}));
|
||||
const typeFilter = view(Inputs.select(typeOpts, {label: "Event type"}));
|
||||
const sinceFilter = view(Inputs.date({label: "Since"}));
|
||||
```
|
||||
|
||||
```js
|
||||
const filtered = data.filter(e =>
|
||||
(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)
|
||||
);
|
||||
|
||||
display(html`<p><strong>${filtered.length}</strong> events shown (append-only, no deletions).</p>`);
|
||||
|
||||
display(Inputs.table(filtered.map(e => ({
|
||||
Time: new Date(e.created_at).toLocaleString(),
|
||||
Type: e.event_type,
|
||||
Author: e.author ?? "—",
|
||||
Time: new Date(e.created_at).toLocaleString(),
|
||||
Type: e.event_type,
|
||||
Author: e.author ?? "—",
|
||||
Summary: e.summary,
|
||||
})), { rows: 50 }));
|
||||
})), {rows: 50}));
|
||||
```
|
||||
|
||||
## Event Volume (Last 30 Days)
|
||||
@@ -45,33 +72,34 @@ display(Inputs.table(filtered.map(e => ({
|
||||
```js
|
||||
import * as Plot from "npm:@observablehq/plot";
|
||||
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - 30);
|
||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
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
|
||||
.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;
|
||||
}, {});
|
||||
|
||||
display(Plot.plot({
|
||||
title: "Progress Events per Day (30-day window)",
|
||||
x: { label: "Date", tickRotate: -30 },
|
||||
y: { label: "Events", grid: true },
|
||||
marks: [
|
||||
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,
|
||||
}));
|
||||
display(byDay.length === 0
|
||||
? html`<p style="color:gray">No events in the last 30 days.</p>`
|
||||
: Plot.plot({
|
||||
title: "Progress events per day (30-day window)",
|
||||
x: {label: "Date", tickRotate: -30},
|
||||
y: {label: "Events", grid: true},
|
||||
marks: [
|
||||
Plot.areaY(byDay, {x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3}),
|
||||
Plot.lineY(byDay, {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>
|
||||
|
||||
@@ -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