From 6b240e6265c060ec4790268ed894cb7e88bc9183 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Mon, 3 Aug 2026 20:18:49 -0400 Subject: [PATCH] feat(frontend): fill project cards from the dashboard API Every ProjectCard on /dashboard and /projects was rendered with budget_used={0} members={0}, so the budget bar sat at 0% and the staff count read "0 members" for every project regardless of the data. Both pages now read GET /projects/dashboard, which returns the same caller-scoped set of projects already carrying `spent` and `staff_count`, so there is nothing left to aggregate client-side. That endpoint is admin-only on main; the branch this is stacked on scopes it to the caller's projects. ProjectCard guarded against a zero budget while here: with a hardcoded budget_used of 0 the division was a harmless 0/0, but with real spend a project with no budget set yields Infinity% straight into the bar width. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/app/components/ProjectCard.tsx | 12 ++- apps/frontend/src/app/dashboard/page.tsx | 21 +++-- apps/frontend/src/app/projects/page.tsx | 21 +++-- apps/frontend/src/types/project.ts | 22 +++++ .../test/components/ProjectCard.test.tsx | 19 ++++ .../test/components/ProjectsPage.test.tsx | 89 +++++++++++++++++++ 6 files changed, 160 insertions(+), 24 deletions(-) create mode 100644 apps/frontend/test/components/ProjectsPage.test.tsx diff --git a/apps/frontend/src/app/components/ProjectCard.tsx b/apps/frontend/src/app/components/ProjectCard.tsx index c27adbbe..e31c08a5 100644 --- a/apps/frontend/src/app/components/ProjectCard.tsx +++ b/apps/frontend/src/app/components/ProjectCard.tsx @@ -23,6 +23,14 @@ type ArchiveProps = { type ProjectCardProps = ActiveProps | ArchiveProps; export default function ProjectCard(props: ProjectCardProps) { + // A project with no budget set divides by zero. Now that these numbers come + // from the API rather than a hardcoded 0, that is a real case: it yields + // NaN% (or Infinity% once anything is spent) straight into the bar width. + const percentUsed = + props.variant === 'active' && props.total_budget > 0 + ? Math.round((props.budget_used / props.total_budget) * 100) + : 0; + return (
@@ -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(); + }); +});