Substantial refinement/polish on content of webpage (not just structural/coding elements)

This commit is contained in:
2026-02-17 14:05:32 +00:00
parent 38e40d36c0
commit 82db5fda54
98 changed files with 19572 additions and 22192 deletions
+1 -5
View File
@@ -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">
+3 -6
View File
@@ -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"
+3 -51
View File
@@ -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}
/>
+2 -5
View File
@@ -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',
-207
View File
@@ -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>
)
}
+102 -28
View File
@@ -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>
)
-45
View File
@@ -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>
+1 -41
View File
@@ -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}
+5 -1
View File
@@ -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>
)
}
+107 -52
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+50 -17
View File
@@ -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',
},
]
+2 -2
View File
@@ -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.
-21
View File
@@ -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',
+9 -1
View File
@@ -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 ?? [],
+31 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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}`,
})
})
+1 -1
View File
@@ -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
View File
@@ -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