From cabeefe0705c51b46dbe77207eed9fa304a7db72 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 25 Feb 2026 00:50:43 +0100 Subject: [PATCH] Add per-workstream task counts to state summary and dashboard API: - WorkstreamWithTaskCounts schema extends WorkstreamRead with tasks_total/todo/in_progress/blocked/done fields - /state/summary now includes these counts in open_workstreams via a single extra GROUP BY query (workstream_id, status) Dashboard: - Replace domain workstream-count bar with a horizontal stacked progress bar per workstream (done/in-progress/blocked/todo) - Workstreams with no tasks show "no tasks yet" annotation - Workstreams with tasks show "X/N done" label after the bar - Sorted by domain then title so domains group naturally Co-Authored-By: Claude Sonnet 4.6 --- api/routers/state.py | 21 +++++++++++-- api/schemas/state.py | 4 +-- api/schemas/workstream.py | 8 +++++ dashboard/src/index.md | 62 ++++++++++++++++++++++++++++++--------- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/api/routers/state.py b/api/routers/state.py index fd9dbed..97593b2 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -23,7 +23,7 @@ from api.schemas.state import ( ) from api.schemas.task import TaskRead from api.schemas.topic import TopicWithWorkstreams -from api.schemas.workstream import WorkstreamRead +from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts router = APIRouter(prefix="/state", tags=["state"]) @@ -63,6 +63,13 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm ) open_ws = list(open_ws_rows.scalars().all()) + # Task counts per workstream (used to enrich open_workstreams) + task_per_ws: dict = {} + for ws_id, tstat, cnt in await session.execute( + select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status) + ): + task_per_ws.setdefault(ws_id, {})[tstat] = cnt + # Totals — one GROUP BY per table topic_counts = {r[0]: r[1] for r in await session.execute( select(Topic.status, func.count()).group_by(Topic.status) @@ -115,7 +122,17 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm blocking_decisions=[DecisionRead.model_validate(d) for d in blocking], blocked_tasks=[TaskRead.model_validate(t) for t in blocked], recent_progress=[ProgressEventRead.model_validate(e) for e in recent], - open_workstreams=[WorkstreamRead.model_validate(w) for w in open_ws], + open_workstreams=[ + WorkstreamWithTaskCounts( + **WorkstreamRead.model_validate(w).model_dump(), + tasks_total=sum(task_per_ws.get(w.id, {}).values()), + tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0), + tasks_in_progress=task_per_ws.get(w.id, {}).get(TaskStatus.in_progress, 0), + tasks_blocked=task_per_ws.get(w.id, {}).get(TaskStatus.blocked, 0), + tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0), + ) + for w in open_ws + ], ) diff --git a/api/schemas/state.py b/api/schemas/state.py index 0db6d39..66c1f53 100644 --- a/api/schemas/state.py +++ b/api/schemas/state.py @@ -6,7 +6,7 @@ from api.schemas.decision import DecisionRead from api.schemas.progress_event import ProgressEventRead from api.schemas.task import TaskRead from api.schemas.topic import TopicWithWorkstreams -from api.schemas.workstream import WorkstreamRead +from api.schemas.workstream import WorkstreamWithTaskCounts class TopicTotals(BaseModel): @@ -55,4 +55,4 @@ class StateSummary(BaseModel): blocking_decisions: list[DecisionRead] blocked_tasks: list[TaskRead] recent_progress: list[ProgressEventRead] - open_workstreams: list[WorkstreamRead] + open_workstreams: list[WorkstreamWithTaskCounts] diff --git a/api/schemas/workstream.py b/api/schemas/workstream.py index ae507b9..ae633bc 100644 --- a/api/schemas/workstream.py +++ b/api/schemas/workstream.py @@ -36,3 +36,11 @@ class WorkstreamRead(BaseModel): due_date: date | None = None created_at: datetime updated_at: datetime + + +class WorkstreamWithTaskCounts(WorkstreamRead): + tasks_total: int = 0 + tasks_todo: int = 0 + tasks_in_progress: int = 0 + tasks_blocked: int = 0 + tasks_done: int = 0 diff --git a/dashboard/src/index.md b/dashboard/src/index.md index 7bdd02a..83668e7 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -117,21 +117,55 @@ if (regs.length === 0) { ```js import * as Plot from "npm:@observablehq/plot"; -const wsData = (summary.topics ?? []).map(t => ({ - domain: t.domain, - count: (t.workstreams ?? []).length, -})); +const topicById = Object.fromEntries((summary.topics ?? []).map(t => [t.id, t.domain])); -display(Plot.plot({ - x: {label: "Domain"}, - y: {label: "Open workstreams", grid: true}, - marks: [ - Plot.barY(wsData, {x: "domain", y: "count", fill: "domain", tip: true}), - Plot.ruleY([0]), - ], - marginBottom: 80, - width: 700, -})); +const openWs = (summary.open_workstreams ?? []).map(w => ({ + title: w.title, + domain: topicById[w.topic_id] ?? "unknown", + done: w.tasks_done ?? 0, + in_progress: w.tasks_in_progress ?? 0, + blocked: w.tasks_blocked ?? 0, + todo: w.tasks_todo ?? 0, + total: w.tasks_total ?? 0, +})).sort((a, b) => a.domain.localeCompare(b.domain) || a.title.localeCompare(b.title)); + +const statusOrder = ["done", "in progress", "blocked", "todo"]; +const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"]; + +const taskRows = openWs.flatMap(w => [ + {label: w.title, domain: w.domain, status: "done", count: w.done}, + {label: w.title, domain: w.domain, status: "in progress", count: w.in_progress}, + {label: w.title, domain: w.domain, status: "blocked", count: w.blocked}, + {label: w.title, domain: w.domain, status: "todo", count: w.todo}, +]).filter(d => d.count > 0); + +if (openWs.length === 0) { + display(html`

No open workstreams.

`); +} else { + display(Plot.plot({ + y: {label: null, tickSize: 0, domain: openWs.map(w => w.title)}, + 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.text(openWs.filter(w => w.total > 0), { + y: "title", x: "total", + text: d => ` ${d.done}/${d.total}`, + dx: 4, textAnchor: "start", fontSize: 11, fill: "gray", + }), + Plot.text(openWs.filter(w => w.total === 0), { + y: "title", x: 0, + text: () => " no tasks yet", + textAnchor: "start", fontSize: 11, fill: "#aaa", + }), + Plot.ruleX([0]), + ], + marginLeft: 200, + marginRight: 70, + height: Math.max(80, openWs.length * 44 + 50), + width: 700, + })); +} ``` ```js