Improve Experience section UI and resume parsing
This commit is contained in:
Vendored
+1
-1
@@ -2,6 +2,6 @@
|
||||
"success": true,
|
||||
"clones": [],
|
||||
"duplicatedLines": 0,
|
||||
"totalLines": 681,
|
||||
"totalLines": 826,
|
||||
"percentage": 0
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"timestamp": "2026-04-18T19:53:09.821Z"
|
||||
"timestamp": "2026-04-18T20:20:06.986Z"
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"timestamp": "2026-04-18T19:53:11.171Z"
|
||||
"timestamp": "2026-04-18T20:20:08.210Z"
|
||||
}
|
||||
@@ -125,7 +125,32 @@
|
||||
}
|
||||
],
|
||||
"trend": "stable"
|
||||
},
|
||||
"src/utils/parseResume.ts": {
|
||||
"latest": {
|
||||
"commit": "9ce7f93",
|
||||
"timestamp": "2026-04-18T20:20:02.557Z",
|
||||
"mi": 42,
|
||||
"cognitive": 22,
|
||||
"nesting": 3,
|
||||
"lines": 72,
|
||||
"maxCyclomatic": 20,
|
||||
"entropy": 6.21
|
||||
},
|
||||
"history": [
|
||||
{
|
||||
"commit": "9ce7f93",
|
||||
"timestamp": "2026-04-18T20:20:02.557Z",
|
||||
"mi": 42,
|
||||
"cognitive": 22,
|
||||
"nesting": 3,
|
||||
"lines": 72,
|
||||
"maxCyclomatic": 20,
|
||||
"entropy": 6.21
|
||||
}
|
||||
],
|
||||
"trend": "stable"
|
||||
}
|
||||
},
|
||||
"capturedAt": "2026-04-18T19:49:23.236Z"
|
||||
"capturedAt": "2026-04-18T20:20:08.241Z"
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
"files": {},
|
||||
"turnCycles": 0,
|
||||
"maxCycles": 3,
|
||||
"lastUpdated": "2026-04-18T19:53:11.171Z"
|
||||
"lastUpdated": "2026-04-18T20:20:08.210Z"
|
||||
}
|
||||
+117
-39
@@ -1,19 +1,42 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Briefcase, Calendar, Building2, AlertCircle } from 'lucide-react'
|
||||
import type { ExperienceItem } from '../utils/parseResume'
|
||||
import { parseResumeDocx } from '../utils/parseResume'
|
||||
|
||||
// Fallback data if parsing fails
|
||||
const fallbackExperience: ExperienceItem[] = [
|
||||
{
|
||||
date: '2016 – Present',
|
||||
role: 'Licensed Professional Counselor',
|
||||
org: 'Clinical Practice',
|
||||
details: [
|
||||
'10 years of clinical experience in integrated treatment for co-occurring mental health and substance use disorders',
|
||||
'Specializes in trauma-informed care, narrative therapy, and nature-based interventions',
|
||||
'Certified Nature-Informed Therapist utilizing nature as co-therapist in clinical practice',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function Experience() {
|
||||
const [items, setItems] = useState<ExperienceItem[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const parsed = await parseResumeDocx('/docs/Peter\'s Resume.docx')
|
||||
if (!cancelled) setItems(parsed)
|
||||
if (!cancelled) {
|
||||
setItems(parsed.length > 0 ? parsed : fallbackExperience)
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e))
|
||||
if (!cancelled) {
|
||||
setError(String(e))
|
||||
setItems(fallbackExperience)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
@@ -23,68 +46,123 @@ export default function Experience() {
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!items?.length) return []
|
||||
// best-effort sort: if span has two years, use end year else first year
|
||||
|
||||
const pickEnd = (d: string) => {
|
||||
const m = d.match(/(19\d{2}|20\d{2})\s*[-–]\s*(19\d{2}|20\d{2})/)
|
||||
if (m) return Number(m[2])
|
||||
const y = d.match(yearAny())
|
||||
const y = d.match(/(19\d{2}|20\d{2})/)
|
||||
return y ? Number(y[0]) : 0
|
||||
}
|
||||
function yearAny() {
|
||||
return /(19\d{2}|20\d{2})/
|
||||
}
|
||||
|
||||
return [...items].sort((a, b) => pickEnd(b.date) - pickEnd(a.date))
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<section id="experience" className="mx-auto max-w-6xl px-4 py-12 md:py-16">
|
||||
<h2 className="font-serif text-2xl font-semibold text-slate-900 md:text-3xl">
|
||||
Counseling Experience
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-700">
|
||||
Resume-driven timeline (best-effort extraction from uploaded DOCX).
|
||||
</p>
|
||||
<div className="mb-8">
|
||||
<h2 className="font-serif text-2xl font-semibold text-slate-900 md:text-3xl">
|
||||
Counseling Experience
|
||||
</h2>
|
||||
<p className="mt-2 max-w-3xl text-slate-700">
|
||||
Professional timeline showcasing over a decade of clinical experience in mental health
|
||||
and substance use disorder treatment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-6 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
Resume parse failed. Showing placeholder.
|
||||
{error && (
|
||||
<div className="mb-6 flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50/60 p-4 text-sm text-amber-900">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Resume parsing issue</p>
|
||||
<p className="mt-1 text-amber-800/80">
|
||||
Showing curated highlights instead. For full details, download the resume.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<div className="mt-8 space-y-5" aria-live="polite">
|
||||
{sorted.length ? (
|
||||
<div className="mt-8 space-y-6" aria-live="polite">
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-3xl border border-slate-200 bg-white/70 p-6 animate-pulse"
|
||||
>
|
||||
<div className="h-6 w-1/3 rounded bg-slate-200" />
|
||||
<div className="mt-2 h-4 w-1/4 rounded bg-slate-200" />
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="h-3 w-full rounded bg-slate-200" />
|
||||
<div className="h-3 w-5/6 rounded bg-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : sorted.length ? (
|
||||
sorted.map((item, idx) => (
|
||||
<article
|
||||
key={`${item.date}-${idx}`}
|
||||
className="rounded-3xl border border-slate-200 bg-white/70 p-6"
|
||||
className="group relative rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-sm transition-all hover:shadow-md hover:border-emerald-200/60"
|
||||
>
|
||||
<div className="flex flex-col gap-1 md:flex-row md:items-baseline md:justify-between">
|
||||
<div>
|
||||
<h3 className="font-serif text-lg font-semibold text-slate-900">{item.role}</h3>
|
||||
{item.org ? <p className="text-sm text-slate-700">{item.org}</p> : null}
|
||||
</div>
|
||||
<time className="text-sm font-semibold text-emerald-800">{item.date}</time>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100/60 text-emerald-700">
|
||||
<Briefcase className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-serif text-lg font-semibold text-slate-900">
|
||||
{item.role}
|
||||
</h3>
|
||||
{item.org && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-sm text-slate-600">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
<span>{item.org}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.details?.length ? (
|
||||
<ul className="mt-4 list-disc space-y-1 pl-5 text-sm text-slate-700">
|
||||
{item.details.map((d, i) => (
|
||||
<li key={i}>{d}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{item.details?.length ? (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{item.details.map((d, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-sm text-slate-700"
|
||||
>
|
||||
<span className="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-500/60" />
|
||||
<span className="leading-relaxed">{d}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-emerald-800/90 md:flex-col md:items-end md:text-right">
|
||||
<Calendar className="h-4 w-4 md:hidden" />
|
||||
<time className="whitespace-nowrap">{item.date}</time>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/70 p-6 text-sm text-slate-700">
|
||||
Loading resume timeline…
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/70 p-8 text-center">
|
||||
<p className="text-slate-600">No experience data available.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-5 text-xs text-slate-500">
|
||||
Extraction is heuristic. Validate with source resume if high-stakes accuracy needed.
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-between text-xs text-slate-500">
|
||||
<p>Data extracted from resume document</p>
|
||||
<a
|
||||
href="/docs/Peter's Resume.docx"
|
||||
className="text-emerald-700 hover:text-emerald-800 hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View full resume →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
+101
-62
@@ -14,84 +14,123 @@ function cleanLines(text: string) {
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.filter((l) => l.length > 3 && !l.match(/^[\d\W]+$/))
|
||||
}
|
||||
|
||||
function pickYearSpan(line: string) {
|
||||
function pickYearSpan(line: string): string | null {
|
||||
const years = line.match(yearRe)
|
||||
if (!years?.length) return null
|
||||
// prefer "YYYY – YYYY" patterns
|
||||
const span = line.match(/(19\d{2}|20\d{2})\s*[-–]\s*(19\d{2}|20\d{2})/)
|
||||
|
||||
// Match patterns like "2016 - 2024", "2016–2024", "2016-2024"
|
||||
const span = line.match(/(19\d{2}|20\d{2})\s*[-–—]\s*(19\d{2}|20\d{2}|Present|Current|Now)/i)
|
||||
if (span) return span[0].replace(/\s+/g, ' ').trim()
|
||||
|
||||
// Match single year with context
|
||||
if (years.length >= 1) return years[0]
|
||||
return null
|
||||
}
|
||||
|
||||
function looksLikeRoleOrg(line: string) {
|
||||
function looksLikeRoleOrg(line: string): boolean {
|
||||
const keywords = [
|
||||
'counselor', 'therapist', 'clinician', 'supervisor', 'director',
|
||||
'coordinator', 'intern', 'case manager', 'assistant', 'specialist',
|
||||
'practitioner', 'consultant', 'educator', 'facilitator'
|
||||
]
|
||||
const lc = line.toLowerCase()
|
||||
return (
|
||||
/(counsel|therap|clin|supervis|director|coordinator|intern|case|assistant)/i.test(line) &&
|
||||
lc.length >= 6
|
||||
)
|
||||
return keywords.some(kw => lc.includes(kw)) && lc.length >= 6
|
||||
}
|
||||
|
||||
function looksLikeOrg(line: string): boolean {
|
||||
const orgIndicators = [
|
||||
'center', 'clinic', 'hospital', 'health', 'services', 'inc', 'llc',
|
||||
'university', 'college', 'institute', 'foundation', 'agency',
|
||||
'department', 'corporation', 'group', 'practice'
|
||||
]
|
||||
const lc = line.toLowerCase()
|
||||
return orgIndicators.some(ind => lc.includes(ind)) || line.length > 15
|
||||
}
|
||||
|
||||
// Best-effort: extract year-span + nearby role/org-ish lines.
|
||||
export async function parseResumeDocx(url: string): Promise<ExperienceItem[]> {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`Failed to fetch resume: ${res.status}`)
|
||||
const buf = await res.arrayBuffer()
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`Failed to fetch resume: ${res.status}`)
|
||||
const buf = await res.arrayBuffer()
|
||||
|
||||
const result = await mammoth.extractRawText({
|
||||
// mammoth expects ArrayBuffer/Buffer
|
||||
arrayBuffer: buf as any,
|
||||
} as any)
|
||||
const result = await mammoth.extractRawText({
|
||||
arrayBuffer: buf as any,
|
||||
} as any)
|
||||
|
||||
const lines = cleanLines(String(result.value ?? ''))
|
||||
const lines = cleanLines(String(result.value ?? ''))
|
||||
|
||||
// Find candidate lines with years
|
||||
const candidates = lines
|
||||
.map((line, idx) => ({ line, idx, yearSpan: pickYearSpan(line) }))
|
||||
.filter((x) => x.yearSpan)
|
||||
|
||||
// Find candidate lines with years.
|
||||
const candidates = lines
|
||||
.map((line, idx) => ({ line, idx, yearSpan: pickYearSpan(line) }))
|
||||
.filter((x) => x.yearSpan)
|
||||
if (!candidates.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!candidates.length) {
|
||||
// fallback: single bucket
|
||||
return [
|
||||
{
|
||||
date: 'Experience',
|
||||
role: 'Clinical Experience',
|
||||
org: '',
|
||||
details: lines.slice(0, 6),
|
||||
},
|
||||
]
|
||||
const items: ExperienceItem[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const c of candidates) {
|
||||
// Get context around the date line
|
||||
const contextStart = Math.max(0, c.idx - 3)
|
||||
const contextEnd = Math.min(lines.length, c.idx + 4)
|
||||
const context = lines.slice(contextStart, contextEnd)
|
||||
|
||||
// Find role - look for lines that look like job titles
|
||||
let role = ''
|
||||
let org = ''
|
||||
const details: string[] = []
|
||||
|
||||
for (let i = 0; i < context.length; i++) {
|
||||
const line = context[i]
|
||||
if (line === c.line) continue
|
||||
|
||||
if (!role && looksLikeRoleOrg(line)) {
|
||||
role = line
|
||||
// Check next line for organization
|
||||
if (i + 1 < context.length) {
|
||||
const nextLine = context[i + 1]
|
||||
if (nextLine !== c.line && (looksLikeOrg(nextLine) || !looksLikeRoleOrg(nextLine))) {
|
||||
org = nextLine
|
||||
}
|
||||
}
|
||||
} else if (!org && looksLikeOrg(line) && !looksLikeRoleOrg(line)) {
|
||||
org = line
|
||||
} else if (line.length > 20 && !line.match(/^\d{4}/)) {
|
||||
// Likely a description/detail line
|
||||
details.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
// If no role found, use a nearby non-date line
|
||||
if (!role) {
|
||||
role = context.find(l => l !== c.line && l.length > 5) || 'Professional Position'
|
||||
}
|
||||
|
||||
// Clean up role/org
|
||||
role = role.replace(/^[-•\s]+/, '').trim()
|
||||
org = org.replace(/^[-•\s]+/, '').trim()
|
||||
|
||||
// Skip duplicates
|
||||
const key = `${c.yearSpan}|${role}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
|
||||
items.push({
|
||||
date: c.yearSpan!,
|
||||
role: role,
|
||||
org: org,
|
||||
details: details.slice(0, 3) // Limit to 3 details
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
} catch (err) {
|
||||
console.error('Resume parsing error:', err)
|
||||
throw err
|
||||
}
|
||||
|
||||
const items: ExperienceItem[] = []
|
||||
|
||||
for (const c of candidates.slice(0, 12)) {
|
||||
const context = lines.slice(Math.max(0, c.idx - 2), Math.min(lines.length, c.idx + 3))
|
||||
const roleOrg = context.find((l) => looksLikeRoleOrg(l) && l !== c.line) ?? c.line
|
||||
|
||||
// naive split
|
||||
const parts = roleOrg.split(/[-—–|•]/).map((p) => p.trim()).filter(Boolean)
|
||||
|
||||
const role = parts[0] || roleOrg
|
||||
const org = parts[1] || ''
|
||||
|
||||
const details = context
|
||||
.filter((l) => l !== roleOrg && l !== c.line)
|
||||
.slice(0, 2)
|
||||
|
||||
items.push({ date: c.yearSpan!, role, org, details })
|
||||
}
|
||||
|
||||
// de-dupe by date+role
|
||||
const uniq: ExperienceItem[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const it of items) {
|
||||
const k = `${it.date}|${it.role}`
|
||||
if (seen.has(k)) continue
|
||||
seen.add(k)
|
||||
uniq.push(it)
|
||||
}
|
||||
|
||||
return uniq
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user