dashboard: cumulative decisions chart with flexible period selector

- Replace static per-month bar chart with cumulative step-area chart
- Period selector: day / week / month / quarter / YTD / year / all
- Time resolution adapts to period:
    day → hours, week → days, month → weeks,
    quarter/YTD/year/all → months
- Chart respects the type/status/search filter (uses filtered, not data)
- Chart and period selector appear before the filter form and list
- Use Generators.input() to decouple filter form creation from its
  display position; display(_filtersForm) renders it below the chart
- Dots on chart mark buckets where decisions occurred; tip shows delta
- "all" period derives start from earliest decision in filtered set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 07:22:25 +01:00
parent a74bb9f732
commit 02b2542a2a

View File

@@ -49,23 +49,13 @@ const _ok = decState.ok ?? false;
const _ts = decState.ts;
```
# Decisions
```js
display(html`<div class="live-bar">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: `<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`);
```
```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(
<div class="filter-search">${search}</div>
</div>`,
}
));
);
```
```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`<div class="live-bar">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: `<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`);
```
## 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`<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,
})
);
```
## Filter & List
```js
display(_filtersForm);
```
```js
if (filtered.length === 0) {
display(html`<p class="dim">No decisions match the current filter.</p>`);
} 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`<p style="color:gray">No resolved decisions yet.</p>`
: 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,
})
);
```
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
.dim { color: gray; font-style: italic; }