@@ -51,11 +59,11 @@ export default function ProjectCard(props: ProjectCardProps) {
-
{Math.round((props.budget_used / props.total_budget) * 100)}%
+
{percentUsed}%
) : (
diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx
index f5dfd992..ece0672e 100644
--- a/apps/frontend/src/app/dashboard/page.tsx
+++ b/apps/frontend/src/app/dashboard/page.tsx
@@ -7,6 +7,7 @@ import Header from '../components/Header';
import ProjectCard from '../components/ProjectCard';
import { useApi } from '@/hooks/useApi';
import { useAuth } from '@/context/AuthContext';
+import { Dashboard, ProjectSummary } from '@/types';
/**
* Landing page for a signed-in user, and the target the login flow redirects to.
@@ -14,23 +15,21 @@ import { useAuth } from '@/context/AuthContext';
* The Navbar has always linked here; the route simply never existed.
*/
-interface ProjectRow {
- project_id: number;
- name: string;
- total_budget: number | string | null;
-}
-
export default function DashboardPage() {
const api = useApi();
const { user } = useAuth();
- const [projects, setProjects] = useState
([]);
+ const [projects, setProjects] = useState([]);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
+ // /projects/dashboard rather than /projects: it returns the same set of
+ // projects (scoped to the caller) already carrying the spend and staff counts
+ // the cards need, so there is nothing left to aggregate client-side.
const load = useCallback(async () => {
try {
setError(null);
- setProjects(await api.get('/projects'));
+ const dashboard = await api.get('/projects/dashboard');
+ setProjects(dashboard.projects ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not load projects');
} finally {
@@ -83,9 +82,9 @@ export default function DashboardPage() {
))}
diff --git a/apps/frontend/src/app/projects/page.tsx b/apps/frontend/src/app/projects/page.tsx
index 1034e880..86e5e438 100644
--- a/apps/frontend/src/app/projects/page.tsx
+++ b/apps/frontend/src/app/projects/page.tsx
@@ -6,6 +6,7 @@ import NavBar from '../components/Navbar';
import Header from '../components/Header';
import ProjectCard from '../components/ProjectCard';
import { useApi } from '@/hooks/useApi';
+import { Dashboard, ProjectSummary } from '@/types';
/**
* Projects index. The Navbar has always linked to /projects, but only the
@@ -15,22 +16,20 @@ import { useApi } from '@/hooks/useApi';
* `output: 'export'`.
*/
-interface ProjectRow {
- project_id: number;
- name: string;
- total_budget: number | string | null;
-}
-
export default function ProjectsListPage() {
const api = useApi();
- const [projects, setProjects] = useState([]);
+ const [projects, setProjects] = useState([]);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
+ // /projects/dashboard rather than /projects: it returns the same set of
+ // projects (scoped to the caller) already carrying the spend and staff counts
+ // the cards need, so there is nothing left to aggregate client-side.
const load = useCallback(async () => {
try {
setError(null);
- setProjects(await api.get('/projects'));
+ const dashboard = await api.get('/projects/dashboard');
+ setProjects(dashboard.projects ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not load projects');
} finally {
@@ -81,9 +80,9 @@ export default function ProjectsListPage() {
))}
diff --git a/apps/frontend/src/types/project.ts b/apps/frontend/src/types/project.ts
index f1af6a27..80834e42 100644
--- a/apps/frontend/src/types/project.ts
+++ b/apps/frontend/src/types/project.ts
@@ -9,6 +9,28 @@ export interface Project {
created_at: string | null;
};
+/** A project as `GET /projects/dashboard` returns it: the row plus its aggregates. */
+export interface ProjectSummary {
+ project_id: number;
+ name: string;
+ total_budget: number | null;
+ currency: string | null;
+ spent: number;
+ staff_count: number;
+ spent_percentage: number;
+}
+
+export interface Dashboard {
+ summary: {
+ topExpenseCategory: { category: string; amount: number } | null;
+ totalSpent: number;
+ totalProjects: number;
+ averageSpendPerProject: number;
+ };
+ projects: ProjectSummary[];
+ expensesByMonth: { month: string; category: string; amount: number }[];
+}
+
export type ProjectRole = 'PI' | 'Accountant' | 'Staff' | 'Admin';
export interface Member {
diff --git a/apps/frontend/test/components/ProjectCard.test.tsx b/apps/frontend/test/components/ProjectCard.test.tsx
index 2a95cbe8..46b88e8c 100644
--- a/apps/frontend/test/components/ProjectCard.test.tsx
+++ b/apps/frontend/test/components/ProjectCard.test.tsx
@@ -41,6 +41,25 @@ describe('ProjectCard (active)', () => {
const percentage = Math.round((activeMockProps.budget_used / activeMockProps.total_budget) * 100);
expect(screen.getByText(`${percentage}%`)).toBeInTheDocument();
});
+
+ it('renders 0% rather than NaN when the project has no budget', () => {
+ render();
+ expect(screen.getByText('0%')).toBeInTheDocument();
+ });
+
+ it('renders 0% rather than Infinity when spend exists but no budget is set', () => {
+ render();
+ expect(screen.getByText('0%')).toBeInTheDocument();
+ });
+
+ it('reports overspend honestly but keeps the bar within the track', () => {
+ const { container } = render(
+ ,
+ );
+ expect(screen.getByText('200%')).toBeInTheDocument();
+ const bar = container.querySelector('.bg-core-green') as HTMLElement;
+ expect(bar.style.width).toBe('100%');
+ });
});
describe('ProjectCard (archive)', () => {
diff --git a/apps/frontend/test/components/ProjectsPage.test.tsx b/apps/frontend/test/components/ProjectsPage.test.tsx
new file mode 100644
index 00000000..66606f6d
--- /dev/null
+++ b/apps/frontend/test/components/ProjectsPage.test.tsx
@@ -0,0 +1,89 @@
+import { render, screen, waitFor } from '../utils';
+import ProjectsListPage from '@/app/projects/page';
+import DashboardPage from '@/app/dashboard/page';
+
+const mockAuthedFetch = jest.fn();
+jest.mock('../../src/lib/authClient', () => ({
+ ...jest.requireActual('../../src/lib/authClient'),
+ authedFetch: (...args: Parameters) => mockAuthedFetch(...args),
+}));
+
+const dashboard = {
+ summary: {
+ topExpenseCategory: { category: 'Travel', amount: 6800 },
+ totalSpent: 13700,
+ totalProjects: 2,
+ averageSpendPerProject: 6850,
+ },
+ projects: [
+ {
+ project_id: 1,
+ name: 'Clinician Communication Study',
+ total_budget: 500000,
+ currency: 'USD',
+ spent: 9200,
+ staff_count: 2,
+ spent_percentage: 1.84,
+ },
+ {
+ project_id: 4,
+ name: 'Proj B',
+ total_budget: null,
+ currency: 'USD',
+ spent: 0,
+ staff_count: 0,
+ spent_percentage: 0,
+ },
+ ],
+ expensesByMonth: [],
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockAuthedFetch.mockImplementation((url: string) => {
+ if (url === '/auth/me') {
+ return Promise.resolve({ userId: 1, email: 'ashley@branch.org', name: 'Ashley Duggan', isAdmin: true });
+ }
+ if (url === '/projects/dashboard') return Promise.resolve(dashboard);
+ return Promise.reject(new Error(`unexpected request: ${url}`));
+ });
+});
+
+describe.each([
+ ['Projects index', ProjectsListPage],
+ ['Dashboard', DashboardPage],
+])('%s', (_name, Page) => {
+ it('reads its project cards from GET /projects/dashboard', async () => {
+ render();
+ await waitFor(() => {
+ expect(mockAuthedFetch).toHaveBeenCalledWith('/projects/dashboard', { method: 'GET' });
+ });
+ // The list endpoint carries no spend or staffing, so it must not be the source.
+ expect(mockAuthedFetch).not.toHaveBeenCalledWith('/projects', expect.anything());
+ });
+
+ it('renders the real spend and staff count, not zeros', async () => {
+ render();
+ expect(
+ await screen.findByText((text) => text.includes('9,200') && text.includes('500,000')),
+ ).toBeInTheDocument();
+ expect(screen.getByText('2 members')).toBeInTheDocument();
+ expect(screen.getByText('2%')).toBeInTheDocument();
+ });
+
+ it('renders a project with no budget as 0% instead of NaN', async () => {
+ render();
+ expect(await screen.findByText('Proj B')).toBeInTheDocument();
+ expect(screen.getByText('0 members')).toBeInTheDocument();
+ expect(screen.getByText('0%')).toBeInTheDocument();
+ });
+
+ it('surfaces a failure instead of rendering empty cards', async () => {
+ mockAuthedFetch.mockImplementation((url: string) => {
+ if (url === '/auth/me') return Promise.resolve({ userId: 1, email: 'a@b.org', name: 'A', isAdmin: false });
+ return Promise.reject(new Error('Authentication required'));
+ });
+ render();
+ expect(await screen.findByText('Authentication required')).toBeInTheDocument();
+ });
+});