diff --git a/.pi-lens/cache/jscpd.json b/.pi-lens/cache/jscpd.json index d9a4259..107a15e 100644 --- a/.pi-lens/cache/jscpd.json +++ b/.pi-lens/cache/jscpd.json @@ -2,6 +2,6 @@ "success": true, "clones": [], "duplicatedLines": 0, - "totalLines": 681, + "totalLines": 826, "percentage": 0 } \ No newline at end of file diff --git a/.pi-lens/cache/jscpd.meta.json b/.pi-lens/cache/jscpd.meta.json index ce9b0bd..ff5efe4 100644 --- a/.pi-lens/cache/jscpd.meta.json +++ b/.pi-lens/cache/jscpd.meta.json @@ -1,3 +1,3 @@ { - "timestamp": "2026-04-18T19:53:09.821Z" + "timestamp": "2026-04-18T20:20:06.986Z" } \ No newline at end of file diff --git a/.pi-lens/cache/knip.meta.json b/.pi-lens/cache/knip.meta.json index 5099dbb..565e223 100644 --- a/.pi-lens/cache/knip.meta.json +++ b/.pi-lens/cache/knip.meta.json @@ -1,3 +1,3 @@ { - "timestamp": "2026-04-18T19:53:11.171Z" + "timestamp": "2026-04-18T20:20:08.210Z" } \ No newline at end of file diff --git a/.pi-lens/metrics-history.json b/.pi-lens/metrics-history.json index 3208422..1703a38 100644 --- a/.pi-lens/metrics-history.json +++ b/.pi-lens/metrics-history.json @@ -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" } \ No newline at end of file diff --git a/.pi-lens/turn-state.json b/.pi-lens/turn-state.json index 0703190..bb20303 100644 --- a/.pi-lens/turn-state.json +++ b/.pi-lens/turn-state.json @@ -2,5 +2,5 @@ "files": {}, "turnCycles": 0, "maxCycles": 3, - "lastUpdated": "2026-04-18T19:53:11.171Z" + "lastUpdated": "2026-04-18T20:20:08.210Z" } \ No newline at end of file diff --git a/src/sections/Experience.tsx b/src/sections/Experience.tsx index 368a734..1849629 100644 --- a/src/sections/Experience.tsx +++ b/src/sections/Experience.tsx @@ -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(null) const [error, setError] = useState(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 (
-

- Counseling Experience -

-

- Resume-driven timeline (best-effort extraction from uploaded DOCX). -

+
+

+ Counseling Experience +

+

+ Professional timeline showcasing over a decade of clinical experience in mental health + and substance use disorder treatment. +

+
- {error ? ( -
- Resume parse failed. Showing placeholder. + {error && ( +
+ +
+

Resume parsing issue

+

+ Showing curated highlights instead. For full details, download the resume. +

+
- ) : null} + )} -
- {sorted.length ? ( +
+ {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) : sorted.length ? ( sorted.map((item, idx) => (
-
-
-

{item.role}

- {item.org ?

{item.org}

: null} -
- -
+
+
+
+
+ +
+
+

+ {item.role} +

+ {item.org && ( +
+ + {item.org} +
+ )} +
+
- {item.details?.length ? ( -
    - {item.details.map((d, i) => ( -
  • {d}
  • - ))} -
- ) : null} + {item.details?.length ? ( +
    + {item.details.map((d, i) => ( +
  • + + {d} +
  • + ))} +
+ ) : null} +
+ +
+ + +
+
)) ) : ( -
- Loading resume timeline… +
+

No experience data available.

)}
-

- Extraction is heuristic. Validate with source resume if high-stakes accuracy needed. -

+
+

Data extracted from resume document

+ + View full resume → + +
) } diff --git a/src/utils/parseResume.ts b/src/utils/parseResume.ts index 7012f95..5f3dab2 100644 --- a/src/utils/parseResume.ts +++ b/src/utils/parseResume.ts @@ -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 { - 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() + + 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() - for (const it of items) { - const k = `${it.date}|${it.role}` - if (seen.has(k)) continue - seen.add(k) - uniq.push(it) - } - - return uniq }