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
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: 'πŸš€ Deploy Next.js Docker App'

on:
push:
branches: [main]
branches: ['main']

jobs:
build-and-deploy:
Expand Down
169 changes: 28 additions & 141 deletions src/app/jobs/JobsPageClient.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,27 @@
"use client"

import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import React from 'react'
import JobsTable from '@/components/jobs/JobsTable'
import type { ApiResponse, JobsListData, JobWithRelations } from '@/lib/jobs/types'

type JobsFetchResult = {
items: JobWithRelations[]
totalCount: number
}
import type { PaginatedResponse } from '@/lib/pagination'
import { usePagination } from '@/lib/pagination'

const JOBS_PER_PAGE = 10
const POLL_INTERVAL_MS = 6000
const MIN_REFRESH_INDICATOR_MS = 500

async function fetchJobsPage(
page: number,
limit: number,
signal?: AbortSignal
): Promise<JobsFetchResult> {
const res = await fetch(`https://api.codebuilder.org/jobs?page=${page}&limit=${limit}`, { signal })
/**
* Fetch function for jobs API using offset-based pagination
*/
async function fetchJobs(url: string, signal?: AbortSignal): Promise<PaginatedResponse<JobWithRelations>> {
const res = await fetch(url, { signal })
const json: ApiResponse<JobsListData> | any = await res.json()

// Primary shape (current backend): { success: true, data: { items: [...], totalCount } }
// Primary shape (current backend): { success: true, data: { items: [...], totalCount, pageInfo } }
if (json?.success === true && Array.isArray(json?.data?.items)) {
return {
items: json.data.items,
totalCount: typeof json.data.totalCount === 'number' ? json.data.totalCount : 0,
pageInfo: json.data.pageInfo,
}
}

Expand All @@ -42,129 +37,21 @@ async function fetchJobsPage(
}

export default function JobsPageClient() {
const searchParams = useSearchParams()
const [jobs, setJobs] = useState<JobWithRelations[]>([])
const [totalJobs, setTotalJobs] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isInitialLoading, setIsInitialLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [refreshSecondsRemaining, setRefreshSecondsRemaining] = useState<number | null>(null)

const refreshInFlightRef = useRef(false)
const nextRefreshAtRef = useRef<number>(0)

const lastIdsSignatureRef = useRef<string>('')
const lastTotalCountRef = useRef<number>(0)
const didInitialLoadRef = useRef(false)

const postsPerPage = JOBS_PER_PAGE

const pageFromParams = useMemo(() => {
return parseInt(searchParams.get('page') || '1', 10)
}, [searchParams])

useEffect(() => {
const controller = new AbortController()
const page = Number.isFinite(pageFromParams) && pageFromParams > 0 ? pageFromParams : 1
setCurrentPage(page)

const isFirstEverLoad = !didInitialLoadRef.current
if (isFirstEverLoad) setIsInitialLoading(true)

fetchJobsPage(page, postsPerPage, controller.signal)
.then(({ items, totalCount }) => {
setJobs(items)
setTotalJobs(totalCount)
lastTotalCountRef.current = totalCount
lastIdsSignatureRef.current = items.map((j) => j.id).join(',')
})
.catch(() => {
setJobs([])
setTotalJobs(0)
lastTotalCountRef.current = 0
lastIdsSignatureRef.current = ''
})
.finally(() => {
didInitialLoadRef.current = true
setIsInitialLoading(false)
})

return () => controller.abort()
}, [pageFromParams, postsPerPage])

useEffect(() => {
if (isInitialLoading) return

let isUnmounted = false
const controller = new AbortController()
let clearRefreshingTimeoutId: number | null = null

const tick = async () => {
if (refreshInFlightRef.current) return
refreshInFlightRef.current = true
setIsRefreshing(true)
const startedAt = Date.now()
try {
const { items, totalCount } = await fetchJobsPage(currentPage, postsPerPage, controller.signal)
if (isUnmounted) return

const nextIdsSignature = items.map((j) => j.id).join(',')
const totalIncreased = totalCount > lastTotalCountRef.current
const rowsChanged = nextIdsSignature !== lastIdsSignatureRef.current

if (totalIncreased || rowsChanged) {
setJobs(items)
setTotalJobs(totalCount)
lastTotalCountRef.current = totalCount
lastIdsSignatureRef.current = nextIdsSignature
}
} catch {
// Keep existing jobs on refresh failure; this is a background enhancement.
} finally {
refreshInFlightRef.current = false
if (!isUnmounted) {
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, MIN_REFRESH_INDICATOR_MS - elapsed)

if (clearRefreshingTimeoutId !== null) {
window.clearTimeout(clearRefreshingTimeoutId)
}

clearRefreshingTimeoutId = window.setTimeout(() => {
if (!isUnmounted) setIsRefreshing(false)
}, remaining)
}
nextRefreshAtRef.current = Date.now() + POLL_INTERVAL_MS
}
}

nextRefreshAtRef.current = Date.now() + POLL_INTERVAL_MS

const countdownId = window.setInterval(() => {
const nextAt = nextRefreshAtRef.current
if (!nextAt) {
setRefreshSecondsRemaining(null)
return
}
const seconds = Math.max(0, Math.ceil((nextAt - Date.now()) / 1000))
setRefreshSecondsRemaining(seconds)
}, 250)

// Do one immediate background refresh after initial load.
void tick()

const intervalId = window.setInterval(tick, POLL_INTERVAL_MS)
return () => {
isUnmounted = true
controller.abort()
window.clearInterval(intervalId)
window.clearInterval(countdownId)

if (clearRefreshingTimeoutId !== null) {
window.clearTimeout(clearRefreshingTimeoutId)
}
}
}, [currentPage, postsPerPage, isInitialLoading])
const {
items: jobs,
paginationState,
isInitialLoading,
isRefreshing,
refreshSecondsRemaining,
} = usePagination<JobWithRelations>({
config: {
type: 'offset-based', // Using offset-based pagination for the API
itemsPerPage: JOBS_PER_PAGE,
},
fetchFn: fetchJobs,
baseUrl: 'https://api.codebuilder.org/jobs',
pollInterval: POLL_INTERVAL_MS,
})

return (
<div className="flex flex-col inset-0 z-50 bg-primary transition-transform">
Expand All @@ -173,9 +60,9 @@ export default function JobsPageClient() {
<h1 className="text-2xl font-bold mb-4">Job Listings</h1>
<JobsTable
jobs={jobs}
totalJobs={totalJobs}
jobsPerPage={postsPerPage}
currentPage={currentPage}
totalJobs={paginationState.totalItems}
jobsPerPage={paginationState.itemsPerPage}
currentPage={paginationState.currentPage}
isInitialLoading={isInitialLoading}
isRefreshing={isRefreshing}
refreshSecondsRemaining={refreshSecondsRemaining}
Expand Down
9 changes: 2 additions & 7 deletions src/lib/jobs/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { PageInfo } from '@/lib/pagination'

export type JobCompany = {
id?: number
name: string
Expand Down Expand Up @@ -39,13 +41,6 @@ export type JobWithRelations = {
data?: unknown
}

export type PageInfo = {
hasNextPage: boolean
hasPreviousPage: boolean
startCursor: string
endCursor: string
}

export type JobsListData = {
items: JobWithRelations[]
pageInfo?: PageInfo
Expand Down
Loading