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

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