Skip to content

Commit 3d5ed94

Browse files
Merge pull request #9 from codebuilderinc/feature/extensible-pagination-system
feat: Implement extensible pagination system with offset-based support
2 parents 0fb8848 + 37e57c0 commit 3d5ed94

11 files changed

Lines changed: 896 additions & 149 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: '🚀 Deploy Next.js Docker App'
22

33
on:
44
push:
5-
branches: [main]
5+
branches: ['main']
66

77
jobs:
88
build-and-deploy:

src/app/jobs/JobsPageClient.tsx

Lines changed: 28 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,27 @@
11
"use client"
22

3-
import React, { useEffect, useMemo, useRef, useState } from 'react'
4-
import { useSearchParams } from 'next/navigation'
3+
import React from 'react'
54
import JobsTable from '@/components/jobs/JobsTable'
65
import type { ApiResponse, JobsListData, JobWithRelations } from '@/lib/jobs/types'
7-
8-
type JobsFetchResult = {
9-
items: JobWithRelations[]
10-
totalCount: number
11-
}
6+
import type { PaginatedResponse } from '@/lib/pagination'
7+
import { usePagination } from '@/lib/pagination'
128

139
const JOBS_PER_PAGE = 10
1410
const POLL_INTERVAL_MS = 6000
15-
const MIN_REFRESH_INDICATOR_MS = 500
1611

