dashboard: prominent KPI infobox, doc-overlay component, decisions reference page

KPI infobox
- Replace slim kpi-bar with a boxed card (border, shadow, 195–240px) floating
  right in a flex header alongside the live indicator
- Rows: avg resolve time (last ≤5 resolved) + avg open age (all open)
- avg open age colored via CSS var --oc: red/orange/black per threshold
- "no open decisions" shown as muted italic when queue is empty

doc-overlay component (src/components/doc-overlay.js)
- withDocHelp(element, docPath) — adds absolute-positioned ? button
  that is invisible until the parent is hovered; click opens overlay
- Overlay: fixed backdrop + animated box with iframe; closes on Esc,
  backdrop click, or the close button
- CSS injected once via style tag (STYLE_ID guard, same pattern as MultiSelect)

? buttons wired up in decisions.md
- KPI infobox → /docs/decisions-kpi
- Cumulative chart (wrapped in position:relative div) → /docs/decisions-kpi
- Filter & List section header → /docs/decisions-kpi

Reference page (src/docs/decisions-kpi.md)
- Standalone Observable Framework page at /docs/decisions-kpi
- Documents: KPI card (avg resolve, avg open age, color thresholds),
  Resolution History chart (cumulative, period→resolution mapping, filter
  interaction, timestamp logic), Filter & List (type/status/search, card
  age badge, escalation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 11:48:47 +01:00
parent d056c142df
commit 267e11bc17
3 changed files with 464 additions and 95 deletions

View File

@@ -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`<div class="my-card">...</div>`;
* 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;
}

View File

