Overview shows workstreams by repo now and allows drilldown
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import uuid
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.workstream import (
|
||||
WorkstreamCreate,
|
||||
@@ -16,6 +20,45 @@ from api.schemas.workstream import (
|
||||
router = APIRouter(prefix="/workstreams", tags=["workstreams"])
|
||||
|
||||
|
||||
def _repo_path(repo: ManagedRepo) -> Path | None:
|
||||
hostname = socket.gethostname()
|
||||
candidates = []
|
||||
host_paths = repo.host_paths or {}
|
||||
if host_paths.get(hostname):
|
||||
candidates.append(host_paths[hostname])
|
||||
if repo.local_path:
|
||||
candidates.append(repo.local_path)
|
||||
for raw in candidates:
|
||||
path = Path(raw).expanduser()
|
||||
if path.is_dir():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _frontmatter(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return {}
|
||||
if not text.startswith("---\n"):
|
||||
return {}
|
||||
end = text.find("\n---", 4)
|
||||
if end == -1:
|
||||
return {}
|
||||
|
||||
data: dict[str, Any] = {}
|
||||
for raw_line in text[4:end].splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
value = value[1:-1]
|
||||
data[key.strip()] = value
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/", response_model=list[WorkstreamRead])
|
||||
async def list_workstreams(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
@@ -44,6 +87,37 @@ async def list_workstreams(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/workplan-index")
|
||||
async def workplan_index(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""Map file-backed workstream ids to their local workplan filenames."""
|
||||
result = await session.execute(
|
||||
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug)
|
||||
)
|
||||
index: dict[str, Any] = {}
|
||||
for repo in result.scalars().all():
|
||||
root = _repo_path(repo)
|
||||
if root is None:
|
||||
continue
|
||||
for directory, archived in (
|
||||
(root / "workplans", False),
|
||||
(root / "workplans" / "archived", True),
|
||||
):
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
for path in sorted(directory.glob("*.md")):
|
||||
data = _frontmatter(path)
|
||||
workstream_id = data.get("state_hub_workstream_id")
|
||||
if not workstream_id:
|
||||
continue
|
||||
index[str(workstream_id)] = {
|
||||
"filename": path.name,
|
||||
"relative_path": str(path.relative_to(root)),
|
||||
"repo_slug": repo.slug,
|
||||
"archived": archived,
|
||||
}
|
||||
return {"workstreams": index}
|
||||
|
||||
|
||||
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workstream(
|
||||
body: WorkstreamCreate,
|
||||
|
||||
@@ -12,10 +12,10 @@ blocking decisions, and system-derived next-step suggestions.
|
||||
|
||||
## Sections
|
||||
|
||||
### Open Workstreams by Domain
|
||||
### Open Workstreams by Repository
|
||||
|
||||
A horizontal stacked bar chart showing every active workstream across all six
|
||||
domains. Each bar is broken into four task-status segments:
|
||||
A horizontal stacked bar chart showing workstreams grouped by domain and then
|
||||
by repository. Each bar is broken into four task-status segments:
|
||||
|
||||
| Colour | Segment |
|
||||
|--------|---------|
|
||||
@@ -24,9 +24,13 @@ domains. Each bar is broken into four task-status segments:
|
||||
| orange-red | blocked |
|
||||
| light grey | todo |
|
||||
|
||||
The left axis shows domain labels (one per group of workstreams). The `done/total`
|
||||
count is printed to the right of each bar. Workstreams with no tasks yet show
|
||||
a grey "— no tasks yet" label.
|
||||
The left axis shows the `domain / repository` label once per repository group.
|
||||
The `done/total` count is printed to the right of each bar. Workstreams with no
|
||||
tasks yet show a grey "— no tasks yet" label.
|
||||
|
||||
Hovering a bar shows the repository, domain, and backing workplan filename when
|
||||
the workstream is file-backed. Clicking a bar or its label opens the workstream
|
||||
drilldown page with the attached task list.
|
||||
|
||||
### Contribution & SBOM Health
|
||||
|
||||
@@ -78,7 +82,9 @@ and summary.
|
||||
|
||||
## Data source
|
||||
|
||||
Polls `GET /state/summary` every **15 seconds**. Blocking decisions are fetched
|
||||
separately via `GET /decisions/?decision_type=pending` and only re-fetched
|
||||
after a successful resolve action — this prevents the inline form from being
|
||||
wiped on every poll.
|
||||
Polls `GET /state/summary` every **15 seconds**. The workstream chart also polls
|
||||
`GET /workstreams/`, `GET /tasks/?limit=2000`, `GET /topics/`, `GET /repos/`,
|
||||
and `GET /workstreams/workplan-index` for repository grouping, task counts, and
|
||||
workplan filename tooltips. Blocking decisions are fetched separately via
|
||||
`GET /decisions/?decision_type=pending` and only re-fetched after a successful
|
||||
resolve action — this prevents the inline form from being wiped on every poll.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,19 +9,56 @@ import {fieldRow} from "../components/field-help.js";
|
||||
|
||||
```js
|
||||
const wsId = observable.params.id;
|
||||
const raw = await fetch(`${API}/workstreams/${wsId}`)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
|
||||
.catch(e => ({error: String(e)}));
|
||||
const [raw, taskRows, workplanIndex] = await Promise.all([
|
||||
fetch(`${API}/workstreams/${wsId}`)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
|
||||
.catch(e => ({error: String(e)})),
|
||||
fetch(`${API}/tasks/?workstream_id=${wsId}&limit=1000`)
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.catch(() => []),
|
||||
fetch(`${API}/workstreams/workplan-index`)
|
||||
.then(r => r.ok ? r.json() : {workstreams: {}})
|
||||
.catch(() => ({workstreams: {}})),
|
||||
]);
|
||||
```
|
||||
|
||||
```js
|
||||
if (raw.error) {
|
||||
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
|
||||
} else {
|
||||
const workplan = (workplanIndex.workstreams ?? {})[wsId] ?? {};
|
||||
const name = raw.title || raw.slug || wsId;
|
||||
const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name;
|
||||
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Workstream · <em>${shortName}</em></h1>`);
|
||||
display(html`<p style="margin-top:0"><a href="/workstreams">← Workstreams</a> | <a href="/token-cost">← Token Cost</a></p>`);
|
||||
display(html`<p style="margin-top:0"><a href="/">← Overview</a> | <a href="/workstreams">← Workstreams</a> | <a href="/token-cost">← Token Cost</a></p>`);
|
||||
|
||||
display(html`<div class="ws-summary">
|
||||
<div><span>Status</span><strong>${raw.status ?? "—"}</strong></div>
|
||||
<div><span>Workplan</span><strong>${workplan.filename ?? "not file-backed"}</strong></div>
|
||||
<div><span>Tasks</span><strong>${taskRows.length}</strong></div>
|
||||
</div>`);
|
||||
|
||||
const statusOrder = {blocked: 0, in_progress: 1, todo: 2, done: 3, cancelled: 4};
|
||||
const sortedTasks = [...taskRows].sort((a, b) => {
|
||||
const statusCompare = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9);
|
||||
if (statusCompare !== 0) return statusCompare;
|
||||
return (a.title ?? "").localeCompare(b.title ?? "");
|
||||
});
|
||||
|
||||
display(html`<h2>Tasks</h2>`);
|
||||
if (sortedTasks.length === 0) {
|
||||
display(html`<p style="color:gray">No tasks are attached to this workstream.</p>`);
|
||||
} else {
|
||||
display(html`<table class="task-table">
|
||||
<thead><tr><th>Status</th><th>Priority</th><th>Task</th><th>Human</th></tr></thead>
|
||||
<tbody>${sortedTasks.map(t => html`<tr>
|
||||
<td><span class="task-status task-status-${t.status}">${t.status}</span></td>
|
||||
<td>${t.priority ?? "—"}</td>
|
||||
<td><a href="/tasks/${t.id}">${t.title ?? t.id}</a></td>
|
||||
<td>${t.needs_human ? "yes" : ""}</td>
|
||||
</tr>`)}</tbody>
|
||||
</table>`);
|
||||
}
|
||||
|
||||
const FIELD_ORDER = [
|
||||
"id","slug","title","status","topic_id","repo_id","repo_goal_id",
|
||||
@@ -39,3 +76,52 @@ if (raw.error) {
|
||||
</table>`);
|
||||
}
|
||||
```
|
||||
|
||||
<style>
|
||||
.ws-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0 1.25rem;
|
||||
max-width: 760px;
|
||||
}
|
||||
.ws-summary div {
|
||||
background: var(--theme-background-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
.ws-summary span {
|
||||
display: block;
|
||||
color: gray;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.ws-summary strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.task-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.task-table th, .task-table td {
|
||||
border-bottom: 1px solid var(--theme-foreground-faint);
|
||||
padding: 0.42rem 0.5rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.task-status {
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.12rem 0.38rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-status-done { background: #e8f5e9; color: #1b5e20; }
|
||||
.task-status-in_progress { background: #e3f2fd; color: #0d47a1; }
|
||||
.task-status-blocked { background: #fff3e0; color: #bf360c; }
|
||||
.task-status-todo { background: #f1f5f9; color: #334155; }
|
||||
.task-status-cancelled { background: #f3f4f6; color: #6b7280; }
|
||||
</style>
|
||||
|
||||
@@ -133,6 +133,11 @@ class TestWorkstreams:
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()) == 1
|
||||
|
||||
async def test_workplan_index_route(self, client):
|
||||
r = await client.get("/workstreams/workplan-index")
|
||||
assert r.status_code == 200
|
||||
assert "workstreams" in r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task tests
|
||||
|
||||
31
workplans/ADHOC-2026-05-02.md
Normal file
31
workplans/ADHOC-2026-05-02.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
id: ADHOC-2026-05-02
|
||||
type: workplan
|
||||
title: "Ad Hoc Tasks — 2026-05-02"
|
||||
domain: custodian
|
||||
repo: the-custodian
|
||||
status: active
|
||||
owner: custodian
|
||||
topic_slug: custodian
|
||||
created: "2026-05-02"
|
||||
updated: "2026-05-02"
|
||||
state_hub_workstream_id: "3f54ce9c-1f95-42de-894f-0f81a52ba2e8"
|
||||
---
|
||||
|
||||
# ADHOC-2026-05-02 — Ad Hoc Tasks
|
||||
|
||||
Small same-day improvements that are useful to track, but do not justify a
|
||||
dedicated requirement/workplan cycle.
|
||||
|
||||
## Overview workstreams by repository
|
||||
|
||||
```task
|
||||
id: ADHOC-2026-05-02-T01
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "e9a302b7-81ab-4643-a256-4565a8c753e0"
|
||||
```
|
||||
|
||||
Changed the State Hub Overview workstream chart from domain-first grouping to
|
||||
domain/repository grouping, added workplan filename detail to chart hover data,
|
||||
and made workstream bars open the workstream drilldown with attached tasks.
|
||||
Reference in New Issue
Block a user