Add a search field next to the Workplans by Repository mode selector so operators can narrow large lists by title, repo, domain, filename, or owner.
768 lines
32 KiB
Markdown
768 lines
32 KiB
Markdown
---
|
|
title: Overview
|
|
---
|
|
|
|
```js
|
|
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
|
import {
|
|
WORKSTREAM_STATUSES,
|
|
isClosedWorkstream,
|
|
isStalledWorkstream,
|
|
needsReviewWorkstream,
|
|
normalizeWorkstreamStatus,
|
|
} from "./components/workplan-status.js";
|
|
```
|
|
|
|
```js
|
|
// Single polling loop — loads one bounded overview read model and keeps
|
|
// last-known-good data visible if a refresh times out.
|
|
const pageState = (async function*() {
|
|
let failures = 0;
|
|
let lastGood = null;
|
|
while (true) {
|
|
let nextState = lastGood
|
|
? {...lastGood, ok: false, stale: true, error: null}
|
|
: {summary: {}, snapshots: [], snapshotCount: 0, totalPkgs: 0, milestones: [], wsAll: [], ok: false, stale: false, error: null, sources: {}, ts: new Date()};
|
|
try {
|
|
const loadJson = async (name, path, options = {}) => {
|
|
const response = await apiFetch(path, options);
|
|
if (!response.ok) throw new Error(`${name} HTTP ${response.status}`);
|
|
return response.json();
|
|
};
|
|
|
|
const overview = await loadJson("overview", "/state/overview", {timeout: 20_000, cache: "reload"});
|
|
|
|
const summaryData = {
|
|
generated_at: overview.generated_at,
|
|
totals: overview.totals ?? {},
|
|
topics: overview.topics ?? [],
|
|
blocking_decisions: overview.blocking_decisions ?? [],
|
|
waiting_tasks: overview.waiting_tasks ?? [],
|
|
blocked_tasks: overview.blocked_tasks ?? overview.waiting_tasks ?? [],
|
|
recent_progress: overview.recent_progress ?? [],
|
|
next_steps: overview.next_steps ?? [],
|
|
contribution_counts: overview.contribution_counts ?? {},
|
|
licence_risk_count: overview.licence_risk_count ?? 0,
|
|
open_capability_requests: overview.open_capability_requests ?? 0,
|
|
};
|
|
|
|
nextState = {
|
|
summary: summaryData,
|
|
snapshots: [],
|
|
snapshotCount: overview.sbom_snapshot_count ?? 0,
|
|
totalPkgs: overview.sbom_package_total ?? 0,
|
|
milestones: overview.registration_milestones ?? [],
|
|
wsAll: (overview.workplan_rows ?? []).map(w => ({
|
|
...w,
|
|
status: normalizeWorkstreamStatus(w.status),
|
|
})),
|
|
ok: true,
|
|
stale: false,
|
|
error: null,
|
|
sources: overview.sources ?? {},
|
|
ts: new Date(),
|
|
};
|
|
lastGood = nextState;
|
|
} catch (e) {
|
|
const message = `Dashboard refresh failed: ${e?.message ?? String(e)}`;
|
|
if (lastGood) {
|
|
nextState = {
|
|
...lastGood,
|
|
ok: false,
|
|
stale: true,
|
|
error: `${message}; showing last successful data from ${lastGood.ts?.toLocaleTimeString?.() ?? "previous refresh"}`,
|
|
summary: {
|
|
...(lastGood.summary ?? {}),
|
|
error: `${message}; showing last successful data from ${lastGood.ts?.toLocaleTimeString?.() ?? "previous refresh"}`,
|
|
},
|
|
};
|
|
} else {
|
|
nextState = {
|
|
summary: {error: message},
|
|
snapshots: [],
|
|
snapshotCount: 0,
|
|
totalPkgs: 0,
|
|
milestones: [],
|
|
wsAll: [],
|
|
ok: false,
|
|
stale: false,
|
|
error: message,
|
|
sources: {},
|
|
ts: new Date(),
|
|
};
|
|
}
|
|
}
|
|
failures = nextState.ok ? 0 : failures + 1;
|
|
yield nextState;
|
|
await waitForVisible(pollDelay({ok: nextState.ok, base: POLL_HEAVY, failures}));
|
|
}
|
|
})();
|
|
```
|
|
|
|
```js
|
|
const summary = pageState.summary ?? {};
|
|
const _ok = pageState.ok ?? false;
|
|
const _stale = pageState.stale ?? false;
|
|
const _ts = pageState.ts;
|
|
const totals = summary.totals ?? {};
|
|
const ws = totals.workstreams ?? {};
|
|
const tasks = totals.tasks ?? {};
|
|
const decisions = totals.decisions ?? {};
|
|
const wsAll = pageState.wsAll ?? [];
|
|
```
|
|
|
|
```js
|
|
// Blocking decisions — fetched once on load, refreshed only after a resolve action.
|
|
// Kept separate from the main poll so in-progress form inputs aren't wiped every 60 s.
|
|
const blockingDecisions = Mutable([]);
|
|
const refreshDecisions = async () => {
|
|
const r = await apiFetch("/decisions/?decision_type=pending", {timeout: 12_000}).catch(() => null);
|
|
const all = r?.ok ? await r.json() : [];
|
|
blockingDecisions.value = all.filter(d => ["open", "escalated"].includes(d.status));
|
|
};
|
|
refreshDecisions();
|
|
```
|
|
|
|
# Custodian State Hub
|
|
|
|
```js
|
|
import {injectTocTop} from "./components/toc-sidebar.js";
|
|
import {withDocHelp} from "./components/doc-overlay.js";
|
|
|
|
const _liveEl = html`<div class="live-indicator">
|
|
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : _stale ? 'orange' : 'red'}">●</span>
|
|
${_ok
|
|
? `Live · updated ${_ts?.toLocaleTimeString()}`
|
|
: _stale
|
|
? `Stale · last successful update ${_ts?.toLocaleTimeString()}`
|
|
: html`<span style="color:red">Offline — run: <code>cd ~/state-hub && make api</code></span>`}
|
|
</div>`;
|
|
withDocHelp(_liveEl, "/docs/live-data");
|
|
injectTocTop("live-indicator", _liveEl);
|
|
|
|
const _h1 = document.querySelector("#observablehq-main h1");
|
|
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/overview"); }
|
|
```
|
|
|
|
```js
|
|
display(html`<div class="warning" style="display:${summary.error ? '' : 'none'}">⚠️ ${summary.error ?? ''}</div>`);
|
|
```
|
|
|
|
## Workplans by Repository
|
|
|
|
```js
|
|
import {workplanMatchesSearch} from "./components/workplan-search.js";
|
|
|
|
// ── Filter workplans by selected mode ───────────────────────────────────────
|
|
// Lifecycle modes match stored canonical status values.
|
|
// Health modes are derived labels; they are not stored lifecycle states.
|
|
// Time modes filter by updated_at / created_at.
|
|
const _STATUS_MODES = new Set(WORKSTREAM_STATUSES);
|
|
const _HEALTH_MODES = new Set(["needs_review", "stalled"]);
|
|
const _MODE_GROUPS = [
|
|
{
|
|
label: "Lifecycle",
|
|
options: [
|
|
["ready", "ready"],
|
|
["active", "active"],
|
|
["blocked", "blocked"],
|
|
["proposed", "proposed"],
|
|
["backlog", "backlog"],
|
|
["finished", "finished"],
|
|
["archived", "archived"],
|
|
],
|
|
},
|
|
{
|
|
label: "Health",
|
|
options: [
|
|
["needs_review", "needs review"],
|
|
["stalled", "stalled"],
|
|
],
|
|
},
|
|
{
|
|
label: "Recently Changed",
|
|
options: [
|
|
["1h", "last 1 hour"],
|
|
["1d", "last 24 hours"],
|
|
["7d", "last 7 days"],
|
|
["30d", "last 30 days"],
|
|
["today", "today"],
|
|
["week", "this week"],
|
|
["month", "this month"],
|
|
],
|
|
},
|
|
];
|
|
const _MODE_VALUES = new Set(_MODE_GROUPS.flatMap(group => group.options.map(([value]) => value)));
|
|
|
|
function _modeValue(mode) {
|
|
const value = typeof mode === "string" ? mode : mode?.value;
|
|
return _MODE_VALUES.has(value) ? value : "active";
|
|
}
|
|
|
|
function _timeCutoff(mode) {
|
|
const now = new Date();
|
|
if (mode === "1h") return new Date(now - 60 * 60 * 1000);
|
|
if (mode === "1d") return new Date(now - 24 * 60 * 60 * 1000);
|
|
if (mode === "7d") return new Date(now - 7 * 24 * 60 * 60 * 1000);
|
|
if (mode === "30d") return new Date(now - 30 * 24 * 60 * 60 * 1000);
|
|
if (mode === "today") return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
if (mode === "week") {
|
|
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); // back to Monday
|
|
return d;
|
|
}
|
|
if (mode === "month") return new Date(now.getFullYear(), now.getMonth(), 1);
|
|
return null;
|
|
}
|
|
|
|
function _validDate(value) {
|
|
const date = new Date(value);
|
|
return Number.isFinite(date.getTime()) ? date : null;
|
|
}
|
|
|
|
function _workstreamsForMode(mode, rows) {
|
|
const modeValue = _modeValue(mode);
|
|
const allRows = Array.isArray(rows) ? rows : [];
|
|
if (_STATUS_MODES.has(modeValue)) {
|
|
return allRows.filter(w => normalizeWorkstreamStatus(w.status) === modeValue);
|
|
}
|
|
if (modeValue === "needs_review") return allRows.filter(needsReviewWorkstream);
|
|
if (modeValue === "stalled") return allRows.filter(w => isStalledWorkstream(w));
|
|
|
|
const since = _timeCutoff(modeValue);
|
|
if (!since) return allRows.filter(w => normalizeWorkstreamStatus(w.status) === "active");
|
|
return allRows.filter(w => {
|
|
const updatedAt = _validDate(w.updated_at);
|
|
const createdAt = _validDate(w.created_at);
|
|
return (updatedAt && updatedAt >= since) || (createdAt && createdAt >= since);
|
|
});
|
|
}
|
|
|
|
const _savedChartMode = _MODE_VALUES.has(globalThis.__stateHubOverviewChartMode)
|
|
? globalThis.__stateHubOverviewChartMode
|
|
: "active";
|
|
const _chartModeState = Mutable(_savedChartMode);
|
|
|
|
function _setChartMode(value) {
|
|
const mode = _modeValue(value);
|
|
globalThis.__stateHubOverviewChartMode = mode;
|
|
_chartModeState.value = mode;
|
|
}
|
|
|
|
// Free-text filter over title / repo / domain / workplan filename (persists in-tab).
|
|
const _savedChartSearch = typeof globalThis.__stateHubOverviewChartSearch === "string"
|
|
? globalThis.__stateHubOverviewChartSearch
|
|
: "";
|
|
const _chartSearchState = Mutable(_savedChartSearch);
|
|
|
|
function _setChartSearch(value) {
|
|
const q = String(value ?? "");
|
|
globalThis.__stateHubOverviewChartSearch = q;
|
|
_chartSearchState.value = q;
|
|
}
|
|
```
|
|
|
|
```js
|
|
const _modeSelect = html`<select
|
|
class="ws-mode-select"
|
|
aria-label="Workplan chart mode with matching workplan counts"
|
|
title="Choose which workplans to show; counts are matching workplans"
|
|
>
|
|
${_MODE_GROUPS.map(group => html`<optgroup label=${group.label}>
|
|
${group.options.map(([value, label]) => html`<option value=${value}>${label} (${_workstreamsForMode(value, wsAll).length})</option>`)}
|
|
</optgroup>`)}
|
|
</select>`;
|
|
_modeSelect.value = _modeValue(_chartModeState);
|
|
_modeSelect.addEventListener("input", () => {
|
|
_setChartMode(_modeSelect.value);
|
|
});
|
|
_modeSelect.addEventListener("change", () => {
|
|
_setChartMode(_modeSelect.value);
|
|
});
|
|
|
|
const _searchInput = html`<input
|
|
type="search"
|
|
class="ws-search-input"
|
|
placeholder="Filter by title, repo, domain…"
|
|
aria-label="Filter workplans by text"
|
|
title="Show only workplans matching this text (title, repository, domain, filename)"
|
|
/>`;
|
|
_searchInput.value = String(_chartSearchState ?? "");
|
|
_searchInput.addEventListener("input", () => {
|
|
_setChartSearch(_searchInput.value);
|
|
});
|
|
|
|
display(html`<div class="filter-bar ws-mode-bar" data-widget-name="Workplans by Repository">
|
|
${_modeSelect}
|
|
<div class="filter-text-input">${_searchInput}</div>
|
|
</div>`);
|
|
```
|
|
|
|
```js
|
|
import * as Plot from "npm:@observablehq/plot";
|
|
|
|
const _chartModeValue = _modeValue(_chartModeState);
|
|
const _chartSearchValue = String(_chartSearchState ?? "");
|
|
const _chartWsFiltered = _workstreamsForMode(_chartModeValue, wsAll)
|
|
.filter(w => workplanMatchesSearch(w, _chartSearchValue));
|
|
|
|
// Sort by domain, then repository, then most recently updated workplan.
|
|
// The axis labels show each domain/repo group once.
|
|
const chartWs = [..._chartWsFiltered].sort((a, b) => {
|
|
const domainCompare = (a.domain ?? "").localeCompare(b.domain ?? "");
|
|
if (domainCompare !== 0) return domainCompare;
|
|
const repoCompare = (a.repo_label ?? "").localeCompare(b.repo_label ?? "");
|
|
if (repoCompare !== 0) return repoCompare;
|
|
return new Date(b.updated_at) - new Date(a.updated_at);
|
|
});
|
|
|
|
// ── Status weight: bold for notable statuses in mixed-status modes ─────────────
|
|
// Color is NOT used for status — avoids green-on-green when finished bars fill the row.
|
|
const _isTimeBased = !_STATUS_MODES.has(_chartModeValue) && !_HEALTH_MODES.has(_chartModeValue);
|
|
function _wsWeight(s) { return (isClosedWorkstream(s) || normalizeWorkstreamStatus(s) === "blocked") ? "bold" : "normal"; }
|
|
|
|
// ── y-axis: domain/repo label for first workplan per repository only ────────
|
|
const _yLabels = {};
|
|
const _seen = new Set();
|
|
for (const w of chartWs) {
|
|
const group = `${w.domain} / ${w.repo_label}`;
|
|
_yLabels[w.id] = _seen.has(group) ? "" : group;
|
|
_seen.add(group);
|
|
}
|
|
|
|
const statusOrder = ["done", "progress", "wait", "todo"];
|
|
const statusColors = ["#4caf50", "#8b5cf6", "#f59e0b", "#e0e0e0"];
|
|
|
|
const _taskRows = chartWs.flatMap(w => [
|
|
{id: w.id, title: w.title, status: "done", count: w.done ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
|
|
{id: w.id, title: w.title, status: "progress", count: w.progress ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
|
|
{id: w.id, title: w.title, status: "wait", count: w.wait ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
|
|
{id: w.id, title: w.title, status: "todo", count: w.todo ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
|
|
]).filter(d => d.count > 0);
|
|
|
|
function _wsTitle(d) {
|
|
return [
|
|
d.title,
|
|
`Repo: ${d.repo ?? "unassigned"}`,
|
|
`Domain: ${d.domain ?? "unknown"}`,
|
|
`Workplan: ${d.workplan ?? "not file-backed"}`,
|
|
`${d.status}: ${d.count}`,
|
|
].join("\n");
|
|
}
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────────
|
|
if (chartWs.length === 0) {
|
|
const _searchQ = _chartSearchValue.trim();
|
|
if (_searchQ) {
|
|
display(html`<p style="color:gray">No workplans match “${_searchQ}” for the selected mode.</p>`);
|
|
} else {
|
|
const _emptyMsg = {
|
|
proposed: "No proposed workplans.",
|
|
ready: "No ready workplans.",
|
|
active: "No active workplans.",
|
|
blocked: "No blocked workplans.",
|
|
backlog: "No backlog workplans.",
|
|
finished: "No finished workplans.",
|
|
archived: "No archived workplans.",
|
|
needs_review: "No ready workplans need review.",
|
|
stalled: "No stalled workplans — everything is moving.",
|
|
"1h": "No workplans changed in the last hour.",
|
|
"1d": "No workplans changed in the last 24 hours.",
|
|
"7d": "No workplans changed in the last 7 days.",
|
|
"30d": "No workplans changed in the last 30 days.",
|
|
today: "No workplans changed today.",
|
|
week: "No workplans changed this week.",
|
|
month: "No workplans changed this month.",
|
|
};
|
|
display(html`<p style="color:gray">${_emptyMsg[_chartModeValue] ?? "No workplans."}</p>`);
|
|
}
|
|
} else {
|
|
display(Plot.plot({
|
|
y: {
|
|
label: null, tickSize: 0,
|
|
domain: chartWs.map(w => w.id),
|
|
tickFormat: t => _yLabels[t] ?? "",
|
|
},
|
|
x: {label: "Tasks", grid: true},
|
|
color: {domain: statusOrder, range: statusColors, legend: true},
|
|
marks: [
|
|
Plot.barX(_taskRows, {
|
|
y: "id", x: "count", fill: "status",
|
|
title: _wsTitle,
|
|
href: "href",
|
|
target: "_self",
|
|
tip: true,
|
|
}),
|
|
// Title label — pushed to lower half of bar row (dy: +7) to separate from count
|
|
Plot.text(chartWs.filter(w => w.total > 0), {
|
|
y: "id", x: 0, dx: 6, dy: 7,
|
|
text: d => d.title.length > 72 ? d.title.slice(0, 70) + "…" : d.title,
|
|
textAnchor: "start", fontSize: 10, fill: "#1e293b",
|
|
fontWeight: d => _isTimeBased ? _wsWeight(d.status) : "normal",
|
|
title: d => [
|
|
d.title,
|
|
`Repo: ${d.repo_label ?? "unassigned"}`,
|
|
`Domain: ${d.domain ?? "unknown"}`,
|
|
`Workplan: ${d.workplan_filename ?? "not file-backed"}`,
|
|
].join("\n"),
|
|
href: "href",
|
|
target: "_self",
|
|
}),
|
|
Plot.text(chartWs.filter(w => w.total === 0), {
|
|
y: "id", x: 0, dx: 6, dy: 7,
|
|
text: d => `${d.title.length > 48 ? d.title.slice(0, 46) + "…" : d.title} — no tasks yet`,
|
|
textAnchor: "start", fontSize: 10, fill: "#94a3b8",
|
|
title: d => [
|
|
d.title,
|
|
`Repo: ${d.repo_label ?? "unassigned"}`,
|
|
`Domain: ${d.domain ?? "unknown"}`,
|
|
`Workplan: ${d.workplan_filename ?? "not file-backed"}`,
|
|
].join("\n"),
|
|
href: "href",
|
|
target: "_self",
|
|
}),
|
|
// Count label — pushed to upper half of bar row (dy: -7) to separate from title
|
|
Plot.text(chartWs.filter(w => w.total > 0), {
|
|
y: "id", x: "total",
|
|
text: d => ` ${d.done}/${d.total}`,
|
|
dx: 4, dy: -7, textAnchor: "start", fontSize: 11, fill: "gray",
|
|
title: d => `${d.title}\nWorkplan: ${d.workplan_filename ?? "not file-backed"}`,
|
|
href: "href",
|
|
target: "_self",
|
|
}),
|
|
Plot.ruleX([0]),
|
|
],
|
|
marginLeft: 220,
|
|
marginRight: 70,
|
|
height: Math.max(80, chartWs.length * 44 + 50),
|
|
width: 700,
|
|
}));
|
|
}
|
|
```
|
|
|
|
## Contribution & SBOM Health
|
|
|
|
```js
|
|
const contribCounts = summary.contribution_counts ?? {};
|
|
const licenceRisk = summary.licence_risk_count ?? 0;
|
|
const totalContribs = ["br","fr","ep","upr"].reduce((s, t) => s + (contribCounts[t] ?? 0), 0);
|
|
const needsFollowUp = (contribCounts["submitted"] ?? 0) + (contribCounts["acknowledged"] ?? 0);
|
|
const sbomSnaps = pageState.snapshots ?? [];
|
|
const sbomSnapCount = pageState.snapshotCount ?? sbomSnaps.length;
|
|
const totalPkgs = pageState.totalPkgs ?? 0;
|
|
|
|
display(html`<div class="grid grid-cols-3" style="gap:1rem;margin-bottom:1.5rem">
|
|
<a class="card card-link" href="./contributions">
|
|
<h3>Contributions</h3>
|
|
<p class="big-num">${totalContribs}</p>
|
|
<small>${needsFollowUp > 0 ? html`<span style="color:orange">${needsFollowUp} awaiting upstream response</span>` : "all up to date"}</small>
|
|
</a>
|
|
<a class="card card-link ${licenceRisk > 0 ? 'warn' : ''}" href="./sbom">
|
|
<h3>Licence Risk</h3>
|
|
<p class="big-num">${licenceRisk}</p>
|
|
<small>${licenceRisk === 0 ? html`<span style="color:green">✓ no copyleft in direct deps</span>` : html`<span style="color:red">copyleft in direct prod deps</span>`}</small>
|
|
</a>
|
|
<a class="card card-link ${licenceRisk > 0 ? 'warn' : ''}" href="./sbom">
|
|
<h3>SBOM</h3>
|
|
<p class="big-num">${totalPkgs.toLocaleString()}</p>
|
|
<small>${sbomSnapCount} snapshot${sbomSnapCount !== 1 ? "s" : ""} tracked · ${licenceRisk > 0 ? html`<span style="color:red">${licenceRisk} copyleft risks</span>` : html`<span style="color:green">✓ no copyleft</span>`}</small>
|
|
</a>
|
|
</div>`);
|
|
```
|
|
|
|
## Status
|
|
|
|
```js
|
|
const waitingTasks = summary.waiting_tasks ?? summary.blocked_tasks ?? [];
|
|
const wsById = Object.fromEntries((summary.open_workplans ?? []).map(w => [w.id, w]));
|
|
const todayCount = (summary.recent_progress ?? []).filter(e =>
|
|
e.created_at?.startsWith(new Date().toISOString().slice(0, 10))).length;
|
|
const decCount = (decisions.open ?? 0) + (decisions.escalated ?? 0);
|
|
|
|
const statusEl = html`<div>
|
|
<div class="grid grid-cols-4" style="gap:1rem;margin-bottom:0.75rem">
|
|
<a class="card card-link" href="./workstreams">
|
|
<h3>Active Workplans</h3>
|
|
<p class="big-num">${ws.active ?? 0}</p>
|
|
<small>${ws.blocked ?? 0} blocked</small>
|
|
</a>
|
|
<a class="card card-link ${decCount > 0 ? 'warn' : ''}" href="#blocking-decisions">
|
|
<h3>Blocking Decisions</h3>
|
|
<p class="big-num">${decCount}</p>
|
|
<small>${decisions.escalated ?? 0} escalated</small>
|
|
</a>
|
|
<div class="card card-link ${waitingTasks.length > 0 ? 'warn' : ''}" data-toggle="waiting-panel">
|
|
<h3>Waiting Tasks</h3>
|
|
<p class="big-num">${waitingTasks.length}</p>
|
|
<small>of ${tasks.total ?? 0} total · click to expand</small>
|
|
</div>
|
|
<a class="card card-link" href="#recent-activity">
|
|
<h3>Events Today</h3>
|
|
<p class="big-num">${todayCount}</p>
|
|
<small>last 20 shown below</small>
|
|
</a>
|
|
</div>
|
|
|
|
<div id="waiting-panel" style="display:none;margin-bottom:1rem">
|
|
${waitingTasks.length === 0
|
|
? html`<p class="dim" style="padding:0.5rem 0">No tasks currently waiting.</p>`
|
|
: html`<div class="bt-list">${waitingTasks.map(t => {
|
|
const wsName = wsById[t.workstream_id]?.title ?? t.workstream_id?.slice(0,8) ?? "—";
|
|
return html`<div class="bt-row">
|
|
<div class="bt-meta">${wsName}</div>
|
|
<div class="bt-title">${t.title}</div>
|
|
${t.blocking_reason ? html`<div class="bt-reason">⊘ ${t.blocking_reason}</div>` : ""}
|
|
</div>`;
|
|
})}</div>`
|
|
}
|
|
</div>
|
|
</div>`;
|
|
|
|
statusEl.querySelector('[data-toggle="waiting-panel"]').addEventListener('click', () => {
|
|
const panel = statusEl.querySelector('#waiting-panel');
|
|
const isOpen = panel.style.display !== 'none';
|
|
panel.style.display = isOpen ? 'none' : 'block';
|
|
statusEl.querySelector('[data-toggle="waiting-panel"] small').textContent =
|
|
isOpen ? `of ${tasks.total ?? 0} total · click to expand` : `of ${tasks.total ?? 0} total · click to collapse`;
|
|
});
|
|
|
|
display(statusEl);
|
|
```
|
|
|
|
## What's next?
|
|
|
|
```js
|
|
// next_steps comes from the summary poll (derived, never persisted)
|
|
const nextSteps = summary.next_steps ?? [];
|
|
|
|
const typeLabel = {
|
|
resolved_decision: "Decision resolved",
|
|
dependency_cleared: "Dependency cleared",
|
|
unblocked_task: "Task unblocked",
|
|
};
|
|
const typeBadgeClass = {
|
|
resolved_decision: "ns-badge-decision",
|
|
dependency_cleared: "ns-badge-dep",
|
|
unblocked_task: "ns-badge-task",
|
|
};
|
|
|
|
if (nextSteps.length === 0) {
|
|
display(html`<p class="ns-empty">No actionable suggestions right now — all open workplans are making progress or waiting on decisions.</p>`);
|
|
} else {
|
|
display(html`<div class="ns-grid">${nextSteps.map(s => html`
|
|
<div class="ns-card">
|
|
<div class="ns-card-header">
|
|
<span class="ns-badge ${typeBadgeClass[s.type] ?? ''}">${typeLabel[s.type] ?? s.type}</span>
|
|
<span class="ns-domain">${s.domain ?? "—"}</span>
|
|
</div>
|
|
<div class="ns-ws">${s.workstream_title ?? "—"}</div>
|
|
<div class="ns-task">${s.task_title ? html`→ <strong>${s.task_title}</strong>` : ""}</div>
|
|
<div class="ns-msg">${s.message}</div>
|
|
</div>
|
|
`)}</div>`);
|
|
}
|
|
```
|
|
|
|
## Registered Projects
|
|
|
|
```js
|
|
const regs = pageState.milestones ?? [];
|
|
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}));
|
|
}
|
|
```
|
|
|
|
```js
|
|
// Registered domains with no workplans yet — show a getting-started hint
|
|
const regs = pageState.milestones ?? [];
|
|
const registeredDomains = new Set(regs.map(e => e.detail?.domain).filter(Boolean));
|
|
const emptyRegistered = (summary.topics ?? []).filter(t =>
|
|
registeredDomains.has(t.domain_slug) && (t.workstreams ?? []).length === 0
|
|
);
|
|
|
|
if (emptyRegistered.length > 0) {
|
|
display(html`<div class="hint-box">
|
|
<strong>💡 Getting started</strong>
|
|
<p>These registered projects have no workplans yet:</p>
|
|
<ul>${emptyRegistered.map(t => html`<li>
|
|
<strong>${t.domain_slug}</strong> — open repo in Claude Code and say <em>"Hi!"</em> to kick off first session, or create a workplan file under <code>workplans/</code> and run <code>statehub fix-consistency</code>
|
|
</li>`)}</ul>
|
|
</div>`);
|
|
}
|
|
```
|
|
|
|
## Blocking Decisions
|
|
|
|
```js
|
|
// Uses blockingDecisions (Mutable) — only re-renders when refreshDecisions() is called,
|
|
// not on every summary poll, so in-progress form input is preserved between polls.
|
|
const blocking = blockingDecisions ?? [];
|
|
if (blocking.length === 0) {
|
|
display(html`<p style="color:green">✓ No blocking decisions.</p>`);
|
|
} else {
|
|
for (const d of blocking) {
|
|
const card = html`<div class="dec-card ${d.escalation_note ? 'dec-escalated' : ''}">
|
|
<div class="dec-header">
|
|
<span class="dec-title">${d.title}</span>
|
|
<span class="dec-meta">
|
|
${d.escalation_note ? html`<span class="dec-warn-badge">⚠ escalated</span>` : ""}
|
|
${d.deadline ? html`<span>Due ${new Date(d.deadline).toLocaleDateString()}</span>` : ""}
|
|
<button class="r-copy" title="Copy decision to clipboard">Copy</button>
|
|
</span>
|
|
</div>
|
|
${d.description ? html`<p class="dec-desc">${d.description}</p>` : ""}
|
|
${d.rationale ? html`<p class="dec-context"><strong>Context:</strong> ${d.rationale}</p>` : ""}
|
|
${d.escalation_note ? html`<p class="dec-context dec-warn-text">${d.escalation_note}</p>` : ""}
|
|
<details class="dec-resolve">
|
|
<summary>Resolve this decision →</summary>
|
|
<div class="dec-resolve-inner">
|
|
<label>Your decision & rationale</label>
|
|
<textarea class="r-text" rows="4" placeholder="State the chosen option and your reasoning…"></textarea>
|
|
<label>Decided by</label>
|
|
<input class="r-by" type="text" value="human">
|
|
<div class="dec-resolve-actions">
|
|
<button class="r-submit">Record & close</button>
|
|
<span class="r-msg"></span>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>`;
|
|
|
|
// Copy to clipboard
|
|
const copyBtn = card.querySelector(".r-copy");
|
|
copyBtn.addEventListener("click", () => {
|
|
const parts = [
|
|
`# ${d.title}`,
|
|
"",
|
|
d.description ?? "",
|
|
d.rationale ? `\n**Context:** ${d.rationale}` : "",
|
|
d.escalation_note ? `\n**⚠ Escalated:** ${d.escalation_note}` : "",
|
|
`\n**Status:** ${d.status} | **Created:** ${new Date(d.created_at).toLocaleDateString()}`,
|
|
d.deadline ? `**Due:** ${new Date(d.deadline).toLocaleDateString()}` : "",
|
|
].filter(Boolean).join("\n");
|
|
navigator.clipboard.writeText(parts).then(() => {
|
|
copyBtn.textContent = "✓ Copied";
|
|
setTimeout(() => { copyBtn.textContent = "Copy"; }, 1500);
|
|
}).catch(() => { copyBtn.textContent = "⚠ Failed"; setTimeout(() => { copyBtn.textContent = "Copy"; }, 2000); });
|
|
});
|
|
|
|
// Resolve
|
|
const btn = card.querySelector(".r-submit");
|
|
const msg = card.querySelector(".r-msg");
|
|
|
|
btn.addEventListener("click", async () => {
|
|
const rationale = card.querySelector(".r-text").value.trim();
|
|
const decidedBy = card.querySelector(".r-by").value.trim() || "human";
|
|
if (!rationale) { msg.textContent = "⚠ Please enter a rationale."; return; }
|
|
btn.disabled = true; btn.textContent = "Saving…";
|
|
try {
|
|
const r = await fetch(`${API}/decisions/${d.id}/resolve`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({rationale, decided_by: decidedBy}),
|
|
});
|
|
if (r.ok) {
|
|
await refreshDecisions(); // re-fetches list — resolved decision won't appear
|
|
} else {
|
|
const err = await r.json().catch(() => ({}));
|
|
msg.textContent = `Error ${r.status}: ${err.detail ?? "unknown"}`;
|
|
btn.disabled = false; btn.textContent = "Record & close";
|
|
}
|
|
} catch (e) {
|
|
msg.textContent = `Network error: ${e.message}`;
|
|
btn.disabled = false; btn.textContent = "Record & close";
|
|
}
|
|
});
|
|
|
|
display(card);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Decisions Due Within 7 Days
|
|
|
|
```js
|
|
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,
|
|
Deadline: new Date(d.deadline).toLocaleString(),
|
|
Status: d.status,
|
|
}))));
|
|
}
|
|
```
|
|
|
|
## Recent Activity
|
|
|
|
```js
|
|
display(Inputs.table((summary.recent_progress ?? []).map(e => ({
|
|
Time: new Date(e.created_at).toLocaleString(),
|
|
Type: e.event_type,
|
|
Author: e.author ?? "—",
|
|
Summary: e.summary,
|
|
})), {maxWidth: 900}));
|
|
```
|
|
|
|
<style>
|
|
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
|
|
.ws-mode-bar { margin-bottom: 0.75rem; }
|
|
.ws-mode-select { min-width: 18rem; max-width: 100%; padding: 0.35rem 0.5rem; border-radius: 4px; border: 1px solid var(--theme-foreground-faint, #ddd); background: var(--theme-background); color: var(--theme-foreground); font: inherit; }
|
|
.ws-search-input { min-width: 14rem; max-width: 100%; height: 30px; font-size: 0.85rem; padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--theme-foreground-faint, #ccc); background: var(--theme-background, #fff); font-family: inherit; color: inherit; }
|
|
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
|
|
.card.warn { border: 2px solid orange; }
|
|
.card-link { cursor: pointer; transition: box-shadow 0.15s, transform 0.1s; text-decoration: none; color: inherit; display: block; }
|
|
.card-link:hover { box-shadow: 0 3px 10px rgba(0,0,0,0.13); transform: translateY(-1px); }
|
|
.bt-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
.bt-row { background: var(--theme-background-alt); border-radius: 6px; padding: 0.6rem 0.9rem; border-left: 3px solid #ff7043; }
|
|
.bt-meta { font-size: 0.7rem; color: gray; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.15rem; }
|
|
.bt-title { font-weight: 600; font-size: 0.9rem; }
|
|
.bt-reason { font-size: 0.8rem; color: #b45309; margin-top: 0.25rem; }
|
|
.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; }
|
|
.hint-box { background: var(--theme-background-alt); border-left: 3px solid steelblue; border-radius: 4px; padding: 0.75rem 1rem; margin-top: 0.75rem; font-size: 0.9rem; }
|
|
.hint-box code { background: var(--theme-background); padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.85rem; }
|
|
.dec-card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; border-left: 4px solid steelblue; }
|
|
.dec-card.dec-escalated { border-left-color: orange; }
|
|
.dec-header { display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
|
|
.dec-title { font-weight: 600; font-size: 1rem; }
|
|
.dec-meta { font-size: 0.8rem; color: gray; display: flex; gap: 0.5rem; align-items: center; }
|
|
.dec-warn-badge { background: orange; color: white; border-radius: 3px; padding: 0.1rem 0.35rem; font-size: 0.75rem; }
|
|
.dec-desc { font-size: 0.9rem; margin: 0.4rem 0 0.25rem; white-space: pre-wrap; line-height: 1.5; }
|
|
.dec-context { font-size: 0.85rem; color: gray; margin: 0.25rem 0; }
|
|
.dec-warn-text { color: #b45309; }
|
|
.dec-resolve { margin-top: 0.75rem; }
|
|
.dec-resolve summary { cursor: pointer; font-size: 0.85rem; color: steelblue; user-select: none; }
|
|
.dec-resolve-inner { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.6rem; }
|
|
.dec-resolve-inner label { font-size: 0.8rem; font-weight: 600; color: gray; }
|
|
.dec-resolve-inner textarea { width: 100%; box-sizing: border-box; padding: 0.4rem; border-radius: 4px; border: 1px solid var(--theme-foreground-faint); background: var(--theme-background); font-family: inherit; font-size: 0.875rem; resize: vertical; }
|
|
.dec-resolve-inner input[type=text] { width: 220px; padding: 0.3rem 0.5rem; border-radius: 4px; border: 1px solid var(--theme-foreground-faint); background: var(--theme-background); font-family: inherit; font-size: 0.875rem; }
|
|
.dec-resolve-actions { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.25rem; }
|
|
.dec-resolve-actions button { padding: 0.35rem 0.9rem; border-radius: 4px; border: none; background: steelblue; color: white; cursor: pointer; font-size: 0.875rem; }
|
|
.dec-resolve-actions button:disabled { opacity: 0.5; cursor: default; }
|
|
.r-msg { font-size: 0.8rem; color: #b45309; }
|
|
.r-copy { padding: 0.15rem 0.55rem; border-radius: 3px; border: 1px solid var(--theme-foreground-faint); background: var(--theme-background); color: var(--theme-foreground-muted); cursor: pointer; font-size: 0.75rem; }
|
|
.r-copy:hover { background: var(--theme-background-alt); }
|
|
/* What's next */
|
|
.ns-empty { color: gray; font-style: italic; }
|
|
.ns-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; }
|
|
.ns-card { background: var(--theme-background-alt); border-radius: 8px; padding: 0.85rem 1rem; border-left: 4px solid #555; }
|
|
.ns-card-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; }
|
|
.ns-badge { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.15rem 0.45rem; border-radius: 10px; font-weight: 600; }
|
|
.ns-badge-decision { background: #d4edda; color: #155724; }
|
|
.ns-badge-dep { background: #cce5ff; color: #004085; }
|
|
.ns-badge-task { background: #fff3cd; color: #856404; }
|
|
.ns-domain { font-size: 0.75rem; color: gray; }
|
|
.ns-ws { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.2rem; }
|
|
.ns-task { font-size: 0.85rem; margin-bottom: 0.35rem; }
|
|
.ns-msg { font-size: 0.78rem; color: #555; line-height: 1.4; }
|
|
</style>
|