diff --git a/dashboard/src/decisions.md b/dashboard/src/decisions.md index 4b56a4a..d58d329 100644 --- a/dashboard/src/decisions.md +++ b/dashboard/src/decisions.md @@ -49,23 +49,13 @@ const _ok = decState.ok ?? false; const _ts = decState.ts; ``` -# Decisions - -```js -display(html`
- - ${_ok - ? `Live · updated ${_ts?.toLocaleTimeString()}` - : `Offline — run: make api`} -
`); -``` - ```js import {MultiSelect} from "./components/multiselect.js"; -const filters = view(Inputs.form( +// Create filter form without displaying — displayed below the chart via display(_filtersForm) +const _filtersForm = Inputs.form( { - type: MultiSelect(["pending", "made"], {label: "Type", placeholder: "All types"}), + type: MultiSelect(["pending", "made"], {label: "Type", placeholder: "All types"}), status: MultiSelect(["open", "escalated", "resolved", "superseded"], {label: "Status", placeholder: "All statuses"}), search: Inputs.text({placeholder: "Search title…", style: "width:160px"}), }, @@ -75,7 +65,12 @@ const filters = view(Inputs.form( `, } -)); +); +``` + +```js +// Reactive value from the form without displaying it +const filters = Generators.input(_filtersForm); ``` ```js @@ -96,7 +91,165 @@ function fmtDate(iso) { function isOverdue(iso) { return iso && new Date(iso) < new Date(); } +``` +# Decisions + +```js +display(html`
+ + ${_ok + ? `Live · updated ${_ts?.toLocaleTimeString()}` + : `Offline — run: make api`} +
`); +``` + +## Resolution History + +```js +const period = view(Inputs.radio( + ["day", "week", "month", "quarter", "YTD", "year", "all"], + {value: "month", label: "Period"} +)); +``` + +```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); +} + +// 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(); + case "day": return new Date(t.getFullYear(), t.getMonth(), t.getDate()).getTime(); + case "week": { + const w = Math.floor((t - start) / (7 * 864e5)); + return start.getTime() + w * 7 * 864e5; + } + case "month": return new Date(t.getFullYear(), t.getMonth(), 1).getTime(); + } +} + +// Generate all bucket start timestamps from start to end (inclusive) +function _genBuckets(start, end, unit) { + const bkts = []; + let cur = new Date(start); + while (cur <= end) { + bkts.push(cur.getTime()); + if (unit === "hour") cur = new Date(cur.getTime() + 36e5); + else if (unit === "day") cur = new Date(cur.getTime() + 864e5); + else if (unit === "week") cur = new Date(cur.getTime() + 7 * 864e5); + else if (unit === "month") cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1); + } + return bkts; +} + +// Derive window + bucket config from the selected period +const _now = new Date(); +const _y = _now.getFullYear(); +const _mo = _now.getMonth(); + +let _start, _unit, _tickFmt; +switch (period) { + case "day": + _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"; + _tickFmt = d => d.toLocaleDateString(undefined, {weekday: "short", month: "short", day: "numeric"}); + break; + } + case "month": + _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"; + _tickFmt = d => d.toLocaleDateString(undefined, {month: "short"}); + break; + case "YTD": + _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"; + _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 _minD = new Date(_minTs); + _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) { + const key = _bucketKey(_getTs(d), _unit, _start); + 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; + _cum += delta; + return {date: new Date(k), count: _cum, delta}; +}); + +display(_inWindow.length === 0 + ? html`

No decisions in this period.

` + : Plot.plot({ + x: {type: "time", tickFormat: _tickFmt, tickRotate: -30, label: null}, + y: {grid: true, label: "Cumulative decisions"}, + marks: [ + Plot.areaY(_chartData, {x: "date", y: "count", fill: "steelblue", fillOpacity: 0.15, curve: "step-after"}), + Plot.lineY(_chartData, {x: "date", y: "count", stroke: "steelblue", strokeWidth: 2, curve: "step-after"}), + Plot.dot(_chartData.filter(d => d.delta > 0), { + x: "date", y: "count", fill: "steelblue", r: 4, tip: true, + title: d => `${_tickFmt(d.date)}\n+${d.delta} → ${d.count} total`, + }), + Plot.ruleY([0]), + ], + marginBottom: 70, + width: 700, + }) +); +``` + +## Filter & List + +```js +display(_filtersForm); +``` + +```js if (filtered.length === 0) { display(html`

No decisions match the current filter.

`); } else { @@ -137,36 +290,6 @@ if (escalated.length > 0) { } ``` -## Resolution Velocity - -```js -import * as Plot from "npm:@observablehq/plot"; - -const resolved = data.filter(d => d.decided_at); -const byMonth = Object.entries( - resolved.reduce((acc, d) => { - const m = d.decided_at.slice(0, 7); - acc[m] = (acc[m] ?? 0) + 1; - return acc; - }, {}) -).map(([month, count]) => ({month, count})); - -display(byMonth.length === 0 - ? html`

No resolved decisions yet.

` - : Plot.plot({ - title: "Decisions resolved per month", - x: {label: "Month", tickRotate: -30}, - y: {label: "Count", grid: true}, - marks: [ - Plot.barY(byMonth, {x: "month", y: "count", fill: "steelblue", tip: true}), - Plot.ruleY([0]), - ], - marginBottom: 60, - width: 700, - }) -); -``` -