From 15b4f8482873a10f83f89b034d81f8cd07e921e5 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Sun, 26 Jul 2026 14:04:32 -0300 Subject: [PATCH 1/5] feat: add sorting functionality and publication date to job table --- .../new_dashboard/NewDashboardPage.tsx | 23 ++- .../new_dashboard/components/jobs/JobRow.tsx | 3 + .../components/jobs/JobTable.tsx | 183 +++++++++++++++++- .../new_dashboard/hooks/useDashboardJobs.ts | 29 ++- .../infrastructure/dashboardJobsApi.ts | 28 ++- frontend/tests/unit/new_dashboard/api.test.ts | 3 + .../tests/unit/new_dashboard/jobs.test.tsx | 78 +++++++- 7 files changed, 327 insertions(+), 20 deletions(-) diff --git a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx index 6e14fe6..350f423 100644 --- a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx +++ b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx @@ -180,6 +180,20 @@ export default function NewDashboardPage() { saveUserProfile, saveSearchPreferences, } = useUserDashboardData(user, { onError: showToast }); + const preferredModelFilter = useMemo( + () => getModelFilterFromJobTypes(searchPreferences.jobTypes), + [searchPreferences.jobTypes], + ); + const initialRecommendationSearch = useMemo( + () => + isLoadingUserData + ? null + : { + keywords: [], + filters: modelFilterToApiFilter(preferredModelFilter), + }, + [isLoadingUserData, preferredModelFilter], + ); const { trackedJobs, recommendedJobs, @@ -190,7 +204,10 @@ export default function NewDashboardPage() { changeJobStatus, changeJobNotesLocally, saveJobNotes, - } = useDashboardJobs(user, { onError: showToast }); + } = useDashboardJobs(user, { + onError: showToast, + initialRecommendationSearch, + }); const matchedTrackedJobs = useMemo( () => @@ -213,10 +230,6 @@ export default function NewDashboardPage() { ), [filterType, matchedRecommendedJobs], ); - const preferredModelFilter = useMemo( - () => getModelFilterFromJobTypes(searchPreferences.jobTypes), - [searchPreferences.jobTypes], - ); const showPreferenceNotice = !hasUserChangedJobFilters && preferredModelFilter !== "Todos" && diff --git a/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx b/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx index b1f410a..a754948 100644 --- a/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx +++ b/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx @@ -63,6 +63,9 @@ export function JobRow({ job, onOpen, onStatusChange }: JobRowProps) { {job.level} + + {job.posted} + = { + "Estágio/Trainee": 0, + Júnior: 1, + Pleno: 2, + Sênior: 3, +}; + +function publicationTimestamp(job: Job) { + const rawPostedAt = job.rawPayload?.postedAt; + if (typeof rawPostedAt === "string" && rawPostedAt.trim()) { + const timestamp = new Date(rawPostedAt).getTime(); + if (!Number.isNaN(timestamp)) return timestamp; + } + + const formattedDate = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(job.posted); + if (!formattedDate) return null; + + const [, day, month, year] = formattedDate; + return Date.UTC(Number(year), Number(month) - 1, Number(day)); +} + +function compareNullableValues( + first: T | null, + second: T | null, + direction: SortDirection, + compare: (left: T, right: T) => number, +) { + if (first === null && second === null) return 0; + if (first === null) return 1; + if (second === null) return -1; + + const result = compare(first, second); + return direction === "asc" ? result : -result; +} + +function compareJobs( + first: Job, + second: Job, + column: SortColumn, + direction: SortDirection, +) { + if (column === "source") { + return compareNullableValues( + first.source.trim() || null, + second.source.trim() || null, + direction, + (left, right) => + left.localeCompare(right, "pt-BR", { sensitivity: "base" }), + ); + } + + if (column === "level") { + return compareNullableValues( + levelOrder[first.level], + levelOrder[second.level], + direction, + (left, right) => left - right, + ); + } + + return compareNullableValues( + publicationTimestamp(first), + publicationTimestamp(second), + direction, + (left, right) => left - right, + ); +} + interface JobTableProps { jobs: Job[]; onOpenJob: (job: Job) => void; @@ -27,6 +104,27 @@ export function JobTable({ }: JobTableProps) { const [requestedPage, setRequestedPage] = useState(1); const [pageSize, setPageSize] = useState(10); + const [sort, setSort] = useState<{ + column: SortColumn; + direction: SortDirection; + } | null>(null); + + const sortedJobs = useMemo(() => { + if (!sort) return jobs; + + return jobs + .map((job, index) => ({ job, index })) + .sort( + (first, second) => + compareJobs( + first.job, + second.job, + sort.column, + sort.direction, + ) || first.index - second.index, + ) + .map(({ job }) => job); + }, [jobs, sort]); if (jobs.length === 0) { return ( @@ -47,8 +145,8 @@ export function JobTable({ const currentPage = Math.min(pagination?.page ?? requestedPage, totalPages); const firstItemIndex = (currentPage - 1) * effectivePageSize; const visibleJobs = isRemotePaginated - ? jobs - : jobs.slice(firstItemIndex, firstItemIndex + pageSize); + ? sortedJobs + : sortedJobs.slice(firstItemIndex, firstItemIndex + pageSize); const firstVisibleItem = firstItemIndex + 1; const lastVisibleItem = Math.min( firstItemIndex + visibleJobs.length, @@ -66,6 +164,39 @@ export function JobTable({ }, ); + const toggleSort = (column: SortColumn) => { + setSort((current) => { + if (current?.column === column) { + return { + column, + direction: current.direction === "asc" ? "desc" : "asc", + }; + } + + return { + column, + direction: column === "posted" ? "desc" : "asc", + }; + }); + }; + + const sortIcon = (column: SortColumn) => { + if (sort?.column !== column) { + return