Context
apps/loopover-miner-ui/src/routes/ has five routes that each client-side paginate a table at PAGE_SIZE = 20, and the { page, pageCount, isPaginated, safePage, visible } computation plus the Pagination/PaginationContent/PaginationItem/PaginationPrevious/PaginationLink/PaginationNext wiring is duplicated three different ways across them instead of sharing one implementation:
run-history.tsx (lines ~118-123) and ranked-candidates.tsx (lines ~130-135) each inline the exact same 5-line useState/pageCount/isPaginated/safePage/visibleRows computation, then hand-roll the full Pagination/PaginationContent/... JSX directly inside the component (no local wrapper at all).
ledgers.tsx and portfolio.tsx each define their own local TablePagination({ page, pageCount, onPageChange }) component (identical in both files) and duplicate the same 5-line page-state computation twice per file (once per table on that route).
attempts.tsx goes one step further and already generalizes the page-state computation into a reusable usePagedRows<T>(rows) hook (lines ~105-118) plus its own local TablePagination component — the closest thing to a canonical, generic version of this pattern already in the codebase — but it's still local to attempts.tsx alone, not shared with the other four routes.
In total this is the identical ~30 lines of logic (a generic page-state hook + a generic pager component) reimplemented independently at 6+ call sites across 5 files, with attempts.tsx's usePagedRows/TablePagination already being the most reusable shape.
Requirements
- Extract
usePagedRows<T>(rows: T[], pageSize?: number) and TablePagination into a single shared module under apps/loopover-miner-ui/src/lib/ (e.g. lib/paged-rows.ts for the hook, reusing/relocating attempts.tsx's existing implementation as the starting point — do not invent a differently-shaped API) and a shared presentational component (e.g. components/table-pagination.tsx built on the existing @loopover/ui-kit/components/pagination primitives, matching attempts.tsx's TablePagination JSX).
- Update all five routes (
attempts.tsx, run-history.tsx, ranked-candidates.tsx, ledgers.tsx, portfolio.tsx) to import and use the shared hook + component instead of their own local copy or inline JSX. Every call site that currently duplicates this logic must be converted — a PR that extracts the shared module but leaves any of the five routes on its own local/inline copy does not resolve this issue.
- Preserve the current
PAGE_SIZE = 20 default and existing pagination behavior exactly (page clamping via Math.min(page, pageCount - 1), aria-disabled on the boundary Previous/Next controls) — this is a refactor, not a behavior change.
- Do not change the
Pagination primitive itself in @loopover/ui-kit as part of this issue (see the separate aria-disabled visual-styling issue for that).
Deliverables
Test Coverage Requirements
apps/loopover-miner-ui is excluded from the root Codecov config (codecov.yml's ignore: - "apps/**") but has its own local vitest coverage floor enforced via apps/loopover-miner-ui/vitest.config.ts's coverage.thresholds (85% statements / 85% branches / 75% functions / 85% lines) — this refactor must not regress that floor, and the new shared hook/component should carry direct unit tests rather than relying solely on the five routes' existing integration-style tests.
Expected Outcome
Client-side table pagination logic exists in exactly one place in apps/loopover-miner-ui, and all five paginated routes consume it — a future pagination bug fix or behavior change (e.g. adjusting PAGE_SIZE, or fixing an edge case) only needs to happen once instead of being replicated across three divergent implementations.
Links & Resources
apps/loopover-miner-ui/src/routes/attempts.tsx:104-118 (the usePagedRows shape to extract, as the closest-to-canonical existing implementation)
apps/loopover-miner-ui/src/routes/ledgers.tsx, portfolio.tsx (the local TablePagination duplicate)
apps/loopover-miner-ui/src/routes/run-history.tsx, ranked-candidates.tsx (the fully-inlined duplicate, no local wrapper at all)
Context
apps/loopover-miner-ui/src/routes/has five routes that each client-side paginate a table atPAGE_SIZE = 20, and the{ page, pageCount, isPaginated, safePage, visible }computation plus thePagination/PaginationContent/PaginationItem/PaginationPrevious/PaginationLink/PaginationNextwiring is duplicated three different ways across them instead of sharing one implementation:run-history.tsx(lines ~118-123) andranked-candidates.tsx(lines ~130-135) each inline the exact same 5-lineuseState/pageCount/isPaginated/safePage/visibleRowscomputation, then hand-roll the fullPagination/PaginationContent/... JSX directly inside the component (no local wrapper at all).ledgers.tsxandportfolio.tsxeach define their own localTablePagination({ page, pageCount, onPageChange })component (identical in both files) and duplicate the same 5-line page-state computation twice per file (once per table on that route).attempts.tsxgoes one step further and already generalizes the page-state computation into a reusableusePagedRows<T>(rows)hook (lines ~105-118) plus its own localTablePaginationcomponent — the closest thing to a canonical, generic version of this pattern already in the codebase — but it's still local toattempts.tsxalone, not shared with the other four routes.In total this is the identical ~30 lines of logic (a generic page-state hook + a generic pager component) reimplemented independently at 6+ call sites across 5 files, with
attempts.tsx'susePagedRows/TablePaginationalready being the most reusable shape.Requirements
usePagedRows<T>(rows: T[], pageSize?: number)andTablePaginationinto a single shared module underapps/loopover-miner-ui/src/lib/(e.g.lib/paged-rows.tsfor the hook, reusing/relocatingattempts.tsx's existing implementation as the starting point — do not invent a differently-shaped API) and a shared presentational component (e.g.components/table-pagination.tsxbuilt on the existing@loopover/ui-kit/components/paginationprimitives, matchingattempts.tsx'sTablePaginationJSX).attempts.tsx,run-history.tsx,ranked-candidates.tsx,ledgers.tsx,portfolio.tsx) to import and use the shared hook + component instead of their own local copy or inline JSX. Every call site that currently duplicates this logic must be converted — a PR that extracts the shared module but leaves any of the five routes on its own local/inline copy does not resolve this issue.PAGE_SIZE = 20default and existing pagination behavior exactly (page clamping viaMath.min(page, pageCount - 1),aria-disabledon the boundary Previous/Next controls) — this is a refactor, not a behavior change.Paginationprimitive itself in@loopover/ui-kitas part of this issue (see the separatearia-disabledvisual-styling issue for that).Deliverables
usePagedRows<T>extracted to a sharedapps/loopover-miner-ui/src/lib/moduleTablePaginationextracted to a sharedapps/loopover-miner-ui/src/components/moduleattempts.tsx,run-history.tsx,ranked-candidates.tsx,ledgers.tsx,portfolio.tsxall updated to use the shared hook + component, with their local duplicate implementations removedusePagedRowshook covering: empty rows, rows under one page, rows spanning multiple pages, and the page-clamping behavior whenrowsshrinks below the current page's start indexTest Coverage Requirements
apps/loopover-miner-uiis excluded from the root Codecov config (codecov.yml'signore: - "apps/**") but has its own local vitest coverage floor enforced viaapps/loopover-miner-ui/vitest.config.ts'scoverage.thresholds(85% statements / 85% branches / 75% functions / 85% lines) — this refactor must not regress that floor, and the new shared hook/component should carry direct unit tests rather than relying solely on the five routes' existing integration-style tests.Expected Outcome
Client-side table pagination logic exists in exactly one place in
apps/loopover-miner-ui, and all five paginated routes consume it — a future pagination bug fix or behavior change (e.g. adjustingPAGE_SIZE, or fixing an edge case) only needs to happen once instead of being replicated across three divergent implementations.Links & Resources
apps/loopover-miner-ui/src/routes/attempts.tsx:104-118(theusePagedRowsshape to extract, as the closest-to-canonical existing implementation)apps/loopover-miner-ui/src/routes/ledgers.tsx,portfolio.tsx(the localTablePaginationduplicate)apps/loopover-miner-ui/src/routes/run-history.tsx,ranked-candidates.tsx(the fully-inlined duplicate, no local wrapper at all)