generated from coulomb/repo-seed
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 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ from api.schemas.state import (
|
|||||||
)
|
)
|
||||||
from api.schemas.task import TaskRead
|
from api.schemas.task import TaskRead
|
||||||
from api.schemas.topic import TopicWithWorkstreams
|
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"])
|
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())
|
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
|
# Totals — one GROUP BY per table
|
||||||
topic_counts = {r[0]: r[1] for r in await session.execute(
|
topic_counts = {r[0]: r[1] for r in await session.execute(
|
||||||
select(Topic.status, func.count()).group_by(Topic.status)
|
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],
|
blocking_decisions=[DecisionRead.model_validate(d) for d in blocking],
|
||||||
blocked_tasks=[TaskRead.model_validate(t) for t in blocked],
|
blocked_tasks=[TaskRead.model_validate(t) for t in blocked],
|
||||||
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
|
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
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from api.schemas.decision import DecisionRead
|
|||||||
from api.schemas.progress_event import ProgressEventRead
|
from api.schemas.progress_event import ProgressEventRead
|
||||||
from api.schemas.task import TaskRead
|
from api.schemas.task import TaskRead
|
||||||
from api.schemas.topic import TopicWithWorkstreams
|
from api.schemas.topic import TopicWithWorkstreams
|
||||||
from api.schemas.workstream import WorkstreamRead
|
from api.schemas.workstream import WorkstreamWithTaskCounts
|
||||||
|
|
||||||
|
|
||||||
class TopicTotals(BaseModel):
|
class TopicTotals(BaseModel):
|
||||||
@@ -55,4 +55,4 @@ class StateSummary(BaseModel):
|
|||||||
blocking_decisions: list[DecisionRead]
|
blocking_decisions: list[DecisionRead]
|
||||||
blocked_tasks: list[TaskRead]
|
blocked_tasks: list[TaskRead]
|
||||||
recent_progress: list[ProgressEventRead]
|
recent_progress: list[ProgressEventRead]
|
||||||
open_workstreams: list[WorkstreamRead]
|
open_workstreams: list[WorkstreamWithTaskCounts]
|
||||||
|
|||||||
@@ -36,3 +36,11 @@ class WorkstreamRead(BaseModel):
|
|||||||
due_date: date | None = None
|
due_date: date | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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
|
||||||
|
|||||||
@@ -117,21 +117,55 @@ if (regs.length === 0) {
|
|||||||
```js
|
```js
|
||||||
import * as Plot from "npm:@observablehq/plot";
|
import * as Plot from "npm:@observablehq/plot";
|
||||||
|
|
||||||
const wsData = (summary.topics ?? []).map(t => ({
|
const topicById = Object.fromEntries((summary.topics ?? []).map(t => [t.id, t.domain]));
|
||||||
domain: t.domain,
|
|
||||||
count: (t.workstreams ?? []).length,
|
|
||||||
}));
|
|
||||||
|
|
||||||
display(Plot.plot({
|
const openWs = (summary.open_workstreams ?? []).map(w => ({
|
||||||
x: {label: "Domain"},
|
title: w.title,
|
||||||
y: {label: "Open workstreams", grid: true},
|
domain: topicById[w.topic_id] ?? "unknown",
|
||||||
marks: [
|
done: w.tasks_done ?? 0,
|
||||||
Plot.barY(wsData, {x: "domain", y: "count", fill: "domain", tip: true}),
|
in_progress: w.tasks_in_progress ?? 0,
|
||||||
Plot.ruleY([0]),
|
blocked: w.tasks_blocked ?? 0,
|
||||||
],
|
todo: w.tasks_todo ?? 0,
|
||||||
marginBottom: 80,
|
total: w.tasks_total ?? 0,
|
||||||
width: 700,
|
})).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`<p style="color:gray">No open workstreams.</p>`);
|
||||||
|
} 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
|
```js
|
||||||
|
|||||||
Reference in New Issue
Block a user