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`
${_ok ? `Live · updated ${_ts?.toLocaleTimeString()}` : html`Offline — run: make api`}
-
- ${_meanResolveMs !== null ? html` - avg resolve - ${fmtDuration(_meanResolveMs)} - ${_resolved5.length < 5 ? html`n=${_resolved5.length}` : ""} - ` : ""} - ${_meanOpenMs !== null ? html` - avg open age - ${fmtDuration(_meanOpenMs)} - ${_openDecs.length} open - ` : ""} -
+ ${_kpiBox}
`); ``` @@ -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.*