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;
|
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
|
```js
|
||||||
import {MultiSelect} from "./components/multiselect.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"}),
|
status: MultiSelect(["open", "escalated", "resolved", "superseded"], {label: "Status", placeholder: "All statuses"}),
|
||||||
search: Inputs.text({placeholder: "Search title…", style: "width:160px"}),
|
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 class="filter-search">${search}</div>
|
||||||
</div>`,
|
</div>`,
|
||||||
}
|
}
|
||||||
));
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Reactive value from the form without displaying it
|
||||||
|
const filters = Generators.input(_filtersForm);
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -96,7 +91,165 @@ function fmtDate(iso) {
|
|||||||
function isOverdue(iso) {
|
function isOverdue(iso) {
|
||||||
return iso && new Date(iso) < new Date();
|
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) {
|
if (filtered.length === 0) {
|
||||||
display(html`<p class="dim">No decisions match the current filter.</p>`);
|
display(html`<p class="dim">No decisions match the current filter.</p>`);
|
||||||
} else {
|
} 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>
|
<style>
|
||||||
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
|
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
|
||||||
.dim { color: gray; font-style: italic; }
|
.dim { color: gray; font-style: italic; }
|
||||||
|
|||||||
Reference in New Issue
Block a user