generated from coulomb/repo-seed
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:
200
state-hub/dashboard/src/components/doc-overlay.js
Normal file
200
state-hub/dashboard/src/components/doc-overlay.js
Normal 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;
|
||||
}
|
||||
@@ -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 ${_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} 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>
|
||||
|
||||
115
state-hub/dashboard/src/docs/decisions-kpi.md
Normal file
115
state-hub/dashboard/src/docs/decisions-kpi.md
Normal 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.*
|
||||
Reference in New Issue
Block a user