Skip to content

feat: Implement extensible pagination system with offset-based support#9

Merged
digitalnomad91 merged 2 commits into
mainfrom
feature/extensible-pagination-system
Feb 26, 2026
Merged

feat: Implement extensible pagination system with offset-based support#9
digitalnomad91 merged 2 commits into
mainfrom
feature/extensible-pagination-system

Conversation

@digitalnomad91
Copy link
Copy Markdown
Member

🎯 Overview

Fixes the broken pagination on the jobs page by implementing an extensible pagination system that supports both page-based and offset-based (cursor) pagination.

🔧 Changes

New Pagination System (src/lib/pagination/)

  • Dual Mode Support: Works with both page-based (?page=X&limit=Y) and offset-based (?skip=X&first=Y) APIs
  • React Hook: usePagination hook with built-in state management, auto-refresh, and URL synchronization
  • Type-Safe: Comprehensive TypeScript types for both pagination modes
  • Reusable: Easily applicable to any paginated API endpoint

Updated Job Components

  • JobsPageClient: Simplified from 188 to 75 lines (60% reduction) using the new hook
  • Fixed API calls: Now uses offset-based pagination (skip/first) instead of page-based
  • Maintained features: Auto-refresh, loading states, and background polling still work

Documentation

  • Comprehensive README with API reference and usage examples
  • Example implementations for both page-based and cursor-based APIs

✅ Testing

The pagination system:

  • ✅ Correctly converts page numbers to offset parameters
  • ✅ Maintains URL synchronization with ?page=X query param
  • ✅ Supports background polling and refresh indicators
  • ✅ Handles both API response formats gracefully
  • ✅ TypeScript types are fully compatible

📚 Usage Example

const { items, paginationState } = usePagination({
  config: {
    type: 'offset-based', // or 'page-based'
    itemsPerPage: 10,
  },
  fetchFn: fetchData,
  baseUrl: 'https://api.example.com/endpoint',
  pollInterval: 6000, // Optional auto-refresh
})

🔗 API Compatibility

Before (Broken):

/jobs?page=2 → API: ?page=2&limit=10 ❌

After (Fixed):

/jobs?page=2 → API: ?first=10&skip=10 ✅

- Create reusable pagination system supporting both page-based and offset-based APIs
- Add usePagination hook with auto-refresh, URL sync, and TypeScript support
- Update JobsPageClient to use offset-based pagination (skip/first params)
- Reduce JobsPageClient from 188 to 75 lines (60% reduction)
- Add comprehensive documentation and examples
- Fix jobs page pagination to work with api.codebuilder.org/jobs endpoint
Copilot AI review requested due to automatic review settings February 26, 2026 07:29
@digitalnomad91 digitalnomad91 merged commit 3d5ed94 into main Feb 26, 2026
2 checks passed
@digitalnomad91 digitalnomad91 deleted the feature/extensible-pagination-system branch February 26, 2026 07:32
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a reusable pagination module and migrates the Jobs page to use it, fixing the previously broken pagination by switching Jobs API requests to offset-based (skip/first) parameters while keeping ?page= in the URL.

Changes:

  • Added src/lib/pagination/ with shared types, query builders, utilities, and a usePagination hook (optional background polling + URL sync).
  • Updated JobsPageClient to use the new hook and fetch jobs with offset-based pagination.
  • Added pagination documentation and example implementations for page-based and cursor/offset-based APIs.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/lib/pagination/utils.ts Adds helpers for param conversion, query building, and state calculations.
src/lib/pagination/usePagination.ts Implements the new pagination hook with URL sync and polling.
src/lib/pagination/types.ts Defines shared pagination types (PaginatedResponse, PageInfo, etc.).
src/lib/pagination/index.ts Barrel exports for pagination module.
src/lib/pagination/example-page-based.tsx Example usage for classic page/limit APIs.
src/lib/pagination/example-cursor-based.tsx Example usage for cursor/offset-style APIs.
src/lib/pagination/README.md Documents the new pagination system and usage patterns.
src/lib/jobs/types.ts Deduplicates/centralizes PageInfo by importing from pagination module.
src/app/jobs/JobsPageClient.tsx Migrates Jobs page to usePagination + offset-based Jobs API calls.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +151 to +157
.catch((error) => {
// Only handle non-abort errors
if (error.name !== 'AbortError') {
setItems([])
setTotalCount(0)
setPageInfo(undefined)
}
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the initial fetch .catch, error is treated as having a .name property. If fetchFn rejects with a non-Error value, this will throw and mask the original failure. Safer pattern is to check error instanceof Error before reading error.name (and/or treat aborts by checking controller.signal.aborted).

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +56
): PaginationState {
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))

Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calculatePaginationState divides by itemsPerPage when computing totalPages. If itemsPerPage is 0 (or negative), totalPages becomes Infinity/nonsensical and breaks clamping/navigation. Add input validation (e.g., clamp itemsPerPage to >= 1 or throw early) to keep the pagination state consistent.

