Overview
Both src/app/leaderboard/page.tsx and src/app/history/page.tsx render pagination as one real <Button> per page, with no upper bound and no active-page semantics beyond a visual color swap:
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<Button
key={p}
variant={p === currentPage ? "solid" : "outline"}
bg={p === currentPage ? "app.accent" : undefined}
color={p === currentPage ? "app.onAccent" : "app.text"}
onClick={() => setPage(p)}
...
>
{p}
</Button>
))}
There are two independent, compounding problems: (1) Unbounded rendering. totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) — with PAGE_SIZE = 10 (leaderboard) or 20 (history), a leaderboard with, say, 2,000 ranked farmers renders 200 real button elements in one Flex, every render, with no windowing/ellipsis/"jump to page" input. This is real DOM bloat and layout jank on any dataset larger than a couple hundred entries, and on mobile it's effectively unusable — 200 small tap targets wrapped across dozens of rows. (2) No aria-current. The only signal that button p represents the active page is a background/text color change (variant/bg/color props) — there is no aria-current="page" attribute anywhere on the active button, and the buttons themselves have no descriptive aria-label beyond their bare number ({p}), so a screen reader announces a plain sequence of numbered buttons with no way to determine which one — if any — represents the user's current position, unlike the sort-column headers in this same page, which do correctly set aria-sort (leaderboard/page.tsx:194,227).
Requirements
- Cap the number of rendered page-number buttons (e.g. a fixed window around the current page plus first/last with ellipsis, a common pagination pattern), independent of
totalPages.
- Add
aria-current="page" to the active page's button.
- Add a descriptive
aria-label to each page button (e.g. Go to page ${p}) so screen reader users get more than a bare number out of context.
- Apply the fix identically to both
LeaderboardPage and HistoryPage, since both currently duplicate the same unbounded-button pattern.
Acceptance Criteria
Additional Notes
More precise references
src/app/leaderboard/page.tsx:332-349 — confirmed the exact Array.from({ length: totalPages }, ...) unbounded-button rendering and confirmed no aria-current/descriptive aria-label anywhere in the block (only key={p} and the visible {p} text).
src/app/history/page.tsx:231-248 — confirmed history's pagination block is structurally identical (same Array.from({ length: totalPages }, ...) pattern, same missing aria-current/aria-label), confirming this is a duplicated, not isolated, gap.
src/app/leaderboard/page.tsx:194,227 (aria-sort={sortKey === "credits" ? "descending" : "none"}) — confirmed as a direct, in-file counterexample: the same page correctly implements ARIA state for its sort-column headers, making the pagination buttons' lack of aria-current a clear, avoidable inconsistency rather than an unknown pattern to the codebase.
src/hooks/useLeaderboard.ts:40 (totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))) with PAGE_SIZE = 10 (useLeaderboard.ts:13) — confirmed the scaling math: any total in the low thousands already produces hundreds of buttons.
Additional edge cases
- This compounds directly with the separate "Leaderboard search only searches the current page" bug filed in this batch: a user unable to find an address via search, on a leaderboard large enough to trigger this pagination-button explosion, has no practical way to page-hunt for it either — both issues stem from the same underlying "the UI doesn't have a real answer for a large leaderboard" gap, but are filed separately since they're independently reproducible with different root code paths (
useLeaderboard's filter logic vs. the page component's pagination render).
- Mobile layout: the pagination
Flex already sets wrap="wrap" (leaderboard/page.tsx:318), meaning an unbounded button count doesn't overflow horizontally but instead grows the page's vertical height substantially — worth including a screenshot/measurement in the PR for a realistic total (e.g. 500+) to make the scale of the problem concrete for reviewers.
Implementation sketch
function pageWindow(current: number, total: number, span = 2): (number | "…")[] {
const pages = new Set<number>([1, total, current]);
for (let d = 1; d <= span; d++) { pages.add(current - d); pages.add(current + d); }
const sorted = [...pages].filter((p) => p >= 1 && p <= total).sort((a, b) => a - b);
const result: (number | "…")[] = [];
sorted.forEach((p, i) => {
if (i > 0 && p - (sorted[i - 1] as number) > 1) result.push("…");
result.push(p);
});
return result;
}
// render:
{pageWindow(currentPage, totalPages).map((p, i) =>
p === "…" ? <Text key={`ellipsis-${i}`}>…</Text> : (
<Button key={p} aria-current={p === currentPage ? "page" : undefined} aria-label={`Go to page ${p}`} ...>{p}</Button>
)
)}
Extract this into a shared Pagination component consumed by both LeaderboardPage and HistoryPage to eliminate the current duplication.
Test/reproduction plan
- Render
LeaderboardPage/HistoryPage with a mocked totalPages of, e.g., 500; assert the number of rendered page-number buttons is well below 500 (bounded by the windowing constant).
- Assert
screen.getByRole("button", { current: "page" }) resolves to exactly the button matching currentPage.
- Assert each visible page button has an accessible name distinguishable from its neighbors (e.g. via
getByLabelText/getByRole("button", { name: /go to page 3/i })).
Cross-references
Overview
Both
src/app/leaderboard/page.tsxandsrc/app/history/page.tsxrender pagination as one real<Button>per page, with no upper bound and no active-page semantics beyond a visual color swap:There are two independent, compounding problems: (1) Unbounded rendering.
totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))— withPAGE_SIZE = 10(leaderboard) or20(history), a leaderboard with, say, 2,000 ranked farmers renders 200 real button elements in oneFlex, every render, with no windowing/ellipsis/"jump to page" input. This is real DOM bloat and layout jank on any dataset larger than a couple hundred entries, and on mobile it's effectively unusable — 200 small tap targets wrapped across dozens of rows. (2) Noaria-current. The only signal that buttonprepresents the active page is a background/text color change (variant/bg/colorprops) — there is noaria-current="page"attribute anywhere on the active button, and the buttons themselves have no descriptivearia-labelbeyond their bare number ({p}), so a screen reader announces a plain sequence of numbered buttons with no way to determine which one — if any — represents the user's current position, unlike the sort-column headers in this same page, which do correctly setaria-sort(leaderboard/page.tsx:194,227).Requirements
totalPages.aria-current="page"to the active page's button.aria-labelto each page button (e.g.Go to page ${p}) so screen reader users get more than a bare number out of context.LeaderboardPageandHistoryPage, since both currently duplicate the same unbounded-button pattern.Acceptance Criteria
totalPagesin the hundreds, the number of rendered page-number<Button>elements stays bounded (e.g. capped at a small constant plus first/last/ellipsis), not equal tototalPages.aria-current="page"; no other page button does.aria-labeldistinguishing it from a bare number (verifiable via testing-library's accessible-name queries).LeaderboardPageandHistoryPageshare the fix (ideally via one extracted, reusable pagination component, given they currently duplicate near-identical JSX).Additional Notes
More precise references
src/app/leaderboard/page.tsx:332-349— confirmed the exactArray.from({ length: totalPages }, ...)unbounded-button rendering and confirmed noaria-current/descriptivearia-labelanywhere in the block (onlykey={p}and the visible{p}text).src/app/history/page.tsx:231-248— confirmed history's pagination block is structurally identical (sameArray.from({ length: totalPages }, ...)pattern, same missingaria-current/aria-label), confirming this is a duplicated, not isolated, gap.src/app/leaderboard/page.tsx:194,227(aria-sort={sortKey === "credits" ? "descending" : "none"}) — confirmed as a direct, in-file counterexample: the same page correctly implements ARIA state for its sort-column headers, making the pagination buttons' lack ofaria-currenta clear, avoidable inconsistency rather than an unknown pattern to the codebase.src/hooks/useLeaderboard.ts:40(totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))) withPAGE_SIZE = 10(useLeaderboard.ts:13) — confirmed the scaling math: anytotalin the low thousands already produces hundreds of buttons.Additional edge cases
useLeaderboard's filter logic vs. the page component's pagination render).Flexalready setswrap="wrap"(leaderboard/page.tsx:318), meaning an unbounded button count doesn't overflow horizontally but instead grows the page's vertical height substantially — worth including a screenshot/measurement in the PR for a realistictotal(e.g. 500+) to make the scale of the problem concrete for reviewers.Implementation sketch
Extract this into a shared
Paginationcomponent consumed by bothLeaderboardPageandHistoryPageto eliminate the current duplication.Test/reproduction plan
LeaderboardPage/HistoryPagewith a mockedtotalPagesof, e.g., 500; assert the number of rendered page-number buttons is well below 500 (bounded by the windowing constant).screen.getByRole("button", { current: "page" })resolves to exactly the button matchingcurrentPage.getByLabelText/getByRole("button", { name: /go to page 3/i })).Cross-references
aria-sorton column headers (already partially addressed in the current code, per thearia-sortreference above); this issue covers the separate pagination-controls accessibility and scaling gap, untouched by that fix.