Skip to content

Commit 7324650

Browse files
authored
Merge pull request #139 from ichinya/feat/project-task-overview
feat(projects): add compact project task overview endpoint (additive)
2 parents a4e347b + 916e0a9 commit 7324650

10 files changed

Lines changed: 378 additions & 1 deletion

File tree

docs/api.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,64 @@ GET /projects
139139
]
140140
```
141141

142+
### Project Task Overview
143+
144+
```
145+
GET /projects/overview
146+
```
147+
148+
Returns compact per-project task aggregates for the projects overview screen.
149+
This endpoint does not return full task rows.
150+
151+
**Response:** `200 OK`
152+
153+
```json
154+
[
155+
{
156+
"projectId": "uuid",
157+
"totalTasks": 12,
158+
"completedTasks": 2,
159+
"verifiedTasks": 0,
160+
"backlogTasks": 4,
161+
"activeTasks": 6,
162+
"blockedTasks": 1,
163+
"autoModeTasks": 3,
164+
"fixTasks": 1,
165+
"totalRetries": 3,
166+
"totalTokenInput": 1200,
167+
"totalTokenOutput": 800,
168+
"totalTokenTotal": 2000,
169+
"totalCostUsd": 0.25,
170+
"statusCounts": {
171+
"backlog": 4,
172+
"planning": 1,
173+
"plan_ready": 2,
174+
"implementing": 1,
175+
"review": 1,
176+
"blocked_external": 1,
177+
"done": 2,
178+
"verified": 0
179+
},
180+
"statusPreviews": {
181+
"backlog": [{ "id": "task-1", "title": "Queued work" }],
182+
"planning": [],
183+
"plan_ready": [],
184+
"implementing": [],
185+
"review": [],
186+
"blocked_external": [],
187+
"done": [],
188+
"verified": []
189+
}
190+
}
191+
]
192+
```
193+
194+
`completedTasks` counts `done` + `verified`. `activeTasks` counts every status
195+
that is not `backlog`, `done`, or `verified`. `blockedTasks` counts
196+
`blocked_external`. `statusPreviews` lists are small (bounded in SQL) and
197+
include only task id/title pairs — never plan text, logs, or other detail-only
198+
fields.
199+
142200
### Create Project
143201

144202
```

docs/architecture.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,9 @@ loads bounded:
343343
options, auto-review state, and QA markdown artifacts.
344344
- `GET /tasks/:id` remains the full task detail endpoint for task detail, chat,
345345
comments, and agent workflows.
346+
- `GET /projects/overview` uses `listProjectTaskOverviews()` to serve compact
347+
per-project counts, metric totals, and small title previews without loading
348+
every task row into the web client.
346349

347350
## Database
348351

packages/api/src/__tests__/projects.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,71 @@ describe("projects API", () => {
131131
expect(await res.json()).toEqual([]);
132132
});
133133

