Substantial refinement/polish on content of webpage (not just structural/coding elements)
This commit is contained in:
@@ -4,7 +4,6 @@ import Sidebar from './Sidebar'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import { DetailPanel } from './DetailPanel'
|
||||
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
|
||||
import { ProjectsTile } from './tiles/ProjectsTile'
|
||||
import { ParentSection } from './ParentSection'
|
||||
import CareerConstellation from './constellation/CareerConstellation'
|
||||
import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsection'
|
||||
@@ -210,7 +209,7 @@ export function DashboardLayout() {
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={sidebarVariants}
|
||||
style={{ flexShrink: 0 }}
|
||||
style={{ flexShrink: 0, height: '100%' }}
|
||||
>
|
||||
<Sidebar
|
||||
activeSection={activeSection}
|
||||
@@ -237,9 +236,6 @@ export function DashboardLayout() {
|
||||
<PatientSummaryTile />
|
||||
</div>
|
||||
|
||||
{/* ProjectsTile — full width */}
|
||||
<ProjectsTile />
|
||||
|
||||
{/* Patient Pathway — parent section with constellation graph + subsections */}
|
||||
<ParentSection title="Patient Pathway" tileId="patient-pathway">
|
||||
<div className="pathway-columns">
|
||||
|
||||
@@ -12,7 +12,7 @@ interface LastConsultationCardProps {
|
||||
|
||||
export function LastConsultationCard({ highlightedRoleId }: LastConsultationCardProps) {
|
||||
const { openPanel } = useDetailPanel()
|
||||
const consultation = timelineConsultations[0]
|
||||
const consultation = timelineConsultations.find(c => c.isCurrent) ?? timelineConsultations[0]
|
||||
if (!consultation) {
|
||||
return null
|
||||
}
|
||||
@@ -42,10 +42,7 @@ export function LastConsultationCard({ highlightedRoleId }: LastConsultationCard
|
||||
}
|
||||
|
||||
const getBand = (): string => {
|
||||
if (consultation.role.includes('Head')) {
|
||||
return '8a'
|
||||
}
|
||||
return '—'
|
||||
return consultation.band ?? '—'
|
||||
}
|
||||
|
||||
const fieldLabelStyle: React.CSSProperties = {
|
||||
@@ -75,7 +72,7 @@ export function LastConsultationCard({ highlightedRoleId }: LastConsultationCard
|
||||
margin: '-8px',
|
||||
}}
|
||||
>
|
||||
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Most recent role" />
|
||||
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Current role" />
|
||||
|
||||
<div
|
||||
role="button"
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CardHeader } from './Card'
|
||||
import { skills } from '@/data/skills'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { getSkillsUICopy } from '@/lib/profile-content'
|
||||
import type { SkillMedication, SkillCategory } from '@/types/pmr'
|
||||
import type { SkillMedication } from '@/types/pmr'
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
BarChart3, Code2, Database, PieChart, FileCode2,
|
||||
@@ -20,7 +20,6 @@ const iconMap: Record<string, LucideIcon> = {
|
||||
MessageSquare, UserPlus, RefreshCw, Calculator, Presentation,
|
||||
}
|
||||
|
||||
const SKILLS_PER_CATEGORY = 4
|
||||
|
||||
interface SkillRowProps {
|
||||
skill: SkillMedication
|
||||
@@ -129,32 +128,23 @@ function SkillRow({ skill, yearsSuffix, onClick, onHighlight }: SkillRowProps) {
|
||||
|
||||
interface CategorySectionProps {
|
||||
label: string
|
||||
categoryId: SkillCategory
|
||||
skills: SkillMedication[]
|
||||
itemCountSuffix: string
|
||||
yearsSuffix: string
|
||||
viewAllLabel: string
|
||||
onSkillClick: (skill: SkillMedication) => void
|
||||
onViewAll: (category: SkillCategory) => void
|
||||
isFirst: boolean
|
||||
onNodeHighlight?: (id: string | null) => void
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
label,
|
||||
categoryId,
|
||||
skills: categorySkills,
|
||||
itemCountSuffix,
|
||||
yearsSuffix,
|
||||
viewAllLabel,
|
||||
onSkillClick,
|
||||
onViewAll,
|
||||
isFirst,
|
||||
onNodeHighlight,
|
||||
}: CategorySectionProps) {
|
||||
const visibleSkills = categorySkills.slice(0, SKILLS_PER_CATEGORY)
|
||||
const remainingCount = categorySkills.length - SKILLS_PER_CATEGORY
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: isFirst ? 0 : '16px' }}>
|
||||
<div
|
||||
@@ -196,7 +186,7 @@ function CategorySection({
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{visibleSkills.map((skill) => (
|
||||
{categorySkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
@@ -206,37 +196,6 @@ function CategorySection({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{remainingCount > 0 && (
|
||||
<button
|
||||
onClick={() => onViewAll(categoryId)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
marginTop: '8px',
|
||||
padding: '4px 0',
|
||||
minHeight: '44px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--accent)',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent)'
|
||||
}}
|
||||
aria-label={`${viewAllLabel} ${categorySkills.length} ${label} skills`}
|
||||
>
|
||||
{viewAllLabel} ({categorySkills.length})
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -254,17 +213,13 @@ export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicatio
|
||||
label,
|
||||
skills: skills
|
||||
.filter((s) => s.category === id)
|
||||
.sort((a, b) => b.proficiency - a.proficiency),
|
||||
.sort((a, b) => b.yearsOfExperience - a.yearsOfExperience),
|
||||
}))
|
||||
|
||||
const handleSkillClick = (skill: SkillMedication) => {
|
||||
openPanel({ type: 'skill', skill })
|
||||
}
|
||||
|
||||
const handleViewAll = (category: SkillCategory) => {
|
||||
openPanel({ type: 'skills-all', category })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardHeader
|
||||
@@ -277,13 +232,10 @@ export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicatio
|
||||
<CategorySection
|
||||
key={group.id}
|
||||
label={group.label}
|
||||
categoryId={group.id}
|
||||
skills={group.skills}
|
||||
itemCountSuffix={skillsCopy.itemCountSuffix}
|
||||
yearsSuffix={skillsCopy.yearsSuffix}
|
||||
viewAllLabel={skillsCopy.viewAllLabel}
|
||||
onSkillClick={handleSkillClick}
|
||||
onViewAll={handleViewAll}
|
||||
isFirst
|
||||
onNodeHighlight={onNodeHighlight}
|
||||
/>
|
||||
|
||||
@@ -3,10 +3,8 @@ import type { CSSProperties, ReactNode } from 'react'
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
GraduationCap,
|
||||
type LucideIcon,
|
||||
Menu,
|
||||
Pill,
|
||||
Search,
|
||||
UserRound,
|
||||
Workflow,
|
||||
@@ -34,10 +32,8 @@ interface NavSection {
|
||||
}
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{ id: 'overview', label: 'Overview', tileId: 'patient-summary', Icon: UserRound },
|
||||
{ id: 'projects', label: 'Projects', tileId: 'projects', Icon: Pill },
|
||||
{ id: 'overview', label: 'Overview / Highlights', tileId: 'patient-summary', Icon: UserRound },
|
||||
{ id: 'experience', label: 'Experience', tileId: 'section-experience', Icon: Workflow },
|
||||
{ id: 'education', label: 'Education', tileId: 'section-education', Icon: GraduationCap },
|
||||
{ id: 'skills', label: 'Skills', tileId: 'section-skills', Icon: Wrench },
|
||||
]
|
||||
|
||||
@@ -221,6 +217,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
height: isDesktop ? '100%' : undefined,
|
||||
width: isExpanded ? 'var(--sidebar-width)' : 'var(--sidebar-rail-width)',
|
||||
minWidth: isExpanded ? 'var(--sidebar-width)' : 'var(--sidebar-rail-width)',
|
||||
background: 'var(--sidebar-bg)',
|
||||
|
||||
@@ -43,53 +43,113 @@ function TimelineInterventionItem({
|
||||
onMouseEnter={() => onHighlight?.(entity.id)}
|
||||
onMouseLeave={() => onHighlight?.(null)}
|
||||
renderHeader={() => (
|
||||
<>
|
||||
<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>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.3,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{entity.title}
|
||||
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
|
||||
{interventionLabel}
|
||||
</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>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{entity.organization}
|
||||
<span
|
||||
{(entity.band || entity.employmentBasis) && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
paddingLeft: '6px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: '3px',
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
}}
|
||||
>
|
||||
{entity.dateRange.display}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
{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',
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { CardHeader } from './Card'
|
||||
import { ExpandableCardShell } from './ExpandableCardShell'
|
||||
import { timelineConsultations } from '@/data/timeline'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { hexToRgba } from '@/lib/utils'
|
||||
import { DEFAULT_ORG_COLOR } from '@/lib/theme-colors'
|
||||
|
||||
interface RoleItemProps {
|
||||
consultation: typeof timelineConsultations[0]
|
||||
isExpanded: boolean
|
||||
isHighlightedFromGraph: boolean
|
||||
onToggle: () => void
|
||||
onViewFull: () => void
|
||||
onHighlight?: (id: string | null) => void
|
||||
}
|
||||
|
||||
function RoleItem({ consultation, isExpanded, isHighlightedFromGraph, onToggle, onViewFull, onHighlight }: RoleItemProps) {
|
||||
const orgColor = consultation.orgColor ?? DEFAULT_ORG_COLOR
|
||||
|
||||
return (
|
||||
<ExpandableCardShell
|
||||
isExpanded={isExpanded}
|
||||
isHighlighted={isHighlightedFromGraph}
|
||||
accentColor={orgColor}
|
||||
onToggle={onToggle}
|
||||
ariaLabel={`${consultation.role} at ${consultation.organization}, ${consultation.duration}. Click to ${isExpanded ? 'collapse' : 'expand'} details.`}
|
||||
headerPadding="12px 14px"
|
||||
onMouseEnter={() => onHighlight?.(consultation.id)}
|
||||
onMouseLeave={() => onHighlight?.(null)}
|
||||
renderHeader={() => (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{consultation.role}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{consultation.organization}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: '3px',
|
||||
}}
|
||||
>
|
||||
{consultation.duration}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
renderBody={() => (
|
||||
<>
|
||||
{/* Examination bullets */}
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: '0 0 10px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '5px',
|
||||
}}
|
||||
>
|
||||
{consultation.examination.map((bullet, 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: consultation.orgColor ?? 'var(--accent)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
{bullet}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Coded entries */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
{consultation.codedEntries.map((entry) => (
|
||||
<span
|
||||
key={entry.code}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '4px',
|
||||
background: hexToRgba(orgColor, 0.08),
|
||||
color: consultation.orgColor ?? 'var(--accent)',
|
||||
border: `1px solid ${hexToRgba(orgColor, 0.2)}`,
|
||||
}}
|
||||
>
|
||||
{entry.code}: {entry.description}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View full record link */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onViewFull()
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: consultation.orgColor ?? 'var(--accent)',
|
||||
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'
|
||||
}}
|
||||
>
|
||||
View full record
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface WorkExperienceSubsectionProps {
|
||||
onNodeHighlight?: (id: string | null) => void
|
||||
highlightedRoleId?: string | null
|
||||
}
|
||||
|
||||
export function WorkExperienceSubsection({ onNodeHighlight, highlightedRoleId }: WorkExperienceSubsectionProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const { openPanel } = useDetailPanel()
|
||||
|
||||
const handleToggle = useCallback((id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}, [])
|
||||
|
||||
const handleViewFull = useCallback(
|
||||
(consultation: typeof timelineConsultations[0]) => {
|
||||
openPanel({ type: 'career-role', consultation })
|
||||
},
|
||||
[openPanel],
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardHeader dotColor="teal" title="WORK EXPERIENCE" rightText={`${timelineConsultations.length} roles`} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{timelineConsultations.map((c) => (
|
||||
<RoleItem
|
||||
key={c.id}
|
||||
consultation={c}
|
||||
isExpanded={expandedId === c.id}
|
||||
isHighlightedFromGraph={highlightedRoleId === c.id}
|
||||
onToggle={() => handleToggle(c.id)}
|
||||
onViewFull={() => handleViewFull(c)}
|
||||
onHighlight={onNodeHighlight}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import { useForceSimulation, getHeight } from '@/hooks/useForceSimulation'
|
||||
import { useConstellationHighlight } from '@/hooks/useConstellationHighlight'
|
||||
import { useConstellationInteraction } from '@/hooks/useConstellationInteraction'
|
||||
import { useTimelineAnimation } from '@/hooks/useTimelineAnimation'
|
||||
import { useFocusTrap } from '@/hooks/useFocusTrap'
|
||||
import { MobileAccordion } from './MobileAccordion'
|
||||
import { ConstellationLegend } from './ConstellationLegend'
|
||||
import { AccessibleNodeOverlay } from './AccessibleNodeOverlay'
|
||||
import { PlayPauseButton } from './PlayPauseButton'
|
||||
import { FullscreenButton } from './FullscreenButton'
|
||||
import { srDescription } from './screen-reader-description'
|
||||
import {
|
||||
MIN_HEIGHT,
|
||||
@@ -45,6 +47,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 })
|
||||
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
|
||||
const [chartInView, setChartInView] = useState(true)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover }
|
||||
|
||||
@@ -69,27 +72,27 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
if (!container) return
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const CHANGE_THRESHOLD = 0.3
|
||||
const X_CHANGE_THRESHOLD = 0.3
|
||||
|
||||
const updateDimensions = () => {
|
||||
const width = container.clientWidth
|
||||
const viewportWidth = window.innerWidth
|
||||
const height = getHeight(viewportWidth, containerHeight)
|
||||
const height = isFullscreen ? window.innerHeight : getHeight(viewportWidth, containerHeight)
|
||||
const scaleFactor = viewportWidth >= 1024
|
||||
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
|
||||
: 1
|
||||
setDimensions(prev => {
|
||||
const widthDelta = Math.abs(prev.width - width) / prev.width
|
||||
const heightDelta = Math.abs(prev.height - height) / prev.height
|
||||
if (widthDelta < CHANGE_THRESHOLD && heightDelta < CHANGE_THRESHOLD) {
|
||||
const heightRatio = Math.max(height / prev.height, prev.height / height)
|
||||
if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) {
|
||||
return prev
|
||||
}
|
||||
return { width, height, scaleFactor }
|
||||
})
|
||||
}
|
||||
|
||||
// Initial measurement (no debounce)
|
||||
updateDimensions()
|
||||
// Use rAF for fullscreen toggle so CSS layout settles before measuring
|
||||
requestAnimationFrame(updateDimensions)
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
@@ -100,7 +103,29 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
observer.disconnect()
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
}
|
||||
}, [containerHeight])
|
||||
}, [containerHeight, isFullscreen])
|
||||
|
||||
const toggleFullscreen = useCallback(() => setIsFullscreen(prev => !prev), [])
|
||||
|
||||
// ESC key to exit fullscreen
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setIsFullscreen(false) }
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [isFullscreen])
|
||||
|
||||
// Body scroll lock while fullscreen
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [isFullscreen])
|
||||
|
||||
// Focus trap when fullscreen
|
||||
useFocusTrap(containerRef, isFullscreen)
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
|
||||
const sf = isMobile ? 1 : dimensions.scaleFactor
|
||||
@@ -240,84 +265,112 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-light)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||
role="img"
|
||||
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: dimensions.height,
|
||||
opacity: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
|
||||
|
||||
<MobileAccordion pinnedCareerEntity={pinnedCareerEntity} show={showAccordion} />
|
||||
|
||||
{!prefersReducedMotion && (
|
||||
<PlayPauseButton
|
||||
isPlaying={animation.isPlaying}
|
||||
onToggle={animation.togglePlayPause}
|
||||
isMobile={isMobile}
|
||||
visible={chartInView}
|
||||
containerRef={containerRef}
|
||||
<>
|
||||
{isFullscreen && (
|
||||
<div
|
||||
onClick={toggleFullscreen}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 899,
|
||||
background: 'var(--backdrop-bg)',
|
||||
backdropFilter: 'blur(var(--backdrop-blur))',
|
||||
animation: 'backdrop-fade-in 200ms ease-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p
|
||||
<div
|
||||
ref={containerRef}
|
||||
{...(isFullscreen ? {
|
||||
role: 'dialog',
|
||||
'aria-modal': true,
|
||||
'aria-label': 'Career constellation fullscreen view',
|
||||
} : {})}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1, height: 1, padding: 0, margin: -1,
|
||||
overflow: 'hidden', clip: 'rect(0,0,0,0)',
|
||||
whiteSpace: 'nowrap', border: 0,
|
||||
width: '100%',
|
||||
borderRadius: isFullscreen ? 0 : 'var(--radius-sm)',
|
||||
border: isFullscreen ? 'none' : '1px solid var(--border-light)',
|
||||
overflow: 'hidden',
|
||||
position: isFullscreen ? 'fixed' : 'relative',
|
||||
...(isFullscreen ? { inset: 0, zIndex: 900, background: 'var(--surface)' } : {}),
|
||||
animation: isFullscreen ? 'constellation-fullscreen-in 200ms ease-out' : undefined,
|
||||
}}
|
||||
>
|
||||
{srDescription}
|
||||
</p>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||
role="img"
|
||||
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: dimensions.height,
|
||||
opacity: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<AccessibleNodeOverlay
|
||||
nodes={constellationNodes}
|
||||
nodeButtonPositions={sim.nodeButtonPositions}
|
||||
dimensions={dimensions}
|
||||
onFocus={(nodeId) => {
|
||||
setFocusedNodeId(nodeId)
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
const node = nodeById.get(nodeId)
|
||||
if (node?.type !== 'skill') onNodeHover?.(nodeId)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocusedNodeId(null)
|
||||
highlightGraphRef.current?.(resolveGraphFallback())
|
||||
onNodeHover?.(resolveRoleFallback())
|
||||
}}
|
||||
onClick={(nodeId, nodeType) => {
|
||||
setPinnedNodeId(nodeId)
|
||||
pinnedNodeIdRef.current = nodeId
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
if (nodeType !== 'skill') {
|
||||
onNodeHover?.(nodeId)
|
||||
onRoleClick(nodeId)
|
||||
} else {
|
||||
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
|
||||
|
||||
<MobileAccordion pinnedCareerEntity={pinnedCareerEntity} show={showAccordion} />
|
||||
|
||||
{!prefersReducedMotion && (
|
||||
<PlayPauseButton
|
||||
isPlaying={animation.isPlaying}
|
||||
onToggle={animation.togglePlayPause}
|
||||
isMobile={isMobile}
|
||||
visible={chartInView}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FullscreenButton
|
||||
isFullscreen={isFullscreen}
|
||||
onToggle={toggleFullscreen}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
<p
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1, height: 1, padding: 0, margin: -1,
|
||||
overflow: 'hidden', clip: 'rect(0,0,0,0)',
|
||||
whiteSpace: 'nowrap', border: 0,
|
||||
}}
|
||||
>
|
||||
{srDescription}
|
||||
</p>
|
||||
|
||||
<AccessibleNodeOverlay
|
||||
nodes={constellationNodes}
|
||||
nodeButtonPositions={sim.nodeButtonPositions}
|
||||
dimensions={dimensions}
|
||||
onFocus={(nodeId) => {
|
||||
setFocusedNodeId(nodeId)
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
const node = nodeById.get(nodeId)
|
||||
if (node?.type !== 'skill') onNodeHover?.(nodeId)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocusedNodeId(null)
|
||||
highlightGraphRef.current?.(resolveGraphFallback())
|
||||
onNodeHover?.(resolveRoleFallback())
|
||||
onSkillClick(nodeId)
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleNodeKeyDown}
|
||||
/>
|
||||
</div>
|
||||
}}
|
||||
onClick={(nodeId, nodeType) => {
|
||||
setPinnedNodeId(nodeId)
|
||||
pinnedNodeIdRef.current = nodeId
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
if (nodeType !== 'skill') {
|
||||
onNodeHover?.(nodeId)
|
||||
onRoleClick(nodeId)
|
||||
} else {
|
||||
onNodeHover?.(resolveRoleFallback())
|
||||
onSkillClick(nodeId)
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleNodeKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import { Maximize2, Minimize2 } from 'lucide-react'
|
||||
|
||||
interface FullscreenButtonProps {
|
||||
isFullscreen: boolean
|
||||
onToggle: () => void
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
export const FullscreenButton: React.FC<FullscreenButtonProps> = ({
|
||||
isFullscreen, onToggle, isMobile,
|
||||
}) => {
|
||||
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
|
||||
const scale = vw >= 1440 ? 1.75 : vw >= 1280 ? 1.5 : vw >= 1080 ? 1.25 : 1
|
||||
const size = isMobile ? 44 : Math.round(36 * scale)
|
||||
const offset = isMobile ? 8 : Math.round(12 * scale)
|
||||
const iconSize = isMobile ? 16 : Math.round(14 * scale)
|
||||
const Icon = isFullscreen ? Minimize2 : Maximize2
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: offset,
|
||||
top: offset,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
border: '1.5px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
boxShadow: '0 1px 4px rgba(26,43,42,0.10)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: 0.85,
|
||||
transition: 'opacity 200ms ease',
|
||||
zIndex: 5,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.opacity = '1' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.opacity = '0.85' }}
|
||||
>
|
||||
<Icon size={iconSize} color="var(--text-secondary)" strokeWidth={2} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -94,6 +94,32 @@ export function ProjectDetail({ investigation }: ProjectDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain skills */}
|
||||
{investigation.skills && investigation.skills.length > 0 && (
|
||||
<div>
|
||||
<h3 style={sectionHeadingStyle}>Domain Skills</h3>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{investigation.skills.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: '#0D9488',
|
||||
backgroundColor: 'rgba(13,148,136,0.08)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid rgba(13,148,136,0.2)',
|
||||
}}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<div>
|
||||
<h3 style={sectionHeadingStyle}>Results</h3>
|
||||
@@ -106,37 +132,85 @@ export function ProjectDetail({ investigation }: ProjectDetailProps) {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* External link */}
|
||||
{investigation.externalUrl && (
|
||||
<a
|
||||
href={investigation.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{/* Action buttons */}
|
||||
{(investigation.externalUrl || investigation.demoUrl) && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignSelf: 'flex-start' }}>
|
||||
{investigation.externalUrl && (
|
||||
<a
|
||||
href={investigation.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--font-ui)',
|
||||
color: 'var(--surface)',
|
||||
backgroundColor: 'var(--accent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textDecoration: 'none',
|
||||
transition: 'background-color 150ms',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent)'
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
View Live Project
|
||||
</a>
|
||||
)}
|
||||
{investigation.demoUrl && (
|
||||
<a
|
||||
href={investigation.demoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--font-ui)',
|
||||
color: '#0D9488',
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #0D9488',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textDecoration: 'none',
|
||||
transition: 'background-color 150ms, color 150ms',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(13,148,136,0.08)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Interactive Demo
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail */}
|
||||
{investigation.thumbnail && (
|
||||
<img
|
||||
src={investigation.thumbnail}
|
||||
alt={`${investigation.name} screenshot`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--font-ui)',
|
||||
color: 'var(--surface)',
|
||||
backgroundColor: 'var(--accent)',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textDecoration: 'none',
|
||||
alignSelf: 'flex-start',
|
||||
transition: 'background-color 150ms',
|
||||
border: '1px solid var(--border-light)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent)'
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
View Live Project
|
||||
</a>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,13 +13,6 @@ const categoryLabels: Record<SkillMedication['category'], string> = {
|
||||
Leadership: 'Strategic & Leadership',
|
||||
}
|
||||
|
||||
// Proficiency bar color based on value
|
||||
function getProficiencyColor(proficiency: number): string {
|
||||
if (proficiency >= 90) return 'var(--success)'
|
||||
if (proficiency >= 75) return 'var(--accent)'
|
||||
return 'var(--amber)'
|
||||
}
|
||||
|
||||
export function SkillDetail({ skill }: SkillDetailProps) {
|
||||
// Find roles that use this skill from constellation data
|
||||
const usedInRoles = roleSkillMappings
|
||||
@@ -96,44 +89,6 @@ export function SkillDetail({ skill }: SkillDetailProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Proficiency bar */}
|
||||
<div>
|
||||
<h3 style={sectionHeadingStyle}>Proficiency</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '6px',
|
||||
backgroundColor: 'var(--border-light)',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${skill.proficiency}%`,
|
||||
height: '100%',
|
||||
backgroundColor: getProficiencyColor(skill.proficiency),
|
||||
borderRadius: '3px',
|
||||
transition: 'width 400ms ease-out',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-geist)',
|
||||
color: getProficiencyColor(skill.proficiency),
|
||||
minWidth: '36px',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{skill.proficiency}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Years of experience */}
|
||||
<div>
|
||||
<h3 style={sectionHeadingStyle}>Experience</h3>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function SkillsAllDetail({ category }: SkillsAllDetailProps) {
|
||||
label,
|
||||
skills: skills
|
||||
.filter((s) => s.category === id)
|
||||
.sort((a, b) => b.proficiency - a.proficiency),
|
||||
.sort((a, b) => b.yearsOfExperience - a.yearsOfExperience),
|
||||
}))
|
||||
|
||||
const handleSkillClick = (skill: SkillMedication) => {
|
||||
@@ -200,46 +200,6 @@ function SkillRow({ skill, yearsSuffix, onClick }: SkillRowProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proficiency */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--border-light)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${skill.proficiency}%`,
|
||||
height: '100%',
|
||||
backgroundColor: skill.proficiency >= 90 ? 'var(--success)' : skill.proficiency >= 75 ? 'var(--accent)' : 'var(--amber)',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: '"Geist Mono", monospace',
|
||||
color: 'var(--text-tertiary)',
|
||||
minWidth: '28px',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{skill.proficiency}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight
|
||||
size={14}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { KPI } from '@/types/pmr'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { getLatestResultsCopy, getProfileSectionTitle, getProfileSummaryText } from '@/lib/profile-content'
|
||||
import { KPI_COLORS } from '@/lib/theme-colors'
|
||||
import { ProjectsCarousel } from './ProjectsTile'
|
||||
|
||||
interface MetricCardProps {
|
||||
kpi: KPI
|
||||
@@ -120,7 +121,7 @@ export function PatientSummaryTile() {
|
||||
const kpiGridStyles: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gap: '10px',
|
||||
gridTemplateColumns: '1fr',
|
||||
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -149,6 +150,9 @@ export function PatientSummaryTile() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projects carousel */}
|
||||
<ProjectsCarousel />
|
||||
</ParentSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { investigations } from '@/data/investigations'
|
||||
import { Card, CardHeader } from '../Card'
|
||||
import { CardHeader } from '../Card'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import type { Investigation } from '@/types/pmr'
|
||||
import { PROJECT_STATUS_COLORS } from '@/lib/theme-colors'
|
||||
@@ -9,7 +9,6 @@ interface ProjectItemProps {
|
||||
project: Investigation
|
||||
slideWidth: string
|
||||
cardMinHeight: number
|
||||
thumbnailHeight: number
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
@@ -17,7 +16,6 @@ function ProjectItem({
|
||||
project,
|
||||
slideWidth,
|
||||
cardMinHeight,
|
||||
thumbnailHeight,
|
||||
onClick,
|
||||
}: ProjectItemProps) {
|
||||
const dotColor = PROJECT_STATUS_COLORS[project.status]
|
||||
@@ -78,15 +76,16 @@ function ProjectItem({
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minHeight: `${thumbnailHeight}px`,
|
||||
flex: 1,
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--border-light)',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(19, 94, 94, 0.12), rgba(212, 171, 46, 0.18))',
|
||||
background: project.thumbnail
|
||||
? undefined
|
||||
: 'linear-gradient(135deg, rgba(19, 94, 94, 0.12), rgba(212, 171, 46, 0.18))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.08em',
|
||||
@@ -94,7 +93,20 @@ function ProjectItem({
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Thumbnail Pending
|
||||
{project.thumbnail ? (
|
||||
<img
|
||||
src={project.thumbnail}
|
||||
alt={`${project.name} thumbnail`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
'Thumbnail Pending'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -129,38 +141,95 @@ function ProjectItem({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{project.techStack && project.techStack.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
{project.techStack.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '3px',
|
||||
background: 'var(--amber-light)',
|
||||
color: '#92400E',
|
||||
border: '1px solid var(--amber-border)',
|
||||
}}
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{project.techStack && project.techStack.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', minWidth: 0 }}>
|
||||
{project.techStack.slice(0, 3).map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '3px',
|
||||
background: 'var(--amber-light)',
|
||||
color: '#92400E',
|
||||
border: '1px solid var(--amber-border)',
|
||||
}}
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
{project.techStack.length > 3 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '3px 6px',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
+{project.techStack.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{project.skills && project.skills.length > 0 && (
|
||||
<div
|
||||
className="skills-tags"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
justifyContent: 'flex-end',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{project.skills.slice(0, 2).map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '3px',
|
||||
background: 'rgba(13,148,136,0.08)',
|
||||
color: '#0D9488',
|
||||
border: '1px solid rgba(13,148,136,0.2)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{project.skills.length > 2 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '3px 6px',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
+{project.skills.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectsTile() {
|
||||
export function ProjectsCarousel() {
|
||||
const { openPanel } = useDetailPanel()
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -250,7 +319,7 @@ export function ProjectsTile() {
|
||||
|
||||
const cardsPerView = useMemo(() => {
|
||||
if (viewportWidth < 768) {
|
||||
return 1
|
||||
return 2
|
||||
}
|
||||
return 4
|
||||
}, [viewportWidth])
|
||||
@@ -275,25 +344,12 @@ export function ProjectsTile() {
|
||||
return 214
|
||||
}, [viewportWidth])
|
||||
|
||||
const thumbnailHeight = useMemo(() => {
|
||||
if (viewportWidth < 640) {
|
||||
return 62
|
||||
}
|
||||
if (viewportWidth < 1024) {
|
||||
return 68
|
||||
}
|
||||
if (viewportWidth < 1440) {
|
||||
return 76
|
||||
}
|
||||
return 84
|
||||
}, [viewportWidth])
|
||||
|
||||
const setPaused = (value: boolean) => {
|
||||
isPausedRef.current = value
|
||||
}
|
||||
|
||||
return (
|
||||
<Card full tileId="projects">
|
||||
<div style={{ marginTop: '28px' }}>
|
||||
<CardHeader dotColor="amber" title="SIGNIFICANT INTERVENTIONS" />
|
||||
|
||||
<div
|
||||
@@ -329,7 +385,6 @@ export function ProjectsTile() {
|
||||
project={project}
|
||||
slideWidth={slideWidth}
|
||||
cardMinHeight={cardMinHeight}
|
||||
thumbnailHeight={thumbnailHeight}
|
||||
onClick={() => openPanel({ type: 'project', investigation: project })}
|
||||
/>
|
||||
))}
|
||||
@@ -337,6 +392,6 @@ export function ProjectsTile() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+13071
-11904
File diff suppressed because it is too large
Load Diff
+50
-17
@@ -3,20 +3,24 @@ import type { Investigation } from '@/types/pmr'
|
||||
export const investigations: Investigation[] = [
|
||||
{
|
||||
id: 'inv-pharmetrics',
|
||||
name: 'PharMetrics Switching Dashboard',
|
||||
name: 'PharMetrics',
|
||||
requestedYear: 2025,
|
||||
reportedYear: 2025,
|
||||
status: 'Live',
|
||||
resultSummary: 'Live at medicines.charlwood.xyz',
|
||||
requestingClinician: 'A. Charlwood',
|
||||
methodology: 'Dashboard tracking patient-level switching data from the PharMetrics algorithm, monitoring which patients have been switched with quality metrics providing points for each intervention. Enables practices to monitor their own progress against the switching scheme.',
|
||||
methodology: 'First TypeScript project. Interactive health economics educational platform combining clinical trial statistics, health economics modules, and game-based learning. Features a risk calculator (ARR, RRR, NNT), five health economics education modules (NNT cost analysis, QALY, ICER with NICE thresholds, sensitivity analysis with Monte Carlo simulations, and budget impact analysis), and two educational games: Placebo Playground (a p-hacking simulator demonstrating how study parameters can be manipulated to achieve statistical significance) and Medical Trials Tycoon (a pharmaceutical business simulation exploring ethical trade-offs between patient outcomes and profit).',
|
||||
results: [
|
||||
'Patient-level switching progress tracking',
|
||||
'Practice-level self-serve monitoring',
|
||||
'Quality metrics and points-based tracking per patient intervention',
|
||||
'Risk calculator with interactive ARR, RRR, and NNT visualisations',
|
||||
'Five health economics modules covering NNT, QALY, ICER, sensitivity analysis, and budget impact',
|
||||
'P-hacking game teaching research manipulation through parameter adjustment',
|
||||
'Pharma business simulation with ethical decision-making and clinical trial mechanics',
|
||||
'Monte Carlo simulations and tornado charts for sensitivity analysis',
|
||||
],
|
||||
techStack: ['Power BI', 'SQL', 'DAX'],
|
||||
techStack: ['React', 'TypeScript', 'Vite', 'Zustand', 'Recharts', 'D3', 'Styled Components'],
|
||||
skills: ['Health Economics', 'Data Visualisation', 'Educational Design'],
|
||||
externalUrl: 'https://medicines.charlwood.xyz',
|
||||
thumbnail: '/thumbnails/pharmmetrics.jpg',
|
||||
},
|
||||
{
|
||||
id: 'inv-switching-algorithm',
|
||||
@@ -34,6 +38,7 @@ export const investigations: Investigation[] = [
|
||||
'Novel GP payment system linking rewards to savings',
|
||||
],
|
||||
techStack: ['Python', 'Pandas', 'SQL'],
|
||||
skills: ['Health Economics', 'Medicines Optimisation', 'Prescribing Analytics'],
|
||||
},
|
||||
{
|
||||
id: 'inv-blueteq-gen',
|
||||
@@ -51,6 +56,8 @@ export const investigations: Investigation[] = [
|
||||
'Integrated with secondary care activity databases',
|
||||
],
|
||||
techStack: ['Python', 'SQL'],
|
||||
skills: ['High-Cost Drugs', 'Prior Approval', 'Process Automation'],
|
||||
thumbnail: '/thumbnails/blueteq.jpg',
|
||||
},
|
||||
{
|
||||
id: 'inv-cd-monitoring',
|
||||
@@ -68,22 +75,48 @@ export const investigations: Investigation[] = [
|
||||
'Previously impossible population-scale analysis',
|
||||
],
|
||||
techStack: ['Python', 'SQL'],
|
||||
skills: ['Controlled Drugs', 'Patient Safety', 'Prescribing Analytics'],
|
||||
},
|
||||
{
|
||||
id: 'inv-sankey-tool',
|
||||
name: 'Sankey Chart Analysis Tool',
|
||||
requestedYear: 2023,
|
||||
reportedYear: 2023,
|
||||
id: 'inv-nms-training',
|
||||
name: 'NMS National Training Video',
|
||||
requestedYear: 2018,
|
||||
reportedYear: 2018,
|
||||
status: 'Complete',
|
||||
resultSummary: 'Pathway audit capability',
|
||||
resultSummary: 'Shared nationally across Tesco Pharmacy',
|
||||
requestingClinician: 'A. Charlwood',
|
||||
methodology: 'Python-based visualisation tool for patient journey mapping through high-cost drug pathways, enabling trust-level compliance auditing.',
|
||||
methodology: 'Self-produced training video covering the full New Medicine Service workflow — the three-stage consultation process (Engagement, Intervention, Follow-up), eligibility criteria for target conditions (asthma/COPD, hypertension, anticoagulation, type 2 diabetes), and practical techniques like the "star" prescription marking system. Features a patient case study demonstrating how NMS intervention corrected inhaler technique, and presents adherence data showing non-adherence halving from 20% to 10%. Created independently to address inconsistent NMS delivery across stores.',
|
||||
results: [
|
||||
'Visual patient pathway representation',
|
||||
'Trust compliance auditing capability',
|
||||
'Improvement opportunity identification',
|
||||
'Multi-specialty pathway coverage',
|
||||
'Shared across Tesco Pharmacy nationally to support delivery of the New Medicine Service',
|
||||
'Empowered non-pharmacist staff to identify and enrol eligible patients',
|
||||
'Improved consistency and quality of NMS engagement from non-pharmacist staff',
|
||||
'Supported successful uplift in NMS performance metrics across stores',
|
||||
|
||||
],
|
||||
techStack: ['Python', 'Matplotlib', 'SQL'],
|
||||
techStack: ['Video Production'],
|
||||
skills: ['Training & Development', 'Clinical Services', 'Leadership'],
|
||||
externalUrl: 'https://www.youtube.com/watch?v=Rm1wcX92XlQ',
|
||||
thumbnail: '/thumbnails/nms.jpg',
|
||||
},
|
||||
{
|
||||
id: 'inv-pathway-analysis',
|
||||
name: 'Patient Pathway Analysis Platform',
|
||||
requestedYear: 2023,
|
||||
reportedYear: 2024,
|
||||
status: 'Complete',
|
||||
resultSummary: '9 interactive chart types, sub-50ms responses',
|
||||
requestingClinician: 'A. Charlwood',
|
||||
methodology: 'Interactive Dash web application for analysing high-cost drug patient pathways. Features a Snowflake→SQLite pre-computation pipeline feeding 9 interactive Plotly chart types including hierarchical icicle charts with Trust→Directorate→Drug→Pathway drill-down. Achieves ~93% GP diagnosis matching via SNOMED cluster mapping. Packaged as a standalone desktop application via PyWebView for secure NHS deployment without browser dependencies.',
|
||||
results: [
|
||||
'9 interactive chart types for pathway analysis',
|
||||
'Sub-50ms response times via pre-computed SQLite pipeline',
|
||||
'~93% GP diagnosis match rate using SNOMED clusters',
|
||||
'Standalone desktop packaging via PyWebView',
|
||||
'Trust-level compliance auditing across all high-cost drug pathways',
|
||||
],
|
||||
techStack: ['Python', 'Dash', 'Plotly', 'Pandas', 'NumPy', 'SQLite', 'Snowflake', 'PyWebView'],
|
||||
skills: ['Health Economics', 'Clinical Pathways', 'Medicines Optimisation', 'Data Visualisation', 'NHS Secondary Care', 'Patient Safety'],
|
||||
demoUrl: 'https://demo.charlwood.xyz',
|
||||
thumbnail: '/thumbnails/pathways.jpg',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -61,8 +61,8 @@ Completed pre-registration training across multiple community pharmacy sites in
|
||||
|
||||
## Projects
|
||||
|
||||
### [proj-inv-pharmetrics] PharMetrics Switching Dashboard (2025, Live)
|
||||
Dashboard tracking patient-level switching data from the PharMetrics algorithm, monitoring which patients have been switched with quality metrics providing points for each intervention. Enables practices to monitor their own progress against the switching scheme. Tech: Power BI, SQL, DAX.
|
||||
### [proj-inv-pharmetrics] PharMetrics (2025, Live)
|
||||
First TypeScript project. Interactive health economics educational platform combining clinical trial statistics, health economics modules, and game-based learning. Features a risk calculator (ARR, RRR, NNT), five health economics education modules (NNT cost analysis, QALY, ICER with NICE thresholds, sensitivity analysis with Monte Carlo simulations, and budget impact analysis), and two educational games: a p-hacking simulator and a pharmaceutical business simulation exploring ethical trade-offs. Tech: React, TypeScript, Vite, Zustand, Recharts, D3, Styled Components.
|
||||
|
||||
### [proj-inv-switching-algorithm] Patient Switching Algorithm (2025, Complete)
|
||||
Annual medicines switching schemes previously required months of manual data trawling by the optimisation team. This Python algorithm ingests real-world GP prescribing data, cross-references dm+d product information, and automatically identifies patients on expensive drugs who could be switched to cost-effective alternatives, with built-in clinical safety rules. Tech: Python, Pandas, SQL. 14,000 patients identified, £2.6M annual savings, novel GP payment system linking incentives to delivered savings.
|
||||
|
||||
@@ -8,7 +8,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Twice daily',
|
||||
startYear: 2016,
|
||||
yearsOfExperience: 9,
|
||||
proficiency: 95,
|
||||
category: 'Technical',
|
||||
status: 'Active',
|
||||
icon: 'BarChart3',
|
||||
@@ -25,7 +24,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Daily',
|
||||
startYear: 2017,
|
||||
yearsOfExperience: 8,
|
||||
proficiency: 90,
|
||||
category: 'Technical',
|
||||
status: 'Active',
|
||||
icon: 'Code2',
|
||||
@@ -43,7 +41,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Daily',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 88,
|
||||
category: 'Technical',
|
||||
status: 'Active',
|
||||
icon: 'Database',
|
||||
@@ -60,7 +57,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2020,
|
||||
yearsOfExperience: 5,
|
||||
proficiency: 92,
|
||||
category: 'Technical',
|
||||
status: 'Active',
|
||||
icon: 'PieChart',
|
||||
@@ -77,7 +73,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'When required',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 70,
|
||||
category: 'Technical',
|
||||
status: 'Active',
|
||||
icon: 'FileCode2',
|
||||
@@ -93,7 +88,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Daily',
|
||||
startYear: 2016,
|
||||
yearsOfExperience: 9,
|
||||
proficiency: 85,
|
||||
category: 'Technical',
|
||||
status: 'Active',
|
||||
icon: 'Sheet',
|
||||
@@ -104,7 +98,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 82,
|
||||
category: 'Technical',
|
||||
status: 'Active',
|
||||
icon: 'GitBranch',
|
||||
@@ -120,7 +113,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2023,
|
||||
yearsOfExperience: 2,
|
||||
proficiency: 75,
|
||||
category: 'Technical',
|
||||
status: 'Active',
|
||||
icon: 'Workflow',
|
||||
@@ -138,7 +130,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Twice daily',
|
||||
startYear: 2016,
|
||||
yearsOfExperience: 9,
|
||||
proficiency: 95,
|
||||
category: 'Domain',
|
||||
status: 'Active',
|
||||
icon: 'Pill',
|
||||
@@ -155,7 +146,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Daily',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 90,
|
||||
category: 'Domain',
|
||||
status: 'Active',
|
||||
icon: 'Users',
|
||||
@@ -171,7 +161,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 92,
|
||||
category: 'Domain',
|
||||
status: 'Active',
|
||||
icon: 'FileCheck',
|
||||
@@ -187,7 +176,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 80,
|
||||
category: 'Domain',
|
||||
status: 'Active',
|
||||
icon: 'TrendingUp',
|
||||
@@ -203,7 +191,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 88,
|
||||
category: 'Domain',
|
||||
status: 'Active',
|
||||
icon: 'Route',
|
||||
@@ -219,7 +206,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'When required',
|
||||
startYear: 2024,
|
||||
yearsOfExperience: 1,
|
||||
proficiency: 85,
|
||||
category: 'Domain',
|
||||
status: 'Active',
|
||||
icon: 'ShieldAlert',
|
||||
@@ -237,7 +223,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Daily',
|
||||
startYear: 2024,
|
||||
yearsOfExperience: 1,
|
||||
proficiency: 90,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'Banknote',
|
||||
@@ -253,7 +238,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Twice daily',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 88,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'Handshake',
|
||||
@@ -269,7 +253,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'When required',
|
||||
startYear: 2024,
|
||||
yearsOfExperience: 1,
|
||||
proficiency: 82,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'MessageSquare',
|
||||
@@ -284,7 +267,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Daily',
|
||||
startYear: 2017,
|
||||
yearsOfExperience: 8,
|
||||
proficiency: 85,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'UserPlus',
|
||||
@@ -300,7 +282,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2018,
|
||||
yearsOfExperience: 7,
|
||||
proficiency: 80,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'RefreshCw',
|
||||
@@ -311,7 +292,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2024,
|
||||
yearsOfExperience: 1,
|
||||
proficiency: 78,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'Calculator',
|
||||
@@ -322,7 +302,6 @@ export const skills: SkillMedication[] = [
|
||||
frequency: 'Twice weekly',
|
||||
startYear: 2024,
|
||||
yearsOfExperience: 1,
|
||||
proficiency: 85,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'Presentation',
|
||||
|
||||
@@ -23,6 +23,9 @@ const timelineEntitySeeds: TimelineEntity[] = [
|
||||
startYear: 2025,
|
||||
endYear: 2025,
|
||||
},
|
||||
band: '8c',
|
||||
employmentBasis: 'Temporary',
|
||||
contextNote: 'Temporary promotion · Returned to substantive post following organisational restructuring',
|
||||
description: 'Led strategic delivery of population health initiatives and data-driven medicines optimisation across Norfolk & Waveney ICS, reporting to Associate Director of Pharmacy with presentation accountability to Chief Medical Officer and system-level programme boards. Responsible for setting analytical priorities, directing the efficiency programme, and ensuring evidence-based recommendations reached executive decision-makers. Returned to substantive Deputy Head role following commencement of ICB-wide organisational consultation.',
|
||||
details: [
|
||||
'Identified and prioritised a £14.6M efficiency programme through comprehensive prescribing data analysis, targeting the highest-value, lowest-risk interventions across the integrated care system',
|
||||
@@ -85,6 +88,7 @@ const timelineEntitySeeds: TimelineEntity[] = [
|
||||
startYear: 2024,
|
||||
endYear: null,
|
||||
},
|
||||
band: '8b',
|
||||
description: 'Driving data analytics strategy for medicines optimisation, developing bespoke datasets and analytical frameworks from messy, real-world GP prescribing data to identify efficiency opportunities and address health inequalities across the integrated care system.',
|
||||
details: [
|
||||
'Managed £220M prescribing budget with sophisticated forecasting models identifying cost pressures and enabling proactive financial planning for ICB board reporting',
|
||||
@@ -142,7 +146,7 @@ const timelineEntitySeeds: TimelineEntity[] = [
|
||||
{
|
||||
id: 'high-cost-drugs-2022',
|
||||
kind: 'career',
|
||||
title: 'High-Cost Drugs & Interface Pharmacist',
|
||||
title: 'High-Cost Drug Pharmacist',
|
||||
graphLabel: 'HCD Pharm',
|
||||
organization: 'NHS Norfolk & Waveney ICB',
|
||||
orgColor: '#005EB8',
|
||||
@@ -153,6 +157,7 @@ const timelineEntitySeeds: TimelineEntity[] = [
|
||||
startYear: 2022,
|
||||
endYear: 2024,
|
||||
},
|
||||
band: '8a',
|
||||
description: 'Led implementation of NICE technology appraisals and high-cost drug pathways across the ICS. Wrote most of the system\'s high-cost drug pathways spanning rheumatology, ophthalmology (wet AMD, DMO, RVO), dermatology, gastroenterology, neurology, and migraine — balancing legal requirements to implement TAs against financial costs and local clinical preferences. Engaged clinical leads across all sectors of care to agree pathways and secure system-wide adoption.',
|
||||
details: [
|
||||
'Developed software automating Blueteq prior authorisation form creation: 70% reduction in required forms, 200 hours immediate savings, and ongoing 7 to 8 hours weekly efficiency gains',
|
||||
@@ -430,6 +435,9 @@ function mapTimelineToConsultation(entity: TimelineEntity): Consultation {
|
||||
role: entity.title,
|
||||
duration: entity.dateRange.display,
|
||||
isCurrent: entity.dateRange.end === null,
|
||||
band: entity.band,
|
||||
contextNote: entity.contextNote,
|
||||
employmentBasis: entity.employmentBasis,
|
||||
history: entity.description,
|
||||
examination: entity.details,
|
||||
plan: entity.outcomes ?? [],
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
const sectionTileMap: Record<string, string> = {
|
||||
'patient-summary': 'overview',
|
||||
'projects': 'projects',
|
||||
'section-experience': 'experience',
|
||||
'section-education': 'education',
|
||||
'section-skills': 'skills',
|
||||
}
|
||||
|
||||
const SCROLL_BOTTOM_THRESHOLD = 40
|
||||
|
||||
/**
|
||||
* Hook to track which section is currently visible using IntersectionObserver.
|
||||
* Observes tiles by their data-tile-id attribute inside main scroll content.
|
||||
* Includes a scroll-position safety net: when scrolled to the very top,
|
||||
* activates 'overview'; when scrolled to the very bottom, activates the
|
||||
* last mapped section ('skills').
|
||||
*
|
||||
* @returns The currently active section ID
|
||||
*/
|
||||
export function useActiveSection(): string {
|
||||
const [activeSection, setActiveSection] = useState<string>('overview')
|
||||
const scrollOverrideRef = useRef<string | null>(null)
|
||||
|
||||
const updateFromScroll = useCallback((root: HTMLElement) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = root
|
||||
const atBottom = scrollHeight - scrollTop - clientHeight <= SCROLL_BOTTOM_THRESHOLD
|
||||
const atTop = scrollTop <= SCROLL_BOTTOM_THRESHOLD
|
||||
|
||||
if (atTop) {
|
||||
scrollOverrideRef.current = 'overview'
|
||||
setActiveSection('overview')
|
||||
} else if (atBottom) {
|
||||
scrollOverrideRef.current = 'skills'
|
||||
setActiveSection('skills')
|
||||
} else {
|
||||
scrollOverrideRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const tiles = Array.from(
|
||||
@@ -27,6 +47,8 @@ export function useActiveSection(): string {
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (scrollOverrideRef.current) return
|
||||
|
||||
const visibleEntries = entries.filter((entry) => entry.isIntersecting)
|
||||
if (visibleEntries.length === 0) return
|
||||
|
||||
@@ -48,11 +70,16 @@ export function useActiveSection(): string {
|
||||
|
||||
tiles.forEach((tile) => observer.observe(tile))
|
||||
|
||||
const handleScroll = () => updateFromScroll(root)
|
||||
root.addEventListener('scroll', handleScroll, { passive: true })
|
||||
handleScroll()
|
||||
|
||||
return () => {
|
||||
tiles.forEach((tile) => observer.unobserve(tile))
|
||||
observer.disconnect()
|
||||
root.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [])
|
||||
}, [updateFromScroll])
|
||||
|
||||
return activeSection
|
||||
}
|
||||
|
||||
+30
-1
@@ -332,7 +332,9 @@ html {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.latest-results-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
}
|
||||
@@ -341,7 +343,7 @@ html {
|
||||
/* Dashboard card grid responsive — mobile-first */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -486,6 +488,12 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== CONSTELLATION FULLSCREEN ANIMATION ===== */
|
||||
@keyframes constellation-fullscreen-in {
|
||||
from { transform: scale(0.95); opacity: 0.8; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ===== FOCUS VISIBLE STYLES (WCAG Compliance) ===== */
|
||||
/* Default focus ring for all focusable elements */
|
||||
*:focus-visible {
|
||||
@@ -539,6 +547,14 @@ textarea:focus-visible {
|
||||
width: var(--panel-wide);
|
||||
}
|
||||
|
||||
/* Desktop: cap panel width at 33% */
|
||||
@media (min-width: 1025px) {
|
||||
.detail-panel[data-width="narrow"],
|
||||
.detail-panel[data-width="wide"] {
|
||||
max-width: 33vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: both narrow and wide become full-width */
|
||||
@media (max-width: 767px) {
|
||||
.detail-panel[data-width="narrow"],
|
||||
@@ -547,6 +563,13 @@ textarea:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide skill tags on project cards at mobile */
|
||||
@media (max-width: 639px) {
|
||||
.skills-tags {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Disable pulse animation on status badge dot */
|
||||
@keyframes pulse {
|
||||
@@ -570,6 +593,12 @@ textarea:focus-visible {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Instant constellation fullscreen */
|
||||
@keyframes constellation-fullscreen-in {
|
||||
from { transform: none; opacity: 1; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
|
||||
/* Static login spinner indicator */
|
||||
.login-spinner {
|
||||
animation: none;
|
||||
|
||||
+3
-3
@@ -56,12 +56,12 @@ export function buildPaletteData(): PaletteItem[] {
|
||||
skills.forEach((skill) => {
|
||||
items.push({
|
||||
id: `skill-${skill.id}`,
|
||||
title: `${skill.name} \u2014 ${skill.proficiency}%`,
|
||||
title: skill.name,
|
||||
subtitle: `${skill.frequency} \u00b7 Since ${skill.startYear} \u00b7 ${skill.category}`,
|
||||
section: 'Core Skills',
|
||||
iconVariant: 'green',
|
||||
iconType: 'skill',
|
||||
keywords: `${skill.name.toLowerCase()} ${skill.proficiency} ${skill.frequency.toLowerCase()} ${skill.category.toLowerCase()}`,
|
||||
keywords: `${skill.name.toLowerCase()} ${skill.frequency.toLowerCase()} ${skill.category.toLowerCase()}`,
|
||||
action: { type: 'panel', panelContent: { type: 'skill', skill } },
|
||||
})
|
||||
})
|
||||
@@ -236,7 +236,7 @@ export function buildEmbeddingTexts(): Array<{ id: string; text: string }> {
|
||||
const context = skillContextMap[skill.id] ?? ''
|
||||
texts.push({
|
||||
id: `skill-${skill.id}`,
|
||||
text: `${skill.name} is a ${skill.category.toLowerCase()} skill used ${skill.frequency.toLowerCase()}, with ${skill.proficiency}% proficiency and ${skill.yearsOfExperience} years of experience since ${skill.startYear}. ${context}`,
|
||||
text: `${skill.name} is a ${skill.category.toLowerCase()} skill used ${skill.frequency.toLowerCase()}, with ${skill.yearsOfExperience} years of experience since ${skill.startYear}. ${context}`,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ function cosineSimilarity(a: number[], b: number[]): number {
|
||||
export function semanticSearch(
|
||||
queryEmbedding: number[],
|
||||
embeddings: EmbeddingEntry[],
|
||||
threshold = 0.3
|
||||
threshold = 0.15
|
||||
): SearchResult[] {
|
||||
return embeddings
|
||||
.map(entry => ({
|
||||
|
||||
+9
-1
@@ -27,6 +27,9 @@ export interface TimelineEntity {
|
||||
outcomes?: string[]
|
||||
codedEntries?: CodedEntry[]
|
||||
skillStrengths?: Record<string, number>
|
||||
band?: string
|
||||
contextNote?: string
|
||||
employmentBasis?: string
|
||||
}
|
||||
|
||||
export interface Consultation {
|
||||
@@ -37,6 +40,9 @@ export interface Consultation {
|
||||
role: string
|
||||
duration: string
|
||||
isCurrent: boolean
|
||||
band?: string
|
||||
contextNote?: string
|
||||
employmentBasis?: string
|
||||
history: string
|
||||
examination: string[]
|
||||
plan: string[]
|
||||
@@ -59,7 +65,10 @@ export interface Investigation {
|
||||
methodology: string
|
||||
results: string[]
|
||||
techStack: string[]
|
||||
skills?: string[]
|
||||
externalUrl?: string
|
||||
demoUrl?: string
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
export type DocumentType = 'Certificate' | 'Registration' | 'Results' | 'Research'
|
||||
@@ -122,7 +131,6 @@ export interface SkillMedication {
|
||||
frequency: string
|
||||
startYear: number
|
||||
yearsOfExperience: number
|
||||
proficiency: number
|
||||
category: 'Technical' | 'Domain' | 'Leadership'
|
||||
status: 'Active' | 'Historical'
|
||||
icon: string
|
||||
|
||||
Reference in New Issue
Block a user