diff --git a/api/main.py b/api/main.py index 04f57bd..e776a1c 100644 --- a/api/main.py +++ b/api/main.py @@ -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) diff --git a/dashboard/src/decisions.md b/dashboard/src/decisions.md index 7885a6e..82b462d 100644 --- a/dashboard/src/decisions.md +++ b/dashboard/src/decisions.md @@ -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`
`); +``` + +```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`No resolved decisions yet.
` + : 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, + }) +); ``` diff --git a/dashboard/src/index.md b/dashboard/src/index.md index e3e45af..f9d3998 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -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`make api.${ws.active ?? 0}
+${ws.active ?? 0}
${ws.blocked ?? 0} blocked${(decisions.open ?? 0) + (decisions.escalated ?? 0)}
+${(decisions.open ?? 0) + (decisions.escalated ?? 0)}
${decisions.escalated ?? 0} escalated${tasks.blocked ?? 0}
+${tasks.blocked ?? 0}
of ${tasks.total ?? 0} total${(summary.recent_progress ?? []).filter(e => e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}
+${(summary.recent_progress ?? []).filter(e => + e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}
last 20 shown belowNo projects registered yet. Run custodian register-project inside a repo.
✓ No blocking decisions.
`); } 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`No decisions due in next 7 days.
`); } 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})); ``` diff --git a/dashboard/src/progress.md b/dashboard/src/progress.md index 3b9f660..1726cbe 100644 --- a/dashboard/src/progress.md +++ b/dashboard/src/progress.md @@ -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``); ``` ```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`${filtered.length} events shown (append-only, no deletions).
`); 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`No events in the last 30 days.
` + : 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, + }) +); ``` + + diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index d30335a..9b62e8c 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -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``); +``` + +```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, })); ``` + +