@@ -113,29 +113,112 @@ function _diffWords(a,b){
113113 return parts . map ( p => p . t === '=' ?esc ( p . v ) :p . t === '+' ?`<span class="df-add">${ esc ( p . v ) } </span>` :`<span class="df-del">${ esc ( p . v ) } </span>` ) . join ( '' ) ;
114114}
115115
116+ // ── Section serializers ───────────────────────────────────────────────────────
117+ // Turn each resume section into diff-friendly plain text: one logical entry per
118+ // block, with a blank line between blocks so the line-diff aligns whole entries.
119+ // A section/entry present only in the new version reads as all-added (green);
120+ // one dropped from the new version reads as all-removed (red). Field ordering
121+ // mirrors the preview so the diff reads naturally. ss()/toArr() come from
122+ // preview.js and coerce the LLM's loose field shapes.
123+ const _j = ( arr , sep ) => arr . filter ( x => x != null && x !== '' ) . join ( sep ) ;
124+
125+ function _seEdu ( list ) { return toArr ( list ) . map ( e => {
126+ const deg = ss ( e . degree ) + ( ss ( e . field_of_study ) ?` in ${ ss ( e . field_of_study ) } ` :'' ) ;
127+ const L = [ _j ( [ deg , ss ( e . institution ) , ss ( e . location ) , _j ( [ ss ( e . start_date ) , ss ( e . end_date ) ] , ' – ' ) ] , ' · ' ) ] ;
128+ if ( ss ( e . gpa ) ) L . push ( 'GPA: ' + ss ( e . gpa ) ) ;
129+ if ( ss ( e . honors ) ) L . push ( ss ( e . honors ) ) ;
130+ const cw = toArr ( e . relevant_coursework ) . map ( ss ) . filter ( Boolean ) ; if ( cw . length ) L . push ( 'Coursework: ' + cw . join ( ', ' ) ) ;
131+ const ac = toArr ( e . activities ) . map ( ss ) . filter ( Boolean ) ; if ( ac . length ) L . push ( 'Activities: ' + ac . join ( ', ' ) ) ;
132+ return L . join ( '\n' ) ;
133+ } ) . join ( '\n\n' ) ; }
134+
135+ function _seProjects ( list ) { return toArr ( list ) . map ( p => {
136+ const L = [ _j ( [ ss ( p . name ) , ss ( p . role ) , ss ( p . type ) , _j ( [ ss ( p . start_date ) , ss ( p . end_date ) ] , ' – ' ) ] , ' · ' ) ] ;
137+ if ( ss ( p . description ) ) L . push ( ss ( p . description ) ) ;
138+ const tech = toArr ( p . technologies ) . map ( ss ) . filter ( Boolean ) ; if ( tech . length ) L . push ( 'Tech: ' + tech . join ( ', ' ) ) ;
139+ toArr ( p . highlights ) . map ( ss ) . filter ( Boolean ) . forEach ( h => L . push ( '• ' + h ) ) ;
140+ const url = ss ( p . url ) || ss ( p . github ) ; if ( url ) L . push ( url ) ;
141+ return L . join ( '\n' ) ;
142+ } ) . join ( '\n\n' ) ; }
143+
144+ function _seOSS ( list ) { return toArr ( list ) . map ( o => {
145+ const L = [ _j ( [ ss ( o . project ) , ss ( o . role ) , ss ( o . language ) , ss ( o . stars ) ?'★ ' + ss ( o . stars ) :'' ] , ' · ' ) ] ;
146+ if ( ss ( o . description ) ) L . push ( ss ( o . description ) ) ;
147+ toArr ( o . contributions ) . map ( ss ) . filter ( Boolean ) . forEach ( c => L . push ( '• ' + c ) ) ;
148+ if ( ss ( o . url ) ) L . push ( ss ( o . url ) ) ;
149+ return L . join ( '\n' ) ;
150+ } ) . join ( '\n\n' ) ; }
151+
152+ function _seCerts ( list ) { return toArr ( list ) . map ( c => {
153+ const d = [ ] ; if ( ss ( c . date ) ) d . push ( 'issued ' + ss ( c . date ) ) ; if ( ss ( c . expiry ) ) d . push ( 'expires ' + ss ( c . expiry ) ) ;
154+ return _j ( [ ss ( c . name ) , ss ( c . issuer ) , d . join ( ', ' ) , ss ( c . credential_id ) ?'ID ' + ss ( c . credential_id ) :'' , ss ( c . url ) ] , ' · ' ) ;
155+ } ) . join ( '\n' ) ; }
156+
157+ function _sePubs ( list ) { return toArr ( list ) . map ( p =>
158+ _j ( [ ss ( p . title ) , ss ( p . type ) , ss ( p . venue ) , ss ( p . date ) , toArr ( p . authors ) . map ( ss ) . filter ( Boolean ) . join ( ', ' ) , ss ( p . url ) ] , ' · ' )
159+ ) . join ( '\n' ) ; }
160+
161+ function _sePatents ( list ) { return toArr ( list ) . map ( p => {
162+ const L = [ _j ( [ ss ( p . title ) , ss ( p . patent_number ) , ss ( p . date ) ] , ' · ' ) ] ;
163+ if ( ss ( p . description ) ) L . push ( ss ( p . description ) ) ;
164+ return L . join ( '\n' ) ;
165+ } ) . join ( '\n\n' ) ; }
166+
167+ function _seVol ( list ) { return toArr ( list ) . map ( v => {
168+ const L = [ _j ( [ ss ( v . role ) , ss ( v . organization ) , _j ( [ ss ( v . start_date ) , ss ( v . end_date ) ] , ' – ' ) ] , ' · ' ) ] ;
169+ if ( ss ( v . description ) ) L . push ( ss ( v . description ) ) ;
170+ return L . join ( '\n' ) ;
171+ } ) . join ( '\n\n' ) ; }
172+
173+ function _seLangs ( list ) { return toArr ( list ) . map ( l =>
174+ typeof l === 'string' ?l :_j ( [ ss ( l . language ) , ss ( l . proficiency ) ?`(${ ss ( l . proficiency ) } )` :'' ] , ' ' )
175+ ) . join ( '\n' ) ; }
176+
177+ function _seContact ( r ) { const pi = r . personal_info || { } ;
178+ return _j ( [ ss ( pi . full_name ) , ss ( pi . professional_title ) , ss ( pi . email ) , ss ( pi . phone ) , ss ( pi . location ) , ss ( pi . linkedin ) , ss ( pi . github ) , ss ( pi . website ) || ss ( pi . portfolio ) ] , '\n' ) ;
179+ }
180+
116181function _buildDiffHtml ( a , b ) {
182+ a = a || { } ; b = b || { } ;
117183 const secs = [ ] ;
118- const check = ( title , va , vb ) => { if ( va !== vb ) secs . push ( { title, html :_diffText ( va || '' , vb || '' ) } ) ; } ;
184+ const check = ( title , va , vb ) => { if ( ( va || '' ) !== ( vb || '' ) ) secs . push ( { title, html :_diffText ( va || '' , vb || '' ) } ) ; } ;
119185
186+ check ( 'Contact' , _seContact ( a ) , _seContact ( b ) ) ;
120187 check ( 'Professional Summary' , a . professional_summary , b . professional_summary ) ;
121188
122- const ccA = ( a . core_competencies || [ ] ) . join ( ', ' ) ;
123- const ccB = ( b . core_competencies || [ ] ) . join ( ', ' ) ;
189+ const ccA = toArr ( a . core_competencies ) . map ( ss ) . filter ( Boolean ) . join ( ', ' ) ;
190+ const ccB = toArr ( b . core_competencies ) . map ( ss ) . filter ( Boolean ) . join ( ', ' ) ;
124191 check ( 'Core Competencies' , ccA , ccB ) ;
125192
126- const expB = b . experience || [ ] ;
127- const expA = a . experience || [ ] ;
128- expB . forEach ( ( job , idx ) => {
129- const pj = expA [ idx ] ;
130- const txtA = pj ?[ ...( pj . responsibilities || [ ] ) , ...( pj . achievements || [ ] ) ] . join ( '\n' ) :'' ;
131- const txtB = [ ...( job . responsibilities || [ ] ) , ...( job . achievements || [ ] ) ] . join ( '\n' ) ;
132- if ( txtA !== txtB ) secs . push ( { title :`Experience — ${ job . title } @ ${ job . company } ` , html :_diffText ( txtA , txtB ) } ) ;
133- } ) ;
134-
135- const skA = Object . values ( a . technical_skills || { } ) . flat ( ) . join ( ', ' ) ;
136- const skB = Object . values ( b . technical_skills || { } ) . flat ( ) . join ( ', ' ) ;
193+ // Experience — per job, so a small wording tweak in one bullet doesn't light up
194+ // the whole section. Walking the longer of the two lists also surfaces jobs
195+ // added to (or removed from) the new version, not just edited ones.
196+ const expA = a . experience || [ ] , expB = b . experience || [ ] ;
197+ for ( let i = 0 ; i < Math . max ( expA . length , expB . length ) ; i ++ ) {
198+ const pj = expA [ i ] , nj = expB [ i ] , job = nj || pj ;
199+ const txtA = pj ?[ ...toArr ( pj . responsibilities ) , ...toArr ( pj . achievements ) ] . map ( ss ) . join ( '\n' ) :'' ;
200+ const txtB = nj ?[ ...toArr ( nj . responsibilities ) , ...toArr ( nj . achievements ) ] . map ( ss ) . join ( '\n' ) :'' ;
201+ if ( txtA !== txtB ) secs . push ( { title :`Experience — ${ ss ( job . title ) } @ ${ ss ( job . company ) } ` , html :_diffText ( txtA , txtB ) } ) ;
202+ }
203+
204+ const skA = Object . values ( a . technical_skills || { } ) . flat ( ) . map ( ss ) . join ( ', ' ) ;
205+ const skB = Object . values ( b . technical_skills || { } ) . flat ( ) . map ( ss ) . join ( ', ' ) ;
137206 check ( 'Technical Skills' , skA , skB ) ;
138207
208+ // Remaining sections — these were previously never compared, so newly added
209+ // entries/sections (a fresh Projects list, a new certification, etc.) silently
210+ // failed to appear in the diff.
211+ check ( 'Projects' , _seProjects ( a . projects ) , _seProjects ( b . projects ) ) ;
212+ check ( 'Open Source' , _seOSS ( a . open_source_contributions ) , _seOSS ( b . open_source_contributions ) ) ;
213+ check ( 'Education' , _seEdu ( a . education ) , _seEdu ( b . education ) ) ;
214+ check ( 'Certifications' , _seCerts ( a . certifications ) , _seCerts ( b . certifications ) ) ;
215+ check ( 'Publications' , _sePubs ( a . publications ) , _sePubs ( b . publications ) ) ;
216+ check ( 'Patents' , _sePatents ( a . patents ) , _sePatents ( b . patents ) ) ;
217+ check ( 'Awards & Honors' , toArr ( a . awards_and_honors ) . map ( ss ) . filter ( Boolean ) . join ( '\n' ) , toArr ( b . awards_and_honors ) . map ( ss ) . filter ( Boolean ) . join ( '\n' ) ) ;
218+ check ( 'Volunteer Experience' , _seVol ( a . volunteer_experience ) , _seVol ( b . volunteer_experience ) ) ;
219+ check ( 'Languages' , _seLangs ( a . languages ) , _seLangs ( b . languages ) ) ;
220+ check ( 'Interests' , toArr ( a . interests ) . map ( ss ) . filter ( Boolean ) . join ( ', ' ) , toArr ( b . interests ) . map ( ss ) . filter ( Boolean ) . join ( ', ' ) ) ;
221+
139222 const metaA = a . metadata || { } , metaB = b . metadata || { } ;
140223 const scoreKeys = [ 'overall_score' , 'ats_score' , 'quality_score' , 'impact_score' ] ;
141224 if ( scoreKeys . some ( k => metaA [ k ] !== metaB [ k ] ) ) {
0 commit comments