@@ -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`<div class="kpi-bar">
// ── Build the KPI infobox ───────────────────────────────────────────────────
const _kpiBox = html`<div class="kpi-infobox">
<div class="kpi-infobox-title">Decision Health</div>
${_meanResolveMs !== null ? html`<div class="kpi-row">
<span class="kpi-row-label">avg resolve</span>
<div class="kpi-row-right">
<div class="kpi-row-value">${fmtDuration(_meanResolveMs)}</div>
<div class="kpi-row-sub">last&nbsp;${_resolved5.length}</div>
</div>
</div>` : ""}
${_meanOpenMs !== null ? html`<div class="kpi-row" style="--oc:${_openAgeColor}">
<span class="kpi-row-label">avg open age</span>
<div class="kpi-row-right">
<div class="kpi-row-value kpi-colorable">${fmtDuration(_meanOpenMs)}</div>
<div class="kpi-row-sub kpi-colorable">${_openDecs.length}&nbsp;open</div>
</div>
</div>` : html`<div class="kpi-row"><span class="kpi-row-label kpi-muted">no open decisions</span></div>`}
</div>`;
withDocHelp(_kpiBox, "/docs/decisions-kpi");
// ── Header: live indicator (left) + KPI box (right) ────────────────────────
display(html`<div class="decisions-header">
<div class="live-indicator">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>
<div class="kpi-badges">
${_meanResolveMs !== null ? html`<span class="kpi-badge">
<span class="kpi-label">avg resolve</span>
<span class="kpi-value">${fmtDuration(_meanResolveMs)}</span>
${_resolved5.length < 5 ? html`<span class="kpi-sub">n=${_resolved5.length}</span>` : ""}
</span>` : ""}
${_meanOpenMs !== null ? html`<span class="kpi-badge" style="--kpi-color:${_openAgeColor}">
<span class="kpi-label">avg open age</span>
<span class="kpi-value">${fmtDuration(_meanOpenMs)}</span>
<span class="kpi-sub">${_openDecs.length} open</span>
</span>` : ""}
</div>
${_kpiBox}
</div>`);
```
@@ -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`<p class="dim">No decisions in this period.</p>`
: 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`<p class="dim">No decisions in this period.</p>`);
} 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`<div style="position:relative">${_plotEl}</div>`;
withDocHelp(_plotWrap, "/docs/decisions-kpi");
display(_plotWrap);
}
```
## Filter & List
```js
display(_filtersForm);
const _filterWrap = html`<div style="position:relative;padding-right:2rem">${_filtersForm}</div>`;
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) {
```
<style>
/* KPI bar */
.kpi-bar { display: flex; justify-content: space-between; align-items: center; font-size: 0.8rem; color: gray; margin-bottom: 0.5rem; }
.live-indicator { color: gray; }
.kpi-badges { display: flex; gap: 0.6rem; align-items: center; }
.kpi-badge { display: inline-flex; align-items: center; gap: 0.3rem; background: var(--theme-background-alt, #f5f5f5); border-radius: 6px; padding: 0.2rem 0.55rem; font-size: 0.78rem; color: var(--kpi-color, var(--theme-foreground-muted, #666)); }
.kpi-label { font-weight: 400; }
.kpi-value { font-weight: 700; font-variant-numeric: tabular-nums; color: var(--kpi-color, var(--theme-foreground, #111)); }
.kpi-sub { font-size: 0.7rem; opacity: 0.7; }
/* ── Page header ──────────────────────────────────────────────────────────── */
.decisions-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.25rem;
gap: 1.5rem;
}
.live-indicator {
font-size: 0.8rem;
color: gray;
padding-top: 0.3rem;
flex-shrink: 0;
}
/* ── KPI infobox ──────────────────────────────────────────────────────────── */
.kpi-infobox {
background: var(--theme-background-alt, #f9f9f9);
border: 1px solid var(--theme-foreground-faint, #e0e0e0);
border-radius: 10px;
padding: 0.75rem 1rem;
min-width: 195px;
max-width: 240px;
position: relative;
box-shadow: 0 1px 6px rgba(0,0,0,0.07);
flex-shrink: 0;
}
.kpi-infobox-title {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--theme-foreground-muted, #888);
margin-bottom: 0.55rem;
padding-right: 1.6rem; /* room for ? button */
}
.kpi-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.3rem 0;
}
.kpi-row + .kpi-row {
border-top: 1px solid var(--theme-foreground-faint, #eee);
}
.kpi-row-label {
font-size: 0.8rem;
color: var(--theme-foreground-muted, #666);
white-space: nowrap;
}
.kpi-muted { color: var(--theme-foreground-faint, #aaa); font-style: italic; }
.kpi-row-right { text-align: right; }
.kpi-row-value {
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.1;
color: var(--theme-foreground, #111);
}
.kpi-colorable { color: var(--oc, inherit); }
.kpi-row-sub {
font-size: 0.68rem;
color: var(--theme-foreground-faint, #aaa);
line-height: 1.2;
}
/* ── Utility ──────────────────────────────────────────────────────────────── */
.dim { color: gray; font-style: italic; }
/* Filter bar */
/* ── Filter bar ───────────────────────────────────────────────────────────── */
.filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.filter-search { display: flex; align-items: center; }
.filter-search input { 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; }
/* Decision list */
/* ── Decision list ────────────────────────────────────────────────────────── */
.dec-list { display: flex; flex-direction: column; gap: 0.5rem; }
.dec-item { border-left: 3px solid #ccc; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; }
.dec-item-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.75rem; }
@@ -387,7 +440,7 @@ if (escalated.length > 0) {
.dec-badge-resolved { background: #dcfce7; color: #166534; }
.dec-badge-superseded { background: #f3f4f6; color: #6b7280; }
.dec-context { color: var(--theme-foreground-muted); }
.dec-due { color: steelblue; }
.dec-due { color: steelblue; }
.dec-overdue { color: #dc2626; font-weight: 600; }
.dec-date { color: var(--theme-foreground-faint); margin-left: auto; }
.dec-age { font-size: 0.72rem; color: var(--theme-foreground-faint, #aaa); font-variant-numeric: tabular-nums; }
@@ -396,6 +449,7 @@ if (escalated.length > 0) {
.dec-snippet { font-size: 0.82rem; color: var(--theme-foreground-muted); line-height: 1.45; white-space: pre-wrap; }
.dec-resolved-by { font-size: 0.78rem; color: #22c55e; margin-top: 0.3rem; }
.dec-escalation-note { font-size: 0.78rem; color: #b45309; margin-top: 0.3rem; background: #fef3c7; border-radius: 4px; padding: 0.25rem 0.5rem; }
/* Escalation warning */
/* ── Escalation warning ───────────────────────────────────────────────────── */
.escalation-box { background: #fff3cd; border: 2px solid orange; border-radius: 8px; padding: 1rem; margin-top: 1rem; }
</style>

View File

@@ -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.*