425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
import { useMemo, useState, useCallback } from 'react'
|
|
import { ChevronRight, ChevronDown, History } from 'lucide-react'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { ExpandableCardShell } from './ExpandableCardShell'
|
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
|
import { timelineEntities, timelineConsultations } from '@/data/timeline'
|
|
import { getExperienceEducationUICopy } from '@/lib/profile-content'
|
|
import type { TimelineEntity } from '@/types/pmr'
|
|
import { hexToRgba, motionSafeTransition } from '@/lib/utils'
|
|
|
|
const VISIBLE_COUNT = 4
|
|
|
|
interface TimelineInterventionItemProps {
|
|
entity: TimelineEntity
|
|
isExpanded: boolean
|
|
isHighlightedFromGraph: boolean
|
|
isDimmedByFocus: boolean
|
|
isEducationAnchor: boolean
|
|
onToggle: () => void
|
|
onViewFull: () => void
|
|
onHighlight?: (id: string | null) => void
|
|
}
|
|
|
|
function TimelineInterventionItem({
|
|
entity,
|
|
isExpanded,
|
|
isHighlightedFromGraph,
|
|
isDimmedByFocus,
|
|
isEducationAnchor,
|
|
onToggle,
|
|
onViewFull,
|
|
onHighlight,
|
|
}: TimelineInterventionItemProps) {
|
|
const experienceEducationCopy = getExperienceEducationUICopy()
|
|
const isEducation = entity.kind === 'education'
|
|
const interventionLabel = isEducation ? experienceEducationCopy.educationLabel : experienceEducationCopy.employmentLabel
|
|
|
|
return (
|
|
<ExpandableCardShell
|
|
isExpanded={isExpanded}
|
|
isHighlighted={isHighlightedFromGraph}
|
|
isDimmedByFocus={isDimmedByFocus}
|
|
accentColor={entity.orgColor}
|
|
onToggle={onToggle}
|
|
ariaLabel={`${entity.title} at ${entity.organization}, ${entity.dateRange.display}. Click to ${isExpanded ? 'collapse' : 'expand'} details.`}
|
|
headerPadding="8px 8px"
|
|
className={isEducation ? 'timeline-intervention-item timeline-intervention-item--education' : 'timeline-intervention-item'}
|
|
dataTileId={isEducationAnchor ? 'section-education' : undefined}
|
|
onMouseEnter={() => onHighlight?.(entity.id)}
|
|
onMouseLeave={() => onHighlight?.(null)}
|
|
renderHeader={() => (
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
|
|
<div style={{ minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
}}
|
|
>
|
|
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
|
|
{interventionLabel}
|
|
</span>
|
|
{entity.dateRange.end === null && (
|
|
<span
|
|
style={{
|
|
fontSize: '9px',
|
|
fontWeight: 700,
|
|
fontFamily: 'var(--font-geist-mono)',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em',
|
|
padding: '2px 7px',
|
|
borderRadius: '9999px',
|
|
background: 'rgba(34, 197, 94, 0.12)',
|
|
color: '#16a34a',
|
|
border: '1px solid rgba(34, 197, 94, 0.3)',
|
|
}}
|
|
>
|
|
Current
|
|
</span>
|
|
)}
|
|
<div
|
|
style={{
|
|
fontSize: '14px',
|
|
fontWeight: 600,
|
|
color: 'var(--text-primary)',
|
|
lineHeight: 1.3,
|
|
}}
|
|
>
|
|
{entity.title}
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: '12px',
|
|
color: 'var(--text-secondary)',
|
|
marginTop: '2px',
|
|
}}
|
|
>
|
|
{entity.organization}
|
|
<span
|
|
style={{
|
|
fontSize: '11px',
|
|
paddingLeft: '6px',
|
|
fontFamily: 'var(--font-geist-mono)',
|
|
color: 'var(--text-tertiary)',
|
|
marginTop: '3px',
|
|
}}
|
|
>
|
|
{entity.dateRange.display}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{(entity.band || entity.employmentBasis) && (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexShrink: 0,
|
|
alignItems: 'center',
|
|
gap: '5px',
|
|
}}
|
|
>
|
|
{entity.band && (
|
|
<span
|
|
style={{
|
|
fontSize: '10px',
|
|
fontFamily: 'var(--font-geist-mono)',
|
|
padding: '2px 6px',
|
|
borderRadius: '3px',
|
|
background: hexToRgba(entity.orgColor, 0.1),
|
|
color: entity.orgColor,
|
|
border: `1px solid ${hexToRgba(entity.orgColor, 0.25)}`,
|
|
lineHeight: 1.4,
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
Band {entity.band.toUpperCase()}
|
|
</span>
|
|
)}
|
|
{entity.employmentBasis && (
|
|
<span
|
|
title={entity.contextNote}
|
|
style={{
|
|
fontSize: '10px',
|
|
padding: '2px 6px',
|
|
borderRadius: '3px',
|
|
background: 'rgba(245, 158, 11, 0.1)',
|
|
color: '#b45309',
|
|
border: '1px solid rgba(245, 158, 11, 0.25)',
|
|
cursor: 'default',
|
|
lineHeight: 1.4,
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{entity.employmentBasis}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
renderBody={() => (
|
|
<>
|
|
{entity.contextNote && (
|
|
<div
|
|
style={{
|
|
fontSize: '12px',
|
|
fontStyle: 'italic',
|
|
color: 'var(--text-tertiary)',
|
|
marginBottom: '10px',
|
|
}}
|
|
>
|
|
{entity.contextNote}
|
|
</div>
|
|
)}
|
|
<ul
|
|
style={{
|
|
listStyle: 'none',
|
|
padding: 0,
|
|
margin: '0 0 10px 0',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '5px',
|
|
}}
|
|
>
|
|
{entity.details.map((detail, i) => (
|
|
<li
|
|
key={i}
|
|
style={{
|
|
fontSize: '13px',
|
|
color: 'var(--text-primary)',
|
|
lineHeight: 1.5,
|
|
paddingLeft: '12px',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
top: '6px',
|
|
width: '4px',
|
|
height: '4px',
|
|
borderRadius: '50%',
|
|
background: entity.orgColor,
|
|
opacity: 0.5,
|
|
}}
|
|
/>
|
|
{detail}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{!!entity.codedEntries?.length && (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: '6px',
|
|
marginBottom: '10px',
|
|
}}
|
|
>
|
|
{entity.codedEntries.map((entry) => (
|
|
<span
|
|
key={entry.code}
|
|
style={{
|
|
fontSize: '11px',
|
|
fontFamily: 'var(--font-geist-mono)',
|
|
padding: '3px 8px',
|
|
borderRadius: '4px',
|
|
background: hexToRgba(entity.orgColor, 0.08),
|
|
color: entity.orgColor,
|
|
border: `1px solid ${hexToRgba(entity.orgColor, 0.2)}`,
|
|
}}
|
|
>
|
|
{entry.code}: {entry.description}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onViewFull()
|
|
}}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '4px',
|
|
fontSize: '12px',
|
|
fontWeight: 500,
|
|
color: entity.orgColor,
|
|
background: 'transparent',
|
|
border: 'none',
|
|
padding: '4px 0',
|
|
cursor: 'pointer',
|
|
fontFamily: 'inherit',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.opacity = '0.7'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.opacity = '1'
|
|
}}
|
|
>
|
|
{experienceEducationCopy.viewFullRecordLabel}
|
|
<ChevronRight size={12} />
|
|
</button>
|
|
</>
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
interface TimelineInterventionsSubsectionProps {
|
|
onNodeHighlight?: (id: string | null) => void
|
|
highlightedRoleId?: string | null
|
|
focusRelatedIds?: Set<string> | null
|
|
}
|
|
|
|
export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRoleId, focusRelatedIds }: TimelineInterventionsSubsectionProps) {
|
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
const [historicalOpen, setHistoricalOpen] = useState(false)
|
|
const { openPanel } = useDetailPanel()
|
|
|
|
const visibleEntities = useMemo(() => timelineEntities.slice(0, VISIBLE_COUNT), [])
|
|
const historicalEntities = useMemo(() => timelineEntities.slice(VISIBLE_COUNT), [])
|
|
|
|
const consultationsById = useMemo(
|
|
() => new Map(timelineConsultations.map((consultation) => [consultation.id, consultation])),
|
|
[],
|
|
)
|
|
|
|
const firstEducationId = useMemo(
|
|
() => timelineEntities.find((entity) => entity.kind === 'education')?.id ?? null,
|
|
[],
|
|
)
|
|
|
|
const handleToggle = useCallback((id: string) => {
|
|
setExpandedId((prev) => (prev === id ? null : id))
|
|
}, [])
|
|
|
|
const handleViewFull = useCallback((entity: TimelineEntity) => {
|
|
const consultation = consultationsById.get(entity.id)
|
|
if (!consultation) return
|
|
openPanel({ type: 'career-role', consultation })
|
|
}, [consultationsById, openPanel])
|
|
|
|
const historicalHasAnyFocusRelevance = focusRelatedIds !== null && focusRelatedIds !== undefined &&
|
|
historicalEntities.some((e) => focusRelatedIds.has(e.id))
|
|
const historicalDimmed = focusRelatedIds !== null && focusRelatedIds !== undefined && !historicalHasAnyFocusRelevance
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
|
{visibleEntities.map((entity) => (
|
|
<TimelineInterventionItem
|
|
key={entity.id}
|
|
entity={entity}
|
|
isExpanded={expandedId === entity.id}
|
|
isHighlightedFromGraph={highlightedRoleId === entity.id}
|
|
isDimmedByFocus={focusRelatedIds !== null && focusRelatedIds !== undefined && !focusRelatedIds.has(entity.id)}
|
|
isEducationAnchor={entity.id === firstEducationId}
|
|
onToggle={() => handleToggle(entity.id)}
|
|
onViewFull={() => handleViewFull(entity)}
|
|
onHighlight={onNodeHighlight}
|
|
/>
|
|
))}
|
|
|
|
{historicalEntities.length > 0 && (
|
|
<div
|
|
style={{
|
|
opacity: historicalDimmed ? 0.25 : 1,
|
|
transition: 'opacity 150ms ease-out',
|
|
}}
|
|
>
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => setHistoricalOpen((prev) => !prev)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
setHistoricalOpen((prev) => !prev)
|
|
}
|
|
}}
|
|
aria-expanded={historicalOpen}
|
|
aria-label={`${historicalOpen ? 'Hide' : 'Show'} ${historicalEntities.length} historical entries`}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
padding: '6px 10px',
|
|
background: 'var(--bg-dashboard)',
|
|
borderRadius: 'var(--radius-sm)',
|
|
border: '1px solid var(--border-light)',
|
|
cursor: 'pointer',
|
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = 'rgba(0, 137, 123, 0.2)'
|
|
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = 'var(--border-light)'
|
|
e.currentTarget.style.boxShadow = 'none'
|
|
}}
|
|
>
|
|
<History size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
|
|
<span
|
|
style={{
|
|
fontSize: '12px',
|
|
color: 'var(--text-secondary)',
|
|
fontFamily: 'var(--font-geist-mono)',
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{historicalOpen ? 'Hide' : 'View'} historical entries ({historicalEntities.length})
|
|
</span>
|
|
<ChevronDown
|
|
size={13}
|
|
style={{
|
|
color: 'var(--text-tertiary)',
|
|
flexShrink: 0,
|
|
transform: historicalOpen ? 'rotate(180deg)' : 'none',
|
|
transition: 'transform 0.15s ease-out',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{historicalOpen && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={motionSafeTransition(0.25)}
|
|
style={{ overflow: 'hidden' }}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', paddingTop: '10px' }}>
|
|
{historicalEntities.map((entity) => (
|
|
<TimelineInterventionItem
|
|
key={entity.id}
|
|
entity={entity}
|
|
isExpanded={expandedId === entity.id}
|
|
isHighlightedFromGraph={highlightedRoleId === entity.id}
|
|
isDimmedByFocus={focusRelatedIds !== null && focusRelatedIds !== undefined && !focusRelatedIds.has(entity.id)}
|
|
isEducationAnchor={entity.id === firstEducationId}
|
|
onToggle={() => handleToggle(entity.id)}
|
|
onViewFull={() => handleViewFull(entity)}
|
|
onHighlight={onNodeHighlight}
|
|
/>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|