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,
|
topic_title: topicMap[d.topic_id]?.title ?? null,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => {
|
.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 rank = {escalated: 0, open: 1, resolved: 2, superseded: 3};
|
||||||
const dr = (rank[a.status] ?? 9) - (rank[b.status] ?? 9);
|
const dr = (rank[a.status] ?? 9) - (rank[b.status] ?? 9);
|
||||||
if (dr !== 0) return dr;
|
if (dr !== 0) return dr;
|
||||||
@@ -52,7 +51,7 @@ const _ts = decState.ts;
|
|||||||
```js
|
```js
|
||||||
import {MultiSelect} from "./components/multiselect.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(
|
const _filtersForm = Inputs.form(
|
||||||
{
|
{
|
||||||
type: MultiSelect(["pending", "made"], {label: "Type", placeholder: "All types"}),
|
type: MultiSelect(["pending", "made"], {label: "Type", placeholder: "All types"}),
|
||||||
@@ -69,7 +68,6 @@ const _filtersForm = Inputs.form(
|
|||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// Reactive value from the form without displaying it
|
|
||||||
const filters = Generators.input(_filtersForm);
|
const filters = Generators.input(_filtersForm);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -91,8 +89,6 @@ function fmtDate(iso) {
|
|||||||
function isOverdue(iso) {
|
function isOverdue(iso) {
|
||||||
return iso && new Date(iso) < new Date();
|
return iso && new Date(iso) < new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format a duration in milliseconds as a compact human string
|
|
||||||
function fmtDuration(ms) {
|
function fmtDuration(ms) {
|
||||||
if (ms < 0) ms = 0;
|
if (ms < 0) ms = 0;
|
||||||
const h = 3_600_000, d = 86_400_000, w = 7 * d;
|
const h = 3_600_000, d = 86_400_000, w = 7 * d;
|
||||||
@@ -107,10 +103,9 @@ function fmtDuration(ms) {
|
|||||||
# Decisions
|
# Decisions
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// ── KPI bar ────────────────────────────────────────────────────────────────
|
import {withDocHelp} from "./components/doc-overlay.js";
|
||||||
// Uses full `data` (not filtered) — these are health metrics for the whole system
|
|
||||||
|
|
||||||
// Mean resolution time from the last ≤5 resolved decisions
|
// ── KPI computation (uses full data, not filtered) ──────────────────────────
|
||||||
const _resolved5 = data
|
const _resolved5 = data
|
||||||
.filter(d => d.decided_at && d.created_at)
|
.filter(d => d.decided_at && d.created_at)
|
||||||
.sort((a, b) => b.decided_at.localeCompare(a.decided_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
|
? _resolved5.reduce((s, d) => s + (new Date(d.decided_at) - new Date(d.created_at)), 0) / _resolved5.length
|
||||||
: null;
|
: 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 _openDecs = data.filter(d => d.status === "open" || d.status === "escalated");
|
||||||
const _openAges = _openDecs.map(d => _nowKpi - new Date(d.created_at));
|
const _openAges = _openDecs.map(d => _nowKpi - new Date(d.created_at));
|
||||||
const _meanOpenMs = _openAges.length
|
const _meanOpenMs = _openAges.length
|
||||||
? _openAges.reduce((s, a) => s + a, 0) / _openAges.length
|
? _openAges.reduce((s, a) => s + a, 0) / _openAges.length
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Color logic for the mean-open-age KPI:
|
// Color: red = mean open > baseline; orange = any individual > baseline; black = all fine
|
||||||
// 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
|
|
||||||
let _openAgeColor = "inherit";
|
let _openAgeColor = "inherit";
|
||||||
if (_meanOpenMs !== null && _meanResolveMs !== null) {
|
if (_meanOpenMs !== null && _meanResolveMs !== null) {
|
||||||
if (_meanOpenMs > _meanResolveMs) {
|
if (_meanOpenMs > _meanResolveMs) _openAgeColor = "#dc2626";
|
||||||
_openAgeColor = "#dc2626";
|
else if (_openAges.some(a => a > _meanResolveMs)) _openAgeColor = "#d97706";
|
||||||
} 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">
|
<div class="live-indicator">
|
||||||
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
|
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
|
||||||
${_ok
|
${_ok
|
||||||
? `Live · updated ${_ts?.toLocaleTimeString()}`
|
? `Live · updated ${_ts?.toLocaleTimeString()}`
|
||||||
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
|
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi-badges">
|
${_kpiBox}
|
||||||
${_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>
|
|
||||||
</div>`);
|
</div>`);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -175,12 +174,8 @@ const period = view(Inputs.radio(
|
|||||||
```js
|
```js
|
||||||
import * as Plot from "npm:@observablehq/plot";
|
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) {
|
function _bucketKey(t, unit, start) {
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case "hour": return new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours()).getTime();
|
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) {
|
function _genBuckets(start, end, unit) {
|
||||||
const bkts = [];
|
const bkts = [];
|
||||||
let cur = new Date(start);
|
let cur = new Date(start);
|
||||||
@@ -207,66 +201,59 @@ function _genBuckets(start, end, unit) {
|
|||||||
return bkts;
|
return bkts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive window + bucket config from the selected period
|
|
||||||
const _now = new Date();
|
const _now = new Date();
|
||||||
const _y = _now.getFullYear();
|
const _y = _now.getFullYear(), _mo = _now.getMonth();
|
||||||
const _mo = _now.getMonth();
|
|
||||||
|
|
||||||
let _start, _unit, _tickFmt;
|
let _start, _unit, _tickFmt;
|
||||||
switch (period) {
|
switch (period) {
|
||||||
case "day":
|
case "day":
|
||||||
_start = new Date(_y, _mo, _now.getDate());
|
_start = new Date(_y, _mo, _now.getDate());
|
||||||
_unit = "hour";
|
_unit = "hour";
|
||||||
_tickFmt = d => `${String(d.getHours()).padStart(2, "0")}:00`;
|
_tickFmt = d => `${String(d.getHours()).padStart(2, "0")}:00`;
|
||||||
break;
|
break;
|
||||||
case "week": {
|
case "week": {
|
||||||
const _7ago = new Date(_now - 7 * 864e5);
|
const _7ago = new Date(_now - 7 * 864e5);
|
||||||
_start = new Date(_7ago.getFullYear(), _7ago.getMonth(), _7ago.getDate());
|
_start = new Date(_7ago.getFullYear(), _7ago.getMonth(), _7ago.getDate());
|
||||||
_unit = "day";
|
_unit = "day";
|
||||||
_tickFmt = d => d.toLocaleDateString(undefined, {weekday: "short", month: "short", day: "numeric"});
|
_tickFmt = d => d.toLocaleDateString(undefined, {weekday: "short", month: "short", day: "numeric"});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "month":
|
case "month":
|
||||||
_start = new Date(_y, _mo, 1);
|
_start = new Date(_y, _mo, 1);
|
||||||
_unit = "week";
|
_unit = "week";
|
||||||
_tickFmt = d => `W/${d.toLocaleDateString(undefined, {month: "short", day: "numeric"})}`;
|
_tickFmt = d => `W/${d.toLocaleDateString(undefined, {month: "short", day: "numeric"})}`;
|
||||||
break;
|
break;
|
||||||
case "quarter":
|
case "quarter":
|
||||||
_start = new Date(_y, Math.floor(_mo / 3) * 3, 1);
|
_start = new Date(_y, Math.floor(_mo / 3) * 3, 1);
|
||||||
_unit = "month";
|
_unit = "month";
|
||||||
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short"});
|
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short"});
|
||||||
break;
|
break;
|
||||||
case "YTD":
|
case "YTD":
|
||||||
_start = new Date(_y, 0, 1);
|
_start = new Date(_y, 0, 1);
|
||||||
_unit = "month";
|
_unit = "month";
|
||||||
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short"});
|
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short"});
|
||||||
break;
|
break;
|
||||||
case "year": {
|
case "year": {
|
||||||
const _52ago = new Date(_now - 365 * 864e5);
|
const _ago = new Date(_now - 365 * 864e5);
|
||||||
_start = new Date(_52ago.getFullYear(), _52ago.getMonth(), 1);
|
_start = new Date(_ago.getFullYear(), _ago.getMonth(), 1);
|
||||||
_unit = "month";
|
_unit = "month";
|
||||||
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "2-digit"});
|
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "2-digit"});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
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);
|
const _minD = new Date(_minTs);
|
||||||
_start = new Date(_minD.getFullYear(), _minD.getMonth(), 1);
|
_start = new Date(_minD.getFullYear(), _minD.getMonth(), 1);
|
||||||
_unit = "month";
|
_unit = "month";
|
||||||
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "numeric"});
|
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "numeric"});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restrict to window (all period uses full filtered set)
|
|
||||||
const _inWindow = period === "all"
|
const _inWindow = period === "all"
|
||||||
? [...filtered]
|
? [...filtered]
|
||||||
: filtered.filter(d => { const t = _getTs(d); return t >= _start && t <= _now; });
|
: filtered.filter(d => { const t = _getTs(d); return t >= _start && t <= _now; });
|
||||||
|
|
||||||
// Count per bucket
|
|
||||||
const _bktKeys = _genBuckets(_start, _now, _unit);
|
const _bktKeys = _genBuckets(_start, _now, _unit);
|
||||||
const _cntMap = new Map(_bktKeys.map(k => [k, 0]));
|
const _cntMap = new Map(_bktKeys.map(k => [k, 0]));
|
||||||
for (const d of _inWindow) {
|
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);
|
if (_cntMap.has(key)) _cntMap.set(key, (_cntMap.get(key) || 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build cumulative series
|
|
||||||
let _cum = 0;
|
let _cum = 0;
|
||||||
const _chartData = _bktKeys.map(k => {
|
const _chartData = _bktKeys.map(k => {
|
||||||
const delta = _cntMap.get(k) || 0;
|
const delta = _cntMap.get(k) || 0;
|
||||||
@@ -282,30 +268,36 @@ const _chartData = _bktKeys.map(k => {
|
|||||||
return {date: new Date(k), count: _cum, delta};
|
return {date: new Date(k), count: _cum, delta};
|
||||||
});
|
});
|
||||||
|
|
||||||
display(_inWindow.length === 0
|
if (_inWindow.length === 0) {
|
||||||
? html`<p class="dim">No decisions in this period.</p>`
|
display(html`<p class="dim">No decisions in this period.</p>`);
|
||||||
: Plot.plot({
|
} else {
|
||||||
x: {type: "time", tickFormat: _tickFmt, tickRotate: -30, label: null},
|
const _plotEl = Plot.plot({
|
||||||
y: {grid: true, label: "Cumulative decisions"},
|
x: {type: "time", tickFormat: _tickFmt, tickRotate: -30, label: null},
|
||||||
marks: [
|
y: {grid: true, label: "Cumulative decisions"},
|
||||||
Plot.areaY(_chartData, {x: "date", y: "count", fill: "steelblue", fillOpacity: 0.15, curve: "step-after"}),
|
marks: [
|
||||||
Plot.lineY(_chartData, {x: "date", y: "count", stroke: "steelblue", strokeWidth: 2, curve: "step-after"}),
|
Plot.areaY(_chartData, {x: "date", y: "count", fill: "steelblue", fillOpacity: 0.15, curve: "step-after"}),
|
||||||
Plot.dot(_chartData.filter(d => d.delta > 0), {
|
Plot.lineY(_chartData, {x: "date", y: "count", stroke: "steelblue", strokeWidth: 2, curve: "step-after"}),
|
||||||
x: "date", y: "count", fill: "steelblue", r: 4, tip: true,
|
Plot.dot(_chartData.filter(d => d.delta > 0), {
|
||||||
title: d => `${_tickFmt(d.date)}\n+${d.delta} → ${d.count} total`,
|
x: "date", y: "count", fill: "steelblue", r: 4, tip: true,
|
||||||
}),
|
title: d => `${_tickFmt(d.date)}\n+${d.delta} → ${d.count} total`,
|
||||||
Plot.ruleY([0]),
|
}),
|
||||||
],
|
Plot.ruleY([0]),
|
||||||
marginBottom: 70,
|
],
|
||||||
width: 700,
|
marginBottom: 70,
|
||||||
})
|
width: 700,
|
||||||
);
|
});
|
||||||
|
const _plotWrap = html`<div style="position:relative">${_plotEl}</div>`;
|
||||||
|
withDocHelp(_plotWrap, "/docs/decisions-kpi");
|
||||||
|
display(_plotWrap);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Filter & List
|
## Filter & List
|
||||||
|
|
||||||
```js
|
```js
|
||||||
display(_filtersForm);
|
const _filterWrap = html`<div style="position:relative;padding-right:2rem">${_filtersForm}</div>`;
|
||||||
|
withDocHelp(_filterWrap, "/docs/decisions-kpi");
|
||||||
|
display(_filterWrap);
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -321,7 +313,6 @@ if (filtered.length === 0) {
|
|||||||
const decided = fmtDate(d.decided_at);
|
const decided = fmtDate(d.decided_at);
|
||||||
const overdue = isOverdue(d.deadline);
|
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 _isOpen = d.status === "open" || d.status === "escalated";
|
||||||
const _ageMs = d.decided_at
|
const _ageMs = d.decided_at
|
||||||
? new Date(d.decided_at) - new Date(d.created_at)
|
? new Date(d.decided_at) - new Date(d.created_at)
|
||||||
@@ -361,20 +352,82 @@ if (escalated.length > 0) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* KPI bar */
|
/* ── Page header ──────────────────────────────────────────────────────────── */
|
||||||
.kpi-bar { display: flex; justify-content: space-between; align-items: center; font-size: 0.8rem; color: gray; margin-bottom: 0.5rem; }
|
.decisions-header {
|
||||||
.live-indicator { color: gray; }
|
display: flex;
|
||||||
.kpi-badges { display: flex; gap: 0.6rem; align-items: center; }
|
justify-content: space-between;
|
||||||
.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)); }
|
align-items: flex-start;
|
||||||
.kpi-label { font-weight: 400; }
|
margin-bottom: 1.25rem;
|
||||||
.kpi-value { font-weight: 700; font-variant-numeric: tabular-nums; color: var(--kpi-color, var(--theme-foreground, #111)); }
|
gap: 1.5rem;
|
||||||
.kpi-sub { font-size: 0.7rem; opacity: 0.7; }
|
}
|
||||||
|
.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; }
|
.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-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||||
.filter-search { display: flex; align-items: center; }
|
.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; }
|
.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-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 { 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; }
|
.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-resolved { background: #dcfce7; color: #166534; }
|
||||||
.dec-badge-superseded { background: #f3f4f6; color: #6b7280; }
|
.dec-badge-superseded { background: #f3f4f6; color: #6b7280; }
|
||||||
.dec-context { color: var(--theme-foreground-muted); }
|
.dec-context { color: var(--theme-foreground-muted); }
|
||||||
.dec-due { color: steelblue; }
|
.dec-due { color: steelblue; }
|
||||||
.dec-overdue { color: #dc2626; font-weight: 600; }
|
.dec-overdue { color: #dc2626; font-weight: 600; }
|
||||||
.dec-date { color: var(--theme-foreground-faint); margin-left: auto; }
|
.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; }
|
.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-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-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; }
|
.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; }
|
.escalation-box { background: #fff3cd; border: 2px solid orange; border-radius: 8px; padding: 1rem; margin-top: 1rem; }
|
||||||
</style>
|
</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