Copilot uses AI. Check for mistakes.
Comment on lines +173 to +179
### Offset-Based Pagination

When `type: 'offset-based'` is configured:
- Converts page numbers to `skip` and `first` parameters
- Example: Page 2 with 10 items → `?first=10&skip=10`
- Supports cursor-based pagination with `after`, `before`, `first`, `last`

Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README says offset-based mode “supports cursor-based pagination with after, before, first, last”, but usePagination currently always derives params from a page number and only supplies first/skip (no way to provide after/before/last). Either adjust the documentation to match the current capabilities or extend the hook API to accept cursor params.

Copilot uses AI. Check for mistakes.
: pageToOffsetParams(page, config.itemsPerPage)

const queryString = buildPaginationQuery(params)
return `${baseUrl}?${queryString}`
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildFetchUrl always concatenates ${baseUrl}?${queryString}. If baseUrl already contains a query string, this will produce an invalid URL with multiple ?. Consider using new URL(baseUrl) + url.searchParams merging, or conditionally joining with & when baseUrl already has ?.

Suggested change
return `${baseUrl}?${queryString}`
const separator = baseUrl.includes('?') ? '&' : '?'
return `${baseUrl}${separator}${queryString}`

Copilot uses AI. Check for mistakes.
// Parse current page from URL
const currentPage = useMemo(() => {
const page = parsePageFromParams(searchParams, config.initialPage ?? 1)
return clampPage(page, Math.ceil(totalCount / config.itemsPerPage) || 1)
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentPage is clamped using totalCount, but totalCount starts at 0, so any direct navigation to ?page>1 will be clamped to 1 and fetch page 1 first, then fetch the intended page again after totalCount updates. This causes a flash of wrong data and double requests. Consider clamping only to >= 1 until you know totalCount, or redirect/replace the URL after computing totalPages from the first response.

Suggested change
return clampPage(page, Math.ceil(totalCount / config.itemsPerPage) || 1)
const totalPages = Math.ceil(totalCount / config.itemsPerPage)
// While totalCount is unknown (0), only enforce a lower bound of 1.
// Once totalPages is known (> 0), clamp to the valid [1, totalPages] range.
if (!totalPages) {
return page < 1 ? 1 : page
}
return clampPage(page, totalPages)

Copilot uses AI. Check for mistakes.
Comment on lines +187 to +188
// Only update if data changed
const itemsChanged = JSON.stringify(response.items) !== JSON.stringify(items)
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the polling tick, using JSON.stringify(response.items) vs JSON.stringify(items) does an O(n) deep serialization every poll and can become expensive for larger payloads. Consider tracking a lightweight signature (e.g., ids/updatedAt) or using a ref-based comparison to avoid full serialization on every refresh.

Suggested change
// Only update if data changed
const itemsChanged = JSON.stringify(response.items) !== JSON.stringify(items)
// Only update if data changed (use a lightweight shallow comparison)
const itemsChanged =
response.items.length !== items.length ||
response.items.some((item, index) => item !== items[index])

Copilot uses AI. Check for mistakes.
Comment on lines +250 to +259
}, [
currentPage,
buildFetchUrl,
fetchFn,
isInitialLoading,
pollInterval,
minRefreshDuration,
items,
totalCount,
])
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling effect depends on items and totalCount, so any state update triggers effect teardown/recreate, which also runs tick() immediately again. If the API data changes frequently, this can cause back-to-back fetches and effectively increase request rate beyond pollInterval. Use refs for the last-seen items/totalCount (or a signature) so the interval effect doesn’t depend on these state values.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants