Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@tanstack/react-query": "^5.102.1",
"next": "15.5.4",
"qrcode": "^1.5.4",
"react": "19.1.0",
Expand Down
47 changes: 12 additions & 35 deletions apps/frontend/src/app/components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"use client";
import Image from "next/image";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { PT_Sans } from "next/font/google";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { useAuth } from "@/context/AuthContext";
import { useApi } from "@/hooks/useApi";
import { useQuery } from "@tanstack/react-query";
import { projectsQuery } from "@/lib/queries";
import { usePermissions } from "@/hooks/usePermissions";
import { authorizeAny, type RbacSubject } from "@branch/rbac";
import { assetPath } from "@/lib/asset";
Expand Down Expand Up @@ -172,7 +173,6 @@ export const NavBar: React.FC<{
const { logout } = useAuth();
const { subject } = usePermissions();
const effectiveSubject = subjectOverride ?? subject;
const api = useApi();

const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [loggingOut, setLoggingOut] = useState(false);
Expand All @@ -181,17 +181,15 @@ export const NavBar: React.FC<{
// content beside the rail, so opening it automatically would cover the very
// page the user just navigated to.
const [projectsOpen, setProjectsOpen] = useState(false);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
// Starts as loading: the menu only renders once expanded, and expanding
// always triggers a load — defaulting to false made "No projects yet" flash
// before the first response arrived.
const [projectsState, setProjectsState] = useState<{ loading: boolean; error: string | null }>({
loading: true,
error: null,
});
const hasLoadedProjects = useRef(false);
const submenuRef = useRef<HTMLLIElement | null>(null);

// Still lazy — `enabled` keeps the request off every page load, exactly as the
// old first-expand latch did. What changed is that it now shares `['projects']`
// with the page behind the rail, so on /projects, /donations, /expenses and
// /reports expanding the flyout costs no request at all.
const projectsList = useQuery({ ...projectsQuery(), enabled: projectsOpen });
const projects = projectsList.data ?? [];

// Same table AuthGate guards with, so a link cannot appear for a page that
// would then refuse to render.
const visibleItems = NAV_ITEMS.filter((item) => {
Expand All @@ -209,27 +207,6 @@ export const NavBar: React.FC<{
return target !== "/" && currentPath.startsWith(`${target}/`);
};

// Fetched on first expand rather than on mount: the list is only ever read by
// this menu, and eagerly loading it would add a request to every page.
const loadProjects = useCallback(async () => {
if (hasLoadedProjects.current) return;
hasLoadedProjects.current = true;
setProjectsState({ loading: true, error: null });
try {
const rows = await api.get<ProjectSummary[]>("/projects");
setProjects(Array.isArray(rows) ? rows : []);
setProjectsState({ loading: false, error: null });
} catch {
// Retryable: clearing the latch lets the next expand try again.
hasLoadedProjects.current = false;
setProjectsState({ loading: false, error: "Could not load projects" });
}
}, [api]);

useEffect(() => {
if (projectsOpen) void loadProjects();
}, [projectsOpen, loadProjects]);

// Dismiss on outside click and Escape, the two things a flyout must honour.
useEffect(() => {
if (!projectsOpen) return;
Expand Down Expand Up @@ -385,8 +362,8 @@ export const NavBar: React.FC<{
{projectsOpen && (
<ProjectsSubmenu
projects={projects}
isLoading={projectsState.loading}
error={projectsState.error}
isLoading={projectsList.isPending}
error={projectsList.isError ? "Could not load projects" : null}
activeProjectId={activeProjectId}
onNavigate={() => setProjectsOpen(false)}
/>
Expand Down
54 changes: 54 additions & 0 deletions apps/frontend/src/app/components/RoutePrefetcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use client';

import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { getAccessToken } from '@/lib/authTokens';
import { queriesForRoute } from '@/lib/queries';

/**
* Starts a route's data requests in parallel with `GET /auth/me`.
*
* Mounted in `providers.tsx` *outside* `AuthGate`, which is the entire trick.
* The gate renders a spinner until the session resolves, so no page component
* mounts and no page effect runs until then -- giving every cold load a hard
* two-round-trip floor (`/auth/me`, then the page's own call), each trip
* potentially paying a Lambda cold start. This component is not behind the gate,
* so its effect fires on the first flush and the two trips overlap. When the
* gate does unblock, the page's `useQuery` finds a warm cache.
*
* Why not hydrate `user` from a cached `/auth/me` payload instead? That also
* removes the wait, but a signed-out visitor with stale storage would briefly
* see admin navigation before the server corrected them. Authorization is
* enforced server-side so nothing leaks, but it is a bad enough appearance bug
* to rule the approach out. Prefetching buys the same parallelism with no
* false UI.
*
* Renders nothing.
*/
export default function RoutePrefetcher() {
const queryClient = useQueryClient();
const pathname = usePathname() ?? '/';

useEffect(() => {
// Without a token these requests can only 401, so an anonymous visitor
// makes no more calls than before. Storage is the only session signal
// available this early -- asking `useAuth()` would reintroduce the wait
// this component exists to remove.
if (!getAccessToken()) return;

// `window.location.search` rather than `useSearchParams()`: the hook forces
// the nearest boundary to suspend under `output: 'export'`, and this
// component must not suspend anything -- it renders no UI and its whole
// value is being early.
const search = new URLSearchParams(window.location.search);

for (const spec of queriesForRoute(pathname, search)) {
// Honours the default staleTime, so a repeat visit to a warm route is a
// no-op rather than a duplicate request.
void queryClient.prefetchQuery(spec);
}
}, [pathname, queryClient]);

return null;
}
65 changes: 38 additions & 27 deletions apps/frontend/src/app/donations/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import NavBar from '../components/Navbar';
import Header from '../components/Header';
import { HStack, Input, Dialog, Portal, CloseButton, Stack } from '@chakra-ui/react';
Expand All @@ -14,6 +14,8 @@ import RowDeleteButton from '../components/RowDeleteButton';
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog';
import Pagination from '../components/Pagination';
import { useApi } from '@/hooks/useApi';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { donationsQuery, donorsQuery, projectsQuery } from '@/lib/queries';
import { usePermissions } from '@/hooks/usePermissions';
import { GatedButton } from '../components/Permission';
import { formatCurrencyPrecise, formatDateNumeric } from '@/lib/format';
Expand All @@ -22,6 +24,12 @@ import type { Donation, Donor, Project } from '@/types';
const ROWS_PER_PAGE = 10;
const SORT_OPTIONS = ['Date', 'Amount'];

// Stable empty defaults. `?? []` hands the useMemo below a fresh array on every
// render, which recomputes the joined rows for nothing.
const NO_DONATIONS: Donation[] = [];
const NO_DONORS: Donor[] = [];
const NO_PROJECTS: Pick<Project, 'project_id' | 'name'>[] = [];

/** `GET /donors/donations` returns bare rows; names are joined in on the client. */
interface DonationRow extends Donation {
donor_name: string;
Expand All @@ -31,12 +39,30 @@ interface DonationRow extends Donation {
export default function DonationsPage() {
const api = useApi();
const { can } = usePermissions();
const queryClient = useQueryClient();

// Client-side pagination and filtering below are unchanged — this page needs
// server-side aggregates it does not have yet. What changed is only that the
// three reads are cached, so returning here is free, and that `/projects` is
// the same cache entry the navbar and every other page reads.
const donationsList = useQuery(donationsQuery());
const donorsList = useQuery(donorsQuery());
const projectsList = useQuery(projectsQuery());

const donations = donationsList.data ?? NO_DONATIONS;
const donors = donorsList.data ?? NO_DONORS;
const projects = projectsList.data ?? NO_PROJECTS;

const [donations, setDonations] = useState<Donation[]>([]);
const [donors, setDonors] = useState<Donor[]>([]);
const [projects, setProjects] = useState<Pick<Project, 'project_id' | 'name'>[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loading =
donationsList.isPending || donorsList.isPending || projectsList.isPending;

const loadError =
donationsList.error ?? donorsList.error ?? projectsList.error;
const error = loadError
? loadError instanceof Error
? loadError.message
: 'Failed to load donations'
: null;

const [currentPage, setCurrentPage] = useState(1);
const [search, setSearch] = useState('');
Expand All @@ -59,27 +85,12 @@ export default function DonationsPage() {

const [donationToDelete, setDonationToDelete] = useState<DonationRow | null>(null);

const load = useCallback(async () => {
setError(null);
try {
const [donationRes, donorRes, projectRes] = await Promise.all([
api.get<{ data: Donation[] }>('/donors/donations'),
api.get<{ data: Donor[] }>('/donors'),
api.get<Project[]>('/projects'),
]);
setDonations(donationRes.data ?? []);
setDonors(donorRes.data ?? []);
setProjects(Array.isArray(projectRes) ? projectRes : []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load donations');
} finally {
setLoading(false);
}
}, [api]);

useEffect(() => {
void load();
}, [load]);
// Recording or deleting a donation changes the list and nothing else, so only
// that key is dropped — donors and projects stay warm.
const load = useCallback(
() => queryClient.invalidateQueries({ queryKey: donationsQuery().queryKey }),
[queryClient],
);

const rows: DonationRow[] = useMemo(() => {
const donorNames = new Map(donors.map((d) => [d.donor_id, d.organization]));
Expand Down
Loading
Loading