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(
${search}
`,
}
-));
+);
+```
+
+```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,
- })
-);
-```
-