Skip to content

Commit 5160dd6

Browse files
authored
Merge pull request #143 from ichinya/feat/dashboard-overview-migration
feat(web): migrate dashboard to GET /projects/overview
2 parents 7324650 + 9f75d18 commit 5160dd6

11 files changed

Lines changed: 374 additions & 118 deletions

File tree

packages/web/src/App.tsx

Lines changed: 56 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
import { useState, useCallback, useEffect, useMemo } from "react";
2-
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
2+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
33
import { Header } from "./components/layout/Header";
44
import { Board } from "./components/kanban/Board";
55
import { TaskDetail } from "./components/task/TaskDetail";
66
import { CommandPalette } from "./components/layout/CommandPalette";
77
import { useWebSocket } from "./hooks/useWebSocket";
88
import { useCommitToasts } from "./hooks/useCommitToasts";
9-
import { useProjects } from "./hooks/useProjects";
9+
import { useProjectTaskOverviews, useProjects } from "./hooks/useProjects";
1010
import { useTasks } from "./hooks/useTasks";
1111
import { useTheme } from "./hooks/useTheme";
1212
import { useKeyboardShortcut } from "./hooks/useKeyboardShortcut";
1313
import { ChatBubble } from "./components/chat/ChatBubble";
1414
import { ChatPanel } from "./components/chat/ChatPanel";
15-
import { calculateTaskMetrics } from "./lib/taskMetrics";
15+
import { calculateOverviewMetrics, calculateTaskMetrics } from "./lib/taskMetrics";
1616
import { readStorage, writeStorage, removeStorage } from "./lib/storage";
1717
import { STORAGE_KEYS } from "./lib/storageKeys";
18-
import { api } from "./lib/api";
19-
import type { Project, Task } from "@aif/shared/browser";
18+
import type { Project } from "@aif/shared/browser";
2019
import { ProjectRuntimeSettings } from "./components/project/ProjectRuntimeSettings";
2120
import { ProjectsOverview } from "./components/project/ProjectsOverview";
2221
import { ToastProvider } from "./components/ui/toast";
@@ -30,13 +29,31 @@ const queryClient = new QueryClient({
3029
},
3130
});
3231

32+
const PROJECT_ROUTE_PATTERN = /^\/project\/([^/]+)(?:\/task\/([^/]+))?/;
33+
34+
function readInitialSelection(): { projectId: string | null; taskId: string | null } {
35+
const match = window.location.pathname.match(PROJECT_ROUTE_PATTERN);
36+
if (match) {
37+
return { projectId: match[1] ?? null, taskId: match[2] ?? null };
38+
}
39+
40+
return {
41+
projectId: readStorage(STORAGE_KEYS.SELECTED_PROJECT),
42+
taskId: null,
43+
};
44+
}
45+
3346
function AppContent() {
3447
useWebSocket();
3548
useCommitToasts();
3649
const { theme, toggleTheme } = useTheme();
3750
const { data: projects } = useProjects();
38-
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
39-
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
51+
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
52+
() => readInitialSelection().projectId,
53+
);
54+
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(
55+
() => readInitialSelection().taskId,
56+
);
4057
const [commandOpen, setCommandOpen] = useState(false);
4158
const [chatOpen, setChatOpen] = useState(false);
4259
const [runtimeSettingsOpen, setRuntimeSettingsOpen] = useState(false);
@@ -52,18 +69,17 @@ function AppContent() {
5269
() => projects?.find((candidate) => candidate.id === selectedProjectId) ?? null,
5370
[projects, selectedProjectId],
5471
);
55-
const { data: projectTasks } = useTasks(project?.id ?? null);
56-
const { data: allTasks } = useQuery<Task[]>({
57-
queryKey: ["tasks", "all"],
58-
queryFn: () => api.listTasks(),
59-
enabled: !project,
60-
});
72+
const { data: projectTasks } = useTasks(selectedProjectId);
73+
const { data: projectTaskOverviews } = useProjectTaskOverviews(!selectedProjectId);
6174
const taskMetrics = useMemo(
62-
() => calculateTaskMetrics((project ? projectTasks : allTasks) ?? []),
63-
[project, projectTasks, allTasks],
75+
() =>
76+
selectedProjectId
77+
? calculateTaskMetrics(projectTasks ?? [])
78+
: calculateOverviewMetrics(projectTaskOverviews ?? []),
79+
[projectTaskOverviews, projectTasks, selectedProjectId],
6480
);
6581
const aggregateProjectTotals = useMemo(() => {
66-
if (project || !projects?.length) return null;
82+
if (selectedProjectId || !projects?.length) return null;
6783
return projects.reduce(
6884
(acc, p) => ({
6985
tokenInput: acc.tokenInput + (p.tokenInput ?? 0),
@@ -73,7 +89,7 @@ function AppContent() {
7389
}),
7490
{ tokenInput: 0, tokenOutput: 0, tokenTotal: 0, costUsd: 0 },
7591
);
76-
}, [project, projects]);
92+
}, [projects, selectedProjectId]);
7793

