diff --git a/state-hub/dashboard/src/components/doc-overlay.js b/state-hub/dashboard/src/components/doc-overlay.js
new file mode 100644
index 0000000..bc501e0
--- /dev/null
+++ b/state-hub/dashboard/src/components/doc-overlay.js
@@ -0,0 +1,200 @@
+/**
+ * doc-overlay — hoverable ? button that opens a documentation page in an overlay.
+ *
+ * Usage:
+ * import {withDocHelp} from "./components/doc-overlay.js";
+ *
+ * const el = html`
...
`;
+ * withDocHelp(el, "/docs/my-page"); // mutates el in place, returns it
+ * display(el);
+ *
+ * The element must have position:relative (or set it via inline style before calling).
+ * The ? button is invisible until the user hovers over the element.
+ */
+
+const _STYLE_ID = "doc-overlay-styles";
+
+function _ensureStyles() {
+ if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
+ const s = document.createElement("style");
+ s.id = _STYLE_ID;
+ s.textContent = `
+/* ── ? help button ─────────────────────────────────────────────────────────── */
+.doc-help-btn {
+ position: absolute;
+ top: 0.45rem;
+ right: 0.45rem;
+ width: 1.25rem;
+ height: 1.25rem;
+ border-radius: 50%;
+ border: 1px solid var(--theme-foreground-faint, #ccc);
+ background: var(--theme-background, #fff);
+ color: var(--theme-foreground-muted, #999);
+ font-size: 0.65rem;
+ font-weight: 700;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.15s, background 0.15s, border-color 0.15s;
+ z-index: 10;
+ padding: 0;
+ line-height: 1;
+ font-family: var(--sans-serif, system-ui, sans-serif);
+}
+.doc-help-wrap:hover .doc-help-btn,
+.doc-help-btn:focus-visible {
+ opacity: 1;
+}
+.doc-help-btn:hover {
+ background: var(--theme-background-alt, #f0f0f0);
+ border-color: steelblue;
+ color: steelblue;
+}
+
+/* ── overlay backdrop ───────────────────────────────────────────────────────── */
+.doc-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ z-index: 9000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: _doc-fade-in 0.15s ease;
+}
+@keyframes _doc-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+/* ── overlay box ────────────────────────────────────────────────────────────── */
+.doc-overlay-box {
+ width: min(780px, 92vw);
+ height: 82vh;
+ background: var(--theme-background, #fff);
+ border-radius: 12px;
+ box-shadow: 0 16px 56px rgba(0, 0, 0, 0.28);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ animation: _doc-rise 0.15s ease;
+}
+@keyframes _doc-rise {
+ from { transform: translateY(14px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+/* ── overlay header bar ─────────────────────────────────────────────────────── */
+.doc-overlay-header {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 0.45rem 0.75rem;
+ border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8);
+ background: var(--theme-background-alt, #f7f7f7);
+ flex-shrink: 0;
+ gap: 0.5rem;
+}
+.doc-overlay-hint {
+ font-size: 0.75rem;
+ color: var(--theme-foreground-faint, #aaa);
+ margin-right: auto;
+}
+.doc-overlay-close {
+ background: none;
+ border: 1px solid transparent;
+ cursor: pointer;
+ font-size: 0.82rem;
+ color: var(--theme-foreground-muted, #888);
+ padding: 0.2rem 0.55rem;
+ border-radius: 6px;
+ line-height: 1.2;
+ font-family: inherit;
+}
+.doc-overlay-close:hover {
+ border-color: var(--theme-foreground-faint, #ccc);
+ background: var(--theme-background, #fff);
+ color: var(--theme-foreground, #111);
+}
+
+/* ── iframe ─────────────────────────────────────────────────────────────────── */
+.doc-overlay-frame {
+ flex: 1;
+ border: none;
+ width: 100%;
+}
+`;
+ document.head.append(s);
+}
+
+function _openOverlay(docPath) {
+ // Remove any existing overlay
+ document.getElementById("_doc-overlay-root")?.remove();
+
+ const root = document.createElement("div");
+ root.id = "_doc-overlay-root";
+ root.className = "doc-overlay";
+ root.setAttribute("role", "dialog");
+ root.setAttribute("aria-modal", "true");
+
+ const box = document.createElement("div");
+ box.className = "doc-overlay-box";
+
+ const header = document.createElement("div");
+ header.className = "doc-overlay-header";
+
+ const hint = document.createElement("span");
+ hint.className = "doc-overlay-hint";
+ hint.textContent = "Press Esc or click outside to close";
+
+ const closeBtn = document.createElement("button");
+ closeBtn.className = "doc-overlay-close";
+ closeBtn.textContent = "✕ close";
+ closeBtn.setAttribute("aria-label", "Close documentation");
+
+ header.append(hint, closeBtn);
+
+ const frame = document.createElement("iframe");
+ frame.className = "doc-overlay-frame";
+ frame.src = docPath;
+ frame.setAttribute("loading", "lazy");
+ frame.title = "Documentation";
+
+ box.append(header, frame);
+ root.append(box);
+ document.body.append(root);
+
+ const close = () => root.remove();
+
+ closeBtn.addEventListener("click", close);
+ root.addEventListener("click", e => { if (e.target === root) close(); });
+
+ const onKey = e => {
+ if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
+ };
+ document.addEventListener("keydown", onKey);
+}
+
+/**
+ * Adds a hoverable ? button to an element that opens a documentation overlay.
+ *
+ * @param {HTMLElement} element - Element to annotate. Must have position:relative.
+ * @param {string} docPath - Root-relative URL, e.g. "/docs/decisions-kpi"
+ * @returns {HTMLElement} The element (mutated in place).
+ */
+export function withDocHelp(element, docPath) {
+ _ensureStyles();
+
+ element.classList.add("doc-help-wrap");
+
+ const btn = document.createElement("button");
+ btn.className = "doc-help-btn";
+ btn.textContent = "?";
+ btn.setAttribute("aria-label", "Open documentation");
+ btn.addEventListener("click", e => { e.stopPropagation(); _openOverlay(docPath); });
+
+ element.append(btn);
+ return element;
+}
diff --git a/state-hub/dashboard/src/decisions.md b/state-hub/dashboard/src/decisions.md
index 01549fe..6553e11 100644
--- a/state-hub/dashboard/src/decisions.md
+++ b/state-hub/dashboard/src/decisions.md
@@ -28,7 +28,6 @@ const decState = (async function*() {
topic_title: topicMap[d.topic_id]?.title ?? null,
}))
.sort((a, b) => {
- // escalated first, then open, then resolved/superseded; within group by deadline asc
const rank = {escalated: 0, open: 1, resolved: 2, superseded: 3};
const dr = (rank[a.status] ?? 9) - (rank[b.status] ?? 9);
if (dr !== 0) return dr;
@@ -52,7 +51,7 @@ const _ts = decState.ts;
```js
import {MultiSelect} from "./components/multiselect.js";
-// Create filter form without displaying — displayed below the chart via display(_filtersForm)
+// Create filter form without displaying — shown below the chart via display(_filtersForm)
const _filtersForm = Inputs.form(
{
type: MultiSelect(["pending", "made"], {label: "Type", placeholder: "All types"}),
@@ -69,7 +68,6 @@ const _filtersForm = Inputs.form(
```
```js
-// Reactive value from the form without displaying it
const filters = Generators.input(_filtersForm);
```
@@ -91,8 +89,6 @@ function fmtDate(iso) {
function isOverdue(iso) {
return iso && new Date(iso) < new Date();
}
-
-// Format a duration in milliseconds as a compact human string
function fmtDuration(ms) {
if (ms < 0) ms = 0;
const h = 3_600_000, d = 86_400_000, w = 7 * d;
@@ -107,10 +103,9 @@ function fmtDuration(ms) {
# Decisions
```js
-// ── KPI bar ────────────────────────────────────────────────────────────────
-// Uses full `data` (not filtered) — these are health metrics for the whole system
+import {withDocHelp} from "./components/doc-overlay.js";
-// Mean resolution time from the last ≤5 resolved decisions
+// ── KPI computation (uses full data, not filtered) ──────────────────────────
const _resolved5 = data
.filter(d => d.decided_at && d.created_at)
.sort((a, b) => b.decided_at.localeCompare(a.decided_at))
@@ -120,46 +115,50 @@ const _meanResolveMs = _resolved5.length
? _resolved5.reduce((s, d) => s + (new Date(d.decided_at) - new Date(d.created_at)), 0) / _resolved5.length
: null;
-// Mean age of currently open decisions
-const _nowKpi = new Date();
+const _nowKpi = new Date();
const _openDecs = data.filter(d => d.status === "open" || d.status === "escalated");
const _openAges = _openDecs.map(d => _nowKpi - new Date(d.created_at));
const _meanOpenMs = _openAges.length
? _openAges.reduce((s, a) => s + a, 0) / _openAges.length
: null;
-// Color logic for the mean-open-age KPI:
-// red — mean open age exceeds avg resolve time
-// orange — at least one open decision exceeds avg resolve time (but mean is still OK)
-// black — all open decisions are younger than avg resolve time
+// Color: red = mean open > baseline; orange = any individual > baseline; black = all fine
let _openAgeColor = "inherit";
if (_meanOpenMs !== null && _meanResolveMs !== null) {
- if (_meanOpenMs > _meanResolveMs) {
- _openAgeColor = "#dc2626";
- } else if (_openAges.some(a => a > _meanResolveMs)) {
- _openAgeColor = "#d97706";
- }
+ if (_meanOpenMs > _meanResolveMs) _openAgeColor = "#dc2626";
+ else if (_openAges.some(a => a > _meanResolveMs)) _openAgeColor = "#d97706";
}
-display(html`
+// ── Build the KPI infobox ───────────────────────────────────────────────────
+const _kpiBox = html`
+
Decision Health
+ ${_meanResolveMs !== null ? html`
+
avg resolve
+
+
${fmtDuration(_meanResolveMs)}
+
last ${_resolved5.length}
+
+
` : ""}
+ ${_meanOpenMs !== null ? html`
+
avg open age
+
+
${fmtDuration(_meanOpenMs)}
+
${_openDecs.length} open
+
+
` : html`
no open decisions
`}
+
`;
+
+withDocHelp(_kpiBox, "/docs/decisions-kpi");
+
+// ── Header: live indicator (left) + KPI box (right) ────────────────────────
+display(html``);
```
@@ -175,12 +174,8 @@ const period = view(Inputs.radio(
```js
import * as Plot from "npm:@observablehq/plot";
-// Returns the most meaningful timestamp for a decision
-function _getTs(d) {
- return new Date(d.decided_at ?? d.created_at);
-}
+function _getTs(d) { return new Date(d.decided_at ?? d.created_at); }
-// Map a timestamp to the start-of-bucket timestamp (as ms)
function _bucketKey(t, unit, start) {
switch (unit) {
case "hour": return new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours()).getTime();
@@ -193,7 +188,6 @@ function _bucketKey(t, unit, start) {
}
}
-// Generate all bucket start timestamps from start to end (inclusive)
function _genBuckets(start, end, unit) {
const bkts = [];
let cur = new Date(start);
@@ -207,66 +201,59 @@ function _genBuckets(start, end, unit) {
return bkts;
}
-// Derive window + bucket config from the selected period
const _now = new Date();
-const _y = _now.getFullYear();
-const _mo = _now.getMonth();
+const _y = _now.getFullYear(), _mo = _now.getMonth();
let _start, _unit, _tickFmt;
switch (period) {
case "day":
- _start = new Date(_y, _mo, _now.getDate());
- _unit = "hour";
+ _start = new Date(_y, _mo, _now.getDate());
+ _unit = "hour";
_tickFmt = d => `${String(d.getHours()).padStart(2, "0")}:00`;
break;
case "week": {
const _7ago = new Date(_now - 7 * 864e5);
- _start = new Date(_7ago.getFullYear(), _7ago.getMonth(), _7ago.getDate());
- _unit = "day";
+ _start = new Date(_7ago.getFullYear(), _7ago.getMonth(), _7ago.getDate());
+ _unit = "day";
_tickFmt = d => d.toLocaleDateString(undefined, {weekday: "short", month: "short", day: "numeric"});
break;
}
case "month":
- _start = new Date(_y, _mo, 1);
- _unit = "week";
+ _start = new Date(_y, _mo, 1);
+ _unit = "week";
_tickFmt = d => `W/${d.toLocaleDateString(undefined, {month: "short", day: "numeric"})}`;
break;
case "quarter":
- _start = new Date(_y, Math.floor(_mo / 3) * 3, 1);
- _unit = "month";
+ _start = new Date(_y, Math.floor(_mo / 3) * 3, 1);
+ _unit = "month";
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short"});
break;
case "YTD":
- _start = new Date(_y, 0, 1);
- _unit = "month";
+ _start = new Date(_y, 0, 1);
+ _unit = "month";
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short"});
break;
case "year": {
- const _52ago = new Date(_now - 365 * 864e5);
- _start = new Date(_52ago.getFullYear(), _52ago.getMonth(), 1);
- _unit = "month";
+ const _ago = new Date(_now - 365 * 864e5);
+ _start = new Date(_ago.getFullYear(), _ago.getMonth(), 1);
+ _unit = "month";
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "2-digit"});
break;
}
default: {
- // "all" — start from earliest decision in filtered set
- const _minTs = filtered.length
- ? Math.min(...filtered.map(d => _getTs(d)))
- : _now.getTime();
+ const _minTs = filtered.length ? Math.min(...filtered.map(d => _getTs(d))) : _now.getTime();
const _minD = new Date(_minTs);
- _start = new Date(_minD.getFullYear(), _minD.getMonth(), 1);
- _unit = "month";
+ _start = new Date(_minD.getFullYear(), _minD.getMonth(), 1);
+ _unit = "month";
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "numeric"});
break;
}
}
-// Restrict to window (all period uses full filtered set)
const _inWindow = period === "all"
? [...filtered]
: filtered.filter(d => { const t = _getTs(d); return t >= _start && t <= _now; });
-// Count per bucket
const _bktKeys = _genBuckets(_start, _now, _unit);
const _cntMap = new Map(_bktKeys.map(k => [k, 0]));
for (const d of _inWindow) {
@@ -274,7 +261,6 @@ for (const d of _inWindow) {
if (_cntMap.has(key)) _cntMap.set(key, (_cntMap.get(key) || 0) + 1);
}
-// Build cumulative series
let _cum = 0;
const _chartData = _bktKeys.map(k => {
const delta = _cntMap.get(k) || 0;
@@ -282,30 +268,36 @@ const _chartData = _bktKeys.map(k => {
return {date: new Date(k), count: _cum, delta};
});
-display(_inWindow.length === 0
- ? html`
No decisions in this period.
`
- : Plot.plot({
- x: {type: "time", tickFormat: _tickFmt, tickRotate: -30, label: null},
- y: {grid: true, label: "Cumulative decisions"},
- marks: [
- Plot.areaY(_chartData, {x: "date", y: "count", fill: "steelblue", fillOpacity: 0.15, curve: "step-after"}),
- Plot.lineY(_chartData, {x: "date", y: "count", stroke: "steelblue", strokeWidth: 2, curve: "step-after"}),
- Plot.dot(_chartData.filter(d => d.delta > 0), {
- x: "date", y: "count", fill: "steelblue", r: 4, tip: true,
- title: d => `${_tickFmt(d.date)}\n+${d.delta} → ${d.count} total`,
- }),
- Plot.ruleY([0]),
- ],
- marginBottom: 70,
- width: 700,
- })
-);
+if (_inWindow.length === 0) {
+ display(html`
No decisions in this period.
`);
+} else {
+ const _plotEl = Plot.plot({
+ x: {type: "time", tickFormat: _tickFmt, tickRotate: -30, label: null},
+ y: {grid: true, label: "Cumulative decisions"},
+ marks: [
+ Plot.areaY(_chartData, {x: "date", y: "count", fill: "steelblue", fillOpacity: 0.15, curve: "step-after"}),
+ Plot.lineY(_chartData, {x: "date", y: "count", stroke: "steelblue", strokeWidth: 2, curve: "step-after"}),
+ Plot.dot(_chartData.filter(d => d.delta > 0), {
+ x: "date", y: "count", fill: "steelblue", r: 4, tip: true,
+ title: d => `${_tickFmt(d.date)}\n+${d.delta} → ${d.count} total`,
+ }),
+ Plot.ruleY([0]),
+ ],
+ marginBottom: 70,
+ width: 700,
+ });
+ const _plotWrap = html`
${_plotEl}
`;
+ withDocHelp(_plotWrap, "/docs/decisions-kpi");
+ display(_plotWrap);
+}
```
## Filter & List
```js
-display(_filtersForm);
+const _filterWrap = html`
${_filtersForm}
`;
+withDocHelp(_filterWrap, "/docs/decisions-kpi");
+display(_filterWrap);
```
```js
@@ -321,7 +313,6 @@ if (filtered.length === 0) {
const decided = fmtDate(d.decided_at);
const overdue = isOverdue(d.deadline);
- // Age: time-to-resolve for closed decisions, time-open for live ones
const _isOpen = d.status === "open" || d.status === "escalated";
const _ageMs = d.decided_at
? new Date(d.decided_at) - new Date(d.created_at)
@@ -361,20 +352,82 @@ if (escalated.length > 0) {
```
diff --git a/state-hub/dashboard/src/docs/decisions-kpi.md b/state-hub/dashboard/src/docs/decisions-kpi.md
new file mode 100644
index 0000000..8e56b1b
--- /dev/null
+++ b/state-hub/dashboard/src/docs/decisions-kpi.md
@@ -0,0 +1,115 @@
+---
+title: Decisions — Reference
+---
+
+# Decisions — KPI & Visualization Reference
+
+This page documents the metrics, chart, and list on the **Decisions** dashboard. All data is sourced from the State Hub API and refreshed every 15 seconds.
+
+---
+
+## Decision Health (KPI card)
+
+The card in the top-right corner of the Decisions page shows two live health metrics for the decision process.
+
+### Avg resolve time
+
+The mean time elapsed from decision creation to resolution, computed across the **last five resolved decisions** (or fewer if fewer than five exist). A sample-size note (`n=3`) appears when fewer than five resolved decisions are available.
+
+This is the **baseline** — the expected time for a decision to move from open to resolved. All color thresholds below compare against this value.
+
+### Avg open age
+
+The mean age of all currently **open and escalated** decisions. This uses the full unfiltered dataset (not affected by the type/status/search filters on the page), so it always reflects the real state of the decision backlog.
+
+### Color coding
+
+The **avg open age** value is colored to signal whether the backlog is healthy:
+
+| Color | Meaning |
+|---|---|
+| **Black** | All open decisions are younger than the avg resolve time — backlog is on track |
+| **Orange** | Mean open age is within baseline, but at least one individual decision has been open longer than the avg resolve time — an outlier exists |
+| **Red** | Mean open age exceeds the avg resolve time — the whole open backlog is running behind |
+
+Individual decision cards also show an **orange age badge** when that specific open decision has been waiting longer than the avg resolve time.
+
+---
+
+## Resolution History
+
+A cumulative step chart showing how many decisions have accumulated in the filtered set over time.
+
+### What "cumulative" means
+
+The y-axis shows the running total of decisions, not the count per bucket. The line rises at each point where a new decision was added and stays flat otherwise. A steeper slope means higher decision velocity.
+
+### Period selector
+
+The radio buttons above the chart control the time window and the time resolution of the x-axis:
+
+| Period | Window | X-axis buckets |
+|---|---|---|
+| Day | Today (midnight → now) | Hours |
+| Week | Last 7 days | Days |
+| Month | Current calendar month | Weeks |
+| Quarter | Current calendar quarter | Months |
+| YTD | 1 Jan → now | Months |
+| Year | Rolling 12 months | Months |
+| All | Earliest decision → now | Months |
+
+### Timestamps used
+
+- For **resolved or made** decisions: the decision's `decided_at` timestamp is used (when it was closed).
+- For **pending or open** decisions: the `created_at` timestamp is used (when it was raised).
+
+### Filter interaction
+
+The chart reflects whatever is currently selected in the **Type**, **Status**, and **Search** filters. Changing the filter updates the chart immediately. This lets you compare, for example, resolution velocity of pending vs made decisions, or open vs resolved.
+
+Dots on the line mark buckets where at least one decision occurred. Hovering a dot shows the count added (`+N`) and the running total.
+
+---
+
+## Filter & List
+
+### Type filter
+
+- **pending** — decisions that have been raised but not yet resolved; the queue that needs attention
+- **made** — decisions that have been resolved or superseded
+
+### Status filter
+
+| Status | Meaning |
+|---|---|
+| open | Pending decision, awaiting resolution |
+| escalated | Requires human sign-off before any action (constitution §4) |
+| resolved | Decision has been made and closed |
+| superseded | Replaced by a later decision |
+
+### Search
+
+Filters by decision title (case-insensitive substring match).
+
+---
+
+## Card age indicator
+
+Each decision card shows a compact age badge in the header row:
+
+- **`open Xd`** (or `Xh`, `Xw`, `Xmo`) — the decision has been waiting for this long with no resolution
+- **`took Xd`** — the time elapsed from creation to resolution (for resolved/superseded decisions)
+
+The age badge turns **orange** when an open decision has been waiting longer than the avg resolve time baseline. This mirrors the orange state of the KPI card but scoped to the individual decision.
+
+---
+
+## Escalation
+
+Decisions with an escalation note are shown with a `⚠ escalated` badge and a highlighted note inline in the card. An escalation warning box at the bottom of the filtered list summarizes all escalated decisions requiring human approval.
+
+Escalated decisions always appear at the top of the list regardless of deadline, per constitution §4.
+
+---
+
+*Data refreshes every 15 seconds. KPI metrics use the full unfiltered dataset; chart and list reflect the active filter.*