17-
async function fetchJobsPage(
18-
page: number,
19-
limit: number,
20-
signal?: AbortSignal
21-
): Promise<JobsFetchResult> {
22-
const res = await fetch(`https://api.codebuilder.org/jobs?page=${page}&limit=${limit}`, { signal })
12+
/**
13+
* Fetch function for jobs API using offset-based pagination
14+
*/
15+
async function fetchJobs(url: string, signal?: AbortSignal): Promise<PaginatedResponse<JobWithRelations>> {
16+
const res = await fetch(url, { signal })
2317
const json: ApiResponse<JobsListData> | any = await res.json()
2418

25-
// Primary shape (current backend): { success: true, data: { items: [...], totalCount } }
19+
// Primary shape (current backend): { success: true, data: { items: [...], totalCount, pageInfo } }
2620
if (json?.success === true && Array.isArray(json?.data?.items)) {
2721
return {
2822
items: json.data.items,
2923
totalCount: typeof json.data.totalCount === 'number' ? json.data.totalCount : 0,
24+
pageInfo: json.data.pageInfo,
3025
}
3126
}
3227

@@ -42,129 +37,21 @@ async function fetchJobsPage(
4237
}
4338

4439
export default function JobsPageClient() {
45-
const searchParams = useSearchParams()
46-
const [jobs, setJobs] = useState<JobWithRelations[]>([])
47-
const [totalJobs, setTotalJobs] = useState(0)
48-
const [currentPage, setCurrentPage] = useState(1)
49-
const [isInitialLoading, setIsInitialLoading] = useState(true)
50-
const [isRefreshing, setIsRefreshing] = useState(false)
51-
const [refreshSecondsRemaining, setRefreshSecondsRemaining] = useState<number | null>(null)
52-
53-
const refreshInFlightRef = useRef(false)
54-
const nextRefreshAtRef = useRef<number>(0)
55-
56-
const lastIdsSignatureRef = useRef<string>('')
57-
const lastTotalCountRef = useRef<number>(0)
58-
const didInitialLoadRef = useRef(false)
59-
60-
const postsPerPage = JOBS_PER_PAGE
61-
62-
const pageFromParams = useMemo(() => {
63-
return parseInt(searchParams.get('page') || '1', 10)
64-
}, [searchParams])
65-
66-
useEffect(() => {
67-
const controller = new AbortController()
68-
const page = Number.isFinite(pageFromParams) && pageFromParams > 0 ? pageFromParams : 1
69-
setCurrentPage(page)
70-
71-
const isFirstEverLoad = !didInitialLoadRef.current
72-
if (isFirstEverLoad) setIsInitialLoading(true)
73-
74-
fetchJobsPage(page, postsPerPage, controller.signal)
75-
.then(({ items, totalCount }) => {
76-
setJobs(items)
77-
setTotalJobs(totalCount)
78-
lastTotalCountRef.current = totalCount
79-
lastIdsSignatureRef.current = items.map((j) => j.id).join(',')
80-
})
81-
.catch(() => {
82-
setJobs([])
83-
setTotalJobs(0)
84-
lastTotalCountRef.current = 0
85-
lastIdsSignatureRef.current = ''
86-
})
87-
.finally(() => {
88-
didInitialLoadRef.current = true
89-
setIsInitialLoading(false)
90-
})
91-
92-
return () => controller.abort()
93-
}, [pageFromParams, postsPerPage])
94-
95-
useEffect(() => {
96-
if (isInitialLoading) return
97-
98-
let isUnmounted = false
99-
const controller = new AbortController()
100-
let clearRefreshingTimeoutId: number | null = null
101-
102-
const tick = async () => {
103-
if (refreshInFlightRef.current) return
104-
refreshInFlightRef.current = true
105-
setIsRefreshing(true)
106-
const startedAt = Date.now()
107-
try {
108-
const { items, totalCount } = await fetchJobsPage(currentPage, postsPerPage, controller.signal)
109-
if (isUnmounted) return
110-
111-
const nextIdsSignature = items.map((j) => j.id).join(',')
112-
const totalIncreased = totalCount > lastTotalCountRef.current
113-
const rowsChanged = nextIdsSignature !== lastIdsSignatureRef.current
114-
115-
if (totalIncreased || rowsChanged) {
116-
setJobs(items)
117-
setTotalJobs(totalCount)
118-
lastTotalCountRef.current = totalCount
119-
lastIdsSignatureRef.current = nextIdsSignature
120-
}
121-
} catch {
122-
// Keep existing jobs on refresh failure; this is a background enhancement.
123-
} finally {
124-
refreshInFlightRef.current = false
125-
if (!isUnmounted) {
126-
const elapsed = Date.now() - startedAt
127-
const remaining = Math.max(0, MIN_REFRESH_INDICATOR_MS - elapsed)
128-
129-
if (clearRefreshingTimeoutId !== null) {
130-
window.clearTimeout(clearRefreshingTimeoutId)
131-
}
132-
133-
clearRefreshingTimeoutId = window.setTimeout(() => {
134-
if (!isUnmounted) setIsRefreshing(false)
135-
}, remaining)
136-
}
137-
nextRefreshAtRef.current = Date.now() + POLL_INTERVAL_MS
138-
}
139-
}
140-
141-
nextRefreshAtRef.current = Date.now() + POLL_INTERVAL_MS
142-
143-
const countdownId = window.setInterval(() => {
144-
const nextAt = nextRefreshAtRef.current
145-
if (!nextAt) {
146-
setRefreshSecondsRemaining(null)
147-
return
148-
}
149-
const seconds = Math.max(0, Math.ceil((nextAt - Date.now()) / 1000))
150-
setRefreshSecondsRemaining(seconds)
151-
}, 250)
152-
153-
// Do one immediate background refresh after initial load.
154-
void tick()
155-
156-
const intervalId = window.setInterval(tick, POLL_INTERVAL_MS)
157-
return () => {
158-
isUnmounted = true
159-
controller.abort()
160-
window.clearInterval(intervalId)
161-
window.clearInterval(countdownId)
162-
163-
if (clearRefreshingTimeoutId !== null) {
164-
window.clearTimeout(clearRefreshingTimeoutId)
165-
}
166-
}
167-
}, [currentPage, postsPerPage, isInitialLoading])
40+
const {
41+
items: jobs,
42+
paginationState,
43+
isInitialLoading,
44+
isRefreshing,
45+
refreshSecondsRemaining,
46+
} = usePagination<JobWithRelations>({
47+
config: {
48+
type: 'offset-based', // Using offset-based pagination for the API
49+
itemsPerPage: JOBS_PER_PAGE,
50+
},
51+
fetchFn: fetchJobs,
52+
baseUrl: 'https://api.codebuilder.org/jobs',
53+
pollInterval: POLL_INTERVAL_MS,
54+
})
16855

16956
return (
17057
<div className="flex flex-col inset-0 z-50 bg-primary transition-transform">
@@ -173,9 +60,9 @@ export default function JobsPageClient() {
17360
<h1 className="text-2xl font-bold mb-4">Job Listings</h1>
17461
<JobsTable
17562
jobs={jobs}
176-
totalJobs={totalJobs}
177-
jobsPerPage={postsPerPage}
178-
currentPage={currentPage}
63+
totalJobs={paginationState.totalItems}
64+
jobsPerPage={paginationState.itemsPerPage}
65+
currentPage={paginationState.currentPage}
17966
isInitialLoading={isInitialLoading}
18067
isRefreshing={isRefreshing}
18168
refreshSecondsRemaining={refreshSecondsRemaining}

src/lib/jobs/types.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { PageInfo } from '@/lib/pagination'
2+
13
export type JobCompany = {
24
id?: number
35
name: string
@@ -39,13 +41,6 @@ export type JobWithRelations = {
3941
data?: unknown
4042
}
4143

42-
export type PageInfo = {
43-
hasNextPage: boolean
44-
hasPreviousPage: boolean
45-
startCursor: string
46-
endCursor: string
47-
}
48-
4944
export type JobsListData = {
5045
items: JobWithRelations[]
5146
pageInfo?: PageInfo

0 commit comments

Comments
 (0)