134+
it("returns compact project task overviews", async () => {
135+
const db = testDb.current;
136+
db.insert(projects)
137+
.values([
138+
{ id: "overview-a", name: "Overview A", rootPath: "/tmp/a" },
139+
{ id: "overview-b", name: "Overview B", rootPath: "/tmp/b" },
140+
])
141+
.run();
142+
db.insert(tasks)
143+
.values([
144+
{
145+
id: "task-a-1",
146+
projectId: "overview-a",
147+
title: "Backlog item",
148+
status: "backlog",
149+
plan: "heavy plan",
150+
implementationLog: "heavy implementation log",
151+
reviewComments: "heavy review comments",
152+
agentActivityLog: "heavy activity log",
153+
tokenInput: 10,
154+
tokenOutput: 20,
155+
tokenTotal: 30,
156+
costUsd: 0.25,
157+
},
158+
{
159+
id: "task-a-2",
160+
projectId: "overview-a",
161+
title: "Done item",
162+
status: "done",
163+
retryCount: 2,
164+
},
165+
{
166+
id: "task-b-1",
167+
projectId: "overview-b",
168+
title: "Other item",
169+
status: "review",
170+
},
171+
])
172+
.run();
173+
174+
const res = await app.request("/projects/overview");
175+
expect(res.status).toBe(200);
176+
const body = await res.json();
177+
const overviewA = body.find(
178+
(overview: { projectId: string }) => overview.projectId === "overview-a",
179+
);
180+
181+
expect(overviewA).toMatchObject({
182+
projectId: "overview-a",
183+
totalTasks: 2,
184+
completedTasks: 1,
185+
backlogTasks: 1,
186+
totalRetries: 2,
187+
totalTokenInput: 10,
188+
totalTokenOutput: 20,
189+
totalTokenTotal: 30,
190+
totalCostUsd: 0.25,
191+
});
192+
expect(overviewA.statusCounts.backlog).toBe(1);
193+
expect(overviewA.statusCounts.done).toBe(1);
194+
expect(overviewA.statusPreviews.backlog).toEqual([{ id: "task-a-1", title: "Backlog item" }]);
195+
expect(overviewA.statusPreviews.backlog[0]).not.toHaveProperty("plan");
196+
expect(overviewA.statusPreviews.backlog[0]).not.toHaveProperty("implementationLog");
197+
});
198+
134199
it("rejects invalid root path for create", async () => {
135200
const res = await app.request("/projects", {
136201
method: "POST",

packages/api/src/repositories/projects.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createProject as createProjectRecord,
88
deleteProject as deleteProjectRecord,
99
findProjectById,
10+
listProjectTaskOverviews,
1011
listProjects,
1112
type ProjectRow,
1213
updateProject as updateProjectRecord,
@@ -158,4 +159,4 @@ export function getProjectMcpServers(projectId: string): Record<string, unknown>
158159
}
159160
}
160161

161-
export { listProjects, findProjectById };
162+
export { listProjects, listProjectTaskOverviews, findProjectById };

packages/api/src/routes/projects.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { getAutoQueueMode, setAutoQueueMode } from "@aif/data";
2727
import { broadcast } from "../ws.js";
2828
import {
2929
listProjects,
30+
listProjectTaskOverviews,
3031
findProjectById,
3132
createProject,
3233
updateProject,
@@ -194,6 +195,16 @@ projectsRouter.get("/", (c) => {
194195
return c.json(all);
195196
});
196197

198+
// GET /projects/overview - compact task metrics and previews for the overview screen
199+
projectsRouter.get("/overview", (c) => {
200+
const overview = listProjectTaskOverviews();
201+
log.debug(
202+
{ projectCount: overview.length, responseType: "ProjectTaskOverview" },
203+
"Listed project task overview",
204+
);
205+
return c.json(overview);
206+
});
207+
197208
// POST /projects
198209
projectsRouter.post("/", jsonValidator(createProjectSchema), async (c) => {
199210
const body = c.req.valid("json");

packages/data/src/__tests__/index.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const {
3737
getLatestHumanComment,
3838
getLatestReworkComment,
3939
listProjects,
40+
listProjectTaskOverviews,
4041
findProjectById,
4142
createProject,
4243
updateProject,
@@ -253,6 +254,68 @@ describe("data layer", () => {
253254
});
254255
});
255256

257+
describe("listProjectTaskOverviews", () => {
258+
it("returns compact per-project aggregates and limited previews", () => {
259+
seedProject("proj-2");
260+
createTask({
261+
projectId: "proj-1",
262+
title: "Backlog A",
263+
description: "D",
264+
isFix: true,
265+
tags: ["x"],
266+
});
267+
const firstBacklog = createTask({
268+
projectId: "proj-1",
269+
title: "Backlog first by position",
270+
description: "D",
271+
position: 10,
272+
})!;
273+
const backlog = listTasks("proj-1").find((task) => task.title === "Backlog A")!;
274+
updateTask(backlog.id, {
275+
tokenInput: 5,
276+
tokenOutput: 6,
277+
tokenTotal: 11,
278+
});
279+
const done = createTask({
280+
projectId: "proj-1",
281+
title: "Done B",
282+
description: "D",
283+
autoMode: false,
284+
})!;
285+
updateTaskStatus(done.id, "done", {
286+
retryCount: 2,
287+
});
288+
updateTask(done.id, {
289+
tokenInput: 10,
290+
tokenOutput: 15,
291+
tokenTotal: 25,
292+
costUsd: 0.5,
293+
});
294+
createTask({ projectId: "proj-2", title: "Other project", description: "D" });
295+
296+
const overviews = listProjectTaskOverviews(1);
297+
const proj1 = overviews.find((overview) => overview.projectId === "proj-1")!;
298+
const proj2 = overviews.find((overview) => overview.projectId === "proj-2")!;
299+
300+
expect(proj1.totalTasks).toBe(3);
301+
expect(proj1.completedTasks).toBe(1);
302+
expect(proj1.backlogTasks).toBe(2);
303+
expect(proj1.fixTasks).toBe(1);
304+
expect(proj1.totalRetries).toBe(2);
305+
expect(proj1.totalTokenInput).toBe(15);
306+
expect(proj1.totalTokenOutput).toBe(21);
307+
expect(proj1.totalTokenTotal).toBe(36);
308+
expect(proj1.totalCostUsd).toBe(0.5);
309+
expect(proj1.statusCounts.backlog).toBe(2);
310+
expect(proj1.statusCounts.done).toBe(1);
311+
expect(proj1.statusPreviews.backlog).toEqual([
312+
{ id: firstBacklog.id, title: "Backlog first by position" },
313+
]);
314+
expect(proj1.statusPreviews.done).toEqual([{ id: done.id, title: "Done B" }]);
315+
expect(proj2.totalTasks).toBe(1);
316+
});
317+
});
318+
256319
describe("updateTask", () => {
257320
it("updates basic fields", () => {
258321
const t = createTask({ projectId: "proj-1", title: "Old", description: "D" });

0 commit comments

Comments
 (0)