Overview shows workstreams by repo now and allows drilldown

This commit is contained in:
2026-05-02 12:01:45 +02:00
parent e521f267ca
commit e0f6a3b7a9
5 changed files with 260 additions and 43 deletions

View File

@@ -88,17 +88,20 @@ const wsChartState = (async function*() {
while (true) {
let wsAll = [], ok = false;
try {
const [rw, rt, rto, rr] = await Promise.all([
const [rw, rt, rto, rr, rwi] = await Promise.all([
fetch(`${API}/workstreams/`),
fetch(`${API}/tasks/?limit=2000`),
fetch(`${API}/topics/`),
fetch(`${API}/repos/`),
fetch(`${API}/workstreams/workplan-index`),
]);
ok = rw.ok && rt.ok && rto.ok && rr.ok;
if (ok) {
const [wsList, taskList, topicList, repoList] = await Promise.all([
rw.json(), rt.json(), rto.json(), rr.json(),
]);
const workplanIndex = rwi.ok ? await rwi.json() : {workstreams: {}};
const workplanMap = workplanIndex.workstreams ?? {};
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
// Aggregate task counts per workstream
@@ -112,11 +115,21 @@ const wsChartState = (async function*() {
else if (t.status === "blocked") counts[wid].blocked++;
else if (t.status === "todo") counts[wid].todo++;
}
wsAll = wsList.map(w => ({
...w,
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}),
}));
wsAll = wsList.map(w => {
const repo = repoMap[w.repo_id];
const topic = topicMap[w.topic_id];
const workplan = workplanMap[w.id] ?? {};
return {
...w,
domain: repo?.domain_slug ?? topic?.domain_slug ?? "unknown",
repo_label: repo?.slug ?? workplan.repo_slug ?? "unassigned",
workplan_filename: workplan.filename ?? null,
workplan_relative_path: workplan.relative_path ?? null,
workplan_archived: workplan.archived ?? false,
href: `./workstreams/${w.id}`,
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}),
};
});
}
} catch {}
yield {wsAll, ok};
@@ -152,7 +165,7 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/overview");
display(html`<div class="warning" style="display:${summary.error ? '' : 'none'}">⚠️ ${summary.error ?? ''}</div>`);
```
## Workstreams by Domain
## Workstreams by Repository
```js
// view() is the idiomatic Observable Framework reactive input:
@@ -236,16 +249,13 @@ const _chartWsFiltered = (
})()
);
// Sort domains top-to-bottom by most recent workstream update (most active domain first).
// Within a domain, most recently updated workstream comes first.
const _domainLatest = {};
for (const w of _chartWsFiltered) {
const t = new Date(w.updated_at).getTime();
if (!_domainLatest[w.domain] || t > _domainLatest[w.domain]) _domainLatest[w.domain] = t;
}
// Sort by domain, then repository, then most recently updated workstream.
// The axis labels show each domain/repo group once.
const chartWs = [..._chartWsFiltered].sort((a, b) => {
const dd = (_domainLatest[b.domain] ?? 0) - (_domainLatest[a.domain] ?? 0);
if (dd !== 0) return dd;
const domainCompare = (a.domain ?? "").localeCompare(b.domain ?? "");
if (domainCompare !== 0) return domainCompare;
const repoCompare = (a.repo_label ?? "").localeCompare(b.repo_label ?? "");
if (repoCompare !== 0) return repoCompare;
return new Date(b.updated_at) - new Date(a.updated_at);
});
@@ -254,24 +264,35 @@ const chartWs = [..._chartWsFiltered].sort((a, b) => {
const _isTimeBased = !_STATUS_MODES.has(_chartMode);
function _wsWeight(s) { return (s === "accepted" || s === "blocked" || s === "stalled") ? "bold" : "normal"; }
// ── y-axis: domain label for first workstream per group only ──────────────────
// ── y-axis: domain/repo label for first workstream per repository only ────────
const _yLabels = {};
const _seen = new Set();
for (const w of chartWs) {
_yLabels[w.title] = _seen.has(w.domain) ? "" : w.domain;
_seen.add(w.domain);
const group = `${w.domain} / ${w.repo_label}`;
_yLabels[w.id] = _seen.has(group) ? "" : group;
_seen.add(group);
}
const statusOrder = ["done", "in progress", "blocked", "todo"];
const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"];
const _taskRows = chartWs.flatMap(w => [
{label: w.title, status: "done", count: w.done ?? 0},
{label: w.title, status: "in progress", count: w.in_progress ?? 0},
{label: w.title, status: "blocked", count: w.blocked ?? 0},
{label: w.title, status: "todo", count: w.todo ?? 0},
{id: w.id, title: w.title, status: "done", count: w.done ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
{id: w.id, title: w.title, status: "in progress", count: w.in_progress ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
{id: w.id, title: w.title, status: "blocked", count: w.blocked ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
{id: w.id, title: w.title, status: "todo", count: w.todo ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
]).filter(d => d.count > 0);
function _wsTitle(d) {
return [
d.title,
`Repo: ${d.repo ?? "unassigned"}`,
`Domain: ${d.domain ?? "unknown"}`,
`Workplan: ${d.workplan ?? "not file-backed"}`,
`${d.status}: ${d.count}`,
].join("\n");
}
// ── Render ────────────────────────────────────────────────────────────────────
if (chartWs.length === 0) {
const _emptyMsg = {
@@ -292,34 +313,59 @@ if (chartWs.length === 0) {
display(Plot.plot({
y: {
label: null, tickSize: 0,
domain: chartWs.map(w => w.title),
domain: chartWs.map(w => w.id),
tickFormat: t => _yLabels[t] ?? "",
},
x: {label: "Tasks", grid: true},
color: {domain: statusOrder, range: statusColors, legend: true},
marks: [
Plot.barX(_taskRows, {y: "label", x: "count", fill: "status", tip: true}),
Plot.barX(_taskRows, {
y: "id", x: "count", fill: "status",
title: _wsTitle,
href: "href",
target: "_self",
tip: true,
}),
// Title label — pushed to lower half of bar row (dy: +7) to separate from count
Plot.text(chartWs.filter(w => w.total > 0), {
y: "title", x: 0, dx: 6, dy: 7,
y: "id", x: 0, dx: 6, dy: 7,
text: d => d.title.length > 72 ? d.title.slice(0, 70) + "…" : d.title,
textAnchor: "start", fontSize: 10, fill: "#1e293b",
fontWeight: d => _isTimeBased ? _wsWeight(d.status) : "normal",
title: d => [
d.title,
`Repo: ${d.repo_label ?? "unassigned"}`,
`Domain: ${d.domain ?? "unknown"}`,
`Workplan: ${d.workplan_filename ?? "not file-backed"}`,
].join("\n"),
href: "href",
target: "_self",
}),
Plot.text(chartWs.filter(w => w.total === 0), {
y: "title", x: 0, dx: 6, dy: 7,
y: "id", x: 0, dx: 6, dy: 7,
text: d => `${d.title.length > 48 ? d.title.slice(0, 46) + "…" : d.title} — no tasks yet`,
textAnchor: "start", fontSize: 10, fill: "#94a3b8",
title: d => [
d.title,
`Repo: ${d.repo_label ?? "unassigned"}`,
`Domain: ${d.domain ?? "unknown"}`,
`Workplan: ${d.workplan_filename ?? "not file-backed"}`,
].join("\n"),
href: "href",
target: "_self",
}),
// Count label — pushed to upper half of bar row (dy: -7) to separate from title
Plot.text(chartWs.filter(w => w.total > 0), {
y: "title", x: "total",
y: "id", x: "total",
text: d => ` ${d.done}/${d.total}`,
dx: 4, dy: -7, textAnchor: "start", fontSize: 11, fill: "gray",
title: d => `${d.title}\nWorkplan: ${d.workplan_filename ?? "not file-backed"}`,
href: "href",
target: "_self",
}),
Plot.ruleX([0]),
],
marginLeft: 160,
marginLeft: 220,
marginRight: 70,
height: Math.max(80, chartWs.length * 44 + 50),
width: 700,