11import type { Job } from "@/types/jobs" ;
22import { useMemo , useState } from "react" ;
33
4+ function normalizeComparableText ( value ?: string | null ) {
5+ return String ( value || "" )
6+ . normalize ( "NFD" )
7+ . replace ( / \p{ Diacritic} / gu, "" )
8+ . toLowerCase ( )
9+ . replace ( / [ ^ \p{ L} \p{ N} ] + / gu, " " )
10+ . trim ( )
11+ . replace ( / \s + / g, " " ) ;
12+ }
13+
14+ function normalizeComparableLink ( value ?: string | null ) {
15+ const rawValue = String ( value || "" ) . trim ( ) ;
16+
17+ if ( ! rawValue ) {
18+ return "" ;
19+ }
20+
21+ try {
22+ const parsedUrl = new URL ( rawValue ) ;
23+ parsedUrl . search = "" ;
24+ parsedUrl . hash = "" ;
25+ return `${ parsedUrl . origin } ${ parsedUrl . pathname } ` . replace ( / \/ + $ / , "" ) ;
26+ } catch {
27+ return rawValue . split ( / [ ? # ] / ) [ 0 ] . replace ( / \/ + $ / , "" ) ;
28+ }
29+ }
30+
31+ function splitJobKeywords ( job : Job ) {
32+ return [
33+ ...new Set (
34+ [
35+ ...( Array . isArray ( job . keywords ) ? job . keywords : [ ] ) ,
36+ ...String ( job . palavra || "" )
37+ . split ( / [ , ; | ] + / )
38+ . map ( ( keyword ) => keyword . trim ( ) ) ,
39+ ] . filter ( Boolean ) ,
40+ ) ,
41+ ] ;
42+ }
43+
44+ function pickPreferredValue ( ...values : Array < string | null | undefined > ) {
45+ return (
46+ values
47+ . map ( ( value ) => String ( value || "" ) . trim ( ) )
48+ . filter ( Boolean )
49+ . sort ( ( left , right ) => right . length - left . length ) [ 0 ] || ""
50+ ) ;
51+ }
52+
53+ function buildDedupKey ( job : Job ) {
54+ const title = normalizeComparableText ( job . titulo ) ;
55+ const company = normalizeComparableText ( job . empresa ) ;
56+ const location = normalizeComparableText ( job . local ) ;
57+
58+ if ( title && company && location ) {
59+ return `identity:${ title } |${ company } |${ location } ` ;
60+ }
61+
62+ if ( title && company ) {
63+ return `identity:${ title } |${ company } |${ location || "sem-local" } ` ;
64+ }
65+
66+ const link = normalizeComparableLink ( job . link ) ;
67+
68+ if ( link ) {
69+ return `url:${ link } ` ;
70+ }
71+
72+ return `fallback:${ title } |${ company } |${ location } |${ normalizeComparableText ( job . source ) } ` ;
73+ }
74+
75+ function dedupeJobs ( jobs : Job [ ] ) {
76+ const unique = new Map < string , Job > ( ) ;
77+
78+ for ( const job of jobs ) {
79+ const key = buildDedupKey ( job ) ;
80+ const existing = unique . get ( key ) ;
81+
82+ if ( ! existing ) {
83+ unique . set ( key , {
84+ ...job ,
85+ palavra : splitJobKeywords ( job ) . join ( ", " ) ,
86+ } ) ;
87+ continue ;
88+ }
89+
90+ const mergedKeywords = [ ...new Set ( [ ...splitJobKeywords ( existing ) , ...splitJobKeywords ( job ) ] ) ] ;
91+ const mergedSources = [
92+ ...new Set (
93+ [ ...( existing . sources ?? [ ] ) , ...( job . sources ?? [ ] ) , existing . source , job . source ]
94+ . map ( ( source ) => String ( source || "" ) . trim ( ) )
95+ . filter ( Boolean ) ,
96+ ) ,
97+ ] ;
98+
99+ unique . set ( key , {
100+ ...existing ,
101+ ...job ,
102+ titulo : pickPreferredValue ( existing . titulo , job . titulo ) ,
103+ empresa : pickPreferredValue ( existing . empresa , job . empresa ) ,
104+ local : pickPreferredValue ( existing . local , job . local ) ,
105+ link : pickPreferredValue ( existing . link , job . link ) ,
106+ source : mergedSources . join ( ", " ) || existing . source || job . source || "" ,
107+ palavra : mergedKeywords . join ( ", " ) ,
108+ keywords : mergedKeywords ,
109+ sources : mergedSources . length > 0 ? mergedSources : undefined ,
110+ } ) ;
111+ }
112+
113+ return [ ...unique . values ( ) ] ;
114+ }
115+
4116export function useJobsFiltering ( jobs : Job [ ] ) {
5117 const [ search , setSearch ] = useState ( "" ) ;
6118 const [ keywordFilter , setKeywordFilter ] = useState < string [ ] > ( [ ] ) ;
7119
120+ const dedupedJobs = useMemo ( ( ) => dedupeJobs ( jobs ) , [ jobs ] ) ;
121+
8122 const keywords = useMemo ( ( ) => {
9- const values = Array . from ( new Set ( jobs . map ( ( job ) => String ( job . palavra || "" ) . trim ( ) ) . filter ( Boolean ) ) ) ;
123+ const values = Array . from ( new Set ( dedupedJobs . flatMap ( ( job ) => splitJobKeywords ( job ) ) ) ) ;
10124 return values . sort ( ( a , b ) => a . localeCompare ( b ) ) ;
11- } , [ jobs ] ) ;
125+ } , [ dedupedJobs ] ) ;
12126
13127 const filteredJobs = useMemo ( ( ) => {
14- const term = search . trim ( ) . toLowerCase ( ) ;
128+ const term = normalizeComparableText ( search ) ;
15129
16- return jobs . filter ( ( job ) => {
17- const currentKeyword = String ( job . palavra || "" ) . trim ( ) ;
18- const byKeyword = keywordFilter . length === 0 || keywordFilter . includes ( currentKeyword ) ;
130+ return dedupedJobs . filter ( ( job ) => {
131+ const currentKeywords = splitJobKeywords ( job ) ;
132+ const byKeyword = keywordFilter . length === 0 || keywordFilter . some ( ( keyword ) => currentKeywords . includes ( keyword ) ) ;
19133 if ( ! byKeyword ) {
20134 return false ;
21135 }
@@ -24,13 +138,13 @@ export function useJobsFiltering(jobs: Job[]) {
24138 return true ;
25139 }
26140
27- const text = [ job . titulo , job . empresa , job . local , job . link , job . palavra ]
28- . map ( ( value ) => String ( value || "" ) . toLowerCase ( ) )
29- . join ( " " ) ;
141+ const text = normalizeComparableText (
142+ [ job . titulo , job . empresa , job . local , job . link , job . palavra , ... ( job . keywords || [ ] ) ] . join ( " " ) ,
143+ ) ;
30144
31145 return text . includes ( term ) ;
32146 } ) ;
33- } , [ jobs , search , keywordFilter ] ) ;
147+ } , [ dedupedJobs , search , keywordFilter ] ) ;
34148
35149 return {
36150 search,
0 commit comments