7894
useEffect(() => {
7995
writeStorage(STORAGE_KEYS.DENSITY, density);
@@ -83,50 +99,40 @@ function AppContent() {
8399
writeStorage(STORAGE_KEYS.VIEW_MODE, viewMode);
84100
}, [viewMode]);
85101

86-
// Restore state from URL or localStorage on initial load
102+
// Validate restored state after projects load.
87103
useEffect(() => {
88-
if (!projects?.length) return;
89-
if (selectedProjectId) return;
104+
if (!projects || !selectedProjectId) return;
90105

91-
const match = window.location.pathname.match(/^\/project\/([^/]+)(?:\/task\/([^/]+))?/);
92-
if (match) {
93-
const urlProjectId = match[1];
94-
const urlTaskId = match[2] ?? null;
95-
const found = projects.find((p) => p.id === urlProjectId);
96-
if (found) {
97-
queueMicrotask(() => {
98-
setSelectedProjectId(found.id);
99-
writeStorage(STORAGE_KEYS.SELECTED_PROJECT, found.id);
100-
if (urlTaskId) setSelectedTaskId(urlTaskId);
101-
});
102-
return;
103-
}
106+
const found = projects.find((p) => p.id === selectedProjectId);
107+
if (found) {
108+
writeStorage(STORAGE_KEYS.SELECTED_PROJECT, found.id);
109+
return;
104110
}
105111

106-
const savedId = readStorage(STORAGE_KEYS.SELECTED_PROJECT);
107-
if (savedId) {
108-
const found = projects.find((p) => p.id === savedId);
109-
if (found) {
110-
queueMicrotask(() => {
111-
setSelectedProjectId(found.id);
112-
});
113-
}
114-
}
112+
console.debug("[app] Clearing stale selected project", {
113+
selectedProjectId,
114+
reason: "missing_from_projects",
115+
});
116+
117+
const clearTimer = window.setTimeout(() => {
118+
setSelectedProjectId(null);
119+
setSelectedTaskId(null);
120+
removeStorage(STORAGE_KEYS.SELECTED_PROJECT);
121+
}, 0);
122+
123+
return () => window.clearTimeout(clearTimer);
115124
}, [projects, selectedProjectId]);
116125

117126
// Handle browser back/forward
118127
useEffect(() => {
119128
const onPopState = () => {
120-
const match = window.location.pathname.match(/^\/project\/([^/]+)(?:\/task\/([^/]+))?/);
129+
const match = window.location.pathname.match(PROJECT_ROUTE_PATTERN);
121130
if (match) {
122131
const urlProjectId = match[1];
123132
const urlTaskId = match[2] ?? null;
124-
const found = projects?.find((p) => p.id === urlProjectId);
125-
if (found) {
126-
setSelectedProjectId(found.id);
127-
setSelectedTaskId(urlTaskId);
128-
return;
129-
}
133+
setSelectedProjectId(urlProjectId);
134+
setSelectedTaskId(urlTaskId);
135+
return;
130136
}
131137
setSelectedProjectId(null);
132138
setSelectedTaskId(null);
@@ -253,7 +259,7 @@ function AppContent() {
253259
onOpenChange={setCommandOpen}
254260
projects={projects ?? []}
255261
tasks={projectTasks ?? []}
256-
selectedProjectId={project?.id ?? null}
262+
selectedProjectId={selectedProjectId}
257263
density={density}
258264
theme={theme}
259265
onSelectProject={handleSelectProject}

packages/web/src/__tests__/AppSmoke.test.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
22
import { render, screen } from "@testing-library/react";
33

44
// Stub network-dependent hooks so the component tree renders without real API calls.
@@ -57,8 +57,21 @@ vi.mock("@tanstack/react-query", async (importOriginal) => {
5757
});
5858

5959
const App = (await import("../App")).default;
60+
const { useProjectTaskOverviews } = await import("@/hooks/useProjects");
61+
const { useTasks } = await import("@/hooks/useTasks");
6062

6163
describe("App smoke test", () => {
64+
beforeEach(() => {
65+
window.history.pushState(null, "", "/");
66+
window.localStorage.clear();
67+
vi.clearAllMocks();
68+
});
69+
70+
afterEach(() => {
71+
window.history.pushState(null, "", "/");
72+
window.localStorage.clear();
73+
});
74+
6275
it("renders without crashing (providers are wired correctly)", () => {
6376
expect(() => render(<App />)).not.toThrow();
6477
});
@@ -67,4 +80,14 @@ describe("App smoke test", () => {
6780
render(<App />);
6881
expect(screen.getByText("No projects yet")).toBeInTheDocument();
6982
});
83+
84+
it("uses the project id from the URL for the initial task query", () => {
85+
const projectId = "00000000-0000-4000-8000-000000000001";
86+
window.history.pushState(null, "", `/project/${projectId}`);
87+
88+
render(<App />);
89+
90+
expect(vi.mocked(useTasks)).toHaveBeenCalledWith(projectId);
91+
expect(vi.mocked(useProjectTaskOverviews)).toHaveBeenCalledWith(false);
92+
});
7093
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { render, screen } from "@testing-library/react";
3+
import type { Project, ProjectTaskOverview } from "@aif/shared/browser";
4+
import { ProjectsOverview } from "@/components/project/ProjectsOverview";
5+
6+
// Regression test for the empty-project loading behavior.
7+
//
8+
// ProjectsOverview derives `isLoading` from the overview query state, so a
9+
// project with zero tasks must render its card (0 / 0 badge) instead of
10+
// hanging on skeleton cards forever.
11+
12+
const emptyProject: Project = {
13+
id: "proj-empty",
14+
name: "Empty Project",
15+
rootPath: "/tmp/empty",
16+
plannerMaxBudgetUsd: null,
17+
planCheckerMaxBudgetUsd: null,
18+
implementerMaxBudgetUsd: null,
19+
reviewSidecarMaxBudgetUsd: null,
20+
autoQueueMode: false,
21+
parallelEnabled: false,
22+
defaultTaskRuntimeProfileId: null,
23+
defaultPlanRuntimeProfileId: null,
24+
defaultReviewRuntimeProfileId: null,
25+
defaultChatRuntimeProfileId: null,
26+
tokenInput: undefined,
27+
tokenOutput: undefined,
28+
tokenTotal: undefined,
29+
costUsd: undefined,
30+
createdAt: "2026-01-01T00:00:00.000Z",
31+
updatedAt: "2026-01-01T00:00:00.000Z",
32+
};
33+
34+
const emptyOverview: ProjectTaskOverview = {
35+
projectId: "proj-empty",
36+
totalTasks: 0,
37+
completedTasks: 0,
38+
verifiedTasks: 0,
39+
backlogTasks: 0,
40+
activeTasks: 0,
41+
blockedTasks: 0,
42+
autoModeTasks: 0,
43+
fixTasks: 0,
44+
totalRetries: 0,
45+
totalTokenInput: 0,
46+
totalTokenOutput: 0,
47+
totalTokenTotal: 0,
48+
totalCostUsd: 0,
49+
statusCounts: {
50+
backlog: 0,
51+
planning: 0,
52+
plan_ready: 0,
53+
implementing: 0,
54+
review: 0,
55+
blocked_external: 0,
56+
done: 0,
57+
verified: 0,
58+
},
59+
statusPreviews: {
60+
backlog: [],
61+
planning: [],
62+
plan_ready: [],
63+
implementing: [],
64+
review: [],
65+
blocked_external: [],
66+
done: [],
67+
verified: [],
68+
},
69+
};
70+
71+
describe("ProjectsOverview empty-project loading regression", () => {
72+
it("does not stay in loading state when a project has zero tasks", () => {
73+
// Simulate the resolved state of useProjectTaskOverviews: the overview
74+
// query resolved successfully with a zero-task project (no loading).
75+
vi.mock("@/hooks/useProjects", () => ({
76+
useProjectTaskOverviews: () => ({
77+
data: [emptyOverview],
78+
isLoading: false,
79+
}),
80+
}));
81+
82+
render(<ProjectsOverview projects={[emptyProject]} onSelectProject={() => {}} />);
83+
84+
// The project card must render (not skeleton cards). The "0 / 0" badge
85+
// confirms the empty project resolved to a real, non-loading card.
86+
expect(screen.getByText("Empty Project")).toBeDefined();
87+
expect(screen.getByText("0 / 0")).toBeDefined();
88+
// No skeleton should be rendered.
89+
expect(screen.queryByRole("status")).toBeNull();
90+
});
91+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { api } from "@/lib/api";
3+
4+
function jsonResponse(body: unknown): Response {
5+
return new Response(JSON.stringify(body), {
6+
status: 200,
7+
headers: { "Content-Type": "application/json" },
8+
});
9+
}
10+
11+
describe("api client", () => {
12+
beforeEach(() => {
13+
vi.stubGlobal(
14+
"fetch",
15+
vi.fn(async () => jsonResponse([])),
16+
);
17+
});
18+
19+
afterEach(() => {
20+
vi.unstubAllGlobals();
21+
});
22+
23+
it("requires projectId for task list requests", async () => {
24+
await api.listTasks("project 1");
25+
26+
const fetchMock = vi.mocked(fetch);
27+
const [url] = fetchMock.mock.calls[0];
28+
expect(String(url)).toContain("/tasks?projectId=project%201");
29+
expect(String(url)).not.toBe("/tasks");
30+
});
31+
32+
it("uses explicit project overview endpoint", async () => {
33+
await api.listProjectTaskOverviews();
34+
35+
const fetchMock = vi.mocked(fetch);
36+
const [url] = fetchMock.mock.calls[0];
37+
expect(String(url)).toContain("/projects/overview");
38+
});
39+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { QueryClient } from "@tanstack/react-query";
3+
import { invalidateProjectTaskOverviews } from "@/hooks/useProjects";
4+
5+
/**
6+
* Regression for the WS overview-invalidation gap (#143 review, must-fix #2).
7+
*
8+
* `project:runtime_limit_updated` fires when persisted runtime-limit / last
9+
* usage state changes. The overview aggregates token/cost fields, so this
10+
* event must invalidate the `["projectTaskOverviews"]` query — otherwise the
11+
* dashboard header and project cards show stale token/cost after usage
12+
* updates. The useWebSocket handler calls `invalidateProjectTaskOverviews`
13+
* (from useProjects) in that branch; this test pins that helper so a future
14+
* change that drops or renames the query key surfaces immediately.
15+
*/
16+
describe("invalidateProjectTaskOverviews (WS runtime_limit_updated branch)", () => {
17+
it("invalidates the projectTaskOverviews query", () => {
18+
const queryClient = new QueryClient();
19+
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
20+
21+
invalidateProjectTaskOverviews(queryClient);
22+
23+
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["projectTaskOverviews"] });
24+
});
25+
});

0 commit comments

Comments
 (0)