chore: auto-commit before merge (loop primary)

This commit is contained in:
2026-02-18 00:42:07 +00:00
parent 62c0d2ea19
commit 134e41f4f9
19 changed files with 925 additions and 349 deletions
+13 -2
View File
@@ -44,12 +44,23 @@ function SkipButton({ onSkip }: { onSkip: () => void }) {
}
function App() {
const [phase, setPhase] = useState<Phase>('boot')
const [phase, setPhase] = useState<Phase>(() => {
if (typeof window !== 'undefined' && sessionStorage.getItem('portfolio-visited')) {
return 'pmr'
}
return 'boot'
})
useEffect(() => {
initModel()
}, [])
useEffect(() => {
if (phase === 'pmr') {
sessionStorage.setItem('portfolio-visited', '1')
}
}, [phase])
const skipToDashboard = () => setPhase('pmr')
return (
@@ -78,7 +89,7 @@ function App() {
<LoginScreen onComplete={() => setPhase('pmr')} />
)}
{phase === 'boot' && (
{(phase === 'boot' || phase === 'login') && (
<SkipButton onSkip={skipToDashboard} />
)}
</div>
+3 -3
View File
@@ -59,7 +59,7 @@ interface TypedLine {
// Global speed multiplier for typing animation.
// 1.0 = default (~3.3s typing). Lower = faster, higher = slower.
const TYPING_SPEED = 2
const TYPING_SPEED = 1.0
const COLORS = {
bright: '#00ff41',
@@ -87,8 +87,8 @@ const BOOT_CONFIG: BootConfig = {
timing: {
lineDelay: 220,
cursorBlinkInterval: 300,
holdAfterComplete: 600,
loadingDuration: 1200,
holdAfterComplete: 200,
loadingDuration: 600,
fadeOutDuration: 500,
cursorShrinkDuration: 400,
},
+37
View File
@@ -299,6 +299,43 @@ export function DashboardLayout() {
paddingBottom: isMobileNav ? 'calc(56px + env(safe-area-inset-bottom) + 16px)' : undefined,
}}
>
{isMobileNav && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
background: 'var(--accent)',
borderRadius: 'var(--radius-sm)',
marginBottom: '12px',
}}
>
<div>
<div
style={{
fontSize: '14px',
fontWeight: 700,
color: '#FFFFFF',
letterSpacing: '0.04em',
fontFamily: 'var(--font-ui)',
}}
>
CHARLWOOD, Andrew
</div>
<div
style={{
fontSize: '11px',
color: 'rgba(255,255,255,0.75)',
fontFamily: 'var(--font-geist-mono)',
letterSpacing: '0.02em',
}}
>
Informatics Pharmacist · NHS Norfolk & Waveney ICB
</div>
</div>
</div>
)}
<div className="dashboard-grid">
{/* PatientSummaryTile — full width (includes Latest Results subsection) */}
<div ref={patientSummaryRef}>
+5 -3
View File
@@ -62,7 +62,7 @@ function getDotColor(content: DetailPanelContent): CardHeaderProps['dotColor'] {
}
export function DetailPanel() {
const { content, closePanel, isOpen } = useDetailPanel()
const { content, closePanel, isOpen, isClosing } = useDetailPanel()
const panelRef = useRef<HTMLDivElement>(null)
const titleId = 'detail-panel-title'
@@ -83,7 +83,7 @@ export function DetailPanel() {
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, closePanel])
if (!isOpen || !content) return null
if ((!isOpen && !isClosing) || !content) return null
const width = widthMap[content.type]
const title = getPanelTitle(content)
@@ -101,6 +101,8 @@ export function DetailPanel() {
backdropFilter: 'blur(var(--backdrop-blur))',
zIndex: 1000,
animation: 'backdrop-fade-in 150ms ease-out',
opacity: isClosing ? 0 : 1,
transition: 'opacity 200ms ease-out',
}}
onClick={closePanel}
aria-hidden="true"
@@ -124,7 +126,7 @@ export function DetailPanel() {
zIndex: 1001,
display: 'flex',
flexDirection: 'column',
animation: 'panel-slide-in 250ms ease-out',
animation: isClosing ? 'panel-slide-out 250ms ease-in forwards' : 'panel-slide-in 250ms ease-out',
}}
>
{/* Header */}
+2 -42
View File
@@ -86,7 +86,7 @@ export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: Las
display: 'flex',
flexWrap: 'wrap',
gap: '20px',
marginBottom: '1=px',
marginBottom: '10px',
paddingBottom: '14px',
borderBottom: '1px solid var(--border-light)',
cursor: 'pointer',
@@ -126,52 +126,12 @@ export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: Las
fontSize: '15px',
fontWeight: 600,
color: consultation.orgColor ?? 'var(--accent)',
marginBottom: '12px',
marginBottom: '4px',
}}
>
{consultation.role}
</div>
<ul
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '7px',
marginBottom: '0px',
}}
>
{consultation.examination.map((bullet, index) => (
<li
key={index}
style={{
fontSize: '14px',
color: 'var(--text-primary)',
paddingLeft: '16px',
lineHeight: '1.5',
position: 'relative',
}}
>
<span
aria-hidden="true"
style={{
position: 'absolute',
left: '0',
top: '8px',
width: '5px',
height: '5px',
borderRadius: '50%',
backgroundColor: consultation.orgColor ?? 'var(--accent)',
opacity: 0.5,
}}
/>
{bullet}
</li>
))}
</ul>
<button
onClick={handleOpenPanel}
style={{
+6 -6
View File
@@ -54,11 +54,11 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
setIsLoading(true)
addTimeout(() => {
setIsExiting(true)
// After dissolve completes (~600ms), remove overlay and reveal dashboard
// After dissolve completes (~400ms), remove overlay and reveal dashboard
addTimeout(() => {
requestFocusAfterLogin()
onComplete()
}, prefersReducedMotion ? 0 : 600)
}, prefersReducedMotion ? 0 : 400)
}, prefersReducedMotion ? 0 : 600)
}, 100)
}, [canLogin, isExiting, isLoading, onComplete, requestFocusAfterLogin, prefersReducedMotion, addTimeout])
@@ -100,10 +100,10 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
setTypingComplete(true)
// Button becomes interactive — user clicks to proceed
}
}, 60)
}, 300)
}, 40)
}, 200)
}
}, 80)
}, 55)
}, [prefersReducedMotion, addTimeout])
// Focus the login button when login becomes available for keyboard accessibility
@@ -147,7 +147,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
// Full motion: 400ms card entrance + 1000ms logo animation + 100ms pause = 1500ms
const startTimeout = addTimeout(() => {
startLoginSequence()
}, prefersReducedMotion ? 400 : 1500)
}, prefersReducedMotion ? 400 : 600)
// Capture ref value for cleanup
const pendingTimeouts = timeoutRefs.current
@@ -62,6 +62,24 @@ function TimelineInterventionItem({
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
{interventionLabel}
</span>
{entity.dateRange.end === null && (
<span
style={{
fontSize: '9px',
fontWeight: 700,
fontFamily: 'var(--font-geist-mono)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
padding: '2px 7px',
borderRadius: '9999px',
background: 'rgba(34, 197, 94, 0.12)',
color: '#16a34a',
border: '1px solid rgba(34, 197, 94, 0.3)',
}}
>
Current
</span>
)}
<div
style={{
fontSize: '14px',
+82 -5
View File
@@ -1,12 +1,13 @@
import React from 'react'
import { FileText, ChevronRight } from 'lucide-react'
import { FileText, ChevronRight, Mail, Linkedin, Github, Download } from 'lucide-react'
import { CardHeader } from '../Card'
import { ParentSection } from '../ParentSection'
import { kpis } from '@/data/kpis'
import type { KPI } from '@/types/pmr'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { getLatestResultsCopy, getProfileSectionTitle, getProfileSummaryText } from '@/lib/profile-content'
import { getLatestResultsCopy, getProfileSectionTitle, getStructuredProfile } from '@/lib/profile-content'
import { KPI_COLORS } from '@/lib/theme-colors'
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
import { ProjectsCarousel } from './ProjectsTile'
interface MetricCardProps {
@@ -107,10 +108,37 @@ function MetricCard({ kpi }: MetricCardProps) {
)
}
const ACTION_LINKS = [
{ label: 'Email', href: 'mailto:andy@charlwood.xyz', icon: Mail, external: false },
{ label: 'LinkedIn', href: 'https://linkedin.com/in/andycharlwood', icon: Linkedin, external: true },
{ label: 'GitHub', href: 'https://github.com/andycharlwood', icon: Github, external: true },
{ label: 'Download CV', href: '/References/CV_v4.md', icon: Download, external: true },
] as const
const actionButtonStyles: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: 600,
fontFamily: 'var(--font-geist-mono)',
letterSpacing: '0.03em',
textTransform: 'uppercase',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--accent)',
cursor: 'pointer',
transition: 'background-color 150ms, border-color 150ms',
textDecoration: 'none',
}
export function PatientSummaryTile() {
const summaryText = getProfileSummaryText()
const structuredProfile = getStructuredProfile()
const latestResultsCopy = getLatestResultsCopy()
const sectionTitle = getProfileSectionTitle()
const isMobile = useIsMobileNav()
const profileTextStyles: React.CSSProperties = {
fontSize: '15px',
@@ -118,6 +146,30 @@ export function PatientSummaryTile() {
color: 'var(--text-primary)',
}
const fieldsGridStyles: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'auto 1fr',
gap: isMobile ? '2px 0' : '6px 16px',
borderTop: '1px solid var(--border-light)',
paddingTop: '14px',
marginTop: '14px',
}
const fieldLabelStyles: React.CSSProperties = {
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
fontFamily: 'var(--font-geist-mono)',
}
const fieldValueStyles: React.CSSProperties = {
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: isMobile ? '8px' : undefined,
}
const kpiGridStyles: React.CSSProperties = {
display: 'grid',
gap: '10px',
@@ -125,8 +177,33 @@ export function PatientSummaryTile() {
return (
<ParentSection title={sectionTitle} tileId="patient-summary">
{/* Profile text */}
<div style={profileTextStyles}>{summaryText}</div>
{/* Presenting complaint */}
<div style={profileTextStyles}>{structuredProfile.presentingComplaint}</div>
{/* Structured profile fields */}
<div style={fieldsGridStyles}>
{structuredProfile.fields.map((field) => (
<React.Fragment key={field.label}>
<span style={fieldLabelStyles}>{field.label}</span>
<span style={fieldValueStyles}>{field.value}</span>
</React.Fragment>
))}
</div>
{/* Contact / CTA action bar */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '16px' }}>
{ACTION_LINKS.map((action) => (
<a
key={action.label}
href={action.href}
{...(action.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
style={actionButtonStyles}
>
<action.icon size={13} aria-hidden="true" />
{action.label}
</a>
))}
</div>
{/* Latest Results subsection */}
<div style={{ marginTop: '28px' }}>
+146 -35
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import useEmblaCarousel from 'embla-carousel-react'
import Autoplay from 'embla-carousel-autoplay'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { investigations } from '@/data/investigations'
import { CardHeader } from '../Card'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
@@ -168,6 +169,21 @@ function ProjectItem({
</span>
</div>
{project.resultSummary && (
<div
style={{
fontSize: '12px',
fontWeight: 700,
fontFamily: 'var(--font-geist-mono)',
color: 'var(--accent)',
letterSpacing: '-0.01em',
lineHeight: 1.3,
}}
>
{project.resultSummary}
</div>
)}
<div
style={{
display: 'flex',
@@ -391,6 +407,56 @@ function ContinuousScrollCarousel() {
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false,
)
const resumeTimeoutRef = useRef<number>(0)
const jumpByCards = useCallback((direction: 1 | -1) => {
const trackEl = trackRef.current
const firstSetEl = firstSetRef.current
if (!trackEl || !firstSetEl) return
const gap = 12
const cardsPerView = 4
const totalGap = (cardsPerView - 1) * gap
const cardWidth = (viewportWidth - totalGap) / cardsPerView
const jumpPx = cardWidth + gap
// Pause auto-scroll
isPausedRef.current = true
window.clearTimeout(resumeTimeoutRef.current)
// Apply CSS transition for smooth jump
if (!prefersReducedMotion) {
trackEl.style.transition = 'transform 0.4s ease'
}
// Calculate new offset
const setWidth = firstSetEl.offsetWidth
let newOffset = offsetRef.current + (direction * jumpPx)
if (setWidth > 0) {
newOffset = ((newOffset % setWidth) + setWidth) % setWidth
}
offsetRef.current = newOffset
trackEl.style.transform = `translate3d(-${newOffset}px, 0, 0)`
// Remove transition after completion
if (!prefersReducedMotion) {
const transitionEnd = () => {
trackEl.style.transition = ''
}
trackEl.addEventListener('transitionend', transitionEnd, { once: true })
}
// Resume auto-scroll after 6s
resumeTimeoutRef.current = window.setTimeout(() => {
isPausedRef.current = false
}, 6000)
}, [viewportWidth, prefersReducedMotion])
useEffect(() => {
return () => {
window.clearTimeout(resumeTimeoutRef.current)
}
}, [])
useEffect(() => {
const viewportEl = viewportRef.current
@@ -460,46 +526,91 @@ function ContinuousScrollCarousel() {
isPausedRef.current = value
}
const arrowStyle: React.CSSProperties = {
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: '50%',
cursor: 'pointer',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
color: 'var(--text-secondary)',
transition: 'opacity 150ms, background-color 150ms',
zIndex: 2,
opacity: 0.7,
padding: 0,
}
return (
<div
ref={viewportRef}
style={{ overflow: 'hidden' }}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setPaused(false)
}
}}
>
<div style={{ position: 'relative' }}>
<div
ref={trackRef}
style={{
display: 'flex',
width: 'max-content',
willChange: 'transform',
transform: 'translate3d(0, 0, 0)',
ref={viewportRef}
style={{ overflow: 'hidden' }}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setPaused(false)
}
}}
>
{[0, 1].map((setIndex) => (
<div
key={setIndex}
ref={setIndex === 0 ? firstSetRef : undefined}
style={{ display: 'flex', gap: '12px', paddingRight: '12px', flexShrink: 0 }}
>
{investigations.map((project) => (
<ProjectItem
key={`${setIndex}-${project.id}`}
project={project}
slideWidth={slideWidth}
cardMinHeight={cardMinHeight}
onClick={() => openPanel({ type: 'project', investigation: project })}
/>
))}
</div>
))}
<div
ref={trackRef}
style={{
display: 'flex',
width: 'max-content',
willChange: 'transform',
transform: 'translate3d(0, 0, 0)',
}}
>
{[0, 1].map((setIndex) => (
<div
key={setIndex}
ref={setIndex === 0 ? firstSetRef : undefined}
style={{ display: 'flex', gap: '12px', paddingRight: '12px', flexShrink: 0 }}
>
{investigations.map((project) => (
<ProjectItem
key={`${setIndex}-${project.id}`}
project={project}
slideWidth={slideWidth}
cardMinHeight={cardMinHeight}
onClick={() => openPanel({ type: 'project', investigation: project })}
/>
))}
</div>
))}
</div>
</div>
{/* Left arrow */}
<button
onClick={() => jumpByCards(-1)}
aria-label="Previous project"
style={{ ...arrowStyle, left: '-4px' }}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1' }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.7' }}
>
<ChevronLeft size={16} />
</button>
{/* Right arrow */}
<button
onClick={() => jumpByCards(1)}
aria-label="Next project"
style={{ ...arrowStyle, right: '-4px' }}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1' }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.7' }}
>
<ChevronRight size={16} />
</button>
</div>
)
}
+21 -6
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, useState, ReactNode } from 'react'
import { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react'
import { DetailPanelContent } from '@/types/pmr'
interface DetailPanelContextValue {
@@ -6,6 +6,7 @@ interface DetailPanelContextValue {
openPanel: (content: DetailPanelContent) => void
closePanel: () => void
isOpen: boolean
isClosing: boolean
}
const DetailPanelContext = createContext<DetailPanelContextValue | undefined>(
@@ -18,14 +19,27 @@ interface DetailPanelProviderProps {
export function DetailPanelProvider({ children }: DetailPanelProviderProps) {
const [content, setContent] = useState<DetailPanelContent | null>(null)
const [isClosing, setIsClosing] = useState(false)
const closeTimerRef = useRef<number>(0)
const openPanel = (newContent: DetailPanelContent) => {
const openPanel = useCallback((newContent: DetailPanelContent) => {
// If we're in the middle of closing, cancel it
if (closeTimerRef.current) {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = 0
}
setIsClosing(false)
setContent(newContent)
}
}, [])
const closePanel = () => {
setContent(null)
}
const closePanel = useCallback(() => {
setIsClosing(true)
closeTimerRef.current = window.setTimeout(() => {
setIsClosing(false)
setContent(null)
closeTimerRef.current = 0
}, 250) // match panel-slide-out duration
}, [])
const isOpen = content !== null
@@ -34,6 +48,7 @@ export function DetailPanelProvider({ children }: DetailPanelProviderProps) {
openPanel,
closePanel,
isOpen,
isClosing,
}
return (
+11 -1
View File
@@ -4,8 +4,18 @@ export const profileContent: DeepReadonly<ProfileContent> = {
profile: {
sectionTitle: 'Patient Summary',
patientSummaryNarrative: 'Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics, self-taught over the past decade through a drive to find root causes in data and build the most efficient solutions to complex problems. Currently leading population health analytics for NHS Norfolk & Waveney ICB, serving a population of 1.2 million. Experienced in working with messy, real-world prescribing data at scale to deliver actionable insights — from financial scenario modelling and pharmaceutical rebate negotiation to algorithm design and population-level pathway development. Proven track record of identifying and prioritising efficiency programmes worth £14.6M+ through automated, data-driven analysis. Skilled at translating complex clinical, financial, and analytical requirements into clear recommendations for executive stakeholders.',
structuredProfile: {
presentingComplaint: 'Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics. Currently leading population health analytics for NHS Norfolk & Waveney ICB, serving 1.2 million.',
fields: [
{ label: 'Specialisation', value: 'Population Health Analytics & Medicines Optimisation' },
{ label: 'Current System', value: 'NHS Norfolk & Waveney ICB' },
{ label: 'Population', value: '1.2 million' },
{ label: 'Focus Areas', value: 'Prescribing analytics, financial modelling, algorithm design, data pipelines' },
{ label: 'Key Achievement', value: '£14.6M+ efficiency programmes identified' },
],
},
latestResults: {
title: 'LATEST RESULTS (CLICK TO VIEW FULL REFERENCE RANGE)',
title: 'KEY METRICS',
rightText: 'Updated February 2026',
helperText: 'Select a metric to inspect methodology, impact, and outcomes.',
evidenceCta: 'Click to view evidence',
+1 -1
View File
@@ -103,7 +103,7 @@
--sidebar-bg: #F7FAFA;
--text-primary: #1A2B2A;
--text-secondary: #5B7A78;
--text-tertiary: #8DA8A5;
--text-tertiary: #6B8886;
--accent: #0D6E6E;
--accent-hover: #0A8080;
--accent-pressed: #085858;
+4 -5
View File
@@ -8,12 +8,9 @@ import type {
QuickActionCopyEntry,
SidebarCopy,
SkillsUICopy,
StructuredProfile,
} from '@/types/profile-content'
export function getProfileSummaryText(): string {
return profileContent.profile.patientSummaryNarrative
}
export function getProfileSectionTitle(): string {
return profileContent.profile.sectionTitle
}
@@ -46,4 +43,6 @@ export function getEducationEntries(): ReadonlyArray<EducationCopyEntry> {
return profileContent.experienceEducation.educationEntries
}
export function getStructuredProfile(): DeepReadonly<StructuredProfile> {
return profileContent.profile.structuredProfile
}
+11
View File
@@ -80,10 +80,21 @@ export interface SkillsUICopy {
readonly categories: ReadonlyArray<SkillsCategoryCopyEntry>
}
export interface StructuredProfileField {
readonly label: string
readonly value: string
}
export interface StructuredProfile {
readonly presentingComplaint: string
readonly fields: ReadonlyArray<StructuredProfileField>
}
export interface ProfileContent {
readonly profile: {
readonly sectionTitle: string
readonly patientSummaryNarrative: string
readonly structuredProfile: StructuredProfile
readonly latestResults: LatestResultsCopy
readonly sidebar: SidebarCopy
}