generated from coulomb/repo-seed
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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user