Compare commits

...

43 Commits

Author SHA1 Message Date
admin 192d629125 Completed login screen transition, and started the spec work on design file info 2026-02-11 22:15:29 +00:00
admin 1a1f1f1938 docs: mark Task 1 complete, update progress log 2026-02-11 20:49:58 +00:00
admin 93051021fc feat(pmr): configure design system foundation
Task 1: Design system foundation and font setup
- Add Geist Mono font to Google Fonts import for PMR coded entries and timestamps
- Extend PMR color tokens in Tailwind config (card, text variants, borders, alert colors)
- Update border-radius defaults: 4px for cards/inputs, 12px for login card
- Add PMR-specific CSS custom properties in index.css
- Add .pmr-theme, .font-inter, .font-geist-mono utility classes
- Add pmr shadow token (minimal clinical system shadow)

All PMR color tokens now match ref-design-system.md spec exactly.
2026-02-11 20:49:36 +00:00
admin a52cb9f84b docs: mark Task 16 complete, document project status as COMPLETE
- Reviewed build against goal.md design specification
- Documented known gaps as scope decisions:
  - Breadcrumb navigation (not essential)
  - Context menus (not essential)
  - Full search (nav-only sufficient)
  - Download CV (placeholder, PDF out of scope)
- All 16 implementation tasks complete
- Core PMR system fully functional
2026-02-11 03:24:43 +00:00
admin 06ebef80c1 feat(pmr): add interface materialization animations
- Login card fades out with scale animation (200ms)
- Patient banner slides down from top (200ms)
- Sidebar slides in from left (250ms, 50ms delay)
- Main content fades in (300ms, 150ms delay)
- Mobile nav slides up (200ms)
- All animations respect prefers-reduced-motion
- Mark Task 15 complete in IMPLEMENTATION_PLAN.md
2026-02-11 03:22:29 +00:00
admin ef5bc9c3a6 Ralph iteration 10: work in progress 2026-02-11 03:07:52 +00:00
admin ac113f23c7 docs: Mark Task 14 complete, update progress with responsive design iteration 2026-02-11 03:07:32 +00:00
admin 4ec108484e feat: Implement responsive design for tablet and mobile breakpoints
- Add useBreakpoint hook for responsive breakpoint detection
- Add MobileBottomNav component for mobile navigation
- Update ClinicalSidebar with tablet icon-only mode and tooltips
- Update PatientBanner with mobile minimal mode and overflow menu
- Update PMRInterface to handle responsive layouts and mobile search
- Add mobile card layouts to MedicationsView, ProblemsView,
  InvestigationsView, and DocumentsView
- Desktop: 220px sidebar, full banner, tables
- Tablet: 56px icon sidebar, condensed banner, scrollable tables
- Mobile: Bottom nav, minimal banner, card layouts, search bar
2026-02-11 03:07:25 +00:00
admin a7df2d0037 Ralph iteration 9: work in progress 2026-02-11 02:50:28 +00:00
admin f7f7e0db8c feat(a11y): Implement keyboard shortcuts and accessibility (Task 13)
- Create AccessibilityContext for global focus management and expanded state
- Add roving tabindex to sidebar with Up/Down/Enter/Home/End navigation
- Focus management: after login, after view change, after item expansion
- Global Escape closes expanded items across all views
- Add scope='col' to SummaryView table headers
- Add focus-after-expand to ConsultationsView
- Update ARIA roles: role='menu', role='menuitem', aria-current
2026-02-11 02:49:51 +00:00
admin fc3c0659b2 Ralph iteration 8: work in progress 2026-02-11 02:30:01 +00:00
admin 89e93b805e Mark Task 12 complete and update progress log 2026-02-11 02:28:40 +00:00
admin 4104dd32d8 Task 12: Build ReferralsView with clinical referral form
- Created ReferralsView component with clinical referral form UI
- Pre-filled patient info (CHARLWOOD, Andrew; NHS Number)
- Priority toggle: Urgent/Routine/Two-Week Wait with tooltips
- Form fields: Referrer Name/Email/Org, Reason textarea
- Contact method radio: Email/Phone/LinkedIn
- Form validation for required fields
- Loading state with spinner on submit
- Success state with REF-YYYY-MMDD-NNN reference number
- Direct Contact table with clickable email/phone/LinkedIn links
- Responsive two-column layout for form fields
- Consistent clinical system styling with NHS blue accents
2026-02-11 02:27:41 +00:00
admin 47afa9171e Ralph iteration 7: work in progress 2026-02-11 02:16:56 +00:00
admin f648e7a4fc Progress update: Mark Task 11 complete and log iteration 2026-02-11 02:14:55 +00:00
admin 315259f44e Task 11: Build DocumentsView for education/certifications 2026-02-11 02:11:55 +00:00
admin a0bc1e34f1 Ralph iteration 6: work in progress 2026-02-11 02:05:53 +00:00
admin e73a1c6cb8 Progress update: Mark Task 10 complete and log iteration 2026-02-11 02:05:38 +00:00
admin 53b633bfd7 Task 10: Build InvestigationsView with results panel
- Created InvestigationsView component with expandable rows
- Status badges: Complete (green), Ongoing (amber), Live (pulsing green)
- Tree-indented results panel with methodology, results, tech stack
- View Results button for PharMetrics linking to medicines.charlwood.xyz
- Proper semantic table markup with scope attributes
- Traffic lights always accompanied by text labels
- Accordion behavior: only one row expanded at a time
- Height animation 200ms ease-out for expand/collapse
- Respects prefers-reduced-motion
- Updated PMRInterface to include InvestigationsView
2026-02-11 02:04:27 +00:00
admin 3bce29efe4 Ralph iteration 5: work in progress 2026-02-11 01:58:48 +00:00
admin f20791a7ff feat: build ProblemsView with traffic light status system
- Create ProblemsView with two tables: Active Problems and Resolved Problems
- Traffic light indicators: 8px circles with text labels (green=Active/Resolved, amber=In Progress)
- Expandable rows showing full narrative and linked consultations
- Linked consultations navigate to Consultations view
- Proper semantic table markup with scope="col"
- Height animation for expand/collapse (200ms, respects reduced motion)
- Task 9 complete
2026-02-11 01:58:32 +00:00
admin 81e8fdf7c7 Ralph iteration 4: work in progress 2026-02-11 01:50:07 +00:00
admin 511691ee78 docs: mark Task 8 complete, update progress with iteration 8 learnings 2026-02-11 01:49:51 +00:00
admin 1e1ba2d6a4 feat: implement MedicationsView with sortable table and prescribing history
- Create MedicationsView component with three category tabs (Active, Clinical, PRN)
- Implement sortable columns with visual sort indicators
- Add expandable rows showing prescribing history timeline
- Use proper semantic table markup with scope attributes
- Add fadeIn animation for expanded content
- Traffic light status dots with text labels for accessibility
- Alternating row colors and hover states (#EFF6FF)
- Respects prefers-reduced-motion preference

Task 8 of Clinical Record PMR implementation
2026-02-11 01:48:49 +00:00
admin 59962776df Ralph iteration 3: work in progress 2026-02-11 01:42:12 +00:00
admin a521b2ff2d Ralph iteration 2: work in progress 2026-02-11 01:41:17 +00:00
admin 4272ca4dfe Task 7: Build ConsultationsView with History/Examination/Plan structure
- Create ConsultationsView with 5 expandable consultation entries
- Each entry has color-coded left border by employer (NHS blue vs Teal)
- Collapsed state shows date, org, role, key coded entry
- Expanded state shows Duration, HISTORY, EXAMINATION, PLAN, CODED ENTRIES
- Accordion behavior: only one entry expanded at a time
- Expand animation 200ms ease-out, respects reduced motion
- Section headers in uppercase with letter-spacing
- Coded entries in [XXX000] format with Geist Mono font
2026-02-11 01:40:56 +00:00
admin 4bf4d1171f Ralph iteration 1: work in progress 2026-02-11 01:35:05 +00:00
admin 8ca61c6afc docs: update progress for Task 6 completion 2026-02-11 01:34:04 +00:00
admin f40b98a6e5 feat: implement SummaryView with Clinical Alert (Task 6)
- Create SummaryView component with clinical alert banner
- Clinical alert: amber background, warning icon, acknowledge interaction
- Alert animates in with spring effect, dismisses with checkmark → collapse
- Demographics card: full width, two-column key-value layout
- Active Problems card: 3 active/in-progress items with traffic lights
- Current Medications Quick View: 4-column table showing top 5 skills
- Last Consultation card: preview of most recent role
- Add navigation handlers for view switching
- Respects prefers-reduced-motion for all animations
- Proper table semantics and accessibility attributes
2026-02-11 01:33:55 +00:00
admin f73c626421 Update progress: Mark Task 5 complete, add iteration 5 learnings 2026-02-11 01:17:32 +00:00
admin 4434c6e437 Task 5: Build ClinicalSidebar with navigation and search
- Create ClinicalSidebar component with 7 navigation items
- NHS blue active state with 3px left border
- Search input with basic filtering (fuse.js integration pending)
- Keyboard shortcuts Alt+1-7 for navigation
- URL hash routing (#summary, #consultations, etc.)
- Session footer with current time
- Create PMRInterface container component
- Update App.tsx to use 'pmr' phase instead of 'content'
2026-02-11 01:16:19 +00:00
admin 65fc23e79b Update progress: Mark Task 4 complete, add iteration 4 learnings 2026-02-11 01:05:40 +00:00
admin 8d26049b17 Task 4: Add PatientBanner component with full/condensed modes
- Create useScrollCondensation hook with IntersectionObserver
- PatientBanner with 80px full mode and 48px condensed mode
- Smooth height transition (200ms) on scroll past 100px
- Status dot (green) and badge for 'Open to opportunities'
- Action buttons: Download CV, Email, LinkedIn
- NHS blue outlined buttons with hover fill effect
- Proper GPhC number formatting with tooltip
- Sticky positioning for persistent visibility
2026-02-11 01:04:51 +00:00
admin de34ec48cc Ralph iteration 3: work in progress 2026-02-11 00:57:35 +00:00
admin a9300501fd Update progress: Mark Task 3 complete, log iteration 3 2026-02-11 00:57:16 +00:00
admin 8ee9046bb3 Task 3: Build LoginScreen component with typing animation
- Created LoginScreen.tsx with character-by-character username typing (30ms/char)
- Password dots fill at 20ms per dot
- Button shows pressed state before transition
- Added 'login' phase to App.tsx flow
- Added PMR colors and fonts to tailwind.config.js
- Added Inter font family to index.html
- Respects prefers-reduced-motion: instant completion in ~500ms
2026-02-11 00:54:48 +00:00
admin 02d7dcabd9 Ralph iteration 2: work in progress 2026-02-11 00:46:50 +00:00
admin 9ec62e9c12 Update progress: Task 2 complete, ECG flatline transition implemented 2026-02-11 00:46:18 +00:00
admin 2692e7ee86 Task 2: Modify ECGAnimation for PMR flatline transition
- Changed exit phase from fade-to-white to clinical flatline transition
- After name tracing: hold 300ms, draw flatline rightward 300ms
- Fade canvas to black 200ms, then transition background to #1E293B (login screen color)
- Total ECG phase timing preserved (~5-6 seconds), exit adds ~1 second
- Prepares for LoginScreen component in Task 3
2026-02-11 00:45:05 +00:00
admin 6a5de6ee33 Ralph iteration 1: work in progress 2026-02-11 00:37:48 +00:00
admin 9d078420bc docs: mark Task 1 complete and update progress log 2026-02-11 00:37:27 +00:00
admin 2033a93ecb feat(pmr): create PMR data layer and TypeScript types
- Add src/types/pmr.ts with interfaces for Patient, Consultation, Medication, Problem, Investigation, Document
- Add src/data/consultations.ts with 5 roles mapped to clinical consultation format
- Add src/data/medications.ts with 18 skills as medications across Active/Clinical/PRN categories
- Add src/data/problems.ts with 11 achievements/problems using traffic light status system
- Add src/data/investigations.ts with 5 projects as clinical investigations
- Add src/data/documents.ts with 5 education/certification documents
- Add src/data/patient.ts with patient demographic data

All data matches CV_v4.md exactly (dates, numbers, achievements).
Task 1 of 15 complete.
2026-02-11 00:37:20 +00:00
47 changed files with 8458 additions and 546 deletions
+494
View File
@@ -0,0 +1,494 @@
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
// ─── Heartbeat generation ────────────────────────────────────────────────────
function generateHeartbeatPoints(
amplitude: number,
): { x: number; y: number }[] {
const points: { x: number; y: number }[] = [];
const steps = 200;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
let y = 0;
if (t >= 0.05 && t < 0.2) {
const pt = (t - 0.05) / 0.15;
y = 0.12 * Math.sin(pt * Math.PI);
} else if (t >= 0.25 && t < 0.32) {
const pt = (t - 0.25) / 0.07;
y = -0.1 * Math.sin(pt * Math.PI);
} else if (t >= 0.32 && t < 0.42) {
const pt = (t - 0.32) / 0.1;
y = 1.0 * Math.sin(pt * Math.PI);
} else if (t >= 0.42 && t < 0.5) {
const pt = (t - 0.42) / 0.08;
y = -0.25 * Math.sin(pt * Math.PI);
} else if (t >= 0.55 && t < 0.75) {
const pt = (t - 0.55) / 0.2;
y = 0.2 * Math.sin(pt * Math.PI);
}
points.push({ x: t, y: y * amplitude });
}
return points;
}
type Beat = { startFrame: number; widthPx: number; amplitude: number };
function buildBeats(fps: number): Beat[] {
const beats: Beat[] = [];
beats.push({ startFrame: Math.round(0.6 * fps), widthPx: 60, amplitude: 0.25 });
beats.push({ startFrame: Math.round(1.4 * fps), widthPx: 80, amplitude: 0.45 });
beats.push({ startFrame: Math.round(2.3 * fps), widthPx: 120, amplitude: 0.85 });
const normalStart = 3.2;
for (let i = 0; i < 1; i++) {
beats.push({
startFrame: Math.round((normalStart + i) * fps),
widthPx: 140,
amplitude: 1.0,
});
}
return beats;
}
// ─── Letter definitions ──────────────────────────────────────────────────────
const LETTERS: Record<string, { x: number; y: number }[]> = {
A: [
{ x: 0, y: 0 }, { x: 0.48, y: 1 }, { x: 0.53, y: 0.42 },
{ x: 0.6, y: 0.42 }, { x: 1, y: 0 },
],
N: [
{ x: 0, y: 0 }, { x: 0.12, y: 1 }, { x: 0.72, y: 0 },
{ x: 0.88, y: 1 }, { x: 1, y: 0 },
],
D: [
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.5, y: 1 },
{ x: 0.85, y: 0.55 }, { x: 1, y: 0 },
],
R: [
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.35, y: 1 },
{ x: 0.5, y: 0.6 }, { x: 0.55, y: 0.45 }, { x: 1, y: 0 },
],
E: [
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.4, y: 1 },
{ x: 0.45, y: 0.5 }, { x: 0.65, y: 0.5 }, { x: 0.7, y: 0 },
{ x: 1, y: 0 },
],
W: [
{ x: 0, y: 0 }, { x: 0.05, y: 1 }, { x: 0.27, y: 0 },
{ x: 0.5, y: 0.65 }, { x: 0.73, y: 0 }, { x: 0.95, y: 1 },
{ x: 1, y: 0 },
],
C: [
{ x: 0, y: 0 }, { x: 0.08, y: 0.6 }, { x: 0.18, y: 1 },
{ x: 0.6, y: 1 }, { x: 0.8, y: 0.5 }, { x: 0.95, y: 0.1 },
{ x: 1, y: 0 },
],
H: [
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.18, y: 0.5 },
{ x: 0.82, y: 0.5 }, { x: 0.9, y: 1 }, { x: 1, y: 0 },
],
L: [
{ x: 0, y: 0 }, { x: 0.12, y: 1 }, { x: 0.3, y: 1 },
{ x: 0.38, y: 0 }, { x: 1, y: 0 },
],
O: [
{ x: 0, y: 0 }, { x: 0.2, y: 0.85 }, { x: 0.35, y: 1 },
{ x: 0.65, y: 1 }, { x: 0.8, y: 0.85 }, { x: 1, y: 0 },
],
};
function interpolateLetterY(
points: { x: number; y: number }[],
t: number,
): number {
if (t <= points[0].x) return points[0].y;
if (t >= points[points.length - 1].x) return points[points.length - 1].y;
for (let i = 0; i < points.length - 1; i++) {
if (t >= points[i].x && t <= points[i + 1].x) {
const segT = (t - points[i].x) / (points[i + 1].x - points[i].x);
return points[i].y + (points[i + 1].y - points[i].y) * segT;
}
}
return 0;
}
// ─── Text layout ─────────────────────────────────────────────────────────────
const TEXT = "ANDREW CHARLWOOD";
const LETTER_WIDTH = 72;
const LETTER_GAP = 10;
const SPACE_WIDTH = 30;
const BASE_LEFT_INSET = 9;
const BASE_RIGHT_INSET = 0;
type LetterLayout = {
char: string;
startX: number;
endX: number;
startConnector: number;
endConnector: number;
};
type ConnectorProfile = { leftInset: number; rightInset: number };
const CONNECTOR_PROFILES: Record<string, ConnectorProfile> = {
C: { leftInset: 20, rightInset: 8 },
O: { leftInset: 17, rightInset: 7 },
D: { leftInset: 0, rightInset: 13 },
L: { leftInset: 5, rightInset: 0 },
E: { leftInset: 5, rightInset: 0 },
};
const DEFAULT_PROFILE: ConnectorProfile = { leftInset: 0, rightInset: 0 };
function layoutText(offsetX: number): LetterLayout[] {
const layout: LetterLayout[] = [];
let cursor = offsetX;
for (const char of TEXT) {
if (char === " ") {
cursor += SPACE_WIDTH;
continue;
}
const profile = CONNECTOR_PROFILES[char] ?? DEFAULT_PROFILE;
const startX = cursor;
const endX = cursor + LETTER_WIDTH;
layout.push({
char,
startX,
endX,
startConnector: startX + BASE_LEFT_INSET + profile.leftInset,
endConnector: endX - BASE_RIGHT_INSET - profile.rightInset,
});
cursor += LETTER_WIDTH + LETTER_GAP;
}
return layout;
}
function getTextTotalWidth(): number {
return (
TEXT.replace(/ /g, "").length * (LETTER_WIDTH + LETTER_GAP) -
LETTER_GAP +
(TEXT.split(" ").length - 1) * SPACE_WIDTH
);
}
// ─── Timing constants ────────────────────────────────────────────────────────
const TRACE_SPEED = 350;
const HEAD_SCREEN_RATIO = 1;
const FLAT_GAP_SECONDS = 0.5;
const HOLD_SECONDS = 1.25;
const COMP_FPS = 60;
// How long the dot/line takes to exit the right side after text finishes
const EXIT_SECONDS = 1.5;
// Pre-compute duration for export
const _beats = buildBeats(COMP_FPS);
const _lastBeat = _beats[_beats.length - 1];
const _lastBeatEndWX = (_lastBeat.startFrame / COMP_FPS) * TRACE_SPEED + _lastBeat.widthPx;
const _textStartWX = _lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED;
const _totalTextW = getTextTotalWidth();
const _textEndWX = _textStartWX + _totalTextW;
const _textEndFrame = Math.round((_textEndWX / TRACE_SPEED) * COMP_FPS);
export const ECGCOMBINED_DURATION = _textEndFrame + Math.round(HOLD_SECONDS * COMP_FPS) + Math.round(EXIT_SECONDS * COMP_FPS);
// ─── Component ───────────────────────────────────────────────────────────────
export const ECGCombined = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const baselineY = height * 0.5;
const lineColor = "#00ff41";
const ecgMaxDeflection = height * 0.28;
const textMaxDeflection = height * 0.09;
const beats = buildBeats(fps);
// ── World-space text position ──
const lastBeat = beats[beats.length - 1];
const lastBeatEndWorldX = (lastBeat.startFrame / fps) * TRACE_SPEED + lastBeat.widthPx;
const textStartWorldX = lastBeatEndWorldX + FLAT_GAP_SECONDS * TRACE_SPEED;
const totalTextWidth = getTextTotalWidth();
const textEndWorldX = textStartWorldX + totalTextWidth;
const textLayout = layoutText(textStartWorldX); // world-space positions
// ── Final screen position: text centered when done ──
const desiredTextStartScreen = (width - totalTextWidth) / 2;
const finalHeadScreenX = desiredTextStartScreen + totalTextWidth;
const headScreenDuringEcg = HEAD_SCREEN_RATIO * width;
// ── Head position (world space, keeps moving past text) ──
const currentTime = frame / fps;
const headX = currentTime * TRACE_SPEED;
const textEndFrame = Math.round((textEndWorldX / TRACE_SPEED) * fps);
const isTextPhase = headX > textStartWorldX;
const isTextDone = frame >= textEndFrame - 3;
// ── Viewport: keeps scrolling, head drifts from 75% → right edge ──
let headScreenX: number;
let viewOffset: number;
if (headX <= textStartWorldX) {
viewOffset = Math.max(0, headX - headScreenDuringEcg);
headScreenX = headX - viewOffset;
} else if (headX >= textEndWorldX) {
// Lock viewport so text stays centered; dot keeps moving right
viewOffset = textEndWorldX - finalHeadScreenX;
headScreenX = headX - viewOffset;
} else {
const p = (headX - textStartWorldX) / (textEndWorldX - textStartWorldX);
headScreenX = headScreenDuringEcg + p * (finalHeadScreenX - headScreenDuringEcg);
viewOffset = headX - headScreenX;
}
// ── Y function (world space) ──
function getYAtX(worldX: number): number {
for (const beat of beats) {
const beatStartX = (beat.startFrame / fps) * TRACE_SPEED;
const beatEndX = beatStartX + beat.widthPx;
if (worldX >= beatStartX && worldX <= beatEndX) {
const progress = (worldX - beatStartX) / beat.widthPx;
const beatPoints = generateHeartbeatPoints(beat.amplitude);
const idx = Math.min(
Math.floor(progress * (beatPoints.length - 1)),
beatPoints.length - 1,
);
return baselineY - beatPoints[idx].y * ecgMaxDeflection;
}
}
for (const item of textLayout) {
if (worldX >= item.startX && worldX <= item.endX) {
const t = (worldX - item.startX) / (item.endX - item.startX);
const letterDef = LETTERS[item.char];
if (letterDef) {
return baselineY - interpolateLetterY(letterDef, t) * textMaxDeflection;
}
}
}
return baselineY;
}
// ── ECG trace path (up to text start) ──
const firstBeatWorldX = (beats[0].startFrame / fps) * TRACE_SPEED;
const traceStartWX = Math.max(Math.floor(firstBeatWorldX), Math.floor(viewOffset));
const ecgTraceEndWX = Math.min(
Math.ceil(headX),
Math.ceil(textStartWorldX),
Math.ceil(viewOffset + width),
);
const traceSegments: string[] = [];
if (ecgTraceEndWX >= traceStartWX) {
for (let wx = traceStartWX; wx <= ecgTraceEndWX; wx++) {
const sx = wx - viewOffset;
const y = getYAtX(wx);
traceSegments.push(wx === traceStartWX ? `M ${sx} ${y}` : `L ${sx} ${y}`);
}
}
const tracePathD = traceSegments.join(" ");
// ── Flat exit line after text finishes ──
let exitPathD = "";
if (isTextDone && headX > textEndWorldX) {
const exitStartSX = textEndWorldX - viewOffset - 32;
const exitEndSX = headX - viewOffset;
exitPathD = `M ${exitStartSX} ${baselineY} L ${exitEndSX} ${baselineY}`;
}
// ── Neon fade ──
const neonLengthPx = 200;
const neonFadeScreenEnd = headScreenX;
const neonFadeScreenStart = neonFadeScreenEnd - neonLengthPx;
// ── Text mask ──
const maskBrushSize = 1;
const clipLeadPx = 20;
const blockUnmaskDelay = 15;
const blockFeatherPx = 10;
const textMaskEndSX = isTextPhase
? (isTextDone ? width : Math.max(0, Math.min(Math.ceil(headScreenX), width)))
: 0;
const textMaskSegments: string[] = [];
if (isTextPhase && textMaskEndSX > 0 && !isTextDone) {
for (let sx = 0; sx <= textMaskEndSX; sx++) {
const y = getYAtX(viewOffset + sx);
textMaskSegments.push(sx === 0 ? `M ${sx} ${y}` : `L ${sx} ${y}`);
}
}
const textMaskPathD = textMaskSegments.join(" ");
const blockUnmaskX = isTextDone ? width : Math.max(0, textMaskEndSX - blockUnmaskDelay);
// ── Connectors (screen space) ──
const connectorSegments: string[] = [];
for (let i = 0; i < textLayout.length - 1; i++) {
const curr = textLayout[i];
const next = textLayout[i + 1];
connectorSegments.push(
`M ${curr.endConnector - viewOffset - 18} ${baselineY} L ${next.startConnector - viewOffset} ${baselineY}`,
);
}
const connectorPathD = connectorSegments.join(" ");
return (
<AbsoluteFill style={{ backgroundColor: "#000000", overflow: "hidden" }}>
<svg width={width} height={height} style={{ position: "absolute", top: 0, left: 0 }}>
<defs>
<filter id="neon" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur1" />
<feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur2" />
<feGaussianBlur in="SourceGraphic" stdDeviation="14" result="blur3" />
<feMerge>
<feMergeNode in="blur3" />
<feMergeNode in="blur2" />
<feMergeNode in="blur1" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="neonText" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="tblur1" />
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="tblur2" />
<feMerge>
<feMergeNode in="tblur2" />
<feMergeNode in="tblur1" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<linearGradient
id="neonMaskGrad"
gradientUnits="userSpaceOnUse"
x1={neonFadeScreenStart} y1={0}
x2={neonFadeScreenEnd} y2={0}
>
<stop offset="0%" stopColor="black" />
<stop offset="100%" stopColor="white" />
</linearGradient>
<mask id="neonMask">
<rect x={0} y={0} width={width} height={height} fill="url(#neonMaskGrad)" />
</mask>
<clipPath id="textReveal">
<rect
x={0} y={0}
width={isTextDone ? width : Math.max(0, headScreenX + clipLeadPx)}
height={height}
/>
</clipPath>
<linearGradient
id="blockUnmaskGrad"
gradientUnits="userSpaceOnUse"
x1={blockUnmaskX - blockFeatherPx} y1={0}
x2={blockUnmaskX} y2={0}
>
<stop offset="0%" stopColor="white" />
<stop offset="100%" stopColor="black" />
</linearGradient>
<mask id="textWipeMask">
<rect x={0} y={0} width={width} height={height} fill={isTextDone ? "white" : "black"} />
{!isTextDone && blockUnmaskX > 0 && (
<rect x={0} y={0} width={blockUnmaskX} height={height} fill="url(#blockUnmaskGrad)" />
)}
{!isTextDone && textMaskPathD && (
<path
d={textMaskPathD}
fill="none"
stroke="white"
strokeWidth={15 * maskBrushSize}
strokeLinejoin="round"
strokeLinecap="round"
filter="url(#neonText)"
/>
)}
</mask>
<radialGradient id="headGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#ffffff" stopOpacity={0.8} />
<stop offset="30%" stopColor={lineColor} stopOpacity={0.6} />
<stop offset="100%" stopColor={lineColor} stopOpacity={0} />
</radialGradient>
</defs>
{/* ECG trace */}
{tracePathD && (
<g>
<path d={tracePathD} fill="none" stroke={lineColor} strokeWidth={2}
strokeLinejoin="round" strokeLinecap="round" />
<path d={tracePathD} fill="none" stroke={lineColor} strokeWidth={2.5}
strokeLinejoin="round" strokeLinecap="round"
filter="url(#neon)" mask="url(#neonMask)" />
</g>
)}
{/* Text + connectors */}
{isTextPhase && (
<g clipPath="url(#textReveal)">
<g mask="url(#textWipeMask)">
{textLayout.map((item, i) => (
<text
key={i}
x={(item.startX + item.endX) / 2 - viewOffset}
y={baselineY}
textAnchor="middle"
dominantBaseline="alphabetic"
fontSize={Math.round(textMaxDeflection / 0.715)}
fontFamily="Arial, Helvetica, sans-serif"
fontWeight="bold"
fill="none"
stroke={lineColor}
strokeWidth={1.5}
filter="url(#neonText)"
>
{item.char}
</text>
))}
{connectorPathD && (
<path d={connectorPathD} fill="none" stroke={lineColor}
strokeWidth={1.5} strokeLinecap="round" />
)}
</g>
</g>
)}
{/* Flat exit line after text */}
{exitPathD && (
<g>
<path d={exitPathD} fill="none" stroke={lineColor} strokeWidth={2}
strokeLinejoin="round" strokeLinecap="round" />
<path d={exitPathD} fill="none" stroke={lineColor} strokeWidth={2.5}
strokeLinejoin="round" strokeLinecap="round"
filter="url(#neon)" />
</g>
)}
{/* Head dot */}
{headScreenX >= 0 && headScreenX <= width && (
<>
<circle cx={headScreenX} cy={getYAtX(headX)} r={20} fill="url(#headGlow)" />
<circle cx={headScreenX} cy={getYAtX(headX)} r={3} fill={lineColor} />
</>
)}
</svg>
{/* Scanlines */}
<div style={{
position: "absolute", top: 0, left: 0, width: "100%", height: "100%",
background: "repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.08) 2px, rgba(0,0,0,0.08) 4px)",
pointerEvents: "none",
}} />
{/* Vignette */}
<div style={{
position: "absolute", top: 0, left: 0, width: "100%", height: "100%",
background: "radial-gradient(ellipse at center, transparent 60%, rgba(0,0,0,0.5) 100%)",
pointerEvents: "none",
}} />
</AbsoluteFill>
);
};
+29 -111
View File
@@ -1,137 +1,55 @@
# Implementation Plan — React Conversion
# Implementation Plan — Design 7: The Clinical Record (v2)
## Project Overview
Convert the completed `concept.html` (ECG Heartbeat CV Website) into a modern React application with TypeScript, Vite, and Tailwind CSS. The project will be a portfolio-grade React implementation that preserves all animations, interactions, and design details from the HTML concept while following React best practices.
Redesign of the Clinical Record PMR interface to achieve **absolute thematic fidelity** to real NHS clinical software (EMIS Web, SystmOne). The codebase has existing components from a prior iteration that are functionally complete but lack the visual density, authentic clinical system feel, and design quality described in the specification. This plan rebuilds each visual component to production-showcase quality.
**Key Features to Port:**
- Boot sequence with terminal typing animation
- ECG flatline and heartbeat SVG animations
- Branching lines that trace UI elements into existence
- Color transition from green ECG to teal/coral design system
- Floating pill navigation with active section tracking
- SVG circular skill gauges with scroll-triggered animations
- Experience timeline with ECG decoration
- Scroll-reveal animations using IntersectionObserver
- Fully responsive design (desktop/tablet/mobile)
**Core principle:** This is NOT a clinical "theme" loosely applied — it's a **faithful digital clinical information system**. Every element must look like it belongs in actual NHS software. Border-heavy, table-heavy, functional — zero decorative flourish.
**Tech Stack:**
- React 18+ with TypeScript
- Vite for build tooling
- Tailwind CSS for styling
- Framer Motion for complex animations (boot sequence, ECG transitions)
- React Intersection Observer for scroll-triggered animations
- Lucide React for icons (replacing unicode symbols)
**Project Structure:**
```
src/
├── components/
│ ├── BootSequence.tsx # Terminal typing animation
│ ├── ECGAnimation.tsx # Flatline, heartbeats, branching
│ ├── FloatingNav.tsx # Pill navigation with active tracking
│ ├── Hero.tsx # About section with vitals
│ ├── Skills.tsx # Skill gauges with SVG circles
│ ├── Experience.tsx # Timeline layout
│ ├── Education.tsx # Education cards
│ ├── Projects.tsx # Project cards with gradient borders
│ ├── Contact.tsx # Contact grid
│ └── Footer.tsx # Footer with ECG decoration
├── hooks/
│ ├── useScrollReveal.ts # IntersectionObserver for scroll animations
│ └── useActiveSection.ts # Track active nav section
├── lib/
│ └── utils.ts # Utility functions (skill gauge math)
├── types/
│ └── index.ts # TypeScript interfaces
├── App.tsx # Main app with boot/ECG/CV phases
├── main.tsx # Entry point
└── index.css # Tailwind + custom CSS variables
```
**Reference Materials:**
- `References/concept.html` — Complete working HTML implementation with all animations
- `References/CV_v4.md` — Source CV content to populate sections
- `References/ECGVideo/` — Remotion video project with ECG animation patterns
**Data files are correct and complete** (`src/data/*`). The existing type system (`src/types/pmr.ts`) is sound. The phase management in `App.tsx` works. This plan focuses on **rebuilding the visual layer** to match the specification.
## Quality Checks
- `npm run dev` — Development server starts without errors
- `npm run build` — Production build completes without errors
- `npm run lint` — No ESLint errors
- `npm run typecheck` — No TypeScript errors
- Open `http://localhost:5173` and verify:
- Boot sequence plays exactly as in concept.html (terminal typing, 4 second duration)
- ECG flatline draws left-to-right
- Three heartbeats animate with increasing amplitude
- Branching lines trace outward on third beat
- Background transitions from black to white
- Final CV design renders with all sections
- Floating pill nav tracks active section on scroll
- Skill gauges animate when scrolled into view
- All hover effects work (card elevation, gradient borders)
- Responsive layouts work at 768px and 480px
- No console errors
Run ALL of these after each task. All must pass before committing.
- `npm run typecheck`
- `npm run lint`
- `npm run build`
## Reference Files
Each task below references a specific file in `Ralph/refs/` — read ONLY that file for the task context. Do NOT read goal.md directly (it's 1100+ lines and will overwhelm your context).
## Tasks
- [x] **Task 1: Initialize React project with Vite + TypeScript + Tailwind**
- [x] **Task 1: Design system foundation and font setup.** Read `Ralph/refs/ref-design-system.md`. Audit and fix the Tailwind config (`tailwind.config.js`) and global CSS (`src/index.css`) to ensure ALL PMR color tokens, typography, and spacing match the design system spec exactly. Specific fixes needed: (a) Ensure Geist Mono font is loaded via Google Fonts or local import — currently the project uses Fira Code for monospace but the spec requires Geist Mono for coded entries, timestamps, and data values. (b) Verify all PMR color tokens exist in Tailwind config: main content `#F5F7FA`, cards `#FFFFFF`, sidebar `#1E293B`, patient banner `#334155`, NHS blue `#005EB8`, green `#22C55E`, amber `#F59E0B`, red `#EF4444`, text primary `#111827`, text secondary `#6B7280`. (c) Ensure border-radius defaults to 4px for cards/inputs (not 8px or 12px — clinical systems use minimal rounding). (d) Add a `.pmr-theme` class or CSS custom properties layer for PMR-specific tokens if not already present. (e) Verify Inter font is loaded and configured as the primary font family. Do NOT invoke /frontend-design for this task — it's pure configuration.
Run `npm create vite@latest . -- --template react-ts` to scaffold the project. Install dependencies: `npm install framer-motion lucide-react`. Initialize Tailwind: `npm install -D tailwindcss postcss autoprefixer && npx tailwindcss init -p`. Configure `tailwind.config.js` with custom colors (teal #00897B, coral #FF6B6B, etc.). Set up `src/index.css` with Tailwind directives and CSS custom properties matching concept.html.
- [ ] **Task 1b: Rebuild boot sequence and ECG animation.** Read `Ralph/refs/ref-boot-ecg.md` and `Ralph/refs/ref-design-system.md`. Also read `ECGCombined.tsx` in the project root for the Remotion reference implementation of the mask-based text reveal. This task covers the full pre-login animation flow: (a) **Refactor BootSequence.tsx** — replace hardcoded HTML strings with a clean config-driven structure. Each line type (header, field, separator, module, ready) maps to a React component. Keep the same visual output: green-on-black terminal, Fira Code font, 220ms staggered line reveals, `#00ff41` bright green / `#3a6b45` dim green / `#00e5ff` cyan labels. (b) **Cursor → dot transition** — the blinking green cursor at the end of boot must smoothly morph into the ECG's glowing trace dot. Capture the cursor's screen position and pass it to ECGAnimation as a `startPosition` prop. The cursor stops blinking, transitions from block to circular glow (~300ms), then begins moving rightward as the ECG trace dot. (c) **ECG start sync** — ECGAnimation must start its trace from the cursor position (not the far left edge). The first beat begins after a flat gap from the cursor position. Shift the world-space origin so the trace starts where the cursor was. (d) **Mask-based text reveal** — adopt ECGCombined.tsx's technique where pre-rendered stroke-only text is revealed by a wipe mask following the trace head (instead of the current alpha fade approach). Keep the current character spacing (`LETTER_W`, `LETTER_G`, `SPACE_W`) and heartbeat waveform. Add connector lines between letters at baseline. (e) **Keep**: heartbeat shape, beat timing (0.3→0.55→0.85→1.0 amplitude), canvas rendering, viewport scrolling, flatline draw, scanlines, vignette, background transition to `#1E293B`. (f) Respect `prefers-reduced-motion` — with reduced motion, skip animation and show static final frame or jump to login.
- [x] **Task 2: Set up project structure and types**
- [ ] **Task 2: Rebuild LoginScreen component.** Read `Ralph/refs/ref-transition-login.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/LoginScreen.tsx` to match the login sequence specification exactly: (a) Dark blue-gray `#1E293B` background. (b) White card: 320px wide, **12px border-radius** (exception to the 4px rule — login cards can be rounder), subtle shadow. (c) NHS-blue shield icon at top with "CareerRecord PMR" branding text. (d) Username field types `A.CHARLWOOD` at 30ms/char in **Geist Mono** font. (e) Password field fills 8 dots at 20ms/dot. (f) Blinking cursor (530ms interval) in active field. (g) "Log In" button: NHS blue `#005EB8`, full width, pressed state darkens to `#004494`. (h) After submit: card scales to 103% and fades out over 200ms. (i) Respect `prefers-reduced-motion`. The login must feel like actually logging into NHS software at 8am on a Monday.
Create the folder structure (`components/`, `hooks/`, `lib/`, `types/`). Define TypeScript interfaces in `types/index.ts` for: `Skill` (name, level, category, color), `Experience` (role, org, date, bullets), `Education` (degree, institution, period, detail), `Project` (title, description, link?). Create `lib/utils.ts` with helper function `calculateSkillOffset(level: number, radius: number): number` that returns `2 * Math.PI * radius * (1 - level / 100)`.
- [ ] **Task 3: Rebuild PatientBanner component.** Read `Ralph/refs/ref-banner-sidebar.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/PatientBanner.tsx` to match the specification exactly: (a) Full banner 80px: background `#334155`, bottom border `1px solid #475569`. Name in Inter 600 **20px** (not 18px), details in Inter 400 14px. Layout must match the ASCII art in the ref file — surname-first format "CHARLWOOD, Andrew (Mr)", DOB/NHS No/Address on second row, phone/email/buttons on third row. (b) Status: green dot + "Active" text. Badge: "Open to opportunities" as blue pill. (c) Action buttons: outlined rectangles with NHS blue text and 1px border, 4px radius. Hover fills with NHS blue bg + white text. (d) Condensed banner 48px: single line with name, NHS number, status, action buttons only. Triggers at 100px scroll via IntersectionObserver. Smooth 200ms height transition. (e) Mobile banner: minimal top bar `CHARLWOOD, A (Mr) | 2211810 | dot` with overflow "..." menu. NHS Number tooltip: "GPhC Registration Number".
- [x] **Task 3: Build BootSequence component**
- [ ] **Task 4: Rebuild ClinicalSidebar component.** Read `Ralph/refs/ref-banner-sidebar.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/ClinicalSidebar.tsx` to match the specification exactly: (a) 220px fixed width, `#1E293B` background, `1px solid #334155` right border. (b) Header: "CareerRecord PMR / v1.0.0" in Inter 500, 13px, white at 50% opacity. (c) Search input: "Search record..." placeholder, integrated in sidebar below header. (d) **Navigation labels use CV-friendly terms**: Summary, Experience, Skills, Achievements, Projects, Education, Contact (NOT clinical jargon like Consultations, Medications, etc.). Same Lucide icons as before. Items: 44px height, 16px left padding, icons 18px + labels Inter 500 14px. Exact states: default white/70%, hover white/100% + `rgba(255,255,255,0.08)` bg, active white/100% + 3px NHS blue left border + `rgba(255,255,255,0.12)` bg + Inter 600 bold. (e) Separator line between Summary and Experience. (f) Footer: "Session: A.CHARLWOOD / Logged in: [time]" in Inter 400, 11px, `#64748B`. Time updates on mount. (g) Tablet mode: 56px icon-only with tooltips. (h) URL hash routing (`#summary`, `#experience`, `#skills`, `#achievements`, `#projects`, `#education`, `#contact`). (i) Alt+1-7 keyboard shortcuts, arrow key navigation, "/" for search focus. (j) Update the ViewId type in `src/types/pmr.ts` if needed to match the new labels.
Create `components/BootSequence.tsx`. Implement terminal typing animation using Framer Motion or CSS transitions. Display boot lines with correct colors (cyan labels, green values, dim text). Use exact boot text from concept.html: "CLINICAL TERMINAL v3.2.1", "Initialising pharmacist profile...", SYSTEM/USER/ROLE/LOCATION, module loading, [OK] lines, READY. Duration: ~4 seconds. Emit `onComplete` callback when finished. Styling: black background, Fira Code font.
- [ ] **Task 5: Rebuild PMRInterface layout and add Breadcrumb.** Read `Ralph/refs/ref-banner-sidebar.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/PMRInterface.tsx` to ensure correct layout composition: (a) Fixed sidebar (220px) + sticky patient banner + scrollable main content area with `#F5F7FA` background. (b) Main content padding: 24px. No max-width — content fills available space. (c) Create `src/components/Breadcrumb.tsx`: displays "Patient Record > [Current View]" at the top of the main content area (using CV-friendly view names: Experience, Skills, Achievements, etc.). When a consultation/skill/achievement is expanded, the breadcrumb deepens to show the item name. Styled in Inter 400, 13px, gray-400, with chevron separators. Clickable links navigate back. (d) Interface materialization animations: patient banner slides down (200ms ease-out), sidebar slides from left (250ms ease-out, 50ms delay), content fades in (300ms, 100ms delay after sidebar). (e) View switching must be INSTANT — no crossfade or slide between views. (f) Ensure mobile layout uses bottom nav bar (56px height with safe area padding). (g) Update the view switching logic to use the new CV-friendly ViewId values (summary, experience, skills, achievements, projects, education, contact).
- [x] **Task 4: Build ECGAnimation component**
- [ ] **Task 6: Rebuild SummaryView with Clinical Alert.** Read `Ralph/refs/ref-summary-alert.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/views/SummaryView.tsx` and the clinical alert: (a) Clinical Alert: amber `#FEF3C7` background, 4px left border `#F59E0B`, text `#92400E`. Slides down with **spring animation** (not ease-out — use Framer Motion `type: "spring"` with appropriate damping). Acknowledge button: amber outlined. On click: warning icon cross-fades to green checkmark (200ms), holds 200ms, then alert height collapses (200ms ease-out). (b) 2-column card grid on desktop, single column on mobile. Cards have: header bar with title in Inter 600, 14px, uppercase, on `#F9FAFB` background with `1px solid #E5E7EB` bottom border. Card body: 16px padding, `1px solid #E5E7EB` border, 4px radius. (c) Demographics card: two-column key-value layout. Labels right-aligned Inter 500 13px gray-500, values left-aligned Inter 400 14px gray-900. (d) Active Problems card: traffic light dots + problem text + date in Geist Mono 12px. (e) Quick Meds card: 4-column table with top 5 skills. "View Full List ->" link. (f) Last Consultation card: preview with "View Full Record ->" link. All navigation links must actually switch the sidebar view.
Create `components/ECGAnimation.tsx`. Port the ECG logic from concept.html:
- SVG flatline drawing left-to-right (1000ms)
- Three PQRST heartbeats with increasing amplitude (40px → 60px → 100px)
- Color interpolation: #00ff41#00C9A7#00897B
- Branching lines from third R peak tracing UI outlines (pill nav, hero, cards)
- Background transition from black to white
- Emit `onComplete` callback when animation finishes
Use Framer Motion for path drawing animations (pathLength).
- [ ] **Task 7: Rebuild ConsultationsView.** Read `Ralph/refs/ref-consultations.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/views/ConsultationsView.tsx`: (a) Collapsed entries: date in Geist Mono 13px gray-500, organization in Inter 400 13px NHS blue, role in Inter 600 15px gray-900, "Key:" prefix in Inter 500 gray-500 with single-line achievement. Chevron button right-aligned. (b) Status dot: green for current, gray for historical. (c) 3px left border color-coded by employer (NHS blue `#005EB8` or Tesco teal `#00897B`). (d) Expanded state: Duration line, then HISTORY / EXAMINATION / PLAN sections with headers in Inter 600, 12px, uppercase, letter-spacing 0.05em, gray-400. Plan items as bullet lists. (e) CODED ENTRIES section at bottom: Geist Mono 12px, gray-500, bracket codes. (f) Height-only expand animation, 200ms ease-out. NO opacity fade on content — content simply grows/shrinks. (g) Only one expanded at a time. (h) Implement the second clinical alert (Python switching algorithm alert) that appears on first navigation to Consultations view, dismissible with same Acknowledge pattern.
- [x] **Task 5: Build FloatingNav component**
- [ ] **Task 8: Rebuild MedicationsView.** Read `Ralph/refs/ref-medications.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/views/MedicationsView.tsx`: (a) Three category tabs: "Active Medications" (technical), "Clinical Medications" (healthcare domain), "PRN (As Required)" (strategic/leadership). Tab styling: active tab has white bg + NHS blue bottom border. (b) Full table with proper `<table>` markup: Drug Name | Dose | Frequency | Start | Status. Table headers: Inter 600, 13px, uppercase, `#F9FAFB` bg. Row height: 40px. All borders: `1px solid #E5E7EB`. Alternating `#FFFFFF`/`#F9FAFB` row backgrounds. (c) Hover: `#EFF6FF` background only — no transform, no lift. (d) Sortable columns: click header to sort, arrow indicator in active column. (e) Status dots: 6px green circles + "Active" text. (f) Expandable prescribing history: Geist Mono 12px, year markers bold, descriptions regular. (g) Mobile: card layout (stacked fields per card, not table).
Create `components/FloatingNav.tsx`. Floating pill navigation bar fixed at top center. Links: About, Skills, Experience, Education, Projects, Contact. Active link tracking via `useActiveSection` hook (IntersectionObserver). Smooth scroll to sections on click. Responsive: horizontal scroll on mobile. Styling: white bg, rounded-full, shadow-md, teal active state with dot indicator.
- [ ] **Task 9: Rebuild ProblemsView.** Read `Ralph/refs/ref-problems.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/views/ProblemsView.tsx`: (a) Two sections: "ACTIVE PROBLEMS" and "RESOLVED PROBLEMS" with section headers in clinical style. (b) Table columns — Active: Status | Code | Problem | Since. Resolved: Status | Code | Problem | Resolved | Outcome. (c) Traffic light dots: 8px circles (green=resolved, amber=in-progress). Always paired with text labels — never the sole indicator. (d) Code column in Geist Mono. (e) Expandable rows showing full narrative + linked consultation buttons that navigate to Consultations view. (f) Row hover: `#EFF6FF`. (g) Mobile: card layout.
- [x] **Task 6: Build Hero section component**
- [ ] **Task 10: Rebuild InvestigationsView and DocumentsView.** Read `Ralph/refs/ref-investigations-documents.md` and `Ralph/refs/ref-design-system.md`. Rebuild both views: (a) InvestigationsView: table with Test Name | Requested | Status | Result. Status badges: green "Complete", amber "Ongoing", pulsing green "Live". Expanded view uses tree-indented monospace structure with box-drawing characters (pipe symbols). "View Results" NHS blue button for PharMetrics only. (b) DocumentsView: table with Type icon | Document | Date | Source. Lucide icons per document type (FileText, Award, GraduationCap, FlaskConical). Expanded preview uses same tree-indented structure. (c) Both views share the expandable-row pattern — maintain visual consistency. (d) Mobile: card layouts for both.
Create `components/Hero.tsx`. Port hero section from concept.html: centered layout, name (clamp 36-52px), job title (muted), location pill (teal border), summary paragraph (max-width 560px). Four vital sign metric cards in a row: "10+ Years Experience", "Python/SQL/BI Analytics Stack", "Pop. Health Focus Area", "NHS N&W System". Cards have teal border-top, hover elevation. Responsive: 2x2 grid on tablet, stacked on mobile.
- [ ] **Task 11: Rebuild ReferralsView.** Read `Ralph/refs/ref-referrals.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/views/ReferralsView.tsx`: (a) Form header: "New Referral" with pre-filled patient info (non-editable). (b) Priority radio buttons: Urgent (red), Routine (blue, default), Two-Week Wait (amber). Each with tongue-in-cheek tooltips. (c) Form fields: standard clinical inputs with `1px solid #D1D5DB` border, 4px radius, `8px 12px` padding. Labels in Inter 500, 13px, gray-600 above inputs. Focus: NHS blue border + `box-shadow: 0 0 0 3px rgba(0,94,184,0.15)`. (d) Contact Method radio group. (e) Submit: "Send Referral" NHS blue button. Loading spinner state. Success state with reference number (REF-YYYY-MMDD-NNN format), checkmark, "24-48 hours" response time. (f) Direct Contact table below form: Email, Phone, LinkedIn, Location with action buttons.
- [x] **Task 7: Build Skills section with SVG gauges**
- [ ] **Task 12: Implement fuzzy search with fuse.js.** Read `Ralph/refs/ref-interactions.md`. Install fuse.js (`npm install fuse.js`). Rebuild the search functionality in the sidebar: (a) Build a search index on mount from ALL content: consultation titles/descriptions, medication names, problem descriptions, investigation names, document titles. (b) Search options: `{ keys: ['title', 'description', 'name'], threshold: 0.3, includeScore: true }`. (c) Results dropdown grouped by section (Consultations, Medications, Problems, etc.) with section icon, matching text, and relevance indicator. (d) Clicking a result navigates to the section AND expands/highlights the matching item. (e) Dropdown styled: white bg, `1px solid #E5E7EB`, 4px radius, shadow. Items in Inter 400 14px, 36px height. (f) Escape closes dropdown. (g) Mobile: search bar at top of each view instead of sidebar.
Create `components/Skills.tsx`. Three skill categories: Technical (8 skills, teal), Clinical (6 skills, coral), Strategic (4 skills, teal). Each skill has circular SVG progress gauge using calculated stroke-dashoffset. Scroll-triggered animation: gauges fill when section enters viewport, staggered by 100ms. Port all 18 skills with correct percentages from concept.html.
- [ ] **Task 13: Implement context menus.** Read `Ralph/refs/ref-interactions.md`. Add right-click/long-press context menus: (a) On consultation entries: "Expand", "Copy to clipboard", "View coded entries". (b) On medication rows: "View prescribing history", "Copy to clipboard". (c) On problem entries: "View linked consultations", "Copy to clipboard". (d) Styled: white bg, `1px solid #E5E7EB` border, 4px radius, `box-shadow: 0 4px 12px rgba(0,0,0,0.1)`. Items: Inter 400, 14px, 36px row height. (e) Escape closes menu. Focus returns to trigger element on close. (f) Use Headless UI or a custom implementation — accessible with keyboard navigation.
- [x] **Task 8: Build Experience section with timeline**
- [ ] **Task 14: Responsive design audit and fix.** Read `Ralph/refs/ref-interactions.md`. Systematically test and fix all three breakpoints: (a) Desktop (>1024px): full sidebar 220px, full patient banner 80px condensing to 48px, full tables. (b) Tablet (768-1024px): sidebar collapses to 56px icon-only with tooltips on hover/tap. Patient banner always condensed. Tables may horizontally scroll. Context menus via long-press. (c) Mobile (<768px): bottom nav bar with 7 icon buttons (56px height + safe area). Minimal top bar. ALL tables convert to card layout (stacked fields). Search bar at top of views. Back arrow to Summary. Touch targets minimum 48px. (d) Run `npm run build` and verify no layout breaks.
Create `components/Experience.tsx`. Vertical timeline with 5 roles: Interim Head (May-Nov 2025), Deputy Head (Jul 2024-Present), High-Cost Drugs (May 2022-Jul 2024), Pharmacy Manager (Nov 2017-May 2022), Duty Pharmacy Manager (Aug 2016-Nov 2017). Decorative ECG waveform SVG beside heading. Timeline dot filled for current roles. Cards with hover effect (scale, shadow, left border). Responsive: hide timeline line on mobile, stack cards.
- [x] **Task 9: Build Education, Projects, Contact sections**
Create `components/Education.tsx`, `components/Projects.tsx`, `components/Contact.tsx`.
**Education:** 2-column grid. MPharm (Hons) UEA 2011-2015 (2:1). Mary Seacole Leadership Programme 2018. Gradient top border (teal→coral). A-Levels line below.
**Projects:** 2x2 grid. PharMetrics (with link), Patient Pathway Analysis, Blueteq Generator, NMS Video. Gradient border hover effect.
**Contact:** 4-column grid. Phone, Email, LinkedIn, Location. Use Lucide icons (Phone, Mail, Linkedin, MapPin). Responsive: 2x2 on mobile.
- [x] **Task 10: Build Footer component and main App.tsx**
Create `components/Footer.tsx`. Decorative ECG waveform SVG, attribution text. Update `App.tsx` to orchestrate the three phases: 1) BootSequence (4s), 2) ECGAnimation (4s), 3) CV Content (with all sections). Use React state to track current phase. Ensure smooth transitions between phases.
- [x] **Task 11: Implement scroll animations and responsive design**
Create `hooks/useScrollReveal.ts`. IntersectionObserver-based hook for scroll-triggered section reveals. Add scroll-reveal animations to all sections (opacity 0→1, translateY 24px→0). Ensure animations only trigger once. Add responsive breakpoints: tablet (768px), mobile (480px). Test all layouts.
- [x] **Task 12: Final integration, testing, and polish**
Run all quality checks. Verify TypeScript compiles without errors. Verify no console errors. Test boot sequence timing matches concept.html (~4s). Test ECG animation timing and easing. Verify all CV content accuracy against CV_v4.md. Test all interactive elements (nav, hover effects, scroll animations). Verify responsive layouts at all breakpoints. Final build test.
- [ ] **Task 15: Accessibility audit and final polish.** Read `Ralph/refs/ref-interactions.md`. Audit and fix accessibility: (a) Semantic HTML: sidebar `<nav>` with `aria-label`, main `<main>`, banner `<header>`, tables with `scope="col"`. (b) ARIA: `aria-current="page"` on active sidebar item, `aria-expanded` on expandable items, `role="alert"` on clinical alerts. (c) Focus management: after login focus to sidebar, after view switch focus to first heading, after expand focus to section heading, after alert dismiss focus to main content. (d) Keyboard: Tab order, roving tabindex in sidebar, Escape closes everything. (e) Screen reader: announce view changes, table headers, alert text. (f) `prefers-reduced-motion`: login instant, alert no slide, expand/collapse instant, banner condensation instant. (g) Color contrast: verify all text meets WCAG 2.1 AA. Traffic lights always have text labels. (h) Final visual polish pass: ensure consistent spacing, borders, fonts across all views.
+132 -21
View File
@@ -2,55 +2,166 @@
You are operating inside an automated loop. Each iteration you receive fresh context - you have NO memory of previous iterations. Your only persistence is the filesystem.
You are converting the completed `concept.html` (ECG Heartbeat CV Website) into a modern React application with TypeScript, Vite, and Tailwind CSS. The goal is a portfolio-grade React implementation that preserves all animations, interactions, and design details from the HTML concept.
You are implementing **Design 7: The Clinical Record** — a Patient Medical Record (PMR) system that presents Andy's CV as a clinician would view a patient record. This is a visual redesign rebuilding existing components to achieve absolute thematic fidelity to real NHS clinical software.
**The Concept:**
The "patient" is Andy's career. Users navigate a genuine NHS clinical software interface (similar to EMIS Web, SystmOne, Vision) with a patient banner, sidebar navigation, consultation journal, medications table, clinical alerts, and a login sequence. The clinical metaphor lives in the LAYOUT and VISUAL PRESENTATION — the sidebar labels use CV-friendly terms (Experience, Skills, Achievements, Projects, Education, Contact) while each view is laid out like its clinical equivalent (consultation journal, medications table, problems list, etc.).
**IMPORTANT — Sidebar Label Convention:**
The sidebar uses CV-intuitive labels, NOT clinical jargon. But each view's content is presented in the clinical format:
- **Summary** → Patient summary layout
- **Experience** (not "Consultations") → Consultation journal layout with History/Examination/Plan
- **Skills** (not "Medications") → Medications table layout with dosages/frequency
- **Achievements** (not "Problems") → Problems list layout with traffic lights
- **Projects** (not "Investigations") → Investigation results layout
- **Education** (not "Documents") → Attached documents layout
- **Contact** (not "Referrals") → Referral form layout
## Your Task This Iteration
1. **Use the /frontend-design skill** (REQUIRED for visual components): Before writing ANY code for components that involve visual design, styling, animations, or UI elements, you MUST invoke the `/frontend-design` skill. This includes: BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any component with CSS/styling. This skill gives you access to specialized frontend design capabilities for higher quality, polished output.
1. **Read the Design Guidance in the reference file** (REQUIRED for visual components): Each reference file in `Ralph/refs/` contains a "Design Guidance (from /frontend-design)" section at the bottom with pre-generated design direction, code patterns, and implementation details. You MUST read this section before writing code — it provides the aesthetic direction and code examples for the component. Do NOT invoke the `/frontend-design` skill at runtime — the guidance is already embedded in the ref files.
2. **Read the plan**: Open `IMPLEMENTATION_PLAN.md` and find the highest-priority unchecked item (`- [ ]`). Items are listed in priority order - pick the first unchecked one.
3. **Read accumulated learnings**: Open `progress.txt` and read the "Codebase Patterns" section. This contains learnings from previous iterations.
3. **Read accumulated learnings**: Open `progress.txt` and read the "Codebase Patterns" section. This contains learnings from previous iterations about PMR design system, data architecture, animation approach, and clinical system authenticity.
4. **Read guardrails**: Open `guardrails.md` and read ALL guardrails. These are hard rules you MUST follow. Violating a guardrail is a quality check failure.
4. **Read guardrails**: Open `guardrails.md` and read ALL guardrails. These are hard rules you MUST follow. Key guardrails include:
- Light-mode only (clinical systems don't have dark mode)
- Instant view switching (no animations between views)
- Proper semantic table markup for all data tables
- Traffic lights must always have text labels
- Exact NHS blue color (#005EB8)
- ECG must end with flatline (not fade to white)
- Login typing animation specifics
- Consultation History/Examination/Plan format
- Coded entries in [XXX000] format
- Sidebar labels are CV-friendly (Experience, Skills, etc.), NOT clinical jargon
5. **Implement the item**: Complete the single task you selected. Keep changes focused - one task per iteration. Write production-quality React/TypeScript code that is artistic, creative, and visually polished. This is a design showcase - the output should make someone say "wow, that's slick."
5. **Implement the item**: Complete the single task you selected. Keep changes focused - one task per iteration. Write production-quality React/TypeScript code that faithfully reproduces a clinical information system. This is a design showcase requiring absolute thematic fidelity.
6. **Run quality checks**: Execute the quality check commands listed in `IMPLEMENTATION_PLAN.md` under "Quality Checks". Fix any issues before proceeding.
7. **Commit your changes**: Stage and commit all changes with a descriptive message referencing the task you completed.
7. **Visual Review** (Tasks 1b-11 only — skip for non-visual tasks like Task 1, 12-15): After quality checks pass, verify your work visually in the browser using the Claude in Chrome browser tools:
a. Call `tabs_context_mcp` to get available tabs (create if empty).
b. Navigate to `http://localhost:5173` (dev server runs throughout the loop).
c. **First load only**: The app plays a boot→ECG→login→PMR sequence (~15s). Use `computer` with `action: "wait", duration: 15` then take a screenshot. On subsequent navigations in the same tab, the app stays in PMR phase — no waiting needed.
d. Navigate to the hash route for your task's view:
- Task 1b (Boot/ECG): Refresh page, screenshot during boot sequence, then again during ECG animation
- Task 2 (Login): Refresh page, wait ~8s (after boot+ECG), screenshot the login screen
- Task 3 (Banner): Any PMR view — review the patient banner at top
- Task 4 (Sidebar): Any PMR view — review left sidebar
- Task 5 (Layout/Breadcrumb): Any PMR view — review overall composition
- Task 6: `#summary` | Task 7: `#experience` | Task 8: `#skills`
- Task 9: `#achievements` | Task 10: `#projects` then `#education` | Task 11: `#contact`
e. Take a screenshot (`computer` with `action: "screenshot"`) and compare against your reference file.
f. Check specifically: colors match spec, correct font (Inter vs Geist Mono), proper spacing, `1px solid #E5E7EB` borders, 4px border-radius, layout alignment, NHS blue `#005EB8`.
g. If discrepancies are found: fix them, re-run quality checks, take another screenshot to confirm.
h. Note the visual review outcome in your progress.txt entry (step 10).
8. **Mark the item complete**: In `IMPLEMENTATION_PLAN.md`, change the item from `- [ ]` to `- [x]`.
8. **Commit your changes**: Stage and commit all changes with a descriptive message referencing the task you completed.
9. **Update progress.txt**: Append to the "Iteration Log" section with:
9. **Mark the item complete**: In `IMPLEMENTATION_PLAN.md`, change the item from `- [ ]` to `- [x]`.
10. **Update progress.txt**: Append to the "Iteration Log" section with:
- Which task you completed
- Any learnings or codebase patterns discovered (add to "Codebase Patterns" section)
- Any issues encountered
- Design decisions made (if visual component)
- Visual review outcome (what was checked, any fixes made)
10. **Commit the progress update**: Stage and commit the updated `IMPLEMENTATION_PLAN.md` and `progress.txt`.
11. **Commit the progress update**: Stage and commit the updated `IMPLEMENTATION_PLAN.md` and `progress.txt`.
11. **Check for completion**: If ALL items in the task checklist are now checked (`- [x]`), output the following completion signal on its own line:
12. **Recommend model for next iteration**: Look at the NEXT unchecked task in `IMPLEMENTATION_PLAN.md` (the one after the task you just completed). Assess its complexity and output a model recommendation on its own line:
```
<promise>COMPLETE</promise>
```
```
<next-model>sonnet</next-model>
```
or
```
<next-model>opus</next-model>
```
**Use this decision framework:**
- **Use `sonnet`** for: configuration tasks, search/utility implementation, responsive fixes, accessibility audits, tasks with very prescriptive specs, tasks that are mostly wiring/plumbing
- **Use `opus`** for: visual component rebuilds that invoke /frontend-design (design quality matters), complex animation work, tasks requiring strong aesthetic judgment, tasks where the previous iteration left issues that need creative problem-solving
- **Default to `sonnet`** if unsure — it's cheaper and handles well-specified tasks fine
- If there IS no next task (you just completed the last one), skip this step
13. **Determine if another iteration is needed**: Review your work and the codebase. The project needs another iteration if ANY of these are true:
- Any task in the checklist is unchecked (`- [ ]`) or blocked (`- [B]`)
- Quality checks would fail (run them to verify)
- There are uncommitted changes
- progress.txt has open questions or guidance for "next iteration"
- The implementation doesn't fully satisfy the plan requirements
- You have lingering doubts about correctness or completeness
14. **Send completion signal ONLY if truly complete**: If and ONLY if the project definitely does NOT need another iteration — all tasks verified done, quality checks pass, no guidance for next iteration — output this exact signal on its own line:
```
<promise>COMPLETE</promise>
```
DO NOT output this string if there's any chance another iteration is needed. When in doubt, do NOT send the promise — leave it for the next iteration to determine.
## Critical Rules
- **ALWAYS invoke /frontend-design skill before writing visual component code** — this is mandatory for BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any styled component
- **ALWAYS read the "Design Guidance" section in the ref file before writing visual component code** — do NOT invoke /frontend-design at runtime (it's pre-baked into the ref files)
- **Do NOT invoke the /frontend-design skill** — the design guidance is already embedded in each ref file. Invoking it at runtime will consume your context and stall the iteration.
- **ALWAYS visually review visual components (Tasks 1b-11) in the browser** — use Claude in Chrome tools to screenshot and verify against the spec before committing
- **Only work on ONE task per iteration**
- **Always read progress.txt AND guardrails.md before starting** — previous iterations may have left important context
- **If a task is blocked or unclear**, document why in progress.txt and move to the next unchecked item
- **Keep commits atomic and well-described**
- **If quality checks fail, fix the issues before committing**
- **The visual quality bar is HIGH** — this is a design portfolio piece
- **Preserve all animations exactly** — timing, easing, and visual effects must match concept.html
- **Use TypeScript strictly** — no `any` types, proper interfaces for all data structures
- **Follow the established project structure** — components in `src/components/`, hooks in `src/hooks/`, etc.
- **The visual quality bar is HIGH** — this must look like real clinical software
- **Preserve clinical system authenticity** — instant navigation, proper tables, NHS blue, coded entries, traffic lights
- **Sidebar labels are CV-friendly** — Experience (not Consultations), Skills (not Medications), etc.
- **Use TypeScript strictly** — no `any` types, proper interfaces for all PMR data structures
- **Follow the established project structure** — components in `src/components/`, data in `src/data/`, types in `src/types/`
- **Respect prefers-reduced-motion** — animations must have instant fallbacks
## Reference Files
- `References/concept.html` — The complete working HTML implementation (your source of truth for animations, styling, timing)
- `References/CV_v4.md`CV content to populate sections
- `References/ECGVideo/` — Remotion video project with ECG animation patterns
Each task in the implementation plan references specific files in `Ralph/refs/`:
- `Ralph/refs/ref-boot-ecg.md` — Boot sequence + ECG animation improvements
- `Ralph/refs/ref-design-system.md` — Colors, typography, spacing, borders, motion
- `Ralph/refs/ref-transition-login.md` — ECG flatline + login sequence
- `Ralph/refs/ref-banner-sidebar.md` — Patient banner + sidebar + navigation
- `Ralph/refs/ref-summary-alert.md` — Summary view + clinical alert
- `Ralph/refs/ref-consultations.md` — Experience view (consultation journal layout)
- `Ralph/refs/ref-medications.md` — Skills view (medications table layout)
- `Ralph/refs/ref-problems.md` — Achievements view (problems list layout)
- `Ralph/refs/ref-investigations-documents.md` — Projects + Education views
- `Ralph/refs/ref-referrals.md` — Contact view (referral form layout)
- `Ralph/refs/ref-interactions.md` — Interactions, responsive, accessibility
- `References/CV_v4.md` — Source CV content (roles, achievements, numbers, dates)
Read ONLY the referenced file(s) for each task. Do NOT read goal.md directly.
## Design Document Highlights
**Color Palette (Light-mode only):**
- Main content: `#F5F7FA`
- Cards: `#FFFFFF`
- Sidebar: `#1E293B`
- NHS blue: `#005EB8`
- Green (active): `#22C55E`
- Amber (alerts): `#F59E0B`
**Typography:**
- Inter for general text
- Geist Mono for coded entries and data values
**Key Interactions:**
- Login sequence: typing username/password character-by-character
- Clinical alert: slides down, acknowledges with checkmark → collapse
- Consultation entries: expand/collapse with History/Examination/Plan
- Medications table: sortable columns, expandable prescribing history
- Sidebar: instant view switching, no animations
**Responsive Strategy:**
- Desktop (>1024px): 220px sidebar with labels
- Tablet (768-1024px): 56px icon-only sidebar
- Mobile (<768px): Bottom navigation bar
+82 -71
View File
@@ -1,90 +1,101 @@
# Guardrails — React Conversion
# Guardrails
## Standard Guardrails
Hard rules that MUST be followed in every iteration. Violating these will produce incorrect output.
### Frontend-design skill requirement
- **When**: Writing ANY component with visual styling, animations, or UI elements
- **Rule**: You MUST invoke the `/frontend-design` skill before writing code. This applies to: BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any styled component.
- **Why**: The frontend-design skill provides specialized capabilities for creating polished, professional-grade visual output. Skipping it results in lower quality design.
## Design System Guardrails
### Boot sequence consistency
- **When**: Implementing BootSequence component
- **Rule**: Boot text must match concept.html exactly: "CLINICAL TERMINAL v3.2.1", "Initialising pharmacist profile...", SYSTEM/USER/ROLE/LOCATION labels with values, loading modules line, three [OK] lines, "---", and final ready line. Use Fira Code font, green #00ff41 for [OK] and values, cyan #00e5ff for labels, dim green #3a6b45 for other text.
- **Why**: Boot sequence is the shared identity across all concepts. Must be identical.
### When: Writing ANY visual component
**Rule:** Light-mode only. Do NOT add dark mode classes, `dark:` prefixes, or theme toggles. Clinical record systems operate exclusively in light mode.
**Why:** Dark mode breaks the clinical system metaphor. NHS clinical software is always light-mode due to high ambient lighting in consulting rooms.
### ECG animation fidelity
- **When**: Implementing ECGAnimation component
- **Rule**: Timing and visual effects must match concept.html exactly: flatline 1000ms, three heartbeats with amplitudes 40px→60px→100px, color shift #00ff41#00C9A7#00897B, branching lines from third R peak, background transition black→white.
- **Why**: The ECG animation is the signature visual effect. Any deviation breaks the experience.
### When: Setting border-radius on cards, inputs, or table elements
**Rule:** Use 4px border-radius (`rounded` in Tailwind, which is 4px). Do NOT use `rounded-lg` (8px), `rounded-xl` (12px), or `rounded-2xl` (16px). The only exception is the LoginScreen card which uses 12px.
**Why:** Clinical systems use minimal rounding. Larger radii look like consumer apps, not NHS software.
### CV content accuracy
- **When**: Adding CV content to components
- **Rule**: Use the expanded CV_v4.md content. Key roles in order: Interim Head (May-Nov 2025), Deputy Head (Jul 2024-Present), High-Cost Drugs & Interface Pharmacist (May 2022-Jul 2024), Pharmacy Manager Tesco (Nov 2017-May 2022), Duty Pharmacy Manager Tesco (Aug 2016-Nov 2017). Include Mary Seacole Programme in education. Include key achievements with specific numbers (£14.6M, 14,000 patients, £2.6M, 70%, 200hrs, £1M).
- **Why**: The CV data must be accurate and complete. Missing roles or wrong dates would be a critical error.
### When: Using monospace/code font
**Rule:** Use Geist Mono (font-family: 'Geist Mono', monospace), NOT Fira Code, for coded entries, timestamps, clinical codes, and data values.
**Why:** The spec requires Geist Mono. Fira Code was used in the ECG/boot phase but is wrong for the PMR interface.
### TypeScript strictness
- **When**: Writing any TypeScript code
- **Rule**: No `any` types. Define interfaces for all data structures. Use proper React.FC types or function component signatures with typed props. Enable strict mode in tsconfig.json.
- **Why**: Type safety is a core benefit of the React conversion. `any` defeats the purpose.
### When: Adding shadows to cards or panels
**Rule:** No shadows, or at most `0 1px 2px rgba(0,0,0,0.03)`. Do NOT use prominent shadows like `shadow-md` or `shadow-lg`.
**Why:** Clinical systems structure with borders, not shadows. Prominent shadows look like marketing sites.
### Google Fonts loading
- **When**: Setting up index.html
- **Rule**: Use preconnect links to fonts.googleapis.com AND fonts.gstatic.com (with crossorigin), then the font CSS link. Load ALL fonts: Fira Code, Plus Jakarta Sans, Inter Tight. Test that fonts actually render.
- **Why**: Fonts are critical to the design identity. Missing fonts break the visual concept.
### When: Styling borders
**Rule:** All card and table borders must be `1px solid #E5E7EB` (gray-200). Use `border-gray-200` in Tailwind.
**Why:** This is the universal border color in NHS clinical software.
### Transition timing
- **When**: Building the boot-to-design transition
- **Rule**: Boot phase should take ~4 seconds. ECG animation should take ~5-6 seconds. Total time from page load to fully revealed design: no more than 10 seconds.
- **Why**: Too long and users will leave. Too short and the effect is lost.
## Sidebar Label Convention
### No console errors
- **When**: Writing JavaScript/TypeScript
- **Rule**: No errors in the browser console. Handle edge cases: elements that might not exist, animation cleanup on unmount, proper dependency arrays in hooks.
- **Why**: Console errors suggest broken functionality and are a quality check failure.
### When: Building or modifying sidebar navigation labels
**Rule:** Sidebar labels MUST use CV-friendly terms: Summary, Experience, Skills, Achievements, Projects, Education, Contact. Do NOT use clinical jargon (Consultations, Medications, Problems, Investigations, Documents, Referrals) as sidebar labels. The clinical metaphor lives in the LAYOUT of each view, not the navigation labels.
**Why:** Non-clinical visitors should immediately understand what each section contains. The clinical system aesthetic comes from the visual presentation (consultation journal format, medications table format, etc.), not from the nav labels.
### Responsive breakpoints
- **When**: Adding responsive CSS/Tailwind classes
- **Rule**: Must work at 3 breakpoints: desktop (>768px), tablet (<=768px), mobile (<=480px). Navigation must be usable at all sizes. Content must not overflow horizontally. Touch targets must be reasonable size.
- **Why**: CVs are often viewed on mobile devices.
## Navigation Guardrails
### Scroll animation observer
- **When**: Implementing scroll-triggered animations
- **Rule**: Use IntersectionObserver via custom hook (useScrollReveal), NOT scroll event listeners. Set appropriate threshold (0.1-0.15). Animations should only play once (don't re-trigger on scroll up).
- **Why**: IntersectionObserver is more performant and reliable than scroll listeners.
### When: Switching between sidebar views
**Rule:** View switching must be INSTANT. No crossfade, no slide animation, no opacity transition between views. The main content area simply replaces its content immediately.
**Why:** Clinical systems use instant tab switching. Any animation makes it feel like a website, not clinical software.
### Tailwind CSS usage
- **When**: Writing component styles
- **Rule**: Use Tailwind utility classes for all styling. Only use inline styles or CSS modules for dynamic values that can't be expressed with Tailwind (e.g., stroke-dashoffset calculations). Extend Tailwind config for custom colors.
- **Why**: Consistent styling approach, smaller bundle size, better maintainability.
### When: Building navigation
**Rule:** URL hash routing is required. Each view must update `window.location.hash` and the app must read the hash on load to navigate to the correct view.
**Why:** Direct linking to specific views is required for shareability.
## Project-Specific Guardrails
## Component Guardrails
### Framer Motion for complex animations
- **When**: Animating the boot sequence, ECG paths, branching lines
- **Rule**: Use Framer Motion's `motion` components and props (initial, animate, transition). Use `pathLength` for SVG drawing animations. Use `AnimatePresence` for exit animations. Define transition objects with exact timing from concept.html.
- **Why**: Framer Motion provides declarative, performant animations that are easier to maintain than imperative JS.
### When: Expanding/collapsing consultation entries
**Rule:** Use height animation ONLY (200ms, ease-out). Do NOT fade opacity on the content. Content simply grows/shrinks in height.
**Why:** The spec explicitly states "No opacity fade — the content simply grows/shrinks."
### Skill circle calculation
- **When**: Building SVG circular progress gauges in Skills component
- **Rule**: The circumference formula is `2 * Math.PI * radius`. `strokeDasharray = circumference`. `strokeDashoffset = circumference * (1 - level / 100)`. The circle MUST have `transform: rotate(-90deg)` to start progress from 12 o'clock position.
- **Why**: Wrong math or missing rotation produces circles that fill from the wrong position or have incorrect percentages.
### When: Displaying traffic light status indicators
**Rule:** Traffic lights (colored dots) must ALWAYS be accompanied by text labels (Active, Resolved, In Progress, etc.). Dots are never the sole indicator of state.
**Why:** WCAG accessibility color cannot be the only means of communicating information.
### Component file structure
- **When**: Creating new components
- **Rule**: One component per file in `src/components/`. Named exports for components. Props interface defined at top of file. Follow naming: PascalCase for components (BootSequence.tsx), camelCase for hooks (useScrollReveal.ts).
- **Why**: Consistent organization makes the codebase maintainable.
### When: Writing consultation entries
**Rule:** Use History / Examination / Plan section headers (uppercase, Inter 600, 12px, letter-spacing 0.05em, gray-400). Include CODED ENTRIES at the bottom of each expanded consultation in [XXX000] format.
**Why:** This is the core metaphor — SOAP notes format mapped to career content.
### Lucide React icons
- **When**: Adding icons to Contact or other sections
- **Rule**: Use Lucide React icons instead of unicode symbols. Import specific icons: `import { Phone, Mail, Linkedin, MapPin } from 'lucide-react'`. Size icons consistently (default 24px or specified size prop).
- **Why**: Lucide provides consistent, scalable SVG icons that match the design system.
### When: Rendering the clinical alert
**Rule:** Use Framer Motion `type: "spring"` animation for the alert entrance (not ease-out). The alert uses amber colors: bg `#FEF3C7`, left border `#F59E0B`, text `#92400E`.
**Why:** The spec specifies spring animation with slight overshoot. Alerts demand attention.
### Custom hooks for reusable logic
- **When**: Implementing scroll reveal, active section tracking
- **Rule**: Extract reusable logic into custom hooks in `src/hooks/`. Hooks should be composable and return values/functions needed by components. Name with `use` prefix.
- **Why**: Keeps components clean, enables reuse, follows React best practices.
### When: Writing table markup
**Rule:** Use semantic `<table>`, `<thead>`, `<th>`, `<tbody>`, `<tr>`, `<td>` elements. Column headers must include `scope="col"`. Do NOT use div-based table layouts.
**Why:** Screen readers navigate tables using native table semantics. Div tables are inaccessible.
### Vite configuration
- **When**: Setting up the project build
- **Rule**: Use Vite's default React template. Configure path aliases in `vite.config.ts` for clean imports (e.g., `@/components/Hero`). Ensure `build.outDir` is set correctly.
- **Why**: Vite provides fast dev server and optimized production builds.
## Data Guardrails
### When: Displaying CV content (dates, numbers, roles, achievements)
**Rule:** All data must come from `src/data/*.ts` files. Do NOT hardcode CV content directly in components. Do NOT change any numbers or dates — they are sourced from the verified CV.
**Why:** Data accuracy is critical. The data layer has been validated against CV_v4.md.
### When: Modifying data files
**Rule:** Do NOT modify data files in `src/data/` unless the task explicitly requires it. The data is correct and complete.
**Why:** Data was verified in a prior iteration. Unnecessary changes risk introducing inaccuracies.
## Visual Review Guardrails
### When: Completing any visual component task (Tasks 1b-11)
**Rule:** After quality checks pass, you MUST open the dev server (`http://localhost:5173`) in the browser using Claude in Chrome tools (`tabs_context_mcp`, `navigate`, `computer` with `action: "screenshot"`), take a screenshot of the relevant view, and compare against the reference file spec. Fix any visual discrepancies before committing. If browser tools are unavailable (e.g. Chrome not connected), document this in progress.txt and proceed — do NOT block the iteration.
**Why:** Code review alone cannot catch visual issues. The previous iteration loop produced functionally correct but visually generic output because no one verified the rendered result.
### When: Browser tools fail or Chrome is not connected
**Rule:** If `tabs_context_mcp` or other browser tools fail, skip the visual review step, note it in progress.txt, and continue. Do NOT retry more than twice or spend time debugging browser connectivity.
**Why:** Visual review is valuable but not blocking. The loop must keep making progress.
## Technical Guardrails
### When: Writing TypeScript
**Rule:** No `any` types. All props must have typed interfaces. All data must use the types from `src/types/pmr.ts`.
**Why:** Strict typing prevents runtime errors and maintains code quality.
### When: Adding animations
**Rule:** All animations must respect `prefers-reduced-motion`. With reduced motion: login typing completes instantly, alerts appear without slide, expand/collapse is instant, banner condensation is instant.
**Why:** Accessibility requirement. Users who've opted out of motion must still have a functional experience.
### When: Building visual components (Tasks 1b-11)
**Rule:** Each reference file in `Ralph/refs/` contains a "Design Guidance (from /frontend-design)" section with pre-generated design direction and code patterns. Read this section BEFORE writing code. Do NOT invoke the `/frontend-design` skill at runtime — the guidance is already embedded in the ref files. Follow the aesthetic direction and code patterns provided.
**Why:** The design guidance was pre-generated to avoid context overflow. Previous iterations stalled because the skill output consumed the entire context window, leaving no room to write files.
### When: Running quality checks
**Rule:** Run `npm run typecheck`, `npm run lint`, and `npm run build` after EVERY task. Fix all errors before committing.
**Why:** Build failures compound across iterations. Fix them immediately.
+82 -252
View File
@@ -1,263 +1,93 @@
# Progress Log — React Conversion Phase
# Progress Log
## Codebase Patterns
- **Source of truth**: `References/concept.html` contains the complete working HTML implementation. All animations, timing, colors, and styling must be preserved exactly when porting to React.
- **Tech stack**: React 18+, TypeScript, Vite, Tailwind CSS, Framer Motion, Lucide React
- **Project structure**: Components in `src/components/`, hooks in `src/hooks/`, types in `src/types/`, utilities in `src/lib/`
- **Animation approach**: Framer Motion for complex sequences (boot, ECG), CSS transitions for simple hover effects, IntersectionObserver (via hook) for scroll-triggered animations
- **SVG animations**: Use Framer Motion's `pathLength` prop for drawing effects, or CSS `stroke-dasharray`/`stroke-dashoffset` for skill gauges
- **Skill gauge math**: `circumference = 2 * Math.PI * radius`, `strokeDashoffset = circumference * (1 - level / 100)`, rotate -90deg to start from top
- **Boot sequence timing**: 14 lines × 220ms = ~3080ms, plus 400ms pause, 800ms fade = ~4.28s total
- **ECG timing**: Flatline 1000ms + 3 beats × 600ms + holds 300ms + branching 1500ms + fade 500ms = ~5.5s
- **Color palette**:
- ECG phase: #000 (black), #00ff41 (green), #00e5ff (cyan), #3a6b45 (dim green)
- Final design: #00897B (teal), #FF6B6B (coral), #0F172A (heading), #334155 (text), #94A3B8 (muted)
- **Fonts**: Fira Code (boot), Plus Jakarta Sans (primary), Inter Tight (secondary)
- **Responsive breakpoints**: 768px (tablet), 480px (mobile)
### Project Structure
- Components in `src/components/`, views in `src/components/views/`
- Data files in `src/data/` — consultations.ts, medications.ts, problems.ts, investigations.ts, documents.ts, patient.ts
- Types in `src/types/pmr.ts` (PMR interfaces) and `src/types/index.ts` (Phase type)
- Hooks in `src/hooks/` — useScrollCondensation.ts, useBreakpoint.ts
- Contexts in `src/contexts/` — AccessibilityContext.tsx
- Path alias: `@/` maps to `./src/`
### Phase Management
- App.tsx controls phase: 'boot' -> 'ecg' -> 'login' -> 'pmr'
- BootSequence.tsx handles terminal animation
- ECGAnimation.tsx handles heartbeat + letter tracing + flatline exit
- LoginScreen.tsx bridges to PMRInterface.tsx
### Data Architecture (CORRECT — do not modify)
- All data files are populated with accurate CV content from References/CV_v4.md
- 5 consultation entries (roles), 18 medications (skills), 11 problems (achievements), 6 investigations (projects), 5 documents (education)
- Types are properly defined in pmr.ts — Consultation, Medication, Problem, Investigation, Document, Patient, ViewId
### Design System Requirements (from ref-design-system.md)
- Light-mode ONLY — no dark mode
- NHS blue: #005EB8 (primary interactive)
- Border radius: 4px for cards/inputs (clinical systems use minimal rounding)
- Borders dominate — 1px solid #E5E7EB everywhere
- Table row height: 40px, card padding: 16px, main content padding: 24px
- Fonts: Inter (general text), Geist Mono (coded entries, timestamps, data values)
- Base spacing unit: 4px — clinical density, not marketing site spacing
### Known Dependencies
- React 18.3.1, TypeScript, Vite
- Tailwind CSS for utility classes
- Framer Motion 11.15.0 for animations
- Lucide React 0.468.0 for icons
- fuse.js will need to be installed for Task 12
### Sidebar Label Convention (IMPORTANT)
- Sidebar uses CV-friendly labels, NOT clinical jargon
- Summary (same), Experience (not Consultations), Skills (not Medications), Achievements (not Problems), Projects (not Investigations), Education (not Documents), Contact (not Referrals)
- The clinical metaphor is in the VIEW LAYOUT, not the navigation labels
- Each view should look like its clinical equivalent but the nav label tells the user what CV section they're looking at
### Visual Review (Claude in Chrome)
- Dev server runs on `http://localhost:5173` throughout the loop
- Use browser tools (`tabs_context_mcp`, `navigate`, `computer` screenshot) to verify visual output
- App has boot→ECG→login→PMR sequence (~15s on first load). Wait before screenshotting.
- Once in PMR phase, navigate views via hash routes: `#summary`, `#experience`, `#skills`, `#achievements`, `#projects`, `#education`, `#contact`
- If browser tools fail, skip visual review and note in iteration log — don't block progress
### Critical Styling Notes
- Geist Mono font must be loaded (NOT Fira Code) for coded entries and timestamps
- Patient banner name must be 20px Inter 600 (not 18px)
- Clinical alert must use spring animation (Framer Motion type: "spring"), not ease-out
- View switching must be INSTANT — no crossfade, no slide between views
- Consultation expand/collapse: height animation ONLY, no opacity fade on content
## Iteration Log
### Phase Transition — React Conversion Setup
- Previous phase completed: Single HTML file `concept.html` fully built with all 9 tasks
- New phase started: Convert HTML concept to React + TypeScript + Vite project
- IMPLEMENTATION_PLAN.md updated with 12 React-specific tasks
- RALPH_PROMPT.md updated with explicit /frontend-design skill requirement for all visual components
- This progress.txt reset for new phase
### Iteration 1 — Task 1: Design system foundation and font setup
**Completed:** Task 1
**Changes made:**
- Added Geist Mono font to Google Fonts import in index.html (replacing reliance on Fira Code for PMR components)
- Extended Tailwind config PMR color tokens: added card, text-primary, text-secondary, text-on-dark variants, border colors, selected-row, alert colors
- Fixed borderRadius.card from 16px to 4px (clinical system requirement)
- Added borderRadius.login: 12px (exception for login card per spec)
- Added boxShadow.pmr: minimal clinical shadow
- Added PMR-specific CSS custom properties in index.css (--pmr-* variables)
- Added utility classes: .pmr-theme, .font-inter, .font-geist-mono
### Iteration 4 — Task 5: Build FloatingNav component
- **Completed**: Task 5 - Build FloatingNav component
- **Files created**:
- `src/hooks/useActiveSection.ts` - IntersectionObserver hook for tracking active nav section
- `src/components/FloatingNav.tsx` - Floating pill navigation with active tracking
- **Files modified**:
- `src/index.css` - Added scrollbar-hide utility and smooth scroll behavior
- `src/App.tsx` - Integrated FloatingNav and added section IDs for scroll targets
- **Design decisions**:
- Used IntersectionObserver with rootMargin '-20% 0px -70% 0px' for accurate section detection
- Framer Motion layoutId for smooth indicator dot animation between nav items
- Active section is the topmost visible section (sorted by DOM order)
- Navigation uses button elements for accessibility and proper click handling
- Smooth scroll behavior via CSS `scroll-behavior: smooth` on html element
- Responsive: horizontal scroll with hidden scrollbar on mobile
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- IntersectionObserver thresholds array allows precise tracking of section visibility
- Using a ref to track visible sections prevents React re-render race conditions
**Codebase patterns discovered:**
- The project uses both legacy design tokens (for boot/ECG phases) AND new PMR tokens (for clinical interface) — both need to coexist
- Geist Mono is the correct font for coded entries, timestamps, and clinical codes in PMR (NOT Fira Code)
- Border radius convention: 4px default for clinical components, 12px for login card only, 16px for legacy components
- CSS custom properties namespaced with --pmr- prefix for clarity
<!-- Iterations will be logged here as tasks are completed -->
**Quality checks:** All passed (typecheck, lint, build)
**Visual review:** N/A (configuration task, no visual component)
### Iteration 5 — Task 6: Build Hero section component
- **Completed**: Task 6 - Build Hero section component
- **Files created**:
- `src/components/Hero.tsx` - Hero section with name, title, location, summary, and vital cards
- **Files modified**:
- `src/App.tsx` - Replaced inline hero section with Hero component import
- **Design decisions**:
- Used Framer Motion for staggered entrance animations (name first, then title, location, summary, then vital cards with 0.1s delays)
- VitalCard component with three value size variants: default (28px), small (16px), medium (18px)
- Hover effects: elevation (-translate-y-0.5) and shadow-md transition
- Responsive: flex-wrap with gap-4 for automatic wrapping on smaller screens
- Preserved exact content from concept.html including full summary paragraph
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- CSS clamp() for responsive font sizing works well inline with Framer Motion
- Using a separate VitalCard component with delay prop creates clean staggered animation pattern
**Issues encountered:** None
<!-- Iterations will be logged here as tasks are completed -->
**Design decisions:**
- Kept legacy tokens in place to avoid breaking boot/ECG components
- Used --pmr- namespace for all PMR tokens to distinguish from legacy design system
- Extended Tailwind colors rather than replacing them — allows both themes to work simultaneously
### Iteration 6 — Task 7: Build Skills section with SVG gauges
- **Completed**: Task 7 - Build Skills section with SVG gauges
- **Files created**:
- `src/hooks/useScrollReveal.ts` - IntersectionObserver hook for scroll-triggered animations
- `src/components/Skills.tsx` - Skills section with SVG circular progress gauges
- **Files modified**:
- `src/App.tsx` - Replaced skills placeholder with Skills component
- **Design decisions**:
- SkillGauge component with SVG circular progress using stroke-dashoffset animation
- IntersectionObserver triggers when section is 15% visible
- Staggered animation: 100ms delay between each gauge
- Gauge radius 34px, circumference 213.628, rotates -90deg to start from top
- Transition duration 1.2s ease-out for gauge fill animation
- Framer Motion for card entrance animations (opacity 0→1, y 16→0)
- Color-coded: Technical (teal), Clinical (coral), Strategic (teal)
- Responsive grid: auto-fit with minmax(140px, 1fr)
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- SVG stroke-dashoffset animation triggered via React state + CSS transition works smoothly
- IntersectionObserver cleanup is critical to avoid memory leaks
- Calculating baseDelay per category allows grouped stagger effects
### IMPORTANT — Design Guidance is Pre-Baked
Do NOT invoke the `/frontend-design` skill at runtime — it was pre-run and the output is embedded in each ref file under "Design Guidance (from /frontend-design)". Previous iterations STALLED because the skill output consumed the entire context window. The guidance is now in the ref files — just read and implement.
### Iteration 3 — Task 4: Build ECGAnimation component
- **Completed**: Task 4 - Build ECGAnimation component
- **Files created**:
- `src/components/ECGAnimation.tsx` - Canvas-based ECG animation with heartbeat waveforms and name drawing
- **Files modified**:
- `src/App.tsx` - Updated to use ECGAnimation component instead of placeholder
- **Design decisions**:
- Used canvas API with requestAnimationFrame for smooth 60fps animation
- Ported exact ECG waveform generation from concept.html (PQRST pattern)
- Ported letter waveform interpolation for "ANDREW CHARLWOOD" name drawing
- Implemented glow effects using canvas shadowBlur
- Added scanline overlay (4px horizontal lines) for retro effect
- Added radial gradient vignette for atmosphere
- Background transitions from black to white during exit phase
- Used Framer Motion AnimatePresence for component-level exit animation
- **Animation timing preserved**:
- 4 heartbeat complexes with amplitudes: 0.3, 0.55, 0.85, 1.0
- Trace speed: 450px/s (scaled responsively)
- Hold time after text: 0.75s
- Exit fade: 0.8s
- Total duration: ~5-6 seconds
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- Canvas-based animations need careful cleanup on unmount (cancelAnimationFrame)
- Device pixel ratio (dpr) handling required for crisp rendering on high-DPI displays
- Responsive scaling: `Math.min(1.2, Math.max(0.35, vw / 1400))`
### Iteration 1 — Task 1: Initialize React project
- **Completed**: Task 1 - Initialize React project with Vite + TypeScript + Tailwind
- **Files created**:
- `package.json` with dependencies: React 18, Framer Motion, Lucide React, Tailwind
- `tsconfig.json`, `tsconfig.app.json`, `tsconfig.node.json` for TypeScript
- `vite.config.ts` with path alias `@/` -> `./src/`
- `tailwind.config.js` with custom colors (teal, coral, ecg-green), fonts, shadows
- `postcss.config.js` for Tailwind processing
- `index.html` with Google Fonts (Fira Code, Plus Jakarta Sans, Inter Tight)
- `src/index.css` with Tailwind directives and CSS custom properties
- `src/main.tsx` entry point
- `src/App.tsx` placeholder component
- `src/types/index.ts` with TypeScript interfaces
- `src/lib/utils.ts` with skill gauge calculation helper
- `eslint.config.js` with React hooks and refresh rules
- **Project structure created**: `src/components/`, `src/hooks/`, `src/lib/`, `src/types/`
- **Quality checks**: `npm run typecheck` ✓, `npm run build` ✓, `npm run lint` ✓
- **Learnings**:
- Need `src/vite-env.d.ts` with `/// <reference types="vite/client" />` for CSS imports
- Vite refuses to scaffold in non-empty directory, so manual setup was needed
### Iteration 2 — Task 2 & 3: Project structure and BootSequence
- **Completed**: Task 2 (Set up project structure and types) - was already done in Task 1
- **Completed**: Task 3 - Build BootSequence component
- **Files created**:
- `src/components/BootSequence.tsx` - Terminal typing animation using Framer Motion
- **Design decisions**:
- Used Framer Motion's `motion.div` with `initial`/`animate` props for line reveals
- Each line animates with opacity 0→1, translateY 8px→0 over 400ms
- Staggered delays calculated from cumulative 220ms per line
- Blinking cursor implemented with CSS animation class `animate-blink`
- Used `AnimatePresence` for smooth exit fade (800ms)
- **Boot sequence timing preserved**: 14 lines × 220ms + 400ms pause + 800ms fade = ~4.28s
- **Quality checks**: `npm run typecheck` ✓, `npm run build` ✓, `npm run lint` ✓
- **Learnings**:
- Framer Motion's delay prop uses seconds, not milliseconds
- Used `dangerouslySetInnerHTML` for colored spans within boot lines (matches concept.html structure)
- CSS classes for blink/seed-pulse animations already existed in index.css from Task 1
### Iteration 7 — Task 8: Build Experience section with timeline
- **Completed**: Task 8 - Build Experience section with timeline
- **Files created**:
- `src/components/Experience.tsx` - Timeline component with 5 roles and ECG decoration
- **Files modified**:
- `src/App.tsx` - Replaced Experience placeholder with Experience component
- `src/hooks/useScrollReveal.ts` - Fixed ref type for React 18+ compatibility
- **Design decisions**:
- Vertical timeline with 20% left offset for timeline line and dots
- ECG waveform SVG decoration beside heading (matches concept.html)
- Timeline dots filled (bg-teal) for current roles, outline for past roles
- Cards have hover effects: scale(1.01), shadow-md, left border teal/30
- Framer Motion for staggered entry animations (100ms delay per card)
- useScrollReveal hook triggers animations when section is 10% visible
- Responsive: timeline line and dots hidden on mobile (md:block)
- **Experience data**: 5 roles from Interim Head (May-Nov 2025) to Duty Pharmacy Manager (Aug 2016-Nov 2017)
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- React 18+ RefObject types require non-nullable type param for ref props
- Fixed useScrollReveal to return `RefObject<T>` instead of `RefObject<T | null>`
- data-visible attribute pattern works well for CSS transitions based on JS state
### Iteration 8 — Task 9: Build Education, Projects, Contact sections
- **Completed**: Task 9 - Build Education, Projects, Contact sections
- **Files created**:
- `src/components/Education.tsx` - Education cards with gradient top border
- `src/components/Projects.tsx` - Project cards with gradient border hover effect
- `src/components/Contact.tsx` - Contact grid with Lucide icons
- **Files modified**:
- `src/App.tsx` - Replaced placeholder sections with actual components
- **Design decisions**:
- **Education**: 2-column grid with gradient top border (teal→coral), hover elevation effect
- **Projects**: 2x2 grid with gradient border hover effect using CSS mask technique
- **Contact**: 4-column grid (2x2 on mobile), Lucide icons (Phone, Mail, Linkedin, MapPin)
- Framer Motion for staggered entry animations (100ms delay per card)
- useScrollReveal hook for scroll-triggered visibility
- ExternalLink icon from Lucide for project links
- Contact links use teal color with hover transition
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- CSS gradient border hover effect uses mask-composite: exclude technique
- Lucide icons are tree-shakable - import only what's needed
- Contact items with href conditionally render as anchor tags
### Iteration 9 — Task 10: Build Footer component and main App.tsx
- **Completed**: Task 10 - Build Footer component and main App.tsx
- **Files created**:
- `src/components/Footer.tsx` - Footer with decorative ECG waveform SVG
- **Files modified**:
- `src/App.tsx` - Added Footer import and component to content phase
- **Design decisions**:
- ECG waveform SVG matches concept.html: 120x20 viewBox with PQRST pattern
- Framer Motion for scroll-triggered entrance (opacity 0→1, y 16→0)
- Teal stroke at 30% opacity for subtle branding
- Font-secondary for text-xs muted attribution
- Footer placed outside main element as per semantic HTML
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- App.tsx already had three-phase orchestration working correctly
- Footer scroll animation uses whileInView with once:true and margin:'-50px'
### Iteration 10 — Task 11: Implement scroll animations and responsive design
- **Completed**: Task 11 - Implement scroll animations and responsive design
- **Files modified**:
- `tailwind.config.js` - Added custom 'xs' screen at 480px for mobile breakpoint
- `src/App.tsx` - Added responsive padding (px-5 xs:px-6 md:px-8)
- `src/components/FloatingNav.tsx` - Responsive width and font/padding on mobile
- `src/components/Hero.tsx` - Responsive section padding, vitals grid, title font size
- `src/components/Skills.tsx` - Responsive grid (2→3→auto-fit), gauge size, padding
- `src/components/Experience.tsx` - Responsive card padding, ECG decoration size
- `src/components/Education.tsx` - Responsive section padding
- `src/components/Projects.tsx` - Responsive grid (1 col at tablet, 2 cols at desktop)
- `src/components/Contact.tsx` - Responsive section padding
- `src/components/Footer.tsx` - Responsive padding
- **Design decisions**:
- Added 'xs' breakpoint at 480px to match concept.html mobile breakpoint
- Scroll-reveal animations standardized to opacity 0→1, translateY 24px→0 across all sections
- Responsive patterns from concept.html:
- 768px (md): 2-col grids, smaller nav padding, vitals 2-col grid
- 480px (xs): 1-col grids, smaller fonts, smaller gauges (64px), reduced padding
- Main container uses px-5 xs:px-6 md:px-8 for responsive horizontal padding
- Section padding uses py-12 xs:py-16 md:py-20 for consistent vertical rhythm
- Skills grid: 2 cols mobile, 3 cols tablet, auto-fit desktop
- Hero vitals: stacked mobile, 2-col tablet, flex row desktop
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- Tailwind custom screens allow precise breakpoint matching to design specs
- Using w-16 h-16 xs:w-20 xs:h-20 for SVG gauges maintains aspect ratio while scaling
- Grid-based responsive layouts more reliable than flex-wrap for consistent card sizing
### Iteration 11 — Task 12: Final integration, testing, and polish
- **Completed**: Task 12 - Final integration, testing, and polish
- **Quality checks verified**:
- `npm run typecheck` ✓ - No TypeScript errors
- `npm run lint` ✓ - No ESLint errors
- `npm run build` ✓ - Production build completes (290KB JS, 18KB CSS gzipped to 94KB/4.5KB)
- **CV content accuracy verified** against CV_v4.md:
- Hero: Name, title, location, summary all match
- Experience: 5 roles in correct order with accurate dates and bullet points
- Education: MPharm UEA, Mary Seacole Programme with correct details
- Skills: 18 skills across Technical/Clinical/Strategic categories
- Projects: 4 projects with descriptions and PharMetrics link
- Contact: Phone, email, LinkedIn, location all accurate
- **All 12 tasks completed** - React conversion finished
- **Learnings**:
- Production build size is reasonable at ~94KB gzipped for JS
- All components properly typed with TypeScript strict mode
- IntersectionObserver hooks cleanup correctly on unmount
### ECG Reference Implementation
`ECGCombined.tsx` in the project root is a Remotion version of the ECG animation with a superior mask-based text reveal technique. Task 1b references this for the canvas implementation.
+41 -11
View File
@@ -15,7 +15,8 @@
- Same error repeated N consecutive iterations (stuck)
.PARAMETER Model
Claude model to use. Default: "opus".
Initial Claude model to use. Default: "sonnet". The agent can dynamically switch
models between iterations via <next-model>opus|sonnet</next-model> signals.
.PARAMETER BranchName
Optional git branch name. If provided, creates/checks out the branch before starting.
@@ -34,7 +35,7 @@
#>
param(
[string]$Model = "opus",
[string]$Model = "sonnet",
[string]$BranchName,
[int]$MaxNoProgress = 3,
[int]$MaxSameError = 3
@@ -142,13 +143,40 @@ if (Test-Path $progressFile) {
Write-Host ""
Write-Host "===== Ralph Wiggum Loop (Visualization Improvements) =====" -ForegroundColor Cyan
Write-Host "Model: $Model | Runs until COMPLETE" -ForegroundColor Cyan
Write-Host "Model: $Model (dynamic switching enabled) | Visual review: ON | Runs until COMPLETE" -ForegroundColor Cyan
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
if ($BranchName) { Write-Host "Branch: $BranchName" -ForegroundColor Cyan }
if ($existingIterations -gt 0) { Write-Host "Previous iterations: $existingIterations" -ForegroundColor Cyan }
Write-Host "===========================================" -ForegroundColor Cyan
Write-Host ""
# --- Dev Server (for visual review via Claude in Chrome) ---
$devServerPort = 5173
$devServerPid = $null
try {
$null = Invoke-WebRequest -Uri "http://localhost:$devServerPort" -TimeoutSec 2 -ErrorAction Stop
Write-Host "Dev server detected on port $devServerPort" -ForegroundColor Green
} catch {
Write-Host "Starting dev server (port $devServerPort)..." -ForegroundColor Cyan
$devProc = Start-Process -FilePath "npm.cmd" -ArgumentList "run", "dev" -PassThru -WindowStyle Minimized
$devServerPid = $devProc.Id
for ($w = 1; $w -le 20; $w++) {
Start-Sleep -Seconds 1
try {
$null = Invoke-WebRequest -Uri "http://localhost:$devServerPort" -TimeoutSec 2 -ErrorAction Stop
Write-Host "Dev server ready on port $devServerPort" -ForegroundColor Green
break
} catch {
if ($w -eq 20) {
Write-Warning "Dev server may not be ready - visual review steps may fail"
}
}
}
}
Write-Host ""
$i = 0
while ($true) {
$i++
@@ -306,6 +334,7 @@ while ($true) {
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
Write-Host "No git commits for $MaxNoProgress consecutive iterations. The loop is stalled." -ForegroundColor Red
Write-Host "Check progress.txt and logs/ for details on what went wrong." -ForegroundColor Red
if ($devServerPid) { taskkill /T /F /PID $devServerPid 2>$null | Out-Null }
exit 1
}
} else {
@@ -326,6 +355,7 @@ while ($true) {
Write-Host "Same error pattern for $MaxSameError consecutive iterations:" -ForegroundColor Red
Write-Host " $currentErrorSignature" -ForegroundColor Red
Write-Host "Check progress.txt and logs/ for details." -ForegroundColor Red
if ($devServerPid) { taskkill /T /F /PID $devServerPid 2>$null | Out-Null }
exit 1
}
} elseif ($currentErrorSignature) {
@@ -337,15 +367,14 @@ while ($true) {
$lastErrorSignature = ""
}
# --- Push to Remote ---
$hasRemote = git remote 2>$null
if ($hasRemote) {
$currentBranch = git branch --show-current
git push origin $currentBranch 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host " Pushed to remote." -ForegroundColor Green
# --- Dynamic Model Selection ---
if ($outputString -match "<next-model>(opus|sonnet)</next-model>") {
$nextModel = $Matches[1]
if ($nextModel -ne $Model) {
Write-Host " [Model Switch] $Model -> $nextModel (agent recommendation)" -ForegroundColor Magenta
$Model = $nextModel
} else {
Write-Host " Push failed or no remote configured - continuing." -ForegroundColor DarkYellow
Write-Host " [Model] Staying on $Model" -ForegroundColor DarkGray
}
}
@@ -354,6 +383,7 @@ while ($true) {
Write-Host ""
Write-Host "===== COMPLETE =====" -ForegroundColor Green
Write-Host "Visualization improvements finished after $i iteration(s) this run ($totalIteration total)." -ForegroundColor Green
if ($devServerPid) { taskkill /T /F /PID $devServerPid 2>$null | Out-Null }
exit 0
}
+145
View File
@@ -0,0 +1,145 @@
# Reference: Patient Banner + Sidebar + Navigation
> Extracted from goal.md — Patient Banner, Left Sidebar, and Navigation sections. These are the persistent UI chrome that defines the clinical system feel.
---
## Patient Banner (Persistent Top Chrome)
The patient banner is the most recognizable element of any PMR system. It spans the full viewport width above the main content area and provides constant demographic context.
### Full Banner (80px height, visible at top of page)
```
+---------------------------------------------------------------------------+
| CHARLWOOD, Andrew (Mr) Active (green dot) Open to opps. |
| DOB: 14/02/1993 | NHS No: 2211810 | Norwich, NR1 |
| 07795553088 | andy@charlwood.xyz [Download CV] [Email] [LinkedIn] |
+---------------------------------------------------------------------------+
```
### Content Mapping
| PMR Field | Actual Content | Notes |
|-----------|---------------|-------|
| Patient name | CHARLWOOD, Andrew (Mr) | Surname first, comma-separated — exactly as in clinical systems |
| DOB | 14/02/1993 | DD/MM/YYYY format (UK clinical standard) |
| NHS Number | 221 181 0 | Andy's GPhC registration number formatted like an NHS number (with spaces). Hover tooltip: "GPhC Registration Number" |
| GP Practice | Self-Referred | Tongue-in-cheek — Andy referred himself to this record |
| Address | Norwich, NR1 | Abbreviated postcode area |
| Phone | 07795553088 | Clickable (tel: link) |
| Email | andy@charlwood.xyz | Clickable (mailto: link) |
| Status | Active (green dot) | Like the "registered" status in a PMR |
| Badge | Open to opportunities | Styled as a clinical banner tag (blue background, white text, small pill shape) |
### Action Buttons (top right of banner)
| Button | PMR Equivalent | Action |
|--------|---------------|--------|
| Download CV | Print Summary | Downloads PDF version of CV |
| Email | Send Letter | Opens mailto: link |
| LinkedIn | External Link | Opens LinkedIn profile in new tab |
Buttons are styled as small outlined rectangles with NHS blue text and 1px NHS blue border, 4px radius. On hover: filled NHS blue background with white text.
### Condensed Banner (48px, sticky after scroll)
When the user scrolls past 100px of content, the banner smoothly condenses to show only the essential information on a single line:
```
CHARLWOOD, Andrew (Mr) | NHS No: 2211810 | Active (green dot) [Download CV] [Email]
```
The condensed banner sticks to the top of the viewport (`position: sticky`) with a `z-index` above the content area but below modals/alerts.
---
## Left Sidebar — Clinical Navigation
The sidebar replicates the dark navigation panel found in EMIS Web and similar clinical systems. It provides category-based access to different "record views."
**Width:** 220px (desktop), dark blue-gray (`#1E293B`) background.
### Navigation Items
**IMPORTANT:** Sidebar labels use CV-friendly terms, NOT clinical jargon. The clinical metaphor lives in the LAYOUT of each view, not the labels.
| Icon | Label | View Layout Style | Description |
|------|-------|-------------------|-------------|
| `ClipboardList` | Summary | Patient summary screen | Demographics, active items, current skills, recent role |
| `FileText` | Experience | Consultation journal layout | Reverse-chronological journal of roles with H/E/P format |
| `Pill` | Skills | Medications table layout | Skills table with proficiency dosages and frequency |
| `AlertTriangle` | Achievements | Problems list layout | Challenges resolved and ongoing, with traffic lights |
| `FlaskConical` | Projects | Investigation results layout | Project outcomes with status badges |
| `FolderOpen` | Education | Attached documents layout | Certificates and qualifications |
| `Send` | Contact | Referral form layout | Contact/message form styled as clinical referral |
### Styling
- Each item: 44px height, 16px left padding, icon (18px, `lucide-react`) + label in Inter 500, 14px
- Default state: white text at 70% opacity, transparent background
- Hover state: white text at 100% opacity, background `rgba(255,255,255,0.08)`
- Active state: white text at 100%, NHS blue left border (3px), background `rgba(255,255,255,0.12)`, label in Inter 600
- A thin horizontal separator line (`1px solid rgba(255,255,255,0.1)`) appears between "Summary" and "Consultations" (separating the overview from the detail views)
### Sidebar Footer
At the bottom of the sidebar, in small text (Inter 400, 11px, `#64748B`):
```
Session: A.CHARLWOOD
Logged in: [current time]
```
This updates with the actual current time on mount, reinforcing the "logged in" metaphor.
### Sidebar Header
At the top, above the navigation items, a small logo or system name:
```
CareerRecord PMR
v1.0.0
```
In Inter 500, 13px, white at 50% opacity. Styled like the "EMIS Web" branding that appears in the top-left of the real system.
---
## Navigation
### Primary Navigation: Left Sidebar
The sidebar is always visible on desktop — this is how clinical systems work. There is no floating nav, no hamburger menu on desktop, and no scroll-based navigation. The sidebar provides persistent, direct access to any record section.
### Keyboard Shortcuts
| Sidebar Item | View Layout | Shortcut |
|-------------|-------------|----------|
| Summary | Patient summary | `Alt+1` |
| Experience | Consultation journal | `Alt+2` |
| Skills | Medications table | `Alt+3` |
| Achievements | Problems list | `Alt+4` |
| Projects | Investigation results | `Alt+5` |
| Education | Attached documents | `Alt+6` |
| Contact | Referral form | `Alt+7` |
### URL Hash Routing
Each sidebar item updates the URL hash (`#summary`, `#experience`, `#skills`, `#achievements`, `#projects`, `#education`, `#contact`) for direct linking. On page load, the app reads the hash and navigates to the corresponding view.
### Breadcrumb
A breadcrumb appears at the top of the main content area:
```
Patient Record > Consultations > Interim Head, Population Health
```
The breadcrumb updates as the user navigates deeper (e.g., expanding a consultation). Clicking "Patient Record" returns to Summary. Clicking "Consultations" collapses any expanded entries and shows the full journal list. The breadcrumb is styled in Inter 400, 13px, gray-400, with chevron separators.
### Secondary Navigation: Within-View Interactions
- **Summary:** Clicking "View Full List" or "View Full Record" links navigates to the corresponding sidebar section.
- **Consultations:** Expand/collapse individual entries. "Linked consultations" in Problems view can deep-link to specific consultation entries.
- **Medications:** Category tabs (Active, Clinical, PRN) within the view. Click to expand prescribing history.
- **Problems:** Click to expand. "Linked consultations" navigate to Consultations view.
- **Investigations:** Click to expand results.
- **Documents:** Click to expand preview.
- **Referrals:** No sub-navigation.
+359
View File
@@ -0,0 +1,359 @@
# Reference: Boot Sequence + ECG Animation
> Covers the full pre-login flow: terminal boot → cursor transition → ECG heartbeat → name reveal → flatline. The flatline→login transition is covered in `ref-transition-login.md`.
---
## Current Architecture
Two components manage the pre-login flow:
- `src/components/BootSequence.tsx` → terminal text animation, ends with blinking cursor
- `src/components/ECGAnimation.tsx` → canvas-based heartbeat + name tracing + flatline + bg transition
- `App.tsx` phases: `boot → ecg → login → pmr`
## What Needs to Change
### 1. Boot Sequence — Clean Up for Easy Config
**Problem:** Boot text lines are hardcoded as HTML strings with inline Tailwind classes. Adding/removing/reordering lines requires editing raw HTML. The `dangerouslySetInnerHTML` approach is fragile.
**Fix:** Refactor to a clean config-driven structure:
```typescript
// Example config structure — easy to customize
const BOOT_CONFIG = {
header: { text: 'CLINICAL TERMINAL v3.2.1', style: 'bright' },
lines: [
{ type: 'status', text: 'Initialising pharmacist profile...' },
{ type: 'separator' },
{ type: 'field', label: 'SYSTEM', value: 'NHS Norfolk & Waveney ICB' },
{ type: 'field', label: 'USER', value: 'Andy Charlwood' },
{ type: 'field', label: 'ROLE', value: 'Deputy Head of Population Health & Data Analysis' },
{ type: 'field', label: 'LOCATION', value: 'Norwich, UK' },
{ type: 'separator' },
{ type: 'status', text: 'Loading modules...' },
{ type: 'module', name: 'pharmacist_core.sys' },
{ type: 'module', name: 'population_health.mod' },
{ type: 'module', name: 'data_analytics.eng' },
{ type: 'separator' },
{ type: 'ready', text: 'READY — Rendering CV..' },
],
timing: { lineDelay: 220, holdAfterComplete: 400, fadeOutDuration: 800 },
}
```
- Each line type maps to a React component (not raw HTML)
- Colors remain: bright green `#00ff41`, dim green `#3a6b45`, cyan labels `#00e5ff`
- Staggered reveal timing stays the same (220ms per line)
- Font: Fira Code (this is the terminal phase, NOT the PMR — Fira Code is correct here)
### 2. Cursor → Dot Transition
**Problem:** The boot sequence ends with a blinking green block cursor (`.animate-blink`). The ECG animation starts with a glowing dot that appears at the far left of the screen. There's a visual disconnect — the cursor and dot don't connect.
**Fix:** The blinking cursor at the end of boot should smoothly transition INTO the ECG's glowing trace dot:
- At end of boot, capture the cursor's screen position (x, y)
- Pass this position to ECGAnimation via props
- ECGAnimation starts with its glowing dot AT the cursor position
- The cursor stops blinking and morphs: block cursor → circular glow (scale down width, increase glow, ~300ms)
- The dot then begins moving rightward, drawing the flatline/heartbeat trace behind it
- This means the ECG trace starts at the cursor position, NOT the far left edge
### 3. ECG Start Position
**Problem:** Currently the ECG trace starts at x=0 (far left of viewport). The cursor ends somewhere in the middle-left of the screen. This means the dot "teleports" from cursor position to the left edge.
**Fix:** The ECG animation should:
- Accept a `startPosition: { x: number, y: number }` prop from the boot sequence
- Begin the trace from that position
- The first section of trace is a flat line from the cursor position rightward
- Heartbeats begin after the first flat gap (same timing as now, just offset)
- The viewport scroll logic already handles the "head" position — just shift the world-space origin
### 4. Text Reveal — Mask Technique
**Problem:** The current ECGAnimation.tsx reveals letters by stroking them with progressive alpha (`letterProgress > 0.3` → fade in). This looks like letters fading in, not like the ECG line IS the letter shape.
**Reference:** `ECGCombined.tsx` (Remotion version at project root) uses a superior technique:
- The actual text characters are pre-rendered on the canvas (stroke-only, no fill)
- A wipe mask follows the ECG trace head, revealing the text underneath
- The ECG trace line IS the path that forms each letter shape (via the `getYAtX` function which returns letter Y values when in text region)
- Connector lines between letters sit at the baseline
- The neon glow filter applies to both the trace and revealed text
**What to adopt from ECGCombined.tsx:**
- The mask-based text reveal approach (the trace unveils pre-rendered text)
- The connector lines between letters at baseline
- The neon glow SVG filters (or canvas equivalents)
- The text mask brush that follows the trace head
**What to KEEP from current ECGAnimation.tsx:**
- The character spacing (current `LETTER_W`, `LETTER_G`, `SPACE_W` values — preferred over ECGCombined.tsx spacing)
- The heartbeat waveform shape (`generateHeartbeatPoints`)
- The beat timing and amplitude escalation (0.3 → 0.55 → 0.85 → 1.0)
- The canvas rendering approach (not SVG — canvas is correct for this performance-sensitive animation)
- The viewport scrolling/camera logic
- The flatline draw phase
- The scanline and vignette effects
- The background color transition to `#1E293B`
### 5. Text Rendering
- The name is still "ANDREW CHARLWOOD"
- Letters are stroke-only (no fill) in neon green `#00ff41`
- Each letter shape is defined by the `ECG_LETTERS` point arrays (keep these)
- The trace line passes through each letter's shape points, making the ECG waveform form recognizable letter shapes
- Between letters, the trace returns to baseline with short connector segments
- Neon glow effect on both trace and revealed text
## Transition to Login
After the text is fully revealed and the flatline extends to the right edge, the flow continues as described in `ref-transition-login.md`:
1. Name holds with glow (300ms)
2. Glow fades, flatline extends right
3. Canvas fades to black (200ms)
4. Background transitions to dark blue-gray `#1E293B` (200ms)
5. Login card materializes
## prefers-reduced-motion
With reduced motion enabled:
- Boot text appears instantly (no stagger)
- Cursor appears immediately
- ECG animation is skipped entirely — transition straight from boot to login
- Or: show the final frame (name fully revealed, flatline) as a static image for 1 second, then proceed to login
## Testing Checklist
- [ ] Boot text renders correctly with all lines
- [ ] Blinking cursor visible at end of boot
- [ ] Cursor smoothly transitions to ECG dot (no teleport)
- [ ] ECG trace starts from cursor position
- [ ] Heartbeats render with increasing amplitude
- [ ] Name letters revealed via mask technique (not alpha fade)
- [ ] Connector lines between letters
- [ ] Neon glow on trace and text
- [ ] Flatline extends to right edge after name
- [ ] Background transitions to `#1E293B`
- [ ] Scanlines and vignette present
- [ ] Reduced motion: instant/static
- [ ] Mobile: scales correctly
---
## Design Guidance (from /frontend-design)
> Pre-baked design direction. Do NOT invoke `/frontend-design` at runtime — this section contains the output.
### Aesthetic Direction: Authentic Clinical Terminal → Medical Monitor Realism
This isn't "medical-themed" design — this IS medical equipment interfaces. Two distinct phases:
1. **Boot Terminal**: Authentic 1990s clinical system boot sequence (think legacy pharmacy dispensing systems, hospital terminal logins). CRT monitor aesthetic with phosphor green, scanlines, slight text glow.
2. **ECG Monitor**: Hospital bedside cardiac monitor realism. The heartbeat isn't decorative — it's a functioning waveform that becomes letterforms through technical precision.
**Visual Signature**: The cursor-to-dot morphing transition. Most animations have discrete phases; this creates continuous material transformation — the blinking terminal cursor literally becomes the ECG trace point. It's the moment where "loading system" becomes "reading vital signs."
### Boot Sequence — Type-Safe Config
```typescript
type BootLineType = 'header' | 'status' | 'separator' | 'field' | 'module' | 'ready'
interface BootLine {
type: BootLineType
text: string
label?: string
value?: string
style?: 'bright' | 'dim' | 'cyan'
}
interface BootConfig {
header: string
lines: BootLine[]
timing: {
lineDelay: number
cursorBlinkInterval: number
holdAfterComplete: number
fadeOutDuration: number
}
colors: {
bright: string
dim: string
cyan: string
}
}
```
Component architecture:
- `<BootLine>` — renders individual line types with appropriate styling
- `<BootCursor>` — separate component for cursor with ref exposure for position capture
- Config-driven rendering replaces hardcoded HTML
Example config:
```typescript
const BOOT_CONFIG: BootConfig = {
header: 'CLINICAL TERMINAL v3.2.1',
lines: [
{ type: 'status', text: 'Initialising pharmacist profile...', style: 'dim' },
{ type: 'separator', text: '---', style: 'dim' },
{ type: 'field', label: 'SYSTEM', value: 'NHS Norfolk & Waveney ICB', style: 'cyan' },
{ type: 'field', label: 'USER', value: 'Andy Charlwood', style: 'bright' },
// ... etc
],
timing: { lineDelay: 220, cursorBlinkInterval: 530, holdAfterComplete: 400, fadeOutDuration: 800 },
colors: { bright: '#00ff41', dim: '#3a6b45', cyan: '#00e5ff' }
}
```
Example BootLine component:
```typescript
function BootLine({ line }: { line: BootLine }) {
const colors = BOOT_CONFIG.colors
const color = line.style ? colors[line.style] : colors.dim
if (line.type === 'field') {
return (
<div className="font-mono text-sm leading-relaxed">
<span style={{ color: colors.cyan }}>{line.label?.padEnd(9)}</span>
<span style={{ color }}>{line.value}</span>
</div>
)
}
if (line.type === 'module') {
return (
<div className="font-mono text-sm leading-relaxed">
<span className="font-bold" style={{ color: colors.bright }}>[OK]</span>
{' '}
<span style={{ color: colors.dim }}>{line.text}</span>
</div>
)
}
// ... other types
}
```
### Cursor → Dot Transition — Implementation
```typescript
const [cursorPosition, setCursorPosition] = useState<{x: number, y: number} | null>(null)
const cursorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (cursorRef.current) {
const rect = cursorRef.current.getBoundingClientRect()
setCursorPosition({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
})
}
}, [/* trigger when boot completes */])
// Pass to ECG:
<ECGAnimation startPosition={cursorPosition} onComplete={...} />
```
Morph animation: width 8px→0px, height 16px→6px, border-radius 0→50% (300ms ease-out). Simultaneously fade in ECG dot at same position. After morph complete, begin trace movement.
### Canvas Mask Reveal — Implementation
```javascript
// Pre-render text to offscreen canvas
const textCanvas = document.createElement('canvas')
const textCtx = textCanvas.getContext('2d')
textCtx.strokeStyle = lineColor
textCtx.lineWidth = 1.5
textCtx.font = `bold ${fontSize}px Arial`
textLayout.forEach(item => {
textCtx.strokeText(item.char, item.centerX, baselineY)
})
// During animation loop:
ctx.save()
// Create clipping path following trace head
ctx.beginPath()
ctx.rect(0, 0, headSX + 20, vh) // reveal up to head position + lead
ctx.clip()
// Draw pre-rendered text through clip
ctx.drawImage(textCanvas, -viewOff, 0)
ctx.restore()
// Feathered leading edge:
const gradient = ctx.createLinearGradient(headSX - 30, 0, headSX, 0)
gradient.addColorStop(0, 'rgba(0,255,65,0)')
gradient.addColorStop(1, 'rgba(0,255,65,1)')
```
### Connector Lines Between Letters
```javascript
const connectors: {startX: number, endX: number}[] = []
for (let i = 0; i < textLayout.length - 1; i++) {
const curr = textLayout[i]
const next = textLayout[i + 1]
const endInset = CONNECTOR_INSETS[curr.char] || 0
const startInset = CONNECTOR_INSETS[next.char] || 0
connectors.push({
startX: curr.endX - endInset,
endX: next.startX + startInset
})
}
// During draw:
connectors.forEach(conn => {
if (headWX > conn.startX) {
const connectorEndX = Math.min(conn.endX, headWX)
ctx.beginPath()
ctx.moveTo(conn.startX - viewOff, baselineY)
ctx.lineTo(connectorEndX - viewOff, baselineY)
ctx.stroke()
}
})
```
### Visual Enhancement Details
**CRT Scanlines** (boot phase):
```css
.boot-scanlines {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15) 0px,
transparent 1px,
transparent 2px,
rgba(0, 0, 0, 0.15) 3px
);
pointer-events: none;
animation: scanline-drift 8s linear infinite;
}
```
**Phosphor Glow** (terminal text):
```css
.terminal-text {
text-shadow:
0 0 4px currentColor,
0 0 8px currentColor,
0 0 12px rgba(0, 255, 65, 0.3);
}
```
**ECG Neon Glow** (canvas — multi-layer):
- Primary trace: 2px solid line
- Glow layer 1: 6px @ 25% opacity + shadowBlur 14px
- Glow layer 2: Inner 3px core for sharpness
- Text: Same multi-layer glow approach
**Background Transition** (smooth RGB interpolation):
```javascript
const bgProgress = (elapsed - flatlineStartTime) / BG_TRANSITION
ctx.fillStyle = `rgb(
${Math.round(0 + (30 * bgProgress))},
${Math.round(0 + (41 * bgProgress))},
${Math.round(0 + (59 * bgProgress))}
)` // black → #1E293B
ctx.fillRect(0, 0, vw, vh)
```
+109
View File
@@ -0,0 +1,109 @@
# Reference: Consultations View (= Experience)
> Extracted from goal.md — Consultations View section. Each role is a "consultation entry" in a reverse-chronological journal.
---
## Overview
This is the core content view and the most detailed section. Each role is a "consultation entry" in a reverse-chronological journal.
## Journal List Layout
Entries are stacked vertically, most recent at top. Each entry has a collapsed state and an expanded state.
### Collapsed Entry
```
+------------------------------------------------------------------+
| (green dot) 14 May 2025 | NHS Norfolk & Waveney ICB |
| Interim Head, Population Health & Data Analysis |
| Key: 14.6M efficiency programme identified and delivered |
| [v Expand] |
+------------------------------------------------------------------+
```
- Date in Geist Mono, 13px, gray-500 (left-aligned)
- Organization in Inter 400, 13px, NHS blue
- Role title in Inter 600, 15px, gray-900
- Key coded entry: a single-line summary of the most notable achievement, prefixed with "Key:" in Inter 500, gray-500
- Expand chevron button (right-aligned)
- Status dot: green for current roles, gray for historical
### Expanded Entry (click to expand)
```
+------------------------------------------------------------------+
| (green dot) 14 May 2025 | NHS Norfolk & Waveney ICB [^ Close] |
| Interim Head, Population Health & Data Analysis |
| Duration: May 2025 - Nov 2025 |
| |
| HISTORY |
| Returned to substantive Deputy Head role following |
| commencement of ICB-wide organisational consultation. |
| Led strategic delivery of population health initiatives |
| and data-driven medicines optimisation across Norfolk & |
| Waveney ICS, reporting to Associate Director of Pharmacy. |
| |
| EXAMINATION |
| - Identified 14.6M efficiency programme through |
| comprehensive data analysis |
| - Built Python-based switching algorithm: 14,000 patients |
| identified, 2.6M annual savings |
| - Automated incentive scheme analysis: 50% reduction |
| in targeted prescribing within 2 months |
| |
| PLAN |
| - Achieved over-target performance by October 2025 |
| - 2M on target for delivery this financial year |
| - Presented to CMO bimonthly with evidence-based |
| recommendations |
| - Led transformation to patient-level SQL analytics |
| |
| CODED ENTRIES |
| [EFF001] Efficiency programme: 14.6M identified |
| [ALG001] Algorithm: 14,000 patients, 2.6M savings |
| [AUT001] Automation: 50% prescribing reduction in 2mo |
| [SQL001] Data transformation: practice->patient level |
+------------------------------------------------------------------+
```
## The History / Examination / Plan Structure
This is a direct mapping from the clinical consultation format (SOAP notes) to career content:
| Clinical Term | CV Mapping | What Goes Here |
|--------------|------------|----------------|
| **History** | Context / Background | Why this role existed, what situation Andy walked into, reporting lines |
| **Examination** | Analysis / Findings | What Andy discovered, built, or analyzed — the technical and analytical work |
| **Plan** | Outcomes / Delivery | What was achieved, what impact was measured, what's ongoing |
Section headers ("HISTORY", "EXAMINATION", "PLAN") are styled in Inter 600, 12px, uppercase, letter-spacing 0.05em, gray-400 — exactly like the section dividers in a clinical consultation record.
## Coded Entries
At the bottom of each expanded consultation, "coded entries" appear — short-form tagged achievements with bracket codes. These mimic SNOMED CT / Read codes used in clinical systems. The codes are fictional but consistent (EFF = efficiency, ALG = algorithm, AUT = automation, SQL = data, etc.). Styled in Geist Mono, 12px, gray-500, with the code in brackets and the description after.
## Color Coding by Employer
Each consultation entry has a subtle left border (3px) indicating the employer:
- NHS Norfolk & Waveney ICB: NHS blue (`#005EB8`)
- Tesco PLC: Teal (`#00897B`)
This visual grouping helps the user quickly scan which organization each entry belongs to, without reading the text.
## Full Consultation Journal (all roles)
| Date | Organization | Role | Key Coded Entry |
|------|-------------|------|-----------------|
| May 2025 | NHS N&W ICB | Interim Head, Pop. Health & Data Analysis | [EFF001] 14.6M efficiency programme |
| Jul 2024 | NHS N&W ICB | Deputy Head, Pop. Health & Data Analysis | [BUD001] 220M budget management |
| May 2022 | NHS N&W ICB | High-Cost Drugs & Interface Pharmacist | [AUT002] Blueteq automation: 70% reduction |
| Nov 2017 | Tesco PLC | Pharmacy Manager | [INN001] Asthma screening: ~1M national revenue |
| Aug 2016 | Tesco PLC | Duty Pharmacy Manager | [REG001] GPhC registration commenced |
## Animation Behavior
- **Expand/collapse:** Height animation, 200ms, ease-out. No opacity fade — the content simply grows/shrinks.
- Only one consultation can be expanded at a time. Expanding a new entry collapses the previous one.
- The expand chevron rotates 180 degrees (pointing up when expanded).
+87
View File
@@ -0,0 +1,87 @@
# Reference: Visual Design System
> Extracted from goal.md — Visual System section. This is the SINGLE SOURCE OF TRUTH for colors, typography, spacing, borders, and motion throughout the Clinical Record PMR.
---
## Color Palette
This design is **light-mode only**. Clinical record systems operate in light mode — high ambient lighting in consulting rooms demands white backgrounds and dark text. A dark mode would break the metaphor.
**Backgrounds:**
- Main content area: `#F5F7FA` (cool light gray — the content background of EMIS/SystmOne)
- Card/panel surfaces: `#FFFFFF` (white)
- Sidebar: `#1E293B` (dark blue-gray — EMIS-style dark navigation panel)
- Patient banner: `#334155` (lighter blue-gray with white text)
- Login screen background: `#1E293B` (same as sidebar — institutional dark blue-gray)
**Text:**
- Primary text: `#111827` (gray-900 — near-black for maximum readability)
- Secondary text: `#6B7280` (gray-500)
- On dark surfaces: `#FFFFFF` (white) and `#94A3B8` (slate-400 for secondary)
**Accent and status colors:**
- NHS blue: `#005EB8` — primary interactive color. Used for buttons, active nav states, links, column headers. This is the actual NHS brand blue and will be instantly recognized.
- Green: `#22C55E` — active/resolved/current states. "Active" status dots, resolved problems, current role indicators.
- Amber: `#F59E0B` — alerts, in-progress items, notable achievements. The clinical alert banner uses this as its background.
- Red: `#EF4444` — urgent/critical markers. Used sparingly — only for genuinely important items (e.g., a "priority" flag on the referral form).
- Gray: `#6B7280` — inactive/historical items. Past roles that are no longer current, historical "medications."
**Traffic light system (used throughout):**
- Green circle: Active / Resolved / Current
- Amber circle: In progress / Alert / Notable
- Red circle: Urgent / Critical (rare)
- Gray circle: Inactive / Historical
## Typography
Clinical systems use system fonts — Inter or Segoe UI for general text, monospace for coded entries and data values. No decorative fonts, no variable tracking. Functional typography optimized for scanning dense tables.
- **Patient banner name:** Inter 600, 20px (not huge — clinical systems don't emphasize the patient name with large type)
- **Patient banner details:** Inter 400, 14px
- **Sidebar navigation labels:** Inter 500, 14px, white
- **Section headings (within main area):** Inter 600, 18px
- **Consultation entry titles:** Inter 600, 16px
- **Body text / descriptions:** Inter 400, 14px, line-height 1.6
- **Table headers:** Inter 600, 13px, uppercase, letter-spacing 0.03em, gray-500
- **Table data cells:** Inter 400, 14px
- **Coded entries / data values:** Geist Mono 400, 13px
- **Clinical codes (SNOMED-style):** Geist Mono 400, 12px, gray-400
- **Timestamps:** Geist Mono 400, 12px
- **Alert banner text:** Inter 500, 14px
## Spacing and Layout
- **Sidebar width:** 220px (fixed, desktop). Collapses to 56px (icon-only) on tablet.
- **Patient banner height:** 80px (full), 48px (condensed/sticky)
- **Main content max-width:** No max-width — clinical systems fill available space. Content flows within the area between sidebar and viewport edge.
- **Main content padding:** 24px
- **Card padding:** 16px (clinical systems are more compact than marketing sites)
- **Border radius:** 4px for cards and inputs (clinical systems use minimal rounding — 4px, not 12px or 16px)
- **Table row height:** 40px
- **Section spacing:** 24px between content blocks
- **Base unit:** 4px — tighter spacing than typical, reflecting clinical system density
## Borders and Surfaces
Borders are the dominant visual structuring element. Clinical systems do not rely on shadows or negative space — they use explicit borders to delineate every element.
- **All cards:** `1px solid #E5E7EB` (gray-200) border, `4px` border-radius, no shadow (or at most `0 1px 2px rgba(0,0,0,0.03)`)
- **Table cells:** `1px solid #E5E7EB` borders (all sides)
- **Sidebar border:** `1px solid #334155` (subtle right border in a slightly lighter shade)
- **Patient banner border:** `1px solid #475569` bottom border
- **Input fields:** `1px solid #D1D5DB` border, `4px` radius, `#FFFFFF` background, `8px 12px` padding
- **Active/selected rows:** `#EFF6FF` background (very subtle blue tint) — this is how EMIS highlights the selected row
## Motion
Clinical systems are fast and functional. Animations are minimal and purposeful — no spring physics, no bouncy transitions. Everything is immediate or uses simple ease-out.
- **Navigation switches:** Instant content swap. No crossfade, no slide. When you click a sidebar item, the main content area replaces immediately — just like clicking a tab in EMIS.
- **Consultation expand/collapse:** Height animation, 200ms, `ease-out`. No opacity fade — the content simply grows/shrinks.
- **Alert banner entrance:** Slide down from top, 250ms, with a subtle spring overshoot (this is the one exception — alerts are meant to demand attention).
- **Alert acknowledge:** The alert shrinks in height to zero (200ms) with a small green checkmark that flashes briefly.
- **Hover states:** Background-color transitions, 100ms. No transforms, no lifts. Just color.
- **Login typing:** Character-by-character reveal using `setInterval` (30ms per character for username, 20ms per dot for password).
- **Patient banner scroll condensation:** Smooth height transition (200ms) from full (80px) to condensed (48px) as user scrolls past the first 100px of content.
- **`prefers-reduced-motion`:** Typing animation completes instantly (full text appears), alert slides are replaced with fade-in, expand/collapse is instant.
+162
View File
@@ -0,0 +1,162 @@
# Reference: Interactions, Responsive Design, and Accessibility
> Extracted from goal.md — Interactions, Responsive Strategy, and Accessibility sections.
---
## Interactions and Micro-interactions
### Sidebar Navigation
- Clicking a sidebar item instantly swaps the main content area. No crossfade, no transition — just an immediate swap. This matches clinical system behavior exactly: navigation is instant.
- The active sidebar item updates its left border (3px, NHS blue) and background tint simultaneously, with no animation (instant state change).
### Consultation Expand / Collapse
- Clicking a consultation entry toggles between collapsed and expanded states.
- The expand animation: height grows from 0 to content height over 200ms, ease-out. Content opacity transitions from 0 to 1 over the same duration.
- Only one consultation can be expanded at a time. Expanding a new entry collapses the previous one.
- The expand chevron rotates 180 degrees (pointing up when expanded).
### Medication Row Hover
- Hovering a medication table row changes its background to `#EFF6FF` (subtle blue tint).
- No transform, no elevation change. Just color.
### Table Column Sorting
- Clicking a table column header sorts by that column. An arrow indicator (up/down) appears in the header.
- Clicking the same header again reverses sort direction.
- Sorting is instant (no animation on row reordering).
### Patient Banner Scroll Condensation
- As the user scrolls past 100px of content, the patient banner smoothly transitions from full (80px) to condensed (48px) over 200ms.
- The condensed banner shows only: name, NHS number, status dot, and action buttons.
- Scrolling back to top restores the full banner.
- Uses `position: sticky` with an `IntersectionObserver` to trigger the condensation.
### Alert Acknowledge
- Clicking "Acknowledge" on a clinical alert:
1. The warning icon cross-fades to a green checkmark (200ms)
2. After a 200ms hold, the alert's height animates to 0 (200ms, ease-out)
3. Content below shifts upward to fill the space (same 200ms timing)
### Search
- A search input in the sidebar header ("Search record...") provides fuzzy matching across all PMR sections.
- Typing shows a dropdown of results grouped by section (Consultations, Medications, Problems, etc.).
- Each result shows the section icon, the matching text, and a relevance indicator.
- Pressing Enter or clicking a result navigates to that section with the matching item highlighted/expanded.
- Implementation: fuse.js for fuzzy search across a pre-built index of all content.
### Context Menus
- Right-clicking (desktop) or long-pressing (mobile) on certain elements reveals a context menu:
- On a consultation entry: "Expand", "Copy to clipboard", "View coded entries"
- On a medication row: "View prescribing history", "Copy to clipboard"
- On a problem entry: "View linked consultations", "Copy to clipboard"
- Context menus styled: white background, `1px solid #E5E7EB` border, 4px radius, `box-shadow: 0 4px 12px rgba(0,0,0,0.1)`. Items in Inter 400, 14px, 36px row height.
### Login Screen Typing
- The username types character-by-character (30ms per character).
- The password dots appear faster (20ms per dot).
- A blinking cursor appears in the active field (530ms blink interval).
- The "Log In" button shows a brief active/pressed state before the interface materializes.
---
## Responsive Strategy
### Desktop (>1024px)
The full PMR experience. This is the design's primary target — clinical systems are desktop applications.
- Sidebar: 220px, always visible, dark blue-gray
- Patient banner: full width, 80px height, condensing to 48px on scroll
- Main content: fills remaining width (no max-width constraint)
- Tables: full column display, alternating row colors, sort controls
- Consultations: full History/Examination/Plan expanded view
- Search: integrated in sidebar header
### Tablet (768-1024px)
Sidebar collapses to icon-only mode (56px width). Hovering or tapping an icon shows the label as a tooltip.
- Patient banner: condensed to single-line format always (no full/condensed toggle)
- Main content: nearly full width
- Tables: may horizontally scroll if columns exceed available width
- Context menus: triggered by long-press instead of right-click
### Mobile (<768px)
The sidebar becomes a **bottom navigation bar** with 7 icon buttons.
**Bottom nav layout:**
```
[Summary] [Consult] [Meds] [Problems] [Invest] [Docs] [Refer]
```
Each icon from Lucide, 20px, with the active item highlighted in NHS blue with a label below. Height: 56px with safe area padding.
**Patient banner on mobile:** Minimal top bar: `CHARLWOOD, A (Mr) | 2211810 | (dot)` — action buttons collapse into "..." overflow menu.
**Content adaptations:**
- Tables switch to card layout: each row becomes a small card with fields stacked vertically
- Consultation entries: tap-to-expand pattern with larger tap targets (48px minimum height)
- Medications: table becomes stacked card list
- Referral form: full-width inputs, generous touch targets
- Search: moves to top of each view as a search bar
**Back navigation:** Each view has a back arrow returning to Summary.
### Breakpoint Summary
| Element | Desktop (>1024) | Tablet (768-1024) | Mobile (<768) |
|---------|-----------------|-------------------|---------------|
| Sidebar | 220px, full labels | 56px, icons only | Bottom nav bar |
| Patient banner | 80px full / 48px sticky | 48px always | Minimal top bar |
| Tables | Full columns, horizontal | Scroll if needed | Card layout (stacked) |
| Search | Sidebar header | Sidebar header | Top of each view |
| Context menus | Right-click | Long-press | Long-press |
---
## Accessibility
### Semantic HTML
- Sidebar: `<nav role="navigation" aria-label="Clinical record navigation">` with `<ul>` and `<li>` items. Active item uses `aria-current="page"`.
- Patient banner: `<header role="banner">` containing patient demographics.
- Main content area: `<main>` element with `aria-label` matching the current view name.
- Tables: Proper `<table>`, `<thead>`, `<th>`, `<tbody>`, `<tr>`, `<td>` markup. Column headers use `scope="col"`.
- Consultation entries: `<article>` elements with `<button>` for expand/collapse, `aria-expanded` attribute.
### Keyboard Navigation
- `Tab` moves between: sidebar items, patient banner buttons, main content interactive elements
- `ArrowUp` / `ArrowDown` within the sidebar moves between navigation items (roving tabindex)
- `Enter` / `Space` on sidebar items activates that view
- `Enter` / `Space` on consultation entries toggles expand/collapse
- `Alt+1` through `Alt+7` directly activates sidebar items
- `Escape` closes expanded items, context menus, and search dropdown
- Search input focusable with `/` key
### Screen Reader Experience
1. After login, announces: "Patient Record for Charlwood, Andrew. Summary view."
2. Clinical alert announced via `role="alert"`: full alert text
3. Tables announced with column headers
4. Expandable items announce expanded/collapsed state
5. Breadcrumb uses `<nav aria-label="Breadcrumb">`
### Alert Accessibility
- Uses `role="alert"` and `aria-live="assertive"`
- Acknowledge button: `aria-label="Acknowledge clinical alert"`
- Removal is smooth (element removes from accessibility tree)
### Focus Management
- After login: focus moves to first sidebar item (Summary)
- After navigating to new view: focus moves to first heading in main content
- After expanding consultation: focus moves to HISTORY heading
- After closing context menu: focus returns to trigger element
- After acknowledging alert: focus moves to main content first interactive element
### Color and Contrast
- All text meets WCAG 2.1 AA contrast requirements
- Traffic lights never sole communicator — always with text labels
- NHS blue on white: ~7.3:1 contrast ratio
- Amber alert text on amber bg: ~5.8:1 contrast ratio
### Motion Preferences
When `prefers-reduced-motion: reduce`:
- Login typing completes instantly
- Alert appears without slide
- Expand/collapse is instant
- Banner condensation is instant
- Hover background-color changes remain
+108
View File
@@ -0,0 +1,108 @@
# Reference: Investigations View (= Projects) + Documents View (= Education)
> Extracted from goal.md — Investigations and Documents sections. Two simpler views that share the expandable-row pattern.
---
## Investigations View (= Projects)
Projects presented as diagnostic investigations — tests that were ordered, performed, and returned results.
### Investigation List
```
+--[ Investigation Results ]----------------------------------------------+
| Test Name | Requested | Status | Result |
|------------------------------+-----------+----------+-------------------|
| PharMetrics Interactive | 2024 | Complete | Live (green) |
| Platform | | | |
| Patient Switching Algorithm | 2025 | Complete | 14,000 pts found |
| Blueteq Generator | 2023 | Complete | 70% reduction |
| CD Monitoring System | 2024 | Complete | Population-scale |
| Sankey Chart Analysis Tool | 2023 | Complete | Pathway audit |
| Patient Pathway Analysis | 2024 | Ongoing | In development |
+-------------------------------------------------------------------------+
```
### Status Badges
Styled like laboratory result status indicators:
- **Complete** (green dot): Investigation finished, results available
- **Ongoing** (amber dot): Investigation still in progress
- **Live** (pulsing green dot): Results are actively being used (for PharMetrics, which is a live URL)
### Expanded Investigation View
Clicking an investigation row reveals a detailed "results panel" below the row:
```
PharMetrics Interactive Platform
|-- Date Requested: 2024
|-- Date Reported: 2024
|-- Status: Complete - Live at medicines.charlwood.xyz
|-- Requesting Clinician: A. Charlwood
|-- Methodology:
| Real-time medicines expenditure dashboard providing
| actionable analytics for NHS decision-makers. Built with
| Power BI and SQL, tracking expenditure across the 220M
| prescribing budget.
|-- Results:
| - Real-time tracking of medicines expenditure
| - Actionable analytics for budget holders
| - Self-serve model for wider team
|-- Tech Stack: Power BI, SQL, DAX
|-- [View Results ->] (external link to medicines.charlwood.xyz)
```
The expanded view uses a tree-like indented structure (with box-drawing characters in monospace) to present the investigation report. This mirrors how lab results and imaging reports appear in clinical systems — structured, indented, with labelled fields.
### "View Results" Link
For PharMetrics (the only project with a live URL), a "View Results" button appears styled as an NHS blue action button. For internal projects, this button is absent.
---
## Documents View (= Education & Certifications)
Education and certifications presented as attached documents in the patient record.
### Document List
```
+--[ Attached Documents ]-------------------------------------------------+
| Type | Document | Date | Source |
|----------------+----------------------------------+---------+------------|
| Certificate | MPharm (Hons) 2:1 | 2015 | UEA |
| Registration | GPhC Pharmacist Registration | 2016 | GPhC |
| Certificate | Mary Seacole Programme (78%) | 2018 | NHS LA |
| Results | A-Levels: Maths A*, Chem B, | 2011 | Highworth |
| | Politics C | | Grammar |
| Research | Drug Delivery & Cocrystals | 2015 | UEA |
| | (75.1% Distinction) | | |
+-------------------------------------------------------------------------+
```
### Document Type Icons
Small document icons from Lucide:
- `FileText` for certificates
- `Award` for registrations
- `GraduationCap` for academic results
- `FlaskConical` for research
### Expanded Document Preview
```
MPharm (Hons) 2:1 - University of East Anglia
|-- Type: Academic Qualification
|-- Date Awarded: 2015
|-- Institution: University of East Anglia, Norwich
|-- Classification: Upper Second-Class Honours (2:1)
|-- Duration: 2011 - 2015 (4 years)
|-- Research: Drug delivery and cocrystals
| Grade: 75.1% (Distinction)
|-- Notes: MPharm is a 4-year integrated Master's degree
required for pharmacist registration in the UK.
```
The preview panel uses the same tree-indented structure as the Investigations expanded view, maintaining visual consistency across the PMR interface.
+93
View File
@@ -0,0 +1,93 @@
# Reference: Medications View (= Skills)
> Extracted from goal.md — Medications View section. Skills presented as an active medications list.
---
## Overview
Skills presented as an active medications list — the format every pharmacist and GP reads daily.
## Full Table Layout
```
+--[ Active Medications ]-------------------------------------------------+
| Drug Name | Dose | Frequency | Start | Status |
|--------------------+-------+------------+----------+-------------------|
| Python | 90% | Daily | 2017 | Active (green) |
| SQL | 88% | Daily | 2017 | Active (green) |
| Power BI | 92% | Daily | 2019 | Active (green) |
| Data Analysis | 95% | Daily | 2016 | Active (green) |
| JavaScript / TS | 70% | Weekly | 2020 | Active (green) |
| Dashboard Dev | 88% | Weekly | 2019 | Active (green) |
| Algorithm Design | 82% | Weekly | 2022 | Active (green) |
| Data Pipelines | 80% | Weekly | 2022 | Active (green) |
+-------------------------------------------------------------------------+
+--[ Clinical Medications ]-----------------------------------------------+
| Drug Name | Dose | Frequency | Start | Status |
|-------------------------+-------+------------+--------+----------------|
| Medicines Optimisation | 95% | Daily | 2016 | Active (green) |
| Pop. Health Analytics | 90% | Daily | 2022 | Active (green) |
| NICE TA Implementation | 85% | Weekly | 2022 | Active (green) |
| Health Economics | 80% | Monthly | 2023 | Active (green) |
| Clinical Pathways | 82% | Weekly | 2022 | Active (green) |
| CD Assurance | 88% | Weekly | 2024 | Active (green) |
+-------------------------------------------------------------------------+
+--[ PRN (As Required) ]--------------------------------------------------+
| Drug Name | Dose | Frequency | Start | Status |
|-------------------------+-------+------------+--------+----------------|
| Budget Management | 90% | As needed | 2024 | Active (green) |
| Stakeholder Engagement | 88% | As needed | 2022 | Active (green) |
| Pharma Negotiation | 85% | As needed | 2024 | Active (green) |
| Team Development | 82% | As needed | 2017 | Active (green) |
+-------------------------------------------------------------------------+
```
## Column Definitions
| Column | PMR Meaning | CV Mapping |
|--------|------------|------------|
| Drug Name | Medication name | Skill name |
| Dose | Dosage strength | Proficiency percentage |
| Frequency | How often taken | How often the skill is used (Daily / Weekly / Monthly / As needed) |
| Start | Date prescribed | Year Andy started using this skill (approximate) |
| Status | Active / Stopped | Active (green dot) for current skills, Historical (gray dot) for deprecated skills |
## Medication Categories (tabs within the view)
Skills are grouped into three "medication types," mimicking how clinical systems separate regular, acute, and PRN medications:
- **Active Medications** = Technical skills (the "regular medications" — taken daily, core to function)
- **Clinical Medications** = Healthcare domain skills (the specialist prescriptions)
- **PRN (As Required)** = Strategic & leadership skills (used situationally, not daily)
## Table Styling
- Table headers: Inter 600, 13px, uppercase, gray-400, `#F9FAFB` background
- Table rows: alternating `#FFFFFF` / `#F9FAFB` backgrounds
- Row height: 40px
- All borders: `1px solid #E5E7EB`
- Hover state: row background changes to `#EFF6FF` (subtle blue tint)
- Status dots: 6px circles, inline with status text
## Interaction — Prescribing History
Clicking any medication/skill row expands it downward to show a "prescribing history" — a mini-timeline of how the skill developed:
```
Python | 90% | Daily | 2017 | Active (green)
|-- Prescribing History:
2017 Started: Self-taught for data analysis automation
2019 Increased: Dashboard development, data pipeline work
2022 Specialist use: Blueteq automation, Sankey analysis tools
2024 Advanced: Switching algorithm (14,000 patients), CD monitoring
2025 Current: Population-level analytics, incentive scheme automation
```
The history entries are styled in Geist Mono, 12px, with year markers as bold anchors and descriptions in regular weight. This "prescribing history" shows skill progression in a format that clinicians understand intuitively.
## Sortable Columns
Table columns are sortable by clicking the header. Clicking "Dose" sorts by proficiency descending. Clicking "Start" sorts chronologically. A small sort indicator arrow appears in the active sort column header. Default sort: by category grouping.
+60
View File
@@ -0,0 +1,60 @@
# Reference: Problems View (= Achievements / Challenges)
> Extracted from goal.md — Problems View section. Career achievements framed as clinical problems that were identified, treated, and resolved.
---
## Overview
The "Problems" list in a clinical record tracks diagnoses — conditions that were identified, treated, and either resolved or require ongoing management. This maps perfectly to career achievements: challenges that Andy identified and resolved.
## Two Sections: Active Problems and Resolved Problems
### Active Problems (current / ongoing)
```
+--[ Active Problems ]----------------------------------------------------+
| Status | Code | Problem | Since |
|--------+-----------+--------------------------------------+------------|
| AMB | [MGT001] | 220M prescribing budget oversight | Jul 2024 |
| GRN | [TRN001] | Patient-level SQL transformation | 2025 |
| AMB | [LEA001] | Team data literacy programme | Jul 2024 |
+-------------------------------------------------------------------------+
```
### Resolved Problems (past achievements)
```
+--[ Resolved Problems ]--------------------------------------------------+
| Status | Code | Problem | Resolved | Outcome |
|--------+-----------+--------------------------------+-----------+------------------------------------------|
| GRN | [EFF001] | Manual prescribing analysis | Oct 2025 | Python algorithm: 14,000 pts, 2.6M/yr |
| | | inefficiency | | |
| GRN | [EFF002] | 14.6M efficiency target | Oct 2025 | Over-target performance achieved |
| GRN | [AUT001] | Blueteq form creation backlog | 2023 | 70% reduction, 200hrs saved |
| GRN | [INN001] | Asthma screening scalability | 2019 | National rollout: ~300 branches, ~1M |
| GRN | [AUT002] | Incentive scheme manual calc. | 2025 | Automated: 50% Rx reduction in 2 months |
| GRN | [DAT001] | HCD spend tracking gaps | 2023 | Blueteq-secondary care data integration |
| GRN | [VIS001] | Patient pathway opacity | 2023 | Sankey chart analysis tool |
| GRN | [MON001] | Population opioid exposure | 2024 | CD monitoring system: OME tracking |
| | | monitoring | | |
+-------------------------------------------------------------------------+
```
## Column Definitions
| Column | Meaning |
|--------|---------|
| Status | Traffic light: Green (resolved), Amber (in progress / active), Red (urgent — unused, reserved) |
| Code | SNOMED-style reference code. Fictional but internally consistent. Formatted in Geist Mono. |
| Problem | The challenge or opportunity Andy identified |
| Resolved | Date or year the problem was resolved |
| Outcome | Brief description of the resolution and its measurable impact |
## Expandable Rows
Each problem row can be expanded to show a full narrative: what the problem was, how Andy approached it, what tools/methods were used, and the quantified outcome. The expanded state also shows "linked consultations" — clicking a link navigates to the relevant entry in Consultations view.
## Traffic Light Status Indicators
Traffic lights are 8px circles with the status colors (green, amber, red, gray). They appear inline before the code column. This is exactly how clinical systems indicate problem severity/status — it's an immediately scannable visual language.
+72
View File
@@ -0,0 +1,72 @@
# Reference: Referrals View (= Contact)
> Extracted from goal.md — Referrals View section. Contact information presented as a clinical referral form.
---
## Overview
Contact information presented as a clinical referral form — the mechanism for "referring" a patient (Andy) to another service.
## Referral Form Layout
```
+--[ New Referral ]-------------------------------------------------------+
| |
| Referring to: CHARLWOOD, Andrew (Mr) |
| NHS Number: 221 181 0 |
| |
| Priority: ( ) Urgent (*) Routine ( ) Two-Week Wait |
| |
| Referrer Name: [________________________] |
| Referrer Email: [________________________] |
| Referrer Org: [________________________] (optional) |
| |
| Reason for Referral: |
| [ ] |
| [ ] |
| [ ] |
| |
| Contact Method: ( ) Email ( ) Phone ( ) LinkedIn |
| |
| [ Cancel ] [ Send Referral ] |
+-------------------------------------------------------------------------+
```
## Priority Toggle (tongue-in-cheek)
Three radio options styled like clinical referral priorities:
- **Urgent**: Red label, red dot. Selectable but the tooltip reads "All enquiries are welcome, urgent or not."
- **Routine**: Blue label, blue dot. Default selected.
- **Two-Week Wait**: Amber label. Tooltip: "NHS cancer referral pathway - this isn't that, but the spirit of promptness applies."
This is the design's main moment of humor. The priority options are visually authentic to clinical referral forms, and the tongue-in-cheek tooltips reward exploration without undermining the professional tone.
## Form Fields
Standard clinical form inputs: `1px solid #D1D5DB` border, `4px` radius, `8px 12px` padding. Labels in Inter 500, 13px, gray-600, positioned above inputs. Focus state: border changes to NHS blue, subtle blue glow (`box-shadow: 0 0 0 3px rgba(0, 94, 184, 0.15)`).
## Submit Button
"Send Referral" in NHS blue (`#005EB8`), white text, full width of the right half of the form. On hover: darkens to `#004494`. On click: brief loading state (spinner icon), then a success message:
```
Checkmark Referral sent successfully
Reference: REF-2026-0210-001
Expected response time: 24-48 hours
```
The reference number is generated from the current date. The success state mimics the confirmation screen shown after submitting a clinical referral in EMIS.
## Alternative Contact Methods (below the form)
```
+--[ Direct Contact ]-----------------------------------------------------+
| Email: andy@charlwood.xyz [Send Email ->] |
| Phone: 07795553088 [Call ->] |
| LinkedIn: linkedin.com/in/andycharlwood [View Profile ->] |
| Location: Norwich, UK |
+-------------------------------------------------------------------------+
```
Styled as a simple key-value table, same format as the Patient Demographics card in Summary view.
+116
View File
@@ -0,0 +1,116 @@
# Reference: Summary View + Clinical Alert
> Extracted from goal.md — Summary View and Clinical Alert sections. This is the landing view after login.
---
## Summary View
The landing view after login. This mimics the "Patient Summary" screen — the first screen a clinician sees when opening a patient record, showing the most important information at a glance.
**Layout:** A grid of summary cards arranged in a 2-column layout on desktop, single column on mobile. Each card has a header bar with the card title in Inter 600, 14px, uppercase, on a `#F9FAFB` background with `1px solid #E5E7EB` bottom border.
### Card 1: Patient Demographics (spans full width)
```
+--[ Patient Demographics ]------------------------------------------+
| Name: Andrew Charlwood Status: Active (dot) |
| DOB: 14 February 1993 Location: Norwich, UK |
| Registration: GPhC 2211810 Since: August 2016 |
| Qualification: MPharm (Hons) 2:1 University: UEA, 2015 |
+---------------------------------------------------------------------+
```
A two-column key-value table. Labels in Inter 500, 13px, gray-500. Values in Inter 400, 14px, gray-900. Labels right-aligned, values left-aligned — mimicking clinical system demographics layout.
### Card 2: Active Problems (left column)
```
+--[ Active Problems ]-----------------------------------------------+
| (green dot) Deputy Head, Pop. Health & Data Analysis Jul 2024-Present |
| NHS Norfolk & Waveney ICB |
| (green dot) 220M prescribing budget management Ongoing |
| (amber dot) Patient-level SQL analytics transformation In progress |
+---------------------------------------------------------------------+
```
A list with green dots for active/current items, amber dots for in-progress items. Each entry has a title in Inter 500, 14px, and a date range or status in Geist Mono, 12px, right-aligned. Click an entry to navigate to the corresponding Consultation.
### Card 3: Current Medications — Quick View (right column)
```
+--[ Current Medications (Quick View) ]-------------------------------+
| Python | 90% | Daily | Active (green dot) |
| SQL | 88% | Daily | Active (green dot) |
| Power BI | 92% | Daily | Active (green dot) |
| Data Analysis | 95% | Daily | Active (green dot) |
| JS / TypeScript | 70% | Weekly | Active (green dot) |
| [View Full List ->] |
+---------------------------------------------------------------------+
```
A compact 4-column table showing the top 5 skills. "View Full List" links to the Medications view. Table headers are uppercase, 12px, gray-400. Table rows alternate between `#FFFFFF` and `#F9FAFB` backgrounds.
### Card 4: Last Consultation (spans full width)
```
+--[ Last Consultation ]----------------------------------------------+
| Date: May 2025 Clinician: A. Charlwood Location: NHS N&W ICB |
| |
| Interim Head, Population Health & Data Analysis |
| Led strategic delivery of population health initiatives and |
| data-driven medicines optimisation across Norfolk & Waveney ICS... |
| [View Full Record ->] |
+---------------------------------------------------------------------+
```
A preview of the most recent role, truncated to 2-3 lines. "View Full Record" navigates to Consultations with that entry expanded.
### Card 5: Alerts (full width, positioned above all other cards)
This is the Clinical Alert — see below.
---
## The Clinical Alert (Signature Interaction)
When the user first loads the Summary view (immediately after the login transition), a clinical alert banner slides down from beneath the patient banner.
### Alert Styling
```
+--[ WARNING CLINICAL ALERT ]------------------------------------------+
| WARNING ALERT: This patient has identified 14.6M in prescribing |
| efficiency savings across Norfolk & Waveney ICS. |
| [Acknowledge]|
+----------------------------------------------------------------------+
```
- Background: amber (`#FEF3C7` — amber-100, light amber)
- Left border: 4px solid `#F59E0B` (amber-500)
- Warning icon: `AlertTriangle` from Lucide, amber-600
- Text: Inter 500, 14px, `#92400E` (amber-800)
- "Acknowledge" button: small outlined button, amber border and text
### Behavior
1. The alert slides down from beneath the patient banner with a spring animation (250ms, slight overshoot) after the PMR interface finishes materializing.
2. It pushes the Summary content downward, so it's impossible to miss.
3. Clicking "Acknowledge" triggers a brief animation: a green checkmark replaces the warning icon (200ms), then the alert collapses upward (200ms, ease-out) and is gone.
4. The dismiss state is stored in React state (session-only) — refreshing the page shows the alert again.
### Why This Works
Clinical alerts are the mechanism that clinical systems use to put critical information in front of clinicians before they do anything else. They are the highest-priority information in the system. By framing Andy's most impressive metric ("14.6M") as a clinical alert, it gets the same treatment — it's the first thing the user reads, it demands acknowledgment, and its format gives the number institutional weight. This is not a boast in a paragraph; it's a system-generated alert based on data. The framing makes the achievement feel objective.
### Second Alert (on Consultations view)
When the user first navigates to Consultations, a secondary alert appears:
```
WARNING NOTE: Patient has developed a Python-based switching algorithm
identifying 14,000 patients for cost-effective medication alternatives.
2.6M annual savings potential. Review recommended.
```
This second alert reinforces the key technical achievement in clinical language. It appears only once (on first navigation to Consultations) and is dismissible with the same "Acknowledge" interaction.
+202
View File
@@ -0,0 +1,202 @@
# Reference: ECG Transition + Login Sequence
> Extracted from goal.md — ECG Transition section. This covers the flatline exit from the ECG animation and the immersive login sequence that bridges into the PMR interface.
---
## Starting Point
"ANDREW CHARLWOOD" is on screen in neon green (`#00ff41`) on black. The heartbeat trace is complete. The name is fully formed and glowing.
## Phase 1: The Flatline (600ms)
The neon green name holds for a beat (300ms). Then the glow around the letters begins to fade. Simultaneously, from the right edge of the name, a flatline trace extends rightward — a perfectly horizontal green line drawn at the baseline, extending across the remaining viewport width over 300ms. The visual reads as a patient monitor flatline. This is deliberate: the "patient" (the animation phase) is ending. A new record is about to open.
The flatline has a subtle audio-visual implication without actual sound — the green line is steady and unbroken, the glow around the name letters reduces to zero. The entire canvas is now: a fading green name with a horizontal flatline extending to the right edge. All on black.
## Phase 2: Screen Clear (400ms)
The entire canvas fades to black over 200ms (the name and flatline dissolve into darkness). Then, from black, the background transitions to a dark blue-gray (`#1E293B`) over 200ms. This is the color of a clinical system login screen — the dark institutional background that every NHS worker recognizes from their Monday morning.
## Phase 3: Login Sequence (1200ms)
A login panel materializes center-screen: a white card (320px wide, 12px border-radius, subtle shadow) on the dark blue-gray background. The card contains:
- A small NHS-blue shield icon or generic clinical system logo at the top
- **Username field**: Empty text input with label "Username". After 200ms, a cursor appears and types `A.CHARLWOOD` character by character (30ms per character, ~350ms total). The typing uses Geist Mono / monospace font.
- **Password field**: After a 150ms pause, dots fill the password field in rapid succession (8 dots, 20ms each, ~160ms total).
- **"Log In" button**: NHS blue (`#005EB8`), full width. After another 150ms pause, the button receives a subtle pressed state (darkens slightly, 100ms) as if clicked.
The login card holds for 200ms in its "submitted" state, then...
## Phase 4: Interface Materialization (500ms)
The login card scales up slightly (103%) and fades out (200ms). As it fades, the full PMR interface fades in behind it:
1. **Patient banner** slides down from the top edge (200ms, ease-out)
2. **Sidebar** slides in from the left edge (250ms, ease-out, starting 50ms after the banner)
3. **Main content area** (Summary view) fades in (300ms, starting 100ms after sidebar begins)
4. **Clinical alert banner** slides down from beneath the patient banner (250ms, spring easing, starting 200ms after main content appears)
## Phase 5: Final State
The full PMR interface is visible: patient banner at top, dark sidebar on left, Summary view in the main content area, and the clinical alert banner demanding attention. The user is now "logged in" to Andy's career record.
**Total transition duration:** ~2.7 seconds
## Why This Works
The login sequence is the most immersive transition of all designs. Every NHS worker, every pharmacist, every GP has typed their credentials into a clinical system at 8am on a Monday. This transition puts them right there. It's specific, it's authentic, and it immediately establishes the metaphor: you are opening a patient record. The "patient" happens to be a career.
## Login Animation Implementation Notes
- Component mounts with dark blue-gray background
- Login card fades in (Framer Motion, 200ms)
- Username typing: `setInterval` adds one character per 30ms to a state string
- Password dots: `setInterval` adds one dot per 20ms
- Button press: state change triggers visual pressed state, then 200ms delay
- `onComplete` callback fires, parent component swaps to PMRInterface
- Typing respects `prefers-reduced-motion` — with reduced motion, full username appears instantly and login completes in ~500ms total
- **Font: Geist Mono** for username/password fields (NOT Fira Code)
---
## Design Guidance (from /frontend-design)
> Pre-baked design direction. Do NOT invoke `/frontend-design` at runtime — this section contains the output.
### Aesthetic Direction: Institutional Utilitarian
This is not "exciting" design — it is the visual equivalent of fluorescent lights, laminate desks, and the smell of hand sanitiser at 07:58 on a Monday morning. The card must look like every single hospital login prompt a doctor has ever seen: clean white, unadorned, functional. No personality. The branding is the only concession to identity. The magic is not visual flair — it is the uncanny recognition of "oh, this is exactly what that looks like" combined with the satisfying typewriter rhythm of credentials appearing.
### Key Design Decisions
1. **Active field focus ring**: NHS-blue border (`1px solid #005EB8`) on the currently active field, inactive fields shift to `#FAFAFA` background. Mirrors real NHS login forms (Lorenzo, SystmOne, EMIS Web). Transition 150ms.
2. **Reduced shadow to spec**: Use exactly `0 1px 2px rgba(0,0,0,0.03)`. Card sits on dark background through border definition, not shadow depth — more faithful to real NHS software.
3. **Border**: Use `1px solid #E5E7EB` per design system (not `rgba(255,255,255,0.1)`).
4. **Timer cleanup**: Track every `setInterval` and `setTimeout` via refs, clear all on unmount.
5. **Consolidated active field state**: Single `activeField` state (`'username' | 'password' | null`) instead of separate booleans.
6. **Accessibility**: `role="status"` + `aria-label` on outer container. Cursor pipes `aria-hidden="true"`. Card entrance `scale: 0.98` (not 0).
7. **The Monday-morning feeling**: No gradients, no decorative elements, no loading spinners, no "Welcome back!" messaging. Just white rectangle on gray, shield icon, two fields, button. Typing speed deliberately mechanical.
### Implementation Pattern
```tsx
import { useState, useEffect, useCallback, useRef } from 'react'
import { motion } from 'framer-motion'
import { Shield } from 'lucide-react'
interface LoginScreenProps {
onComplete: () => void
}
// Key state
const [username, setUsername] = useState('')
const [passwordDots, setPasswordDots] = useState(0)
const [showCursor, setShowCursor] = useState(true)
const [activeField, setActiveField] = useState<'username' | 'password' | null>('username')
const [buttonPressed, setButtonPressed] = useState(false)
const [isExiting, setIsExiting] = useState(false)
const fullUsername = 'A.CHARLWOOD'
const passwordLength = 8
```
Card structure:
```tsx
<motion.div
className="bg-white"
style={{
width: '320px',
padding: '32px',
borderRadius: '12px',
border: '1px solid #E5E7EB',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.03)',
}}
initial={{ opacity: 0, scale: 0.98 }}
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
```
Branding header:
```tsx
<div className="flex flex-col items-center" style={{ marginBottom: '28px' }}>
<div style={{
padding: '10px',
borderRadius: '8px',
backgroundColor: 'rgba(0, 94, 184, 0.07)',
marginBottom: '10px',
}}>
<Shield size={26} style={{ color: '#005EB8' }} strokeWidth={2.5} />
</div>
<span style={{
fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '13px', fontWeight: 600,
color: '#64748B', letterSpacing: '0.01em',
}}>CareerRecord PMR</span>
<span style={{
fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '11px', fontWeight: 400,
color: '#94A3B8', marginTop: '2px',
}}>Clinical Information System</span>
</div>
```
Input field pattern (username example):
```tsx
<div style={{
width: '100%',
padding: '9px 11px',
fontFamily: "'Geist Mono', 'Fira Code', monospace",
fontSize: '13px',
backgroundColor: activeField === 'username' ? '#FFFFFF' : '#FAFAFA',
border: activeField === 'username' ? '1px solid #005EB8' : '1px solid #E5E7EB',
borderRadius: '4px',
color: '#111827',
minHeight: '38px',
display: 'flex',
alignItems: 'center',
}}>
<span>{username}</span>
{activeField === 'username' && (
<span style={{ opacity: showCursor ? 1 : 0, color: '#005EB8' }} aria-hidden="true">|</span>
)}
</div>
```
Login button:
```tsx
<button style={{
width: '100%',
padding: '10px 16px',
fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '14px', fontWeight: 600,
color: '#FFFFFF',
backgroundColor: buttonPressed ? '#004494' : '#005EB8',
border: 'none',
borderRadius: '4px',
}}>Log In</button>
```
Typing sequence (reduced motion branch):
```tsx
if (prefersReducedMotion) {
setUsername(fullUsername)
setPasswordDots(passwordLength)
setActiveField(null)
setTimeout(() => { setButtonPressed(true); setTimeout(triggerComplete, 100) }, 300)
return
}
// Normal: username at 30ms/char, 150ms pause, password at 20ms/dot, 150ms pause, button press
```
Footer:
```tsx
<div style={{ marginTop: '22px', paddingTop: '18px', borderTop: '1px solid #E5E7EB' }}>
<p style={{
fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '11px', color: '#94A3B8', textAlign: 'center',
}}>Secure clinical system login</p>
</div>
```
+1127
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -7,7 +7,8 @@
<title>Andy Charlwood — MPharm | CV</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600&family=Inter:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head>
<body>
<div id="root"></div>
+11 -28
View File
@@ -2,48 +2,31 @@ import { useState } from 'react'
import type { Phase } from './types'
import { BootSequence } from './components/BootSequence'
import { ECGAnimation } from './components/ECGAnimation'
import { FloatingNav } from './components/FloatingNav'
import { Hero } from './components/Hero'
import { Skills } from './components/Skills'
import { Experience } from './components/Experience'
import { Education } from './components/Education'
import { Projects } from './components/Projects'
import { Contact } from './components/Contact'
import { Footer } from './components/Footer'
import { LoginScreen } from './components/LoginScreen'
import { PMRInterface } from './components/PMRInterface'
import { AccessibilityProvider } from './contexts/AccessibilityContext'
function App() {
const [phase, setPhase] = useState<Phase>('boot')
return (
<div className="min-h-screen bg-white">
<AccessibilityProvider>
<div className="min-h-screen bg-black">
{phase === 'boot' && (
<BootSequence onComplete={() => setPhase('ecg')} />
)}
{phase === 'ecg' && (
<ECGAnimation onComplete={() => setPhase('content')} />
<ECGAnimation onComplete={() => setPhase('login')} />
)}
{phase === 'content' && (
<>
<FloatingNav />
<main className="max-w-[1000px] mx-auto px-5 xs:px-6 md:px-8">
<Hero />
<Skills />
<Experience />
<Education />
<Projects />
<Contact />
</main>
<Footer />
</>
{phase === 'login' && (
<LoginScreen onComplete={() => setPhase('pmr')} />
)}
{phase === 'pmr' && <PMRInterface />}
</div>
</AccessibilityProvider>
)
}
+19 -17
View File
@@ -7,6 +7,7 @@ interface BootLine {
}
const bootLines: BootLine[] = [
{ html: '<span class="text-[#00ff41] font-bold">CLINICAL TERMINAL v3.2.1</span>', delay: 0 },
{ html: '<span class="text-[#3a6b45]">Initialising pharmacist profile...</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
@@ -23,24 +24,25 @@ const bootLines: BootLine[] = [
{ html: '<span class="text-[#00ff41] font-bold">&gt; READY — Rendering CV..<span class="ecg-seed-dot" id="ecg-seed-dot">.</span></span>', delay: 220 },
]
// Precompute cumulative delays so the first render can use them
const bootLineDelays: number[] = (() => {
const delays: number[] = []
let total = 0
bootLines.forEach((line) => {
delays.push(total)
total += line.delay
})
return delays
})()
interface BootSequenceProps {
onComplete: () => void
}
export function BootSequence({ onComplete }: BootSequenceProps) {
const [isVisible, setIsVisible] = useState(true)
const [lineDelays, setLineDelays] = useState<number[]>([])
useEffect(() => {
const delays: number[] = []
let totalDelay = 0
bootLines.forEach((line) => {
delays.push(totalDelay)
totalDelay += line.delay
})
setLineDelays(delays)
const totalBootTime = totalDelay
const totalBootTime = bootLines.reduce((sum, l) => sum + l.delay, 0)
const fadeStartTime = totalBootTime + 400
const fadeTimer = setTimeout(() => {
@@ -49,7 +51,7 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
const completeTimer = setTimeout(() => {
onComplete()
}, fadeStartTime + 800)
}, fadeStartTime+2000)
return () => {
clearTimeout(fadeTimer)
@@ -63,10 +65,10 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
<motion.div
className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden"
initial={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
exit={{ opacity: 1 }}
transition={{ delay: 2, duration: 0.8, ease: 'easeOut' }}
>
<div className="flex flex-col gap-1 max-w-[640px]">
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2">
{bootLines.map((line, index) => (
<motion.div
key={index}
@@ -74,7 +76,7 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: lineDelays[index] / 1000,
delay: (bootLineDelays[index] ?? 0) / 1000,
duration: 0.4,
ease: 'easeOut',
}}
@@ -85,7 +87,7 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
className="inline-block w-2 h-4 bg-[#00ff41] ml-1 animate-blink"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: lineDelays[lineDelays.length - 1] / 1000 }}
transition={{ delay: 2 + (bootLineDelays[bootLineDelays.length + 1] ?? 0) / 1000 }}
/>
</div>
</motion.div>
+329
View File
@@ -0,0 +1,329 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import {
ClipboardList,
FileText,
Pill,
AlertTriangle,
FlaskConical,
FolderOpen,
Send,
Search,
X,
} from 'lucide-react'
import type { ViewId } from '../types/pmr'
import { useAccessibility } from '../contexts/AccessibilityContext'
interface NavItem {
id: ViewId
label: string
icon: React.ReactNode
}
interface ClinicalSidebarProps {
activeView: ViewId
onViewChange: (view: ViewId) => void
isTablet?: boolean
}
const navItems: NavItem[] = [
{ id: 'summary', label: 'Summary', icon: <ClipboardList size={18} /> },
{ id: 'consultations', label: 'Consultations', icon: <FileText size={18} /> },
{ id: 'medications', label: 'Medications', icon: <Pill size={18} /> },
{ id: 'problems', label: 'Problems', icon: <AlertTriangle size={18} /> },
{ id: 'investigations', label: 'Investigations', icon: <FlaskConical size={18} /> },
{ id: 'documents', label: 'Documents', icon: <FolderOpen size={18} /> },
{ id: 'referrals', label: 'Referrals', icon: <Send size={18} /> },
]
function getCurrentTime(): string {
const now = new Date()
return now.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
})
}
export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }: ClinicalSidebarProps) {
const [currentTime, setCurrentTime] = useState(getCurrentTime)
const [searchQuery, setSearchQuery] = useState('')
const [isSearchFocused, setIsSearchFocused] = useState(false)
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
const [hoveredItem, setHoveredItem] = useState<ViewId | null>(null)
const navButtonRefs = useRef<(HTMLButtonElement | null)[]>([])
const { focusAfterLoginRef } = useAccessibility()
const handleNavClick = useCallback(
(view: ViewId) => {
onViewChange(view)
window.location.hash = view
},
[onViewChange]
)
const handleNavKeyDown = useCallback((e: React.KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
if (index < navItems.length - 1) {
setFocusedIndex(index + 1)
navButtonRefs.current[index + 1]?.focus()
}
break
case 'ArrowUp':
e.preventDefault()
if (index > 0) {
setFocusedIndex(index - 1)
navButtonRefs.current[index - 1]?.focus()
}
break
case 'Enter':
case ' ':
e.preventDefault()
handleNavClick(navItems[index].id)
break
case 'Home':
e.preventDefault()
setFocusedIndex(0)
navButtonRefs.current[0]?.focus()
break
case 'End':
e.preventDefault()
setFocusedIndex(navItems.length - 1)
navButtonRefs.current[navItems.length - 1]?.focus()
break
}
}, [handleNavClick])
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(getCurrentTime())
}, 60000)
return () => clearInterval(interval)
}, [])
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1) as ViewId
if (navItems.some(item => item.id === hash)) {
onViewChange(hash)
}
}
handleHashChange()
window.addEventListener('hashchange', handleHashChange)
return () => window.removeEventListener('hashchange', handleHashChange)
}, [onViewChange])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.altKey && e.key >= '1' && e.key <= '7') {
e.preventDefault()
const index = parseInt(e.key) - 1
if (navItems[index]) {
const view = navItems[index].id
onViewChange(view)
window.location.hash = view
}
}
if (e.key === '/' && !isSearchFocused && document.activeElement?.tagName !== 'INPUT') {
e.preventDefault()
const searchInput = document.getElementById('sidebar-search')
searchInput?.focus()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onViewChange, isSearchFocused])
useEffect(() => {
if (navButtonRefs.current[0]) {
;(focusAfterLoginRef as React.MutableRefObject<HTMLButtonElement | null>).current = navButtonRefs.current[0]
}
}, [focusAfterLoginRef])
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setSearchQuery('')
;(e.target as HTMLInputElement).blur()
}
}
const clearSearch = () => {
setSearchQuery('')
const searchInput = document.getElementById('sidebar-search')
searchInput?.focus()
}
const filteredItems = useMemo(() => {
if (!searchQuery.trim()) return []
const query = searchQuery.toLowerCase()
return navItems.filter(item =>
item.label.toLowerCase().includes(query)
)
}, [searchQuery])
if (isTablet) {
return (
<aside
role="navigation"
aria-label="Clinical record navigation"
className="hidden md:flex lg:hidden flex-col w-14 h-screen sticky top-0 bg-pmr-sidebar text-white"
>
<div className="p-2 border-b border-white/10">
<div className="font-inter font-medium text-[10px] text-white/50 text-center leading-tight">
PMR
</div>
</div>
<nav className="flex-1 py-2 overflow-y-auto">
<ul role="menu" aria-label="Record sections">
{navItems.map((item, index) => (
<li key={item.id} role="none" className="relative">
{index === 1 && (
<div className="mx-2 my-1 border-t border-white/10" role="separator" aria-hidden="true" />
)}
<button
ref={el => { navButtonRefs.current[index] = el }}
type="button"
role="menuitem"
tabIndex={focusedIndex === null ? (index === 0 ? 0 : -1) : (focusedIndex === index ? 0 : -1)}
aria-current={activeView === item.id ? 'page' : undefined}
aria-label={item.label}
onClick={() => handleNavClick(item.id)}
onKeyDown={e => handleNavKeyDown(e, index)}
onMouseEnter={() => setHoveredItem(item.id)}
onMouseLeave={() => setHoveredItem(null)}
className={`
w-full flex items-center justify-center h-11
transition-colors relative
${activeView === item.id
? 'text-white bg-white/12 border-l-[3px] border-pmr-nhsblue'
: 'text-white/70 hover:text-white hover:bg-white/8'}
`}
>
<span className={activeView === item.id ? 'text-white' : 'text-white/60'}>
{item.icon}
</span>
{hoveredItem === item.id && (
<div className="absolute left-full ml-2 px-2 py-1 bg-gray-900 text-white text-xs rounded whitespace-nowrap z-50 font-inter">
{item.label}
</div>
)}
</button>
</li>
))}
</ul>
</nav>
<div className="p-2 border-t border-white/10">
<div className="font-inter text-[9px] text-slate-400 text-center leading-relaxed">
<div>A.C</div>
<div>{currentTime}</div>
</div>
</div>
</aside>
)
}
return (
<aside
role="navigation"
aria-label="Clinical record navigation"
className="hidden lg:flex flex-col w-[220px] h-screen sticky top-0 bg-pmr-sidebar text-white"
>
<div className="p-4 border-b border-white/10">
<div className="font-inter font-medium text-[13px] text-white/50 leading-tight">
CareerRecord PMR
</div>
<div className="font-inter text-[11px] text-white/40 mt-0.5">v1.0.0</div>
</div>
<div className="p-3 border-b border-white/10">
<div className="relative">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
/>
<input
id="sidebar-search"
type="text"
placeholder="Search record..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
onKeyDown={handleSearchKeyDown}
className="w-full h-9 pl-8 pr-7 bg-white/5 border border-white/10 rounded text-sm font-inter text-white placeholder-white/40 focus:outline-none focus:border-pmr-nhsblue focus:bg-white/10 transition-colors"
/>
{searchQuery && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/70 transition-colors"
aria-label="Clear search"
>
<X size={14} />
</button>
)}
{searchQuery && filteredItems.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-pmr-sidebar border border-white/10 rounded overflow-hidden z-50">
{filteredItems.map(item => (
<button
key={item.id}
type="button"
onClick={() => {
handleNavClick(item.id)
setSearchQuery('')
}}
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-white/10 transition-colors"
>
<span className="text-white/60">{item.icon}</span>
<span className="font-inter text-sm">{item.label}</span>
</button>
))}
</div>
)}
</div>
</div>
<nav className="flex-1 py-2 overflow-y-auto">
<ul role="menu" aria-label="Record sections">
{navItems.map((item, index) => (
<li key={item.id} role="none">
{index === 1 && (
<div className="mx-3 my-1 border-t border-white/10" role="separator" aria-hidden="true" />
)}
<button
ref={el => { navButtonRefs.current[index] = el }}
type="button"
role="menuitem"
tabIndex={focusedIndex === null ? (index === 0 ? 0 : -1) : (focusedIndex === index ? 0 : -1)}
aria-current={activeView === item.id ? 'page' : undefined}
onClick={() => handleNavClick(item.id)}
onKeyDown={e => handleNavKeyDown(e, index)}
className={`w-full flex items-center gap-3 h-11 px-4 text-left transition-colors ${
activeView === item.id
? 'text-white bg-white/12 border-l-[3px] border-pmr-nhsblue font-semibold'
: 'text-white/70 hover:text-white hover:bg-white/8'
}`}
>
<span className={activeView === item.id ? 'text-white' : 'text-white/60'}>
{item.icon}
</span>
<span className="font-inter text-sm">{item.label}</span>
</button>
</li>
))}
</ul>
</nav>
<div className="p-4 border-t border-white/10">
<div className="font-inter text-[11px] text-slate-400 leading-relaxed">
<div>Session: A.CHARLWOOD</div>
<div>Logged in: {currentTime}</div>
</div>
</div>
</aside>
)
}
+34 -20
View File
@@ -124,12 +124,15 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
const SPACE_W = 30 * scale
const TRACE_SPEED = 450 * scale
const FLAT_GAP = 0.4
const HOLD_TIME = 0.75
const EXIT_TIME = 0.8
const FLATLINE_HOLD = 0.3
const FLATLINE_DRAW = 0.3
const FADE_TO_BLACK = 0.2
const BG_TRANSITION = 0.2
const baselineY = vh * 0.5
const ecgMaxDefl = vh * 0.25
const textMaxDefl = vh * 0.08
const lineColor = '#00ff41'
const loginBgColor = '#1E293B'
const beats: Beat[] = [
{ startTime: 0.5, widthPx: 60 * scale, amplitude: 0.3, startWX: 0 },
@@ -150,8 +153,11 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
const headScreenRatio = 0.75
const finalHeadSX = (vw - totalTextW) / 2 + totalTextW
const textEndTime = textEndWX / TRACE_SPEED
const holdEndTime = textEndTime + HOLD_TIME
const exitEndTime = holdEndTime + EXIT_TIME
const holdEndTime = textEndTime + FLATLINE_HOLD
const flatlineEndTime = holdEndTime + FLATLINE_DRAW
const fadeEndTime = flatlineEndTime + FADE_TO_BLACK
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION
const exitEndTime = bgTransitionEndTime
const getYAtX = (wx: number): number => {
for (let i = 0; i < beats.length; i++) {
@@ -186,10 +192,12 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
ctx.clearRect(0, 0, vw, vh)
let headWX = elapsed * TRACE_SPEED
const isExitPhase = elapsed >= holdEndTime
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < flatlineEndTime
const isFadePhase = elapsed >= flatlineEndTime && elapsed < fadeEndTime
const isBgTransitionPhase = elapsed >= fadeEndTime
if (isExitPhase) {
headWX = textEndWX + (elapsed - holdEndTime) * TRACE_SPEED * 1.5
if (elapsed >= textEndTime) {
headWX = textEndWX
}
let headSX: number
@@ -199,7 +207,7 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
if (headWX <= textStartWX) {
viewOff = Math.max(0, headWX - headSXEcg)
headSX = headWX - viewOff
} else if (headWX >= textEndWX || isExitPhase) {
} else if (headWX >= textEndWX || elapsed >= textEndTime) {
viewOff = textEndWX - finalHeadSX
headSX = headWX - viewOff
} else {
@@ -208,19 +216,24 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
viewOff = headWX - headSX
}
const fadeAlpha = isExitPhase ? Math.max(0, 1 - (elapsed - holdEndTime) / EXIT_TIME) : 1
let fadeAlpha = 1
if (isFadePhase) {
fadeAlpha = Math.max(0, 1 - (elapsed - flatlineEndTime) / FADE_TO_BLACK)
} else if (isBgTransitionPhase) {
fadeAlpha = 0
}
if (!bgTransitionedRef.current && elapsed >= textEndTime - 0.3) {
if (!bgTransitionedRef.current && elapsed >= flatlineEndTime) {
bgTransitionedRef.current = true
container.style.transition = 'background 1200ms ease-out'
container.style.background = '#FFFFFF'
container.style.transition = `background ${BG_TRANSITION * 1000}ms ease-out`
container.style.background = loginBgColor
}
ctx.save()
ctx.globalAlpha = fadeAlpha
const traceStart = Math.max(0, Math.floor(viewOff))
const traceEnd = Math.min(Math.ceil(isExitPhase ? textEndWX : headWX), Math.ceil(viewOff + vw))
const traceEnd = Math.min(Math.ceil(elapsed >= textEndTime ? textEndWX : headWX), Math.ceil(viewOff + vw))
if (traceEnd > traceStart) {
ctx.beginPath()
@@ -251,15 +264,16 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
ctx.stroke()
}
if (isExitPhase) {
const exitStartSX = textEndWX - viewOff
const exitEndSX = headWX - viewOff
if (isFlatlinePhase) {
const flatlineProgress = (elapsed - holdEndTime) / FLATLINE_DRAW
const flatlineEndSX = finalHeadSX + flatlineProgress * (vw - finalHeadSX + 50)
ctx.beginPath()
ctx.strokeStyle = lineColor
ctx.lineWidth = 2
ctx.shadowBlur = 8
ctx.moveTo(exitStartSX, baselineY)
ctx.lineTo(exitEndSX, baselineY)
ctx.shadowColor = lineColor
ctx.moveTo(finalHeadSX, baselineY)
ctx.lineTo(flatlineEndSX, baselineY)
ctx.stroke()
}
@@ -284,8 +298,8 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
ctx.globalAlpha = fadeAlpha
ctx.shadowBlur = 0
if (headSX >= -20 && headSX <= vw + 20) {
const headY = isExitPhase ? baselineY : getYAtX(headWX)
if (headSX >= -20 && headSX <= vw + 20 && elapsed < flatlineEndTime) {
const headY = isFlatlinePhase ? baselineY : getYAtX(headWX)
const grad = ctx.createRadialGradient(headSX, headY, 0, headSX, headY, 20 * scale)
grad.addColorStop(0, 'rgba(255,255,255,0.8)')
grad.addColorStop(0.3, 'rgba(0,255,65,0.6)')
+281
View File
@@ -0,0 +1,281 @@
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { Shield } from 'lucide-react'
import { useAccessibility } from '../contexts/AccessibilityContext'
interface LoginScreenProps {
onComplete: () => void
}
export function LoginScreen({ onComplete }: LoginScreenProps) {
const [username, setUsername] = useState('')
const [passwordDots, setPasswordDots] = useState(0)
const [showCursor, setShowCursor] = useState(true)
const [isTypingUsername, setIsTypingUsername] = useState(true)
const [isTypingPassword, setIsTypingPassword] = useState(false)
const [buttonPressed, setButtonPressed] = useState(false)
const [isExiting, setIsExiting] = useState(false)
const { requestFocusAfterLogin } = useAccessibility()
const fullUsername = 'A.CHARLWOOD'
const passwordLength = 8
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
const triggerComplete = useCallback(() => {
setIsExiting(true)
setTimeout(() => {
requestFocusAfterLogin()
onComplete()
}, prefersReducedMotion ? 0 : 200)
}, [onComplete, requestFocusAfterLogin, prefersReducedMotion])
const startLoginSequence = useCallback(() => {
if (prefersReducedMotion) {
setUsername(fullUsername)
setPasswordDots(passwordLength)
setTimeout(() => {
setButtonPressed(true)
setTimeout(() => {
triggerComplete()
}, 100)
}, 300)
return
}
setIsTypingUsername(true)
let usernameIndex = 0
const usernameInterval = setInterval(() => {
if (usernameIndex <= fullUsername.length) {
setUsername(fullUsername.slice(0, usernameIndex))
usernameIndex++
} else {
clearInterval(usernameInterval)
setIsTypingUsername(false)
setIsTypingPassword(true)
setTimeout(() => {
let dotCount = 0
const passwordInterval = setInterval(() => {
if (dotCount <= passwordLength) {
setPasswordDots(dotCount)
dotCount++
} else {
clearInterval(passwordInterval)
setIsTypingPassword(false)
setTimeout(() => {
setButtonPressed(true)
setTimeout(() => {
triggerComplete()
}, 100)
}, 150)
}
}, 20)
}, 150)
}
}, 30)
}, [triggerComplete, prefersReducedMotion])
useEffect(() => {
const cursorInterval = setInterval(() => {
setShowCursor(prev => !prev)
}, 530)
startLoginSequence()
return () => clearInterval(cursorInterval)
}, [startLoginSequence])
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: '#1E293B' }}
>
<motion.div
className="bg-white p-8"
style={{
width: '320px',
borderRadius: '12px',
border: '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)',
}}
initial={{ opacity: 0 }}
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
{/* Branding */}
<div className="flex flex-col items-center mb-8">
<div
className="p-3 rounded-lg mb-3"
style={{ backgroundColor: 'rgba(0, 94, 184, 0.08)' }}
>
<Shield
size={28}
style={{ color: '#005EB8' }}
strokeWidth={2.5}
/>
</div>
<span
style={{
fontFamily: 'Inter, sans-serif',
fontSize: '13px',
fontWeight: 600,
color: '#64748B',
letterSpacing: '0.01em',
}}
>
CareerRecord PMR
</span>
<span
style={{
fontFamily: 'Inter, sans-serif',
fontSize: '11px',
fontWeight: 400,
color: '#94A3B8',
marginTop: '2px',
}}
>
Clinical Information System
</span>
</div>
{/* Login Form */}
<div className="space-y-5">
{/* Username Field */}
<div>
<label
style={{
display: 'block',
fontFamily: 'Inter, sans-serif',
fontSize: '12px',
fontWeight: 500,
color: '#64748B',
marginBottom: '6px',
}}
>
Username
</label>
<div
style={{
width: '100%',
padding: '10px 12px',
fontFamily: "'Geist Mono', 'Courier New', monospace",
fontSize: '13px',
backgroundColor: '#FFFFFF',
border: '1px solid #D1D5DB',
borderRadius: '4px',
color: '#111827',
minHeight: '38px',
display: 'flex',
alignItems: 'center',
}}
>
<span>{username}</span>
{isTypingUsername && (
<span
style={{
opacity: showCursor ? 1 : 0,
color: '#005EB8',
marginLeft: '1px',
}}
>
|
</span>
)}
</div>
</div>
{/* Password Field */}
<div>
<label
style={{
display: 'block',
fontFamily: 'Inter, sans-serif',
fontSize: '12px',
fontWeight: 500,
color: '#64748B',
marginBottom: '6px',
}}
>
Password
</label>
<div
style={{
width: '100%',
padding: '10px 12px',
fontFamily: "'Geist Mono', 'Courier New', monospace",
fontSize: '13px',
backgroundColor: '#FFFFFF',
border: '1px solid #D1D5DB',
borderRadius: '4px',
color: '#111827',
letterSpacing: '0.15em',
minHeight: '38px',
display: 'flex',
alignItems: 'center',
}}
>
<span>{'\u2022'.repeat(passwordDots)}</span>
{isTypingPassword && (
<span
style={{
opacity: showCursor ? 1 : 0,
color: '#005EB8',
marginLeft: '2px',
}}
>
|
</span>
)}
</div>
</div>
{/* Log In Button */}
<button
style={{
width: '100%',
padding: '11px 16px',
fontFamily: 'Inter, sans-serif',
fontSize: '14px',
fontWeight: 600,
color: '#FFFFFF',
backgroundColor: buttonPressed ? '#004494' : '#005EB8',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
transition: 'background-color 100ms ease-out',
marginTop: '8px',
}}
>
Log In
</button>
</div>
{/* Footer */}
<div
style={{
marginTop: '24px',
paddingTop: '20px',
borderTop: '1px solid #E5E7EB',
}}
>
<p
style={{
fontFamily: 'Inter, sans-serif',
fontSize: '11px',
color: '#94A3B8',
textAlign: 'center',
lineHeight: '1.4',
}}
>
Secure clinical system login
</p>
</div>
</motion.div>
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { ClipboardList, FileText, Pill, AlertTriangle, FlaskConical, FolderOpen, Send } from 'lucide-react'
import type { ViewId } from '../types/pmr'
interface NavItem {
id: ViewId
label: string
shortLabel: string
icon: React.ReactNode
}
const navItems: NavItem[] = [
{ id: 'summary', label: 'Summary', shortLabel: 'Summary', icon: <ClipboardList size={20} /> },
{ id: 'consultations', label: 'Consultations', shortLabel: 'Consult', icon: <FileText size={20} /> },
{ id: 'medications', label: 'Medications', shortLabel: 'Meds', icon: <Pill size={20} /> },
{ id: 'problems', label: 'Problems', shortLabel: 'Issues', icon: <AlertTriangle size={20} /> },
{ id: 'investigations', label: 'Investigations', shortLabel: 'Tests', icon: <FlaskConical size={20} /> },
{ id: 'documents', label: 'Documents', shortLabel: 'Docs', icon: <FolderOpen size={20} /> },
{ id: 'referrals', label: 'Referrals', shortLabel: 'Refer', icon: <Send size={20} /> },
]
interface MobileBottomNavProps {
activeView: ViewId
onViewChange: (view: ViewId) => void
}
export function MobileBottomNav({ activeView, onViewChange }: MobileBottomNavProps) {
const handleNavClick = (view: ViewId) => {
onViewChange(view)
window.location.hash = view
}
return (
<nav
className="fixed bottom-0 left-0 right-0 z-50 bg-pmr-sidebar border-t border-white/10"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
role="navigation"
aria-label="Mobile navigation"
>
<ul className="flex items-center justify-around h-14">
{navItems.map((item) => {
const isActive = activeView === item.id
return (
<li key={item.id}>
<button
type="button"
onClick={() => handleNavClick(item.id)}
className={`
flex flex-col items-center justify-center
w-12 h-14 rounded-lg
transition-colors duration-100
${isActive
? 'text-pmr-nhsblue'
: 'text-white/60 hover:text-white/90'}
`}
aria-current={isActive ? 'page' : undefined}
aria-label={item.label}
>
{item.icon}
<span className="text-[10px] mt-0.5 font-inter font-medium">
{item.shortLabel}
</span>
</button>
</li>
)
})}
</ul>
</nav>
)
}
+258
View File
@@ -0,0 +1,258 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { motion, Variants } from 'framer-motion'
import { Search, X, ArrowLeft } from 'lucide-react'
import type { ViewId } from '../types/pmr'
import { ClinicalSidebar } from './ClinicalSidebar'
import { PatientBanner } from './PatientBanner'
import { MobileBottomNav } from './MobileBottomNav'
import { SummaryView } from './views/SummaryView'
import { ConsultationsView } from './views/ConsultationsView'
import { MedicationsView } from './views/MedicationsView'
import { ProblemsView } from './views/ProblemsView'
import { InvestigationsView } from './views/InvestigationsView'
import { DocumentsView } from './views/DocumentsView'
import { ReferralsView } from './views/ReferralsView'
import { useAccessibility } from '../contexts/AccessibilityContext'
import { useBreakpoint } from '../hooks/useBreakpoint'
interface PMRInterfaceProps {
children?: React.ReactNode
}
function PMRContent({ children }: PMRInterfaceProps) {
const [activeView, setActiveView] = useState<ViewId>(() => {
const hash = window.location.hash.slice(1) as ViewId
const validViews: ViewId[] = [
'summary',
'consultations',
'medications',
'problems',
'investigations',
'documents',
'referrals',
]
return validViews.includes(hash) ? hash : 'summary'
})
const [mobileSearchQuery, setMobileSearchQuery] = useState('')
const viewHeadingRef = useRef<HTMLDivElement>(null)
const { requestFocusAfterViewChange, expandedItemId, setExpandedItem } = useAccessibility()
const { isMobile, isTablet } = useBreakpoint()
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
const bannerVariants = useMemo<Variants>(() => ({
hidden: prefersReducedMotion ? {} : { y: -80, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
}
}), [prefersReducedMotion])
const sidebarVariants = useMemo<Variants>(() => ({
hidden: prefersReducedMotion ? {} : { x: -220, opacity: 0 },
visible: {
x: 0,
opacity: 1,
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.25, ease: 'easeOut', delay: 0.05 }
}
}), [prefersReducedMotion])
const contentVariants = useMemo<Variants>(() => ({
hidden: prefersReducedMotion ? {} : { opacity: 0 },
visible: {
opacity: 1,
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.3, delay: 0.15 }
}
}), [prefersReducedMotion])
const mobileNavVariants = useMemo<Variants>(() => ({
hidden: prefersReducedMotion ? {} : { y: 56, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
}
}), [prefersReducedMotion])
useEffect(() => {
requestFocusAfterViewChange()
if (viewHeadingRef.current) {
viewHeadingRef.current.focus()
}
}, [activeView, requestFocusAfterViewChange])
const handleViewChange = (view: ViewId) => {
setActiveView(view)
if (expandedItemId) {
setExpandedItem(null)
}
}
const handleNavigate = (view: ViewId) => {
setActiveView(view)
window.location.hash = view
if (expandedItemId) {
setExpandedItem(null)
}
}
const handleBackToSummary = () => {
handleViewChange('summary')
window.location.hash = 'summary'
}
const renderView = () => {
switch (activeView) {
case 'summary':
return <SummaryView onNavigate={handleNavigate} />
case 'consultations':
return <ConsultationsView />
case 'medications':
return <MedicationsView />
case 'problems':
return <ProblemsView onNavigate={handleNavigate} />
case 'investigations':
return <InvestigationsView />
case 'documents':
return <DocumentsView />
case 'referrals':
return <ReferralsView />
default:
return (
<div className="bg-white border border-gray-200 rounded p-6">
<h1 className="font-inter font-semibold text-lg text-gray-900 capitalize">
{activeView} View
</h1>
<p className="font-inter text-sm text-gray-500 mt-2">
Content for {activeView} will be implemented in a separate task.
</p>
</div>
)
}
}
const viewLabels: Record<ViewId, string> = {
summary: 'Patient Summary',
consultations: 'Consultation History',
medications: 'Current Medications',
problems: 'Problem List',
investigations: 'Investigation Results',
documents: 'Attached Documents',
referrals: 'Referral Form',
}
return (
<motion.div
className="min-h-screen bg-pmr-content"
initial="hidden"
animate="visible"
>
<motion.div variants={bannerVariants}>
<PatientBanner isMobile={isMobile} isTablet={isTablet} />
</motion.div>
<div className="flex">
{!isMobile && (
<motion.div variants={sidebarVariants}>
<ClinicalSidebar
activeView={activeView}
onViewChange={handleViewChange}
isTablet={isTablet}
/>
</motion.div>
)}
<motion.main
variants={contentVariants}
role="main"
aria-label={`${activeView} view`}
className={`
flex-1 p-4 md:p-6
${isMobile ? 'pb-20' : ''}
${isTablet ? 'min-h-[calc(100vh-48px)]' : 'min-h-[calc(100vh-80px)]'}
`}
>
{isMobile && (
<MobileSearchBar
query={mobileSearchQuery}
onChange={setMobileSearchQuery}
/>
)}
<div
ref={viewHeadingRef}
tabIndex={-1}
className="outline-none"
aria-label={viewLabels[activeView]}
>
<h1 className="sr-only">{viewLabels[activeView]}</h1>
</div>
{isMobile && activeView !== 'summary' && (
<button
type="button"
onClick={handleBackToSummary}
className="flex items-center gap-1 text-pmr-nhsblue text-sm font-inter font-medium mb-4 hover:underline"
>
<ArrowLeft size={14} />
Back to Summary
</button>
)}
{children || renderView()}
</motion.main>
</div>
{isMobile && (
<motion.div variants={mobileNavVariants}>
<MobileBottomNav
activeView={activeView}
onViewChange={handleViewChange}
/>
</motion.div>
)}
</motion.div>
)
}
interface MobileSearchBarProps {
query: string
onChange: (query: string) => void
}
function MobileSearchBar({ query, onChange }: MobileSearchBarProps) {
return (
<div className="mb-4">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
/>
<input
type="text"
placeholder="Search record..."
value={query}
onChange={e => onChange(e.target.value)}
className="w-full h-10 pl-10 pr-10 bg-white border border-gray-200 rounded text-sm font-inter text-gray-900 placeholder-gray-400 focus:outline-none focus:border-pmr-nhsblue focus:ring-1 focus:ring-pmr-nhsblue/20 transition-colors"
/>
{query && (
<button
type="button"
onClick={() => onChange('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Clear search"
>
<X size={16} />
</button>
)}
</div>
</div>
)
}
export function PMRInterface(props: PMRInterfaceProps) {
return <PMRContent {...props} />
}
+282
View File
@@ -0,0 +1,282 @@
import { Download, Mail, Linkedin, MoreHorizontal } from 'lucide-react'
import { useState } from 'react'
import { patient } from '@/data/patient'
import { useScrollCondensation } from '@/hooks/useScrollCondensation'
interface PatientBannerProps {
isMobile?: boolean
isTablet?: boolean
}
export function PatientBanner({ isMobile = false, isTablet = false }: PatientBannerProps) {
const { isCondensed, sentinelRef } = useScrollCondensation({ threshold: 100 })
if (isMobile) {
return (
<>
<div
ref={sentinelRef}
className="h-0 w-full absolute top-0"
aria-hidden="true"
/>
<MobileBanner />
</>
)
}
const shouldCondense = isTablet || isCondensed
return (
<>
<div
ref={sentinelRef}
className="h-0 w-full absolute top-0"
aria-hidden="true"
/>
<header
className={`
sticky top-0 z-40 w-full
bg-pmr-banner border-b border-slate-600
transition-all duration-200 ease-out
${shouldCondense ? 'h-12' : 'h-20'}
`}
role="banner"
>
{shouldCondense ? (
<CondensedBanner />
) : (
<FullBanner />
)}
</header>
</>
)
}
function MobileBanner() {
const [showOverflow, setShowOverflow] = useState(false)
return (
<header
className="sticky top-0 z-40 w-full h-12 bg-pmr-banner border-b border-slate-600"
role="banner"
>
<div className="h-full px-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<h1 className="font-inter font-semibold text-white text-sm tracking-tight truncate">
CHARLWOOD, A (Mr)
</h1>
<span className="text-slate-500">|</span>
<span className="font-geist text-xs text-slate-300">
221 181 0
</span>
<StatusDot status="Active" />
</div>
<div className="relative">
<button
type="button"
onClick={() => setShowOverflow(!showOverflow)}
className="p-2 text-white/70 hover:text-white transition-colors"
aria-label="Actions menu"
aria-expanded={showOverflow}
>
<MoreHorizontal size={18} />
</button>
{showOverflow && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowOverflow(false)}
aria-hidden="true"
/>
<div className="absolute right-0 top-full mt-1 w-40 bg-white border border-gray-200 rounded shadow-lg z-50 py-1">
<a
href="/cv.pdf"
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setShowOverflow(false)}
>
<Download size={14} />
Download CV
</a>
<a
href={`mailto:${patient.email}`}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setShowOverflow(false)}
>
<Mail size={14} />
Email
</a>
<a
href={`https://${patient.linkedin}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setShowOverflow(false)}
>
<Linkedin size={14} />
LinkedIn
</a>
</div>
</>
)}
</div>
</div>
</header>
)
}
function FullBanner() {
return (
<div className="h-full px-4 lg:px-6 flex flex-col justify-center">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="font-inter font-semibold text-white text-lg tracking-tight">
{patient.name}
</h1>
<StatusDot status={patient.status} />
<span className="text-slate-400 text-sm">{patient.status}</span>
<StatusBadge badge={patient.badge} />
</div>
<div className="flex items-center gap-4 mt-1 flex-wrap text-sm text-slate-300">
<span>
<span className="text-slate-500">DOB:</span> {patient.dob}
</span>
<span className="text-slate-500">|</span>
<span className="flex items-center gap-1">
<span className="text-slate-500">NHS No:</span>{' '}
<span className="font-geist" title={patient.nhsNumberTooltip}>
{patient.nhsNumber}
</span>
</span>
<span className="text-slate-500">|</span>
<span>{patient.address}</span>
</div>
<div className="flex items-center gap-4 mt-1 flex-wrap text-sm text-slate-300">
<a
href={`tel:${patient.phone}`}
className="hover:text-white transition-colors"
>
{patient.phone}
</a>
<span className="text-slate-500">|</span>
<a
href={`mailto:${patient.email}`}
className="hover:text-white transition-colors"
>
{patient.email}
</a>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<ActionButton
icon={<Download size={14} />}
label="Download CV"
href="/cv.pdf"
/>
<ActionButton
icon={<Mail size={14} />}
label="Email"
href={`mailto:${patient.email}`}
/>
<ActionButton
icon={<Linkedin size={14} />}
label="LinkedIn"
href={`https://${patient.linkedin}`}
external
/>
</div>
</div>
</div>
)
}
function CondensedBanner() {
return (
<div className="h-full px-4 lg:px-6 flex items-center justify-between gap-4">
<div className="flex items-center gap-4 min-w-0">
<h1 className="font-inter font-semibold text-white text-sm tracking-tight truncate">
{patient.name}
</h1>
<span className="text-slate-500">|</span>
<span className="flex items-center gap-1 text-sm text-slate-300">
<span className="text-slate-500">NHS No:</span>{' '}
<span className="font-geist" title={patient.nhsNumberTooltip}>
{patient.nhsNumber}
</span>
</span>
<span className="text-slate-500">|</span>
<StatusDot status={patient.status} />
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<ActionButton
icon={<Download size={14} />}
label="Download CV"
href="/cv.pdf"
compact
/>
<ActionButton
icon={<Mail size={14} />}
label="Email"
href={`mailto:${patient.email}`}
compact
/>
</div>
</div>
)
}
interface StatusDotProps {
status: string
}
function StatusDot({ status }: StatusDotProps) {
const colorClass = status === 'Active' ? 'bg-pmr-green' : 'bg-slate-400'
return (
<span
className={`w-2 h-2 rounded-full ${colorClass} flex-shrink-0`}
aria-label={`Status: ${status}`}
/>
)
}
interface StatusBadgeProps {
badge: string
}
function StatusBadge({ badge }: StatusBadgeProps) {
return (
<span className="px-2 py-0.5 bg-pmr-nhsblue text-white text-xs font-medium rounded-sm">
{badge}
</span>
)
}
interface ActionButtonProps {
icon: React.ReactNode
label: string
href: string
external?: boolean
compact?: boolean
}
function ActionButton({ icon, label, href, external, compact }: ActionButtonProps) {
return (
<a
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className={`
inline-flex items-center gap-1.5
border border-pmr-nhsblue text-pmr-nhsblue
hover:bg-pmr-nhsblue hover:text-white
transition-colors duration-100
rounded
${compact ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm'}
font-inter font-medium
`}
>
{icon}
<span>{label}</span>
</a>
)
}
+251
View File
@@ -0,0 +1,251 @@
import { useState, useRef, useEffect } from 'react'
import { ChevronDown } from 'lucide-react'
import { consultations } from '@/data/consultations'
import type { Consultation, ViewId } from '@/types/pmr'
interface ConsultationsViewProps {
onNavigate?: (view: ViewId, itemId?: string) => void
initialExpandedId?: string
}
export function ConsultationsView({ initialExpandedId }: ConsultationsViewProps) {
const [expandedId, setExpandedId] = useState<string | null>(initialExpandedId ?? null)
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
const handleToggle = (id: string) => {
setExpandedId(prev => prev === id ? null : id)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="font-inter font-semibold text-lg text-gray-900">
Consultation History
</h1>
<span className="font-geist text-xs text-gray-500">
{consultations.length} entries
</span>
</div>
<div className="space-y-3">
{consultations.map(consultation => (
<ConsultationEntry
key={consultation.id}
consultation={consultation}
isExpanded={expandedId === consultation.id}
onToggle={() => handleToggle(consultation.id)}
prefersReducedMotion={prefersReducedMotion}
/>
))}
</div>
</div>
)
}
interface ConsultationEntryProps {
consultation: Consultation
isExpanded: boolean
onToggle: () => void
prefersReducedMotion: boolean
}
function ConsultationEntry({
consultation,
isExpanded,
onToggle,
prefersReducedMotion,
}: ConsultationEntryProps) {
const contentRef = useRef<HTMLDivElement>(null)
const expandedContentRef = useRef<HTMLDivElement>(null)
const [height, setHeight] = useState<number | undefined>(isExpanded ? undefined : 0)
useEffect(() => {
if (prefersReducedMotion) {
setHeight(isExpanded ? undefined : 0)
return
}
if (isExpanded) {
const timer = setTimeout(() => {
setHeight(undefined)
}, 200)
return () => clearTimeout(timer)
}
setHeight(0)
}, [isExpanded, prefersReducedMotion])
useEffect(() => {
if (isExpanded && expandedContentRef.current) {
expandedContentRef.current.focus()
}
}, [isExpanded])
const keyCodedEntry = consultation.codedEntries[0]
return (
<div
className="bg-white border border-gray-200 rounded overflow-hidden"
style={{ borderLeftWidth: '3px', borderLeftColor: consultation.orgColor }}
>
<button
type="button"
onClick={onToggle}
className="w-full px-4 py-3 flex items-start gap-3 text-left hover:bg-gray-50 transition-colors duration-100"
aria-expanded={isExpanded}
>
<StatusDot isCurrent={consultation.isCurrent} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-geist text-sm text-gray-500">{consultation.date}</span>
<span className="text-gray-300">|</span>
<span
className="font-inter text-sm"
style={{ color: consultation.orgColor }}
>
{consultation.organization}
</span>
</div>
<h3 className="font-inter font-semibold text-base text-gray-900 mt-1">
{consultation.role}
</h3>
{!isExpanded && keyCodedEntry && (
<p className="font-inter text-sm text-gray-500 mt-1 line-clamp-1">
<span className="text-gray-400">Key:</span>{' '}
<span className="font-geist text-xs text-gray-400">
[{keyCodedEntry.code}]
</span>{' '}
{keyCodedEntry.description}
</p>
)}
</div>
<ChevronDown
size={18}
className={`
flex-shrink-0 text-gray-400 transition-transform duration-200 mt-1
${isExpanded ? 'rotate-180' : ''}
`}
/>
</button>
<div
ref={contentRef}
style={{
height: height !== undefined ? `${height}px` : 'auto',
transition: prefersReducedMotion ? 'none' : 'height 200ms ease-out',
overflow: 'hidden',
}}
>
{isExpanded && (
<ExpandedContent
consultation={consultation}
prefersReducedMotion={prefersReducedMotion}
contentRef={expandedContentRef}
/>
)}
</div>
</div>
)
}
interface StatusDotProps {
isCurrent: boolean
}
function StatusDot({ isCurrent }: StatusDotProps) {
return (
<span className="flex-shrink-0 mt-1.5">
<span
className={`
block w-2 h-2 rounded-full
${isCurrent ? 'bg-green-500' : 'bg-gray-400'}
`}
aria-label={isCurrent ? 'Current role' : 'Historical role'}
/>
</span>
)
}
interface ExpandedContentProps {
consultation: Consultation
prefersReducedMotion: boolean
contentRef: React.RefObject<HTMLDivElement>
}
function ExpandedContent({ consultation, prefersReducedMotion, contentRef }: ExpandedContentProps) {
const opacity = prefersReducedMotion ? 1 : undefined
const transition = prefersReducedMotion ? 'none' : 'opacity 150ms ease-out'
return (
<div
ref={contentRef}
tabIndex={-1}
className="px-4 pb-4 outline-none"
style={{ opacity, transition }}
>
<div className="pl-5 border-l border-gray-200 ml-1">
<div className="mb-4">
<span className="font-inter text-sm text-gray-500">Duration: </span>
<span className="font-geist text-sm text-gray-700">{consultation.duration}</span>
</div>
<SectionHeader>HISTORY</SectionHeader>
<p className="font-inter text-sm text-gray-700 leading-relaxed mb-4">
{consultation.history}
</p>
<SectionHeader>EXAMINATION</SectionHeader>
<ul className="space-y-1.5 mb-4">
{consultation.examination.map((item, index) => (
<li key={index} className="flex gap-2 text-sm">
<span className="text-gray-300 flex-shrink-0">-</span>
<span className="font-inter text-gray-700">{item}</span>
</li>
))}
</ul>
<SectionHeader>PLAN</SectionHeader>
<ul className="space-y-1.5 mb-4">
{consultation.plan.map((item, index) => (
<li key={index} className="flex gap-2 text-sm">
<span className="text-gray-300 flex-shrink-0">-</span>
<span className="font-inter text-gray-700">{item}</span>
</li>
))}
</ul>
<SectionHeader>CODED ENTRIES</SectionHeader>
<div className="space-y-1">
{consultation.codedEntries.map(entry => (
<CodedEntry key={entry.code} code={entry.code} description={entry.description} />
))}
</div>
</div>
</div>
)
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<h4 className="font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 mb-2">
{children}
</h4>
)
}
interface CodedEntryProps {
code: string
description: string
}
function CodedEntry({ code, description }: CodedEntryProps) {
return (
<div className="flex items-start gap-2 text-sm">
<span className="font-geist text-xs text-gray-400 flex-shrink-0">
[{code}]
</span>
<span className="font-inter text-gray-600">{description}</span>
</div>
)
}
+318
View File
@@ -0,0 +1,318 @@
import { useState, useEffect, useRef } from 'react'
import { ChevronDown, ChevronUp, FileText, Award, GraduationCap, FlaskConical } from 'lucide-react'
import { documents } from '@/data/documents'
import type { Document, DocumentType } from '@/types/pmr'
import { useBreakpoint } from '@/hooks/useBreakpoint'
function DocumentTypeIcon({ type }: { type: DocumentType }) {
const iconMap: Record<DocumentType, React.ReactNode> = {
Certificate: <FileText className="w-4 h-4 text-gray-500" />,
Registration: <Award className="w-4 h-4 text-gray-500" />,
Results: <GraduationCap className="w-4 h-4 text-gray-500" />,
Research: <FlaskConical className="w-4 h-4 text-gray-500" />,
}
return (
<div className="flex items-center justify-center">
{iconMap[type]}
</div>
)
}
function DocumentRow({
document,
isExpanded,
onToggle,
}: {
document: Document
isExpanded: boolean
onToggle: () => void
}) {
const contentRef = useRef<HTMLDivElement>(null)
const [contentHeight, setContentHeight] = useState<number | undefined>(undefined)
const prefersReducedMotion = useRef(
window.matchMedia('(prefers-reduced-motion: reduce)').matches
).current
useEffect(() => {
if (contentRef.current) {
setContentHeight(contentRef.current.scrollHeight)
}
}, [isExpanded])
return (
<>
<tr
className={`cursor-pointer hover:bg-blue-50 transition-colors ${
isExpanded ? 'bg-blue-50' : ''
}`}
onClick={onToggle}
aria-expanded={isExpanded}
>
<td className="border border-gray-200 px-3 py-2.5 w-12">
<DocumentTypeIcon type={document.type} />
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="text-sm text-gray-900">{document.title}</span>
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="font-mono text-xs text-gray-500">{document.date}</span>
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="text-sm text-gray-700">{document.source}</span>
</td>
<td className="border border-gray-200 px-3 py-2.5 w-10">
<button
className="p-1 hover:bg-gray-100 rounded transition-colors"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
)}
</button>
</td>
</tr>
<tr>
<td colSpan={5} className="p-0 border border-gray-200">
<div
style={{
height: isExpanded ? contentHeight : 0,
overflow: 'hidden',
transition: prefersReducedMotion ? 'none' : 'height 200ms ease-out',
}}
>
<div ref={contentRef} className="bg-gray-50 p-4">
<div className="font-mono text-sm text-gray-700 leading-relaxed space-y-1">
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Type:</span>
<span>{document.type}</span>
</div>
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Date Awarded:</span>
<span>{document.date}</span>
</div>
{document.institution && (
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Institution:</span>
<span>{document.institution}</span>
</div>
)}
{document.classification && (
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Classification:</span>
<span>{document.classification}</span>
</div>
)}
{document.duration && (
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Duration:</span>
<span>{document.duration}</span>
</div>
)}
{document.researchDetail && (
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Research:</span>
<span className="flex-1">
{document.researchDetail}
{document.researchGrade && (
<><br />Grade: {document.researchGrade}</>
)}
</span>
</div>
)}
{document.notes && (
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Notes:</span>
<span className="flex-1 text-gray-600">{document.notes}</span>
</div>
)}
</div>
</div>
</div>
</td>
</tr>
</>
)
}
function MobileDocumentCard({
document,
isExpanded,
onToggle,
}: {
document: Document
isExpanded: boolean
onToggle: () => void
}) {
return (
<div className="bg-white border border-gray-200 rounded">
<button
type="button"
onClick={onToggle}
className="w-full p-4 text-left"
aria-expanded={isExpanded}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<DocumentTypeIcon type={document.type} />
<span className="text-xs text-gray-500">{document.type}</span>
</div>
<h3 className="font-inter font-medium text-sm text-gray-900">
{document.title}
</h3>
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500">
<span className="font-geist">{document.date}</span>
<span></span>
<span>{document.source}</span>
</div>
</div>
<div className="flex-shrink-0 mt-1">
{isExpanded ? (
<ChevronUp size={16} className="text-gray-400" />
) : (
<ChevronDown size={16} className="text-gray-400" />
)}
</div>
</div>
</button>
{isExpanded && (
<div className="px-4 pb-4 border-t border-gray-100">
<div className="pt-3 font-mono text-xs text-gray-700 leading-relaxed space-y-2">
<div className="flex">
<span className="text-gray-400 w-28 shrink-0">Type:</span>
<span>{document.type}</span>
</div>
<div className="flex">
<span className="text-gray-400 w-28 shrink-0">Date Awarded:</span>
<span>{document.date}</span>
</div>
{document.institution && (
<div className="flex">
<span className="text-gray-400 w-28 shrink-0">Institution:</span>
<span>{document.institution}</span>
</div>
)}
{document.classification && (
<div className="flex">
<span className="text-gray-400 w-28 shrink-0">Classification:</span>
<span>{document.classification}</span>
</div>
)}
{document.duration && (
<div className="flex">
<span className="text-gray-400 w-28 shrink-0">Duration:</span>
<span>{document.duration}</span>
</div>
)}
{document.researchDetail && (
<div className="flex flex-col">
<span className="text-gray-400 w-28 shrink-0">Research:</span>
<span className="mt-1">
{document.researchDetail}
{document.researchGrade && (
<><br />Grade: {document.researchGrade}</>
)}
</span>
</div>
)}
{document.notes && (
<div className="flex flex-col">
<span className="text-gray-400 w-28 shrink-0">Notes:</span>
<span className="mt-1 text-gray-600">{document.notes}</span>
</div>
)}
</div>
</div>
)}
</div>
)
}
export function DocumentsView() {
const [expandedId, setExpandedId] = useState<string | null>(null)
const { isMobile } = useBreakpoint()
const handleToggle = (id: string) => {
setExpandedId(expandedId === id ? null : id)
}
return (
<div className="bg-white border border-gray-200 rounded overflow-hidden">
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
Attached Documents
</h2>
<p className="font-inter text-xs text-gray-400 mt-1">
Education and certifications presented as attached documents in the patient record.
</p>
</div>
{isMobile ? (
<div className="p-3 space-y-3 bg-pmr-content">
{documents.map((document) => (
<MobileDocumentCard
key={document.id}
document={document}
isExpanded={expandedId === document.id}
onToggle={() => handleToggle(document.id)}
/>
))}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-50">
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-12"
>
Type
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Document
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-20"
>
Date
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-32"
>
Source
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
>
<span className="sr-only">Expand</span>
</th>
</tr>
</thead>
<tbody>
{documents.map((document) => (
<DocumentRow
key={document.id}
document={document}
isExpanded={expandedId === document.id}
onToggle={() => handleToggle(document.id)}
/>
))}
</tbody>
</table>
</div>
)}
{documents.length === 0 && (
<div className="p-4 text-sm text-gray-500 text-center">No documents attached</div>
)}
</div>
)
}
+358
View File
@@ -0,0 +1,358 @@
import { useState, useEffect, useRef } from 'react'
import { ChevronDown, ChevronUp, ExternalLink, Circle } from 'lucide-react'
import { investigations } from '@/data/investigations'
import type { Investigation } from '@/types/pmr'
import { useBreakpoint } from '@/hooks/useBreakpoint'
type InvestigationStatus = 'Complete' | 'Ongoing' | 'Live'
function StatusBadge({ status }: { status: InvestigationStatus }) {
if (status === 'Live') {
return (
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<Circle className="relative inline-flex rounded-full h-2 w-2 bg-green-500 fill-green-500" />
</span>
<span className="text-xs text-gray-600">Live</span>
</div>
)
}
const colorMap: Record<Exclude<InvestigationStatus, 'Live'>, { bg: string; label: string }> = {
Complete: { bg: 'bg-green-500', label: 'Complete' },
Ongoing: { bg: 'bg-amber-500', label: 'Ongoing' },
}
const { bg, label } = colorMap[status as Exclude<InvestigationStatus, 'Live'>]
return (
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${bg}`}
aria-label={`Status: ${status}`}
role="img"
/>
<span className="text-xs text-gray-600">{label}</span>
</div>
)
}
function InvestigationRow({
investigation,
isExpanded,
onToggle,
}: {
investigation: Investigation
isExpanded: boolean
onToggle: () => void
}) {
const contentRef = useRef<HTMLDivElement>(null)
const [contentHeight, setContentHeight] = useState<number | undefined>(undefined)
const prefersReducedMotion = useRef(
window.matchMedia('(prefers-reduced-motion: reduce)').matches
).current
useEffect(() => {
if (contentRef.current) {
setContentHeight(contentRef.current.scrollHeight)
}
}, [isExpanded])
return (
<>
<tr
className={`cursor-pointer hover:bg-blue-50 transition-colors ${
isExpanded ? 'bg-blue-50' : ''
}`}
onClick={onToggle}
aria-expanded={isExpanded}
>
<td className="border border-gray-200 px-3 py-2.5">
<span className="text-sm text-gray-900">{investigation.name}</span>
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="font-mono text-xs text-gray-500">{investigation.requestedYear}</span>
</td>
<td className="border border-gray-200 px-3 py-2.5">
<StatusBadge status={investigation.status} />
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="text-sm text-gray-700">{investigation.resultSummary}</span>
</td>
<td className="border border-gray-200 px-3 py-2.5 w-10">
<button
className="p-1 hover:bg-gray-100 rounded transition-colors"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
)}
</button>
</td>
</tr>
<tr>
<td colSpan={5} className="p-0 border border-gray-200">
<div
style={{
height: isExpanded ? contentHeight : 0,
overflow: 'hidden',
transition: prefersReducedMotion ? 'none' : 'height 200ms ease-out',
}}
>
<div ref={contentRef} className="bg-gray-50 p-4">
<div className="font-mono text-sm text-gray-700 leading-relaxed space-y-1">
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Date Requested:</span>
<span>{investigation.requestedYear}</span>
</div>
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Date Reported:</span>
<span>{investigation.reportedYear ?? 'Pending'}</span>
</div>
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Status:</span>
<span>
{investigation.status}
{investigation.status === 'Live' && investigation.externalUrl && (
<> Live at {investigation.externalUrl.replace('https://', '')}</>
)}
</span>
</div>
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Requesting Clinician:</span>
<span>{investigation.requestingClinician}</span>
</div>
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Methodology:</span>
<span className="flex-1">{investigation.methodology}</span>
</div>
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Results:</span>
<ul className="flex-1 space-y-1">
{investigation.results.map((result, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-gray-300 mt-1">-</span>
<span>{result}</span>
</li>
))}
</ul>
</div>
<div className="flex">
<span className="text-gray-400 w-40 shrink-0">Tech Stack:</span>
<span>{investigation.techStack.join(', ')}</span>
</div>
</div>
{investigation.externalUrl && (
<div className="mt-4 pt-4 border-t border-gray-200">
<a
href={investigation.externalUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-2 px-4 py-2 bg-pmr-nhsblue text-white text-sm font-medium rounded hover:bg-blue-700 transition-colors"
>
View Results
<ExternalLink className="w-4 h-4" />
</a>
</div>
)}
</div>
</div>
</td>
</tr>
</>
)
}
function MobileInvestigationCard({
investigation,
isExpanded,
onToggle,
}: {
investigation: Investigation
isExpanded: boolean
onToggle: () => void
}) {
return (
<div className="bg-white border border-gray-200 rounded">
<button
type="button"
onClick={onToggle}
className="w-full p-4 text-left"
aria-expanded={isExpanded}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-inter font-medium text-sm text-gray-900">
{investigation.name}
</h3>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
<span className="font-geist">{investigation.requestedYear}</span>
<span></span>
<StatusBadge status={investigation.status} />
</div>
<p className="text-xs text-gray-700 mt-2 line-clamp-2">
{investigation.resultSummary}
</p>
</div>
<div className="flex-shrink-0 mt-1">
{isExpanded ? (
<ChevronUp size={16} className="text-gray-400" />
) : (
<ChevronDown size={16} className="text-gray-400" />
)}
</div>
</div>
</button>
{isExpanded && (
<div className="px-4 pb-4 border-t border-gray-100">
<div className="pt-3 font-mono text-xs text-gray-700 leading-relaxed space-y-2">
<div className="flex">
<span className="text-gray-400 w-28 shrink-0">Date Requested:</span>
<span>{investigation.requestedYear}</span>
</div>
<div className="flex">
<span className="text-gray-400 w-28 shrink-0">Date Reported:</span>
<span>{investigation.reportedYear ?? 'Pending'}</span>
</div>
<div className="flex">
<span className="text-gray-400 w-28 shrink-0">Status:</span>
<span>
{investigation.status}
{investigation.status === 'Live' && investigation.externalUrl && (
<> Live at {investigation.externalUrl.replace('https://', '')}</>
)}
</span>
</div>
<div className="flex">
<span className="text-gray-400 w-28 shrink-0">Clinician:</span>
<span>{investigation.requestingClinician}</span>
</div>
<div className="flex flex-col">
<span className="text-gray-400 w-28 shrink-0">Methodology:</span>
<span className="mt-1">{investigation.methodology}</span>
</div>
<div className="flex flex-col">
<span className="text-gray-400 w-28 shrink-0">Results:</span>
<ul className="mt-1 space-y-0.5">
{investigation.results.map((result, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-gray-300 mt-0.5">-</span>
<span>{result}</span>
</li>
))}
</ul>
</div>
<div className="flex">
<span className="text-gray-400 w-28 shrink-0">Tech Stack:</span>
<span>{investigation.techStack.join(', ')}</span>
</div>
</div>
{investigation.externalUrl && (
<div className="mt-4">
<a
href={investigation.externalUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-2 px-4 py-2 bg-pmr-nhsblue text-white text-xs font-medium rounded hover:bg-blue-700 transition-colors"
>
View Results
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
)}
</div>
)
}
export function InvestigationsView() {
const [expandedId, setExpandedId] = useState<string | null>(null)
const { isMobile } = useBreakpoint()
const handleToggle = (id: string) => {
setExpandedId(expandedId === id ? null : id)
}
return (
<div className="bg-white border border-gray-200 rounded overflow-hidden">
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
Investigation Results
</h2>
<p className="font-inter text-xs text-gray-400 mt-1">
Projects presented as diagnostic investigations tests that were ordered, performed, and returned results.
</p>
</div>
{isMobile ? (
<div className="p-3 space-y-3 bg-pmr-content">
{investigations.map((investigation) => (
<MobileInvestigationCard
key={investigation.id}
investigation={investigation}
isExpanded={expandedId === investigation.id}
onToggle={() => handleToggle(investigation.id)}
/>
))}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-50">
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Test Name
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-24"
>
Requested
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Status
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Result
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
>
<span className="sr-only">Expand</span>
</th>
</tr>
</thead>
<tbody>
{investigations.map((investigation) => (
<InvestigationRow
key={investigation.id}
investigation={investigation}
isExpanded={expandedId === investigation.id}
onToggle={() => handleToggle(investigation.id)}
/>
))}
</tbody>
</table>
</div>
)}
{investigations.length === 0 && (
<div className="p-4 text-sm text-gray-500 text-center">No investigation results</div>
)}
</div>
)
}
+419
View File
@@ -0,0 +1,419 @@
import { useState, useMemo } from 'react'
import { ChevronDown, ChevronUp, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
import { medications } from '@/data/medications'
import type { Medication } from '@/types/pmr'
import { useBreakpoint } from '@/hooks/useBreakpoint'
type SortField = 'name' | 'dose' | 'frequency' | 'startYear' | 'status'
type SortDirection = 'asc' | 'desc' | null
interface SortState {
field: SortField
direction: SortDirection
}
const categoryTabs = [
{ id: 'Active', label: 'Active Medications', shortLabel: 'Active', description: 'Technical skills (daily use)' },
{ id: 'Clinical', label: 'Clinical Medications', shortLabel: 'Clinical', description: 'Healthcare domain skills' },
{ id: 'PRN', label: 'PRN (As Required)', shortLabel: 'PRN', description: 'Strategic & leadership skills' },
] as const
export function MedicationsView() {
const [activeTab, setActiveTab] = useState<'Active' | 'Clinical' | 'PRN'>('Active')
const [expandedRow, setExpandedRow] = useState<string | null>(null)
const [sort, setSort] = useState<SortState>({ field: 'name', direction: null })
const { isMobile } = useBreakpoint()
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
const filteredMedications = useMemo(() => {
return medications.filter(med => med.category === activeTab)
}, [activeTab])
const sortedMedications = useMemo(() => {
if (!sort.direction) return filteredMedications
return [...filteredMedications].sort((a, b) => {
let comparison = 0
switch (sort.field) {
case 'name':
comparison = a.name.localeCompare(b.name)
break
case 'dose':
comparison = a.dose - b.dose
break
case 'frequency': {
const freqOrder = { 'Daily': 0, 'Weekly': 1, 'Monthly': 2, 'As needed': 3 }
comparison = freqOrder[a.frequency] - freqOrder[b.frequency]
break
}
case 'startYear':
comparison = a.startYear - b.startYear
break
case 'status':
comparison = a.status.localeCompare(b.status)
break
}
return sort.direction === 'asc' ? comparison : -comparison
})
}, [filteredMedications, sort])
const handleSort = (field: SortField) => {
if (sort.field === field) {
if (sort.direction === 'asc') {
setSort({ field, direction: 'desc' })
} else if (sort.direction === 'desc') {
setSort({ field, direction: null })
} else {
setSort({ field, direction: 'asc' })
}
} else {
setSort({ field, direction: 'asc' })
}
}
const toggleRow = (id: string) => {
setExpandedRow(expandedRow === id ? null : id)
}
const getSortIcon = (field: SortField) => {
if (sort.field !== field || !sort.direction) {
return <ArrowUpDown size={12} className="text-gray-400" />
}
return sort.direction === 'asc'
? <ArrowUp size={12} className="text-pmr-nhsblue" />
: <ArrowDown size={12} className="text-pmr-nhsblue" />
}
return (
<div className="space-y-6">
<div className="bg-white border border-gray-200 rounded">
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
<h1 className="font-inter font-semibold text-lg text-gray-900">
Current Medications
</h1>
<p className="font-inter text-sm text-gray-500 mt-1">
Skills mapped as active medications proficiency shown as dosage
</p>
</div>
<div className="border-b border-gray-200">
<nav className="flex" role="tablist">
{categoryTabs.map((tab) => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
onClick={() => {
setActiveTab(tab.id)
setExpandedRow(null)
}}
className={`
flex-1 px-4 py-3 text-left transition-colors duration-100
${activeTab === tab.id
? 'bg-white border-b-2 border-pmr-nhsblue'
: 'bg-gray-50 hover:bg-gray-100 border-b-2 border-transparent'}
`}
>
<span className={`font-inter font-medium text-sm ${activeTab === tab.id ? 'text-gray-900' : 'text-gray-600'}`}>
{isMobile ? tab.shortLabel : tab.label}
</span>
{!isMobile && (
<span className="block font-inter text-xs text-gray-500 mt-0.5">
{tab.description}
</span>
)}
</button>
))}
</nav>
</div>
{isMobile ? (
<MobileMedicationList
medications={sortedMedications}
expandedRow={expandedRow}
onToggle={toggleRow}
prefersReducedMotion={prefersReducedMotion}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full" role="grid">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th scope="col" className="w-8"></th>
<th scope="col" className="text-left">
<button
type="button"
onClick={() => handleSort('name')}
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
>
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
Drug Name
</span>
{getSortIcon('name')}
</button>
</th>
<th scope="col" className="text-left">
<button
type="button"
onClick={() => handleSort('dose')}
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
>
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
Dose
</span>
{getSortIcon('dose')}
</button>
</th>
<th scope="col" className="text-left">
<button
type="button"
onClick={() => handleSort('frequency')}
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
>
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
Frequency
</span>
{getSortIcon('frequency')}
</button>
</th>
<th scope="col" className="text-left">
<button
type="button"
onClick={() => handleSort('startYear')}
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
>
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
Start
</span>
{getSortIcon('startYear')}
</button>
</th>
<th scope="col" className="text-left">
<button
type="button"
onClick={() => handleSort('status')}
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
>
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
Status
</span>
{getSortIcon('status')}
</button>
</th>
</tr>
</thead>
<tbody>
{sortedMedications.map((med, index) => (
<MedicationRow
key={med.id}
medication={med}
isExpanded={expandedRow === med.id}
isAlternating={index % 2 === 1}
onToggle={() => toggleRow(med.id)}
prefersReducedMotion={prefersReducedMotion}
/>
))}
</tbody>
</table>
</div>
)}
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
<p className="font-inter text-xs text-gray-500">
{sortedMedications.length} medications in this category. {isMobile ? 'Tap' : 'Click'} a row to view prescribing history.
</p>
</div>
</div>
</div>
)
}
interface MobileMedicationListProps {
medications: Medication[]
expandedRow: string | null
onToggle: (id: string) => void
prefersReducedMotion: boolean
}
function MobileMedicationList({ medications, expandedRow, onToggle, prefersReducedMotion }: MobileMedicationListProps) {
const statusColors = {
'Active': 'bg-green-500',
'Historical': 'bg-gray-400',
}
return (
<div className="divide-y divide-gray-200">
{medications.map((med) => (
<div key={med.id} className="bg-white">
<button
type="button"
onClick={() => onToggle(med.id)}
className="w-full p-4 text-left"
aria-expanded={expandedRow === med.id}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-inter font-medium text-sm text-gray-900">
{med.name}
</h3>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
<span className="font-geist">{med.dose}%</span>
<span></span>
<span>{med.frequency}</span>
<span></span>
<span className="font-geist">Since {med.startYear}</span>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`w-2 h-2 rounded-full ${statusColors[med.status]}`} />
<span className="text-xs text-gray-600">{med.status}</span>
{expandedRow === med.id ? (
<ChevronUp size={16} className="text-gray-400" />
) : (
<ChevronDown size={16} className="text-gray-400" />
)}
</div>
</div>
</button>
{expandedRow === med.id && (
<div className={`px-4 pb-4 ${prefersReducedMotion ? '' : 'animate-fadeIn'}`}>
<div className="bg-gray-50 rounded p-3">
<p className="font-inter font-medium text-xs uppercase tracking-wide text-gray-400 mb-2">
Prescribing History
</p>
<div className="space-y-1.5">
{med.prescribingHistory.map((entry, index) => (
<div key={index} className="flex gap-3">
<span className="font-geist font-medium text-xs text-gray-500 w-10 flex-shrink-0">
{entry.year}
</span>
<span className="font-geist text-xs text-gray-600">
{entry.description}
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
))}
</div>
)
}
interface MedicationRowProps {
medication: Medication
isExpanded: boolean
isAlternating: boolean
onToggle: () => void
prefersReducedMotion: boolean
}
function MedicationRow({ medication, isExpanded, isAlternating, onToggle, prefersReducedMotion }: MedicationRowProps) {
const statusColors = {
'Active': 'bg-green-500',
'Historical': 'bg-gray-400',
}
return (
<>
<tr
className={`
border-b border-gray-200 cursor-pointer transition-colors duration-100
${isAlternating ? 'bg-gray-50' : 'bg-white'}
hover:bg-blue-50
`}
onClick={onToggle}
role="row"
aria-expanded={isExpanded}
>
<td className="w-8 px-2 py-0">
<button
type="button"
className="p-1 hover:bg-gray-200 rounded transition-colors"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronUp size={16} className="text-gray-500" />
) : (
<ChevronDown size={16} className="text-gray-500" />
)}
</button>
</td>
<td className="px-4 py-2.5">
<span className="font-inter font-medium text-sm text-gray-900">
{medication.name}
</span>
</td>
<td className="px-4 py-2.5">
<span className="font-geist text-sm text-gray-700">
{medication.dose}%
</span>
</td>
<td className="px-4 py-2.5">
<span className="font-inter text-sm text-gray-700">
{medication.frequency}
</span>
</td>
<td className="px-4 py-2.5">
<span className="font-geist text-sm text-gray-700">
{medication.startYear}
</span>
</td>
<td className="px-4 py-2.5">
<span className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${statusColors[medication.status]}`} />
<span className="font-inter text-sm text-gray-600">{medication.status}</span>
</span>
</td>
</tr>
{isExpanded && (
<PrescribingHistory
history={medication.prescribingHistory}
prefersReducedMotion={prefersReducedMotion}
/>
)}
</>
)
}
interface PrescribingHistoryProps {
history: { year: number; description: string }[]
prefersReducedMotion: boolean
}
function PrescribingHistory({ history, prefersReducedMotion }: PrescribingHistoryProps) {
return (
<tr className="bg-gray-50 border-b border-gray-200">
<td colSpan={6} className="px-4 py-4">
<div
className={`
pl-8
${prefersReducedMotion ? '' : 'animate-fadeIn'}
`}
>
<p className="font-inter font-medium text-xs uppercase tracking-wide text-gray-400 mb-3">
Prescribing History
</p>
<div className="space-y-2">
{history.map((entry, index) => (
<div key={index} className="flex gap-4">
<span className="font-geist font-medium text-sm text-gray-500 w-12 flex-shrink-0">
{entry.year}
</span>
<span className="font-geist text-sm text-gray-600">
{entry.description}
</span>
</div>
))}
</div>
</div>
</td>
</tr>
)
}
+418
View File
@@ -0,0 +1,418 @@
import { useState, useEffect, useRef } from 'react'
import { ChevronDown, ChevronUp, ExternalLink } from 'lucide-react'
import { problems } from '@/data/problems'
import { consultations } from '@/data/consultations'
import type { Problem, Consultation } from '@/types/pmr'
import { useBreakpoint } from '@/hooks/useBreakpoint'
interface ProblemsViewProps {
onNavigate?: (view: 'consultations', itemId?: string) => void
}
type ProblemStatus = 'Active' | 'In Progress' | 'Resolved'
function TrafficLight({ status }: { status: ProblemStatus }) {
const colorMap: Record<ProblemStatus, { bg: string; label: string }> = {
Active: { bg: 'bg-green-500', label: 'Active' },
'In Progress': { bg: 'bg-amber-500', label: 'In Progress' },
Resolved: { bg: 'bg-green-500', label: 'Resolved' },
}
const { bg, label } = colorMap[status]
return (
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${bg}`}
aria-label={`Status: ${label}`}
role="img"
/>
<span className="text-xs text-gray-600">{label}</span>
</div>
)
}
function ProblemRow({
problem,
isExpanded,
onToggle,
onNavigate,
showOutcome,
}: {
problem: Problem
isExpanded: boolean
onToggle: () => void
onNavigate?: (view: 'consultations', itemId?: string) => void
showOutcome: boolean
}) {
const contentRef = useRef<HTMLDivElement>(null)
const [contentHeight, setContentHeight] = useState<number | undefined>(undefined)
const prefersReducedMotion = useRef(
window.matchMedia('(prefers-reduced-motion: reduce)').matches
).current
useEffect(() => {
if (contentRef.current) {
setContentHeight(contentRef.current.scrollHeight)
}
}, [isExpanded])
const linkedConsultations = (problem.linkedConsultations ?? [])
.map((id) => consultations.find((c) => c.id === id))
.filter((c): c is Consultation => c !== undefined)
const handleLinkedClick = (consultationId: string) => {
if (onNavigate) {
onNavigate('consultations', consultationId)
}
}
return (
<>
<tr
className={`cursor-pointer hover:bg-blue-50 transition-colors ${
isExpanded ? 'bg-blue-50' : ''
}`}
onClick={onToggle}
aria-expanded={isExpanded}
>
<td className="border border-gray-200 px-3 py-2.5">
<TrafficLight status={problem.status} />
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="font-mono text-xs text-gray-500">[{problem.code}]</span>
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="text-sm text-gray-900">{problem.description}</span>
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="font-mono text-xs text-gray-500">
{problem.resolved || problem.since}
</span>
</td>
{showOutcome && (
<td className="border border-gray-200 px-3 py-2.5">
{problem.outcome && (
<span className="text-sm text-gray-700">{problem.outcome}</span>
)}
</td>
)}
<td className="border border-gray-200 px-3 py-2.5 w-10">
<button
className="p-1 hover:bg-gray-100 rounded transition-colors"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
)}
</button>
</td>
</tr>
<tr>
<td colSpan={showOutcome ? 6 : 5} className="p-0 border border-gray-200">
<div
style={{
height: isExpanded ? contentHeight : 0,
overflow: 'hidden',
transition: prefersReducedMotion ? 'none' : 'height 200ms ease-out',
}}
>
<div ref={contentRef} className="bg-gray-50 p-4">
<div className="text-sm text-gray-700 leading-relaxed mb-4">
{problem.narrative}
</div>
{linkedConsultations.length > 0 && (
<div>
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Linked Consultations:
</span>
<div className="mt-2 flex flex-wrap gap-2">
{linkedConsultations.map((consultation) => (
<button
key={consultation.id}
onClick={(e) => {
e.stopPropagation()
handleLinkedClick(consultation.id)
}}
className="inline-flex items-center gap-1 text-xs text-pmr-nhsblue hover:underline"
>
<ExternalLink className="w-3 h-3" />
{consultation.organization} {consultation.role}
</button>
))}
</div>
</div>
)}
</div>
</div>
</td>
</tr>
</>
)
}
function MobileProblemCard({
problem,
isExpanded,
onToggle,
onNavigate,
showOutcome,
}: {
problem: Problem
isExpanded: boolean
onToggle: () => void
onNavigate?: (view: 'consultations', itemId?: string) => void
showOutcome: boolean
}) {
const linkedConsultations = (problem.linkedConsultations ?? [])
.map((id) => consultations.find((c) => c.id === id))
.filter((c): c is Consultation => c !== undefined)
const handleLinkedClick = (consultationId: string) => {
if (onNavigate) {
onNavigate('consultations', consultationId)
}
}
return (
<div className="bg-white border border-gray-200 rounded">
<button
type="button"
onClick={onToggle}
className="w-full p-4 text-left"
aria-expanded={isExpanded}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<TrafficLight status={problem.status} />
<span className="font-mono text-xs text-gray-500">[{problem.code}]</span>
</div>
<h3 className="font-inter font-medium text-sm text-gray-900">
{problem.description}
</h3>
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500">
<span>{showOutcome ? 'Resolved' : 'Since'}: {problem.resolved || problem.since}</span>
{showOutcome && problem.outcome && (
<>
<span></span>
<span className="text-gray-700">{problem.outcome}</span>
</>
)}
</div>
</div>
<div className="flex-shrink-0 mt-1">
{isExpanded ? (
<ChevronUp size={16} className="text-gray-400" />
) : (
<ChevronDown size={16} className="text-gray-400" />
)}
</div>
</div>
</button>
{isExpanded && (
<div className="px-4 pb-4 border-t border-gray-100">
<div className="pt-3 text-sm text-gray-700 leading-relaxed">
{problem.narrative}
</div>
{linkedConsultations.length > 0 && (
<div className="mt-3">
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Linked Consultations:
</span>
<div className="mt-2 flex flex-wrap gap-2">
{linkedConsultations.map((consultation) => (
<button
key={consultation.id}
onClick={(e) => {
e.stopPropagation()
handleLinkedClick(consultation.id)
}}
className="inline-flex items-center gap-1 text-xs text-pmr-nhsblue hover:underline"
>
<ExternalLink className="w-3 h-3" />
{consultation.organization}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
export function ProblemsView({ onNavigate }: ProblemsViewProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const { isMobile } = useBreakpoint()
const activeProblems = problems.filter(
(p) => p.status === 'Active' || p.status === 'In Progress'
)
const resolvedProblems = problems.filter((p) => p.status === 'Resolved')
const handleToggle = (id: string) => {
setExpandedId(expandedId === id ? null : id)
}
return (
<div className="space-y-6">
<div className="bg-white border border-gray-200 rounded overflow-hidden">
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
Active Problems
</h2>
</div>
{isMobile ? (
<div className="p-3 space-y-3 bg-pmr-content">
{activeProblems.map((problem) => (
<MobileProblemCard
key={problem.id}
problem={problem}
isExpanded={expandedId === problem.id}
onToggle={() => handleToggle(problem.id)}
onNavigate={onNavigate}
showOutcome={false}
/>
))}
</div>
) : (
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-50">
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Status
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Code
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Problem
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Since
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
>
<span className="sr-only">Expand</span>
</th>
</tr>
</thead>
<tbody>
{activeProblems.map((problem) => (
<ProblemRow
key={problem.id}
problem={problem}
isExpanded={expandedId === problem.id}
onToggle={() => handleToggle(problem.id)}
onNavigate={onNavigate}
showOutcome={false}
/>
))}
</tbody>
</table>
)}
{activeProblems.length === 0 && (
<div className="p-4 text-sm text-gray-500 text-center">No active problems</div>
)}
</div>
<div className="bg-white border border-gray-200 rounded overflow-hidden">
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
Resolved Problems
</h2>
</div>
{isMobile ? (
<div className="p-3 space-y-3 bg-pmr-content">
{resolvedProblems.map((problem) => (
<MobileProblemCard
key={problem.id}
problem={problem}
isExpanded={expandedId === problem.id}
onToggle={() => handleToggle(problem.id)}
onNavigate={onNavigate}
showOutcome={true}
/>
))}
</div>
) : (
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-50">
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Status
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Code
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Problem
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Resolved
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Outcome
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
>
<span className="sr-only">Expand</span>
</th>
</tr>
</thead>
<tbody>
{resolvedProblems.map((problem) => (
<ProblemRow
key={problem.id}
problem={problem}
isExpanded={expandedId === problem.id}
onToggle={() => handleToggle(problem.id)}
onNavigate={onNavigate}
showOutcome={true}
/>
))}
</tbody>
</table>
)}
{resolvedProblems.length === 0 && (
<div className="p-4 text-sm text-gray-500 text-center">No resolved problems</div>
)}
</div>
</div>
)
}
+486
View File
@@ -0,0 +1,486 @@
import { useState, useRef } from 'react'
import { Send, Mail, Phone, MapPin, ExternalLink, Loader2, CheckCircle } from 'lucide-react'
import { patient } from '@/data/patient'
type Priority = 'urgent' | 'routine' | 'two-week-wait'
type ContactMethod = 'email' | 'phone' | 'linkedin'
interface FormData {
priority: Priority
referrerName: string
referrerEmail: string
referrerOrg: string
reason: string
contactMethod: ContactMethod
}
interface FormErrors {
referrerName?: string
referrerEmail?: string
}
function generateRefNumber(): string {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const seq = String(Math.floor(Math.random() * 999) + 1).padStart(3, '0')
return `REF-${year}-${month}${day}-${seq}`
}
function PriorityOption({
value,
label,
selected,
tooltip,
onSelect,
}: {
value: Priority
label: string
selected: boolean
tooltip: string
onSelect: () => void
}) {
const dotColors: Record<Priority, string> = {
urgent: 'bg-red-500',
routine: 'bg-pmr-nhsblue',
'two-week-wait': 'bg-amber-500',
}
const labelColors: Record<Priority, string> = {
urgent: 'text-red-700',
routine: 'text-pmr-nhsblue',
'two-week-wait': 'text-amber-700',
}
return (
<label className="flex items-center gap-2 cursor-pointer group relative">
<input
type="radio"
name="priority"
value={value}
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<span
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
selected ? 'border-current' : 'border-gray-300'
}`}
>
{selected && <span className={`w-2 h-2 rounded-full ${dotColors[value]}`} />}
</span>
<span className={`text-sm font-medium ${labelColors[value]}`}>{label}</span>
<span
className="absolute left-0 bottom-full mb-2 px-2 py-1 bg-gray-900 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10"
role="tooltip"
>
{tooltip}
</span>
</label>
)
}
function ContactMethodOption({
value,
label,
selected,
onSelect,
}: {
value: ContactMethod
label: string
selected: boolean
onSelect: () => void
}) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="contactMethod"
value={value}
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<span
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
selected ? 'border-pmr-nhsblue' : 'border-gray-300'
}`}
>
{selected && <span className="w-2 h-2 rounded-full bg-pmr-nhsblue" />}
</span>
<span className="text-sm text-gray-700">{label}</span>
</label>
)
}
function FormField({
label,
id,
required,
error,
children,
}: {
label: string
id: string
required?: boolean
error?: string
children: React.ReactNode
}) {
return (
<div className="space-y-1">
<label htmlFor={id} className="block font-inter font-medium text-[13px] text-gray-600">
{label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
{children}
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</div>
)
}
function DirectContactTable() {
const contactMethods = [
{
label: 'Email',
value: patient.email,
href: `mailto:${patient.email}`,
action: 'Send Email',
icon: Mail,
},
{
label: 'Phone',
value: patient.phone,
href: `tel:${patient.phone}`,
action: 'Call',
icon: Phone,
},
{
label: 'LinkedIn',
value: patient.linkedin,
href: `https://${patient.linkedin}`,
action: 'View Profile',
icon: ExternalLink,
external: true,
},
{
label: 'Location',
value: 'Norwich, UK',
href: null,
action: null,
icon: MapPin,
},
]
return (
<div className="bg-white border border-gray-200 rounded">
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h3 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
Direct Contact
</h3>
</div>
<div className="divide-y divide-gray-200">
{contactMethods.map((method) => (
<div key={method.label} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<method.icon className="w-4 h-4 text-gray-400" />
<span className="font-inter text-sm text-gray-500 w-20">{method.label}</span>
{method.href ? (
<a
href={method.href}
target={method.external ? '_blank' : undefined}
rel={method.external ? 'noopener noreferrer' : undefined}
className="font-mono text-sm text-pmr-nhsblue hover:underline"
>
{method.value}
</a>
) : (
<span className="font-mono text-sm text-gray-900">{method.value}</span>
)}
</div>
{method.href && (
<a
href={method.href}
target={method.external ? '_blank' : undefined}
rel={method.external ? 'noopener noreferrer' : undefined}
className="font-inter text-xs text-pmr-nhsblue hover:underline flex items-center gap-1"
>
{method.action}
{method.external && <ExternalLink className="w-3 h-3" />}
</a>
)}
</div>
))}
</div>
</div>
)
}
export function ReferralsView() {
const prefersReducedMotion = useRef(
window.matchMedia('(prefers-reduced-motion: reduce)').matches
).current
const [formData, setFormData] = useState<FormData>({
priority: 'routine',
referrerName: '',
referrerEmail: '',
referrerOrg: '',
reason: '',
contactMethod: 'email',
})
const [errors, setErrors] = useState<FormErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [refNumber, setRefNumber] = useState('')
const validateForm = (): boolean => {
const newErrors: FormErrors = {}
if (!formData.referrerName.trim()) {
newErrors.referrerName = 'Referrer name is required'
}
if (!formData.referrerEmail.trim()) {
newErrors.referrerEmail = 'Referrer email is required'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.referrerEmail)) {
newErrors.referrerEmail = 'Please enter a valid email address'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) return
setIsSubmitting(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
setRefNumber(generateRefNumber())
setIsSubmitting(false)
setIsSuccess(true)
}
const handleReset = () => {
setFormData({
priority: 'routine',
referrerName: '',
referrerEmail: '',
referrerOrg: '',
reason: '',
contactMethod: 'email',
})
setErrors({})
setIsSuccess(false)
setRefNumber('')
}
if (isSuccess) {
return (
<div className="space-y-6">
<div className="bg-white border border-gray-200 rounded">
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
New Referral
</h2>
</div>
<div className="p-8 text-center">
<div
className={`inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4 ${
prefersReducedMotion ? '' : 'animate-[fadeIn_200ms_ease-out]'
}`}
>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="font-inter font-semibold text-lg text-gray-900 mb-2">
Referral sent successfully
</h3>
<p className="font-mono text-sm text-gray-500 mb-1">Reference: {refNumber}</p>
<p className="font-inter text-sm text-gray-500 mb-6">
Expected response time: 24-48 hours
</p>
<button
onClick={handleReset}
className="font-inter font-medium text-sm px-4 py-2 bg-pmr-nhsblue text-white rounded hover:bg-blue-700 transition-colors"
>
Send Another Referral
</button>
</div>
</div>
<DirectContactTable />
</div>
)
}
return (
<div className="space-y-6">
<div className="bg-white border border-gray-200 rounded">
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
New Referral
</h2>
<p className="font-inter text-xs text-gray-400 mt-1">
Contact Andy using a clinical referral form format.
</p>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-1">
<span className="block font-inter font-medium text-[13px] text-gray-600">
Referring to
</span>
<span className="font-inter text-sm text-gray-900">{patient.name}</span>
</div>
<div className="space-y-1">
<span className="block font-inter font-medium text-[13px] text-gray-600">
NHS Number
</span>
<span className="font-mono text-sm text-gray-900">{patient.nhsNumber}</span>
</div>
</div>
<div className="space-y-2">
<span className="block font-inter font-medium text-[13px] text-gray-600">
Priority
</span>
<div className="flex gap-6">
<PriorityOption
value="urgent"
label="Urgent"
selected={formData.priority === 'urgent'}
tooltip="All enquiries are welcome, urgent or not."
onSelect={() => setFormData({ ...formData, priority: 'urgent' })}
/>
<PriorityOption
value="routine"
label="Routine"
selected={formData.priority === 'routine'}
tooltip="Standard response timeframe."
onSelect={() => setFormData({ ...formData, priority: 'routine' })}
/>
<PriorityOption
value="two-week-wait"
label="Two-Week Wait"
selected={formData.priority === 'two-week-wait'}
tooltip="NHS cancer referral pathway — this isn't that, but the spirit of promptness applies."
onSelect={() => setFormData({ ...formData, priority: 'two-week-wait' })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
label="Referrer Name"
id="referrerName"
required
error={errors.referrerName}
>
<input
type="text"
id="referrerName"
value={formData.referrerName}
onChange={(e) => setFormData({ ...formData, referrerName: e.target.value })}
className="w-full border border-gray-300 rounded px-3 py-2 text-sm font-inter text-gray-900 focus:border-pmr-nhsblue focus:ring-2 focus:ring-pmr-nhsblue/20 focus:outline-none transition-colors"
placeholder="Your name"
/>
</FormField>
<FormField
label="Referrer Email"
id="referrerEmail"
required
error={errors.referrerEmail}
>
<input
type="email"
id="referrerEmail"
value={formData.referrerEmail}
onChange={(e) => setFormData({ ...formData, referrerEmail: e.target.value })}
className="w-full border border-gray-300 rounded px-3 py-2 text-sm font-inter text-gray-900 focus:border-pmr-nhsblue focus:ring-2 focus:ring-pmr-nhsblue/20 focus:outline-none transition-colors"
placeholder="your.email@example.com"
/>
</FormField>
</div>
<FormField label="Referrer Organisation" id="referrerOrg">
<input
type="text"
id="referrerOrg"
value={formData.referrerOrg}
onChange={(e) => setFormData({ ...formData, referrerOrg: e.target.value })}
className="w-full border border-gray-300 rounded px-3 py-2 text-sm font-inter text-gray-900 focus:border-pmr-nhsblue focus:ring-2 focus:ring-pmr-nhsblue/20 focus:outline-none transition-colors"
placeholder="Organisation name (optional)"
/>
</FormField>
<FormField label="Reason for Referral" id="reason">
<textarea
id="reason"
value={formData.reason}
onChange={(e) => setFormData({ ...formData, reason: e.target.value })}
rows={4}
className="w-full border border-gray-300 rounded px-3 py-2 text-sm font-inter text-gray-900 focus:border-pmr-nhsblue focus:ring-2 focus:ring-pmr-nhsblue/20 focus:outline-none transition-colors resize-y"
placeholder="Describe the opportunity or reason for contact..."
/>
</FormField>
<div className="space-y-2">
<span className="block font-inter font-medium text-[13px] text-gray-600">
Contact Method
</span>
<div className="flex gap-6">
<ContactMethodOption
value="email"
label="Email"
selected={formData.contactMethod === 'email'}
onSelect={() => setFormData({ ...formData, contactMethod: 'email' })}
/>
<ContactMethodOption
value="phone"
label="Phone"
selected={formData.contactMethod === 'phone'}
onSelect={() => setFormData({ ...formData, contactMethod: 'phone' })}
/>
<ContactMethodOption
value="linkedin"
label="LinkedIn"
selected={formData.contactMethod === 'linkedin'}
onSelect={() => setFormData({ ...formData, contactMethod: 'linkedin' })}
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={handleReset}
className="font-inter font-medium text-sm px-4 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="font-inter font-medium text-sm px-6 py-2 bg-pmr-nhsblue text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="w-4 h-4" />
Send Referral
</>
)}
</button>
</div>
</form>
</div>
<DirectContactTable />
</div>
)
}
+376
View File
@@ -0,0 +1,376 @@
import { useState, useEffect } from 'react'
import { AlertTriangle, Check, ChevronRight } from 'lucide-react'
import { patient } from '@/data/patient'
import { consultations } from '@/data/consultations'
import { problems } from '@/data/problems'
import { medications } from '@/data/medications'
import type { ViewId } from '@/types/pmr'
interface SummaryViewProps {
onNavigate?: (view: ViewId, itemId?: string) => void
}
export function SummaryView({ onNavigate }: SummaryViewProps) {
const [alertDismissed, setAlertDismissed] = useState(false)
const [alertAnimating, setAlertAnimating] = useState(false)
const [alertVisible, setAlertVisible] = useState(false)
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
useEffect(() => {
const timer = setTimeout(() => {
setAlertVisible(true)
}, prefersReducedMotion ? 0 : 300)
return () => clearTimeout(timer)
}, [prefersReducedMotion])
const handleDismissAlert = () => {
setAlertAnimating(true)
setTimeout(() => {
setAlertDismissed(true)
}, prefersReducedMotion ? 0 : 400)
}
const activeProblems = problems.filter(p => p.status === 'Active' || p.status === 'In Progress')
const topMedications = medications.filter(m => m.category === 'Active').slice(0, 5)
const lastConsultation = consultations[0]
return (
<div className="space-y-6">
{!alertDismissed && (
<ClinicalAlert
visible={alertVisible}
animating={alertAnimating}
onDismiss={handleDismissAlert}
prefersReducedMotion={prefersReducedMotion}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<DemographicsCard />
<div className="lg:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
<ActiveProblemsCard
problems={activeProblems}
onNavigate={onNavigate}
/>
<QuickMedsCard
medications={topMedications}
onNavigate={onNavigate}
/>
</div>
<LastConsultationCard
consultation={lastConsultation}
onNavigate={onNavigate}
/>
</div>
</div>
)
}
interface ClinicalAlertProps {
visible: boolean
animating: boolean
onDismiss: () => void
prefersReducedMotion: boolean
}
function ClinicalAlert({ visible, animating, onDismiss, prefersReducedMotion }: ClinicalAlertProps) {
const [showCheck, setShowCheck] = useState(false)
const handleClick = () => {
if (!prefersReducedMotion) {
setShowCheck(true)
setTimeout(onDismiss, 200)
} else {
onDismiss()
}
}
return (
<div
role="alert"
aria-live="assertive"
className={`
overflow-hidden transition-all duration-200 ease-out
${visible && !animating ? 'max-h-24 opacity-100' : 'max-h-0 opacity-0'}
${prefersReducedMotion ? '!max-h-24 !opacity-100' : ''}
`}
>
<div
className="flex items-start gap-3 p-4 rounded border-l-4"
style={{
backgroundColor: '#FEF3C7',
borderColor: '#F59E0B',
}}
>
<div className="flex-shrink-0 mt-0.5 relative w-5 h-5">
<AlertTriangle
size={20}
className={`
text-amber-600 transition-opacity duration-200
${showCheck ? 'opacity-0' : 'opacity-100'}
`}
/>
<Check
size={20}
className={`
text-green-600 absolute inset-0 transition-opacity duration-200
${showCheck ? 'opacity-100' : 'opacity-0'}
`}
/>
</div>
<div className="flex-1 min-w-0">
<p className="font-inter font-medium text-sm" style={{ color: '#92400E' }}>
<span className="font-semibold">ALERT:</span> This patient has identified <span className="font-semibold">£14.6M</span> in prescribing efficiency savings across Norfolk & Waveney ICS.
</p>
</div>
<button
type="button"
onClick={handleClick}
className="flex-shrink-0 px-3 py-1.5 text-xs font-medium border rounded transition-colors duration-100"
style={{
borderColor: '#F59E0B',
color: '#92400E',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#F59E0B'
e.currentTarget.style.color = '#FFFFFF'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = '#92400E'
}}
>
Acknowledge
</button>
</div>
</div>
)
}
function DemographicsCard() {
return (
<div className="lg:col-span-2 bg-white border border-gray-200 rounded">
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
<h2 className="font-inter font-semibold text-sm uppercase tracking-wide text-gray-500">
Patient Demographics
</h2>
</div>
<div className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-3">
<DemographicsRow label="Name" value={patient.displayName} />
<DemographicsRow
label="Status"
value={
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-pmr-green" />
<span>{patient.status}</span>
</span>
}
/>
<DemographicsRow label="DOB" value={patient.dob} />
<DemographicsRow label="Location" value={patient.address} />
<DemographicsRow
label="Registration"
value={
<span>
<span className="text-gray-500">GPhC</span>{' '}
<span className="font-geist text-sm">{patient.nhsNumber.replace(/ /g, '')}</span>
</span>
}
/>
<DemographicsRow label="Since" value={patient.registrationYear} />
<DemographicsRow label="Qualification" value={patient.qualification} />
<DemographicsRow label="University" value={patient.university} />
</div>
</div>
</div>
)
}
interface DemographicsRowProps {
label: string
value: React.ReactNode
}
function DemographicsRow({ label, value }: DemographicsRowProps) {
return (
<div className="flex items-start gap-4">
<span className="font-inter font-medium text-sm text-gray-500 min-w-[100px] text-right flex-shrink-0">
{label}:
</span>
<span className="font-inter text-sm text-gray-900">{value}</span>
</div>
)
}
interface ActiveProblemsCardProps {
problems: typeof problems
onNavigate?: (view: ViewId, itemId?: string) => void
}
function ActiveProblemsCard({ problems, onNavigate }: ActiveProblemsCardProps) {
return (
<div className="bg-white border border-gray-200 rounded">
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
<h2 className="font-inter font-semibold text-sm uppercase tracking-wide text-gray-500">
Active Problems
</h2>
</div>
<div className="divide-y divide-gray-100">
{problems.map((problem) => (
<button
key={problem.id}
type="button"
onClick={() => onNavigate?.('problems', problem.id)}
className="w-full px-4 py-3 flex items-start gap-3 text-left hover:bg-gray-50 transition-colors"
>
<TrafficLight status={problem.status} />
<div className="flex-1 min-w-0">
<p className="font-inter font-medium text-sm text-gray-900 line-clamp-2">
{problem.description}
</p>
{problem.since && (
<p className="font-geist text-xs text-gray-500 mt-1">{problem.since}</p>
)}
</div>
</button>
))}
</div>
</div>
)
}
interface TrafficLightProps {
status: 'Active' | 'In Progress' | 'Resolved'
}
function TrafficLight({ status }: TrafficLightProps) {
const colors = {
'Active': { bg: 'bg-green-500', label: 'Active' },
'In Progress': { bg: 'bg-amber-500', label: 'In Progress' },
'Resolved': { bg: 'bg-green-500', label: 'Resolved' },
}
const color = colors[status]
return (
<span className="flex items-center gap-2 flex-shrink-0 mt-0.5">
<span className={`w-2 h-2 rounded-full ${color.bg}`} />
</span>
)
}
interface QuickMedsCardProps {
medications: typeof medications
onNavigate?: (view: ViewId) => void
}
function QuickMedsCard({ medications, onNavigate }: QuickMedsCardProps) {
return (
<div className="bg-white border border-gray-200 rounded">
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
<h2 className="font-inter font-semibold text-sm uppercase tracking-wide text-gray-500">
Current Medications (Quick View)
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th scope="col" className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
Drug
</th>
<th scope="col" className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
Dose
</th>
<th scope="col" className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
Freq
</th>
<th scope="col" className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
Status
</th>
</tr>
</thead>
<tbody>
{medications.map((med, index) => (
<tr
key={med.id}
className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
>
<td className="px-4 py-2 font-inter text-sm text-gray-900">
{med.name}
</td>
<td className="px-4 py-2 font-geist text-sm text-gray-700">
{med.dose}%
</td>
<td className="px-4 py-2 font-inter text-sm text-gray-700">
{med.frequency}
</td>
<td className="px-4 py-2">
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-pmr-green" />
<span className="font-inter text-xs text-gray-600">{med.status}</span>
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-4 py-2 border-t border-gray-100">
<button
type="button"
onClick={() => onNavigate?.('medications')}
className="flex items-center gap-1 font-inter text-sm text-pmr-nhsblue hover:underline"
>
View Full List
<ChevronRight size={14} />
</button>
</div>
</div>
)
}
interface LastConsultationCardProps {
consultation: typeof consultations[0]
onNavigate?: (view: ViewId, itemId?: string) => void
}
function LastConsultationCard({ consultation, onNavigate }: LastConsultationCardProps) {
return (
<div className="lg:col-span-2 bg-white border border-gray-200 rounded">
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
<h2 className="font-inter font-semibold text-sm uppercase tracking-wide text-gray-500">
Last Consultation
</h2>
</div>
<div className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 text-sm text-gray-500 mb-2">
<span className="font-geist">{consultation.date}</span>
<span>|</span>
<span className="text-pmr-nhsblue">{consultation.organization}</span>
</div>
<h3 className="font-inter font-semibold text-base text-gray-900 mb-2">
{consultation.role}
</h3>
<p className="font-inter text-sm text-gray-600 line-clamp-3">
{consultation.history}
</p>
</div>
<button
type="button"
onClick={() => onNavigate?.('consultations', consultation.id)}
className="flex-shrink-0 flex items-center gap-1 font-inter text-sm text-pmr-nhsblue hover:underline"
>
View Full Record
<ChevronRight size={14} />
</button>
</div>
</div>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { createContext, useContext, useState, useCallback, useRef, useEffect, type ReactNode } from 'react'
interface AccessibilityContextValue {
expandedItemId: string | null
setExpandedItem: (id: string | null) => void
requestFocusAfterLogin: () => void
focusAfterLoginRef: React.RefObject<HTMLButtonElement | null>
focusAfterViewChangeRef: React.RefObject<HTMLHeadingElement | null>
requestFocusAfterViewChange: () => void
}
const AccessibilityContext = createContext<AccessibilityContextValue | null>(null)
export function AccessibilityProvider({ children }: { children: ReactNode }) {
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
const focusAfterLoginRef = useRef<HTMLButtonElement | null>(null)
const focusAfterViewChangeRef = useRef<HTMLHeadingElement | null>(null)
const [shouldFocusAfterLogin, setShouldFocusAfterLogin] = useState(false)
const [shouldFocusAfterViewChange, setShouldFocusAfterViewChange] = useState(false)
const setExpandedItem = useCallback((id: string | null) => {
setExpandedItemId(id)
}, [])
const requestFocusAfterLogin = useCallback(() => {
setShouldFocusAfterLogin(true)
}, [])
const requestFocusAfterViewChange = useCallback(() => {
setShouldFocusAfterViewChange(true)
}, [])
useEffect(() => {
if (shouldFocusAfterLogin && focusAfterLoginRef.current) {
focusAfterLoginRef.current.focus()
setShouldFocusAfterLogin(false)
}
}, [shouldFocusAfterLogin])
useEffect(() => {
if (shouldFocusAfterViewChange && focusAfterViewChangeRef.current) {
focusAfterViewChangeRef.current.focus()
setShouldFocusAfterViewChange(false)
}
}, [shouldFocusAfterViewChange])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && expandedItemId) {
setExpandedItemId(null)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [expandedItemId])
return (
<AccessibilityContext.Provider
value={{
expandedItemId,
setExpandedItem,
requestFocusAfterLogin,
focusAfterLoginRef,
focusAfterViewChangeRef,
requestFocusAfterViewChange,
}}
>
{children}
</AccessibilityContext.Provider>
)
}
export function useAccessibility() {
const context = useContext(AccessibilityContext)
if (!context) {
throw new Error('useAccessibility must be used within AccessibilityProvider')
}
return context
}
+133
View File
@@ -0,0 +1,133 @@
import type { Consultation } from '@/types/pmr'
export const consultations: Consultation[] = [
{
id: 'interim-head-2025',
date: '14 May 2025',
organization: 'NHS Norfolk & Waveney ICB',
orgColor: '#005EB8',
role: 'Interim Head, Population Health & Data Analysis',
duration: 'May 2025 — Nov 2025',
isCurrent: false,
history: 'Returned to substantive Deputy Head role following commencement of ICB-wide organisational consultation. 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.',
examination: [
'Identified £14.6M efficiency programme through comprehensive data analysis',
'Built Python-based switching algorithm: 14,000 patients identified, £2.6M annual savings',
'Automated incentive scheme analysis: 50% reduction in targeted prescribing within 2 months',
],
plan: [
'Achieved over-target performance by October 2025',
'£2M on target for delivery this financial year',
'Presented to CMO bimonthly with evidence-based recommendations',
'Led transformation to patient-level SQL analytics',
],
codedEntries: [
{ code: 'EFF001', description: 'Efficiency programme: £14.6M identified' },
{ code: 'ALG001', description: 'Algorithm: 14,000 patients, £2.6M savings' },
{ code: 'AUT001', description: 'Automation: 50% prescribing reduction in 2mo' },
{ code: 'SQL001', description: 'Data transformation: practice→patient level' },
],
},
{
id: 'deputy-head-2024',
date: '01 Jul 2024',
organization: 'NHS Norfolk & Waveney ICB',
orgColor: '#005EB8',
role: 'Deputy Head, Population Health & Data Analysis',
duration: 'Jul 2024 — Present',
isCurrent: true,
history: '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.',
examination: [
'Managed £220M prescribing budget with sophisticated forecasting models',
'Created comprehensive medicines data table with dm+d integration, morphine equivalents, Anticholinergic Burden scoring',
'Led financial scenario modelling for DOAC switching programme',
'Renegotiated pharmaceutical rebate terms securing improved commercial position',
'Supported commissioning of tirzepatide (NICE TA1026) with financial projections',
'Developed Python-based controlled drug monitoring system for population-scale OME tracking',
],
plan: [
'Single source of truth established for all medicines analytics',
'GP-led model adopted for tirzepatide delivery following executive sign-off',
'Team data fluency improved through training, documentation, and self-serve tools',
],
codedEntries: [
{ code: 'BUD001', description: 'Budget management: £220M oversight' },
{ code: 'DAT001', description: 'Data infrastructure: dm+d integration' },
{ code: 'LEA001', description: 'Leadership: team data literacy programme' },
{ code: 'MON001', description: 'Monitoring: CD OME tracking system' },
],
},
{
id: 'high-cost-drugs-2022',
date: '01 May 2022',
organization: 'NHS Norfolk & Waveney ICB',
orgColor: '#005EB8',
role: 'High-Cost Drugs & Interface Pharmacist',
duration: 'May 2022 — Jul 2024',
isCurrent: false,
history: '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.',
examination: [
'Developed software automating Blueteq prior approval form creation',
'Integrated Blueteq data with secondary care activity databases',
'Created Python-based Sankey chart analysis tool for patient pathway visualisation',
],
plan: [
'70% reduction in required Blueteq forms, 200 hours immediate savings',
'Ongoing 78 hours weekly efficiency gains',
'Accurate high-cost drug spend tracking enabled',
'Trusts enabled to audit compliance and identify improvement opportunities',
],
codedEntries: [
{ code: 'AUT002', description: 'Automation: Blueteq form generation, 70% reduction' },
{ code: 'DAT002', description: 'Data integration: Blueteq + secondary care' },
{ code: 'VIS001', description: 'Visualisation: Sankey pathway analysis tool' },
],
},
{
id: 'pharmacy-manager-2017',
date: '01 Nov 2017',
organization: 'Tesco PLC',
orgColor: '#00897B',
role: 'Pharmacy Manager',
duration: 'Nov 2017 — May 2022',
isCurrent: false,
history: 'Managed all pharmacy operations with full autonomy across a 100-hour contract, leading regional KPI delivery initiatives and contributing to national operational improvements. Served as Local Pharmaceutical Committee representative for Norfolk.',
examination: [
'Identified and shared asthma screening process adopted nationally across Tesco pharmacy estate (~300 branches)',
'Led creation of national induction training plan and eLearning modules',
'Supervised two staff members through NVQ3 qualifications to pharmacy technician registration',
],
plan: [
'Reduced pharmacist time from ~60 hours to 6 hours per store per month',
'Network enabled to claim approximately £1M in revenue',
'Enhanced leadership development for non-pharmacist team members',
'Full HR responsibilities including recruitment, performance management, grievances',
],
codedEntries: [
{ code: 'INN001', description: 'Innovation: Asthma screening, ~£1M national revenue' },
{ code: 'TRN001', description: 'Training: National induction programme' },
{ code: 'LEA002', description: 'Leadership: Staff development to technician registration' },
],
},
{
id: 'duty-pharmacist-2016',
date: '01 Aug 2016',
organization: 'Tesco PLC',
orgColor: '#00897B',
role: 'Duty Pharmacy Manager',
duration: 'Aug 2016 — Nov 2017',
isCurrent: false,
history: 'Commenced professional career as registered pharmacist following GPhC registration. Developed foundational skills in pharmacy operations, patient care, and team management within a high-volume community pharmacy setting.',
examination: [
'Progressed from newly registered pharmacist to Pharmacy Manager role',
'Developed clinical and operational competencies in community pharmacy',
],
plan: [
'GPhC registration obtained, beginning professional practice',
'Foundation established for progression to management role',
],
codedEntries: [
{ code: 'REG001', description: 'Registration: GPhC pharmacist qualification' },
],
},
]
+62
View File
@@ -0,0 +1,62 @@
import type { Document } from '@/types/pmr'
export const documents: Document[] = [
{
id: 'doc-mpharm',
type: 'Certificate',
title: 'MPharm (Hons) 2:1',
date: '2015',
source: 'UEA',
classification: 'Upper Second-Class Honours (2:1)',
institution: 'University of East Anglia, Norwich',
duration: '2011 — 2015 (4 years)',
researchDetail: 'Drug delivery and cocrystals',
researchGrade: '75.1% (Distinction)',
notes: 'MPharm is a 4-year integrated Master\'s degree required for pharmacist registration in the UK.',
},
{
id: 'doc-gphc',
type: 'Registration',
title: 'GPhC Pharmacist Registration',
date: '2016',
source: 'GPhC',
classification: 'Registered Pharmacist',
institution: 'General Pharmaceutical Council',
duration: 'August 2016 — Present',
notes: 'Professional registration required to practise as a pharmacist in Great Britain.',
},
{
id: 'doc-mary-seacole',
type: 'Certificate',
title: 'Mary Seacole Programme',
date: '2018',
source: 'NHS LA',
classification: '78%',
institution: 'NHS Leadership Academy',
duration: '2018',
notes: 'NHS leadership qualification covering change management, healthcare leadership, and system-level thinking.',
},
{
id: 'doc-alevels',
type: 'Results',
title: 'A-Levels: Maths A*, Chem B, Politics C',
date: '2011',
source: 'Highworth Grammar',
classification: 'A* Mathematics, B Chemistry, C Politics',
institution: 'Highworth Grammar School, Ashford',
duration: '2009 — 2011',
notes: 'A-Levels required for MPharm programme entry.',
},
{
id: 'doc-research',
type: 'Research',
title: 'Drug Delivery & Cocrystals',
date: '2015',
source: 'UEA',
classification: '75.1% (Distinction)',
institution: 'University of East Anglia, Norwich',
duration: '2014 — 2015',
researchDetail: 'Final year research project investigating cocrystal formation for improved drug delivery properties. Awarded Distinction grade.',
notes: 'Part of MPharm degree, contributing to final classification.',
},
]
+89
View File
@@ -0,0 +1,89 @@
import type { Investigation } from '@/types/pmr'
export const investigations: Investigation[] = [
{
id: 'inv-pharmetrics',
name: 'PharMetrics Interactive Platform',
requestedYear: 2024,
reportedYear: 2024,
status: 'Live',
resultSummary: 'Live at medicines.charlwood.xyz',
requestingClinician: 'A. Charlwood',
methodology: 'Real-time medicines expenditure dashboard providing actionable analytics for NHS decision-makers. Built with Power BI and SQL, tracking expenditure across the £220M prescribing budget.',
results: [
'Real-time tracking of medicines expenditure',
'Actionable analytics for budget holders',
'Self-serve model for wider team',
],
techStack: ['Power BI', 'SQL', 'DAX'],
externalUrl: 'https://medicines.charlwood.xyz',
},
{
id: 'inv-switching-algorithm',
name: 'Patient Switching Algorithm',
requestedYear: 2025,
reportedYear: 2025,
status: 'Complete',
resultSummary: '14,000 patients identified',
requestingClinician: 'A. Charlwood',
methodology: 'Python-based algorithm using real-world GP prescribing data to automatically identify patients on expensive drugs suitable for cost-effective alternatives. Compressed months of manual analysis into 3 days.',
results: [
'14,000 patients identified for cost-effective alternatives',
'£2.6M annual savings potential identified',
'£2M on target for delivery this financial year',
'Novel GP payment system linking rewards to savings',
],
techStack: ['Python', 'Pandas', 'SQL'],
},
{
id: 'inv-blueteq-gen',
name: 'Blueteq Generator',
requestedYear: 2023,
reportedYear: 2023,
status: 'Complete',
resultSummary: '70% reduction in forms',
requestingClinician: 'A. Charlwood',
methodology: 'Software automation of Blueteq prior approval form creation, reducing manual data entry and standardising form generation across high-cost drug pathways.',
results: [
'70% reduction in required Blueteq forms',
'200 hours immediate savings',
'78 hours ongoing weekly efficiency gains',
'Integrated with secondary care activity databases',
],
techStack: ['Python', 'SQL'],
},
{
id: 'inv-cd-monitoring',
name: 'CD Monitoring System',
requestedYear: 2024,
reportedYear: 2024,
status: 'Complete',
resultSummary: 'Population-scale OME tracking',
requestingClinician: 'A. Charlwood',
methodology: 'Python-based controlled drug monitoring system calculating oral morphine equivalents (OME) across all opioid prescriptions to track patient-level exposure over time.',
results: [
'Patient-level OME tracking over time',
'High-risk patient identification',
'Potential diversion detection',
'Previously impossible population-scale analysis',
],
techStack: ['Python', 'SQL'],
},
{
id: 'inv-sankey-tool',
name: 'Sankey Chart Analysis Tool',
requestedYear: 2023,
reportedYear: 2023,
status: 'Complete',
resultSummary: 'Pathway audit capability',
requestingClinician: 'A. Charlwood',
methodology: 'Python-based visualisation tool for patient journey mapping through high-cost drug pathways, enabling trust-level compliance auditing.',
results: [
'Visual patient pathway representation',
'Trust compliance auditing capability',
'Improvement opportunity identification',
'Multi-specialty pathway coverage',
],
techStack: ['Python', 'Matplotlib', 'SQL'],
},
]
+262
View File
@@ -0,0 +1,262 @@
import type { Medication } from '@/types/pmr'
export const medications: Medication[] = [
{
id: 'med-python',
name: 'Python',
dose: 90,
frequency: 'Daily',
startYear: 2017,
status: 'Active',
category: 'Active',
prescribingHistory: [
{ year: 2017, description: 'Started: Self-taught for data analysis automation' },
{ year: 2019, description: 'Increased: Dashboard development, data pipeline work' },
{ year: 2022, description: 'Specialist use: Blueteq automation, Sankey analysis tools' },
{ year: 2024, description: 'Advanced: Switching algorithm (14,000 patients), CD monitoring' },
{ year: 2025, description: 'Current: Population-level analytics, incentive scheme automation' },
],
},
{
id: 'med-sql',
name: 'SQL',
dose: 88,
frequency: 'Daily',
startYear: 2017,
status: 'Active',
category: 'Active',
prescribingHistory: [
{ year: 2017, description: 'Started: Basic querying for prescribing analysis' },
{ year: 2019, description: 'Increased: Complex joins, data transformation' },
{ year: 2022, description: 'Advanced: Patient-level analytics, dm+d integration' },
{ year: 2024, description: 'Specialist: Comprehensive medicines data table development' },
{ year: 2025, description: 'Current: Population health data infrastructure' },
],
},
{
id: 'med-powerbi',
name: 'Power BI',
dose: 92,
frequency: 'Daily',
startYear: 2019,
status: 'Active',
category: 'Active',
prescribingHistory: [
{ year: 2019, description: 'Started: Dashboard creation for team reporting' },
{ year: 2021, description: 'Increased: DAX measures, data modelling' },
{ year: 2024, description: 'Advanced: PharMetrics real-time expenditure dashboard' },
{ year: 2025, description: 'Current: DOAC switching scenario model, executive reporting' },
],
},
{
id: 'med-data-analysis',
name: 'Data Analysis',
dose: 95,
frequency: 'Daily',
startYear: 2016,
status: 'Active',
category: 'Active',
prescribingHistory: [
{ year: 2016, description: 'Started: Prescribing data analysis in community pharmacy' },
{ year: 2018, description: 'Increased: Population-level data interpretation' },
{ year: 2022, description: 'Advanced: Real-world GP prescribing data at scale' },
{ year: 2024, description: 'Current: ICS-wide analytics strategy development' },
],
},
{
id: 'med-js-ts',
name: 'JavaScript / TypeScript',
dose: 70,
frequency: 'Weekly',
startYear: 2020,
status: 'Active',
category: 'Active',
prescribingHistory: [
{ year: 2020, description: 'Started: Web development for personal projects' },
{ year: 2022, description: 'Increased: React dashboard components' },
{ year: 2024, description: 'Current: CV/portfolio development, interactive tools' },
],
},
{
id: 'med-dashboard',
name: 'Dashboard Development',
dose: 88,
frequency: 'Weekly',
startYear: 2019,
status: 'Active',
category: 'Active',
prescribingHistory: [
{ year: 2019, description: 'Started: Power BI for prescribing metrics' },
{ year: 2022, description: 'Increased: Sankey visualisation tools' },
{ year: 2024, description: 'Current: Real-time expenditure tracking, scenario modelling' },
],
},
{
id: 'med-algorithm',
name: 'Algorithm Design',
dose: 82,
frequency: 'Weekly',
startYear: 2022,
status: 'Active',
category: 'Active',
prescribingHistory: [
{ year: 2022, description: 'Started: Basic automation logic for form generation' },
{ year: 2024, description: 'Increased: Controlled drug monitoring calculations' },
{ year: 2025, description: 'Current: Patient switching algorithm (14,000 identified)' },
],
},
{
id: 'med-pipelines',
name: 'Data Pipelines',
dose: 80,
frequency: 'Weekly',
startYear: 2022,
status: 'Active',
category: 'Active',
prescribingHistory: [
{ year: 2022, description: 'Started: ETL processes for Blueteq integration' },
{ year: 2024, description: 'Increased: dm+d standardisation, morphine conversions' },
{ year: 2025, description: 'Current: ICS-wide data infrastructure' },
],
},
{
id: 'med-meds-opt',
name: 'Medicines Optimisation',
dose: 95,
frequency: 'Daily',
startYear: 2016,
status: 'Active',
category: 'Clinical',
prescribingHistory: [
{ year: 2016, description: 'Started: Community pharmacy clinical services' },
{ year: 2018, description: 'Increased: MUR/NMS delivery optimisation' },
{ year: 2022, description: 'Advanced: ICS-level optimisation strategy' },
{ year: 2025, description: 'Current: £14.6M efficiency programme delivery' },
],
},
{
id: 'med-pop-health',
name: 'Population Health Analytics',
dose: 90,
frequency: 'Daily',
startYear: 2022,
status: 'Active',
category: 'Clinical',
prescribingHistory: [
{ year: 2022, description: 'Started: GP prescribing data analysis' },
{ year: 2024, description: 'Increased: 1.2M population coverage' },
{ year: 2025, description: 'Current: ICS-wide health inequality analysis' },
],
},
{
id: 'med-nice-ta',
name: 'NICE TA Implementation',
dose: 85,
frequency: 'Weekly',
startYear: 2022,
status: 'Active',
category: 'Clinical',
prescribingHistory: [
{ year: 2022, description: 'Started: High-cost drug pathway development' },
{ year: 2023, description: 'Increased: Multi-specialty pathway authoring' },
{ year: 2024, description: 'Current: Tirzepatide (TA1026) commissioning' },
],
},
{
id: 'med-health-econ',
name: 'Health Economics',
dose: 80,
frequency: 'Monthly',
startYear: 2023,
status: 'Active',
category: 'Clinical',
prescribingHistory: [
{ year: 2023, description: 'Started: Financial scenario modelling' },
{ year: 2024, description: 'Increased: Rebate negotiation, DOAC switching analysis' },
{ year: 2025, description: 'Current: Efficiency programme prioritisation' },
],
},
{
id: 'med-clinical-path',
name: 'Clinical Pathways',
dose: 82,
frequency: 'Weekly',
startYear: 2022,
status: 'Active',
category: 'Clinical',
prescribingHistory: [
{ year: 2022, description: 'Started: Rheumatology, ophthalmology pathway design' },
{ year: 2023, description: 'Increased: Dermatology, gastroenterology, neurology' },
{ year: 2024, description: 'Current: System-wide pathway governance' },
],
},
{
id: 'med-cd-assurance',
name: 'Controlled Drug Assurance',
dose: 88,
frequency: 'Weekly',
startYear: 2024,
status: 'Active',
category: 'Clinical',
prescribingHistory: [
{ year: 2024, description: 'Started: OME calculation system development' },
{ year: 2024, description: 'Increased: Population-scale monitoring capability' },
{ year: 2025, description: 'Current: High-risk patient identification, diversion detection' },
],
},
{
id: 'med-budget',
name: 'Budget Management',
dose: 90,
frequency: 'As needed',
startYear: 2024,
status: 'Active',
category: 'PRN',
prescribingHistory: [
{ year: 2024, description: 'Started: £220M prescribing budget oversight' },
{ year: 2024, description: 'Increased: Forecasting model development' },
{ year: 2025, description: 'Current: Proactive financial planning, pressure identification' },
],
},
{
id: 'med-stakeholder',
name: 'Stakeholder Engagement',
dose: 88,
frequency: 'As needed',
startYear: 2022,
status: 'Active',
category: 'PRN',
prescribingHistory: [
{ year: 2022, description: 'Started: Clinical lead engagement across care sectors' },
{ year: 2024, description: 'Increased: Executive communication, CMO presentations' },
{ year: 2025, description: 'Current: System-level programme board reporting' },
],
},
{
id: 'med-pharma-neg',
name: 'Pharmaceutical Negotiation',
dose: 85,
frequency: 'As needed',
startYear: 2024,
status: 'Active',
category: 'PRN',
prescribingHistory: [
{ year: 2024, description: 'Started: Rebate terms renegotiation' },
{ year: 2024, description: 'Current: Improved commercial position for ICB' },
],
},
{
id: 'med-team-dev',
name: 'Team Development',
dose: 82,
frequency: 'As needed',
startYear: 2017,
status: 'Active',
category: 'PRN',
prescribingHistory: [
{ year: 2017, description: 'Started: NVQ3 supervision to technician registration' },
{ year: 2019, description: 'Increased: National induction training development' },
{ year: 2024, description: 'Current: Data fluency training, self-serve tools' },
],
},
]
+18
View File
@@ -0,0 +1,18 @@
import type { Patient } from '@/types/pmr'
export const patient: Patient = {
name: 'CHARLWOOD, Andrew (Mr)',
displayName: 'Andrew Charlwood',
dob: '14/02/1993',
nhsNumber: '221 181 0',
nhsNumberTooltip: 'GPhC Registration Number',
address: 'Norwich, NR1',
phone: '07795553088',
email: 'andy@charlwood.xyz',
linkedin: 'linkedin.com/in/andycharlwood',
status: 'Active',
badge: 'Open to opportunities',
qualification: 'MPharm (Hons) 2:1',
university: 'UEA, 2015',
registrationYear: 'August 2016',
}
+111
View File
@@ -0,0 +1,111 @@
import type { Problem } from '@/types/pmr'
export const problems: Problem[] = [
{
id: 'prob-budget',
code: 'MGT001',
description: '£220M prescribing budget oversight and management',
since: 'Jul 2024',
status: 'Active',
narrative: 'Responsible for managing the £220M prescribing budget for NHS Norfolk & Waveney ICB. Developed sophisticated forecasting models identifying cost pressures and enabling proactive financial planning. This is an ongoing responsibility requiring continuous monitoring and strategic intervention.',
linkedConsultations: ['deputy-head-2024'],
},
{
id: 'prob-sql-transform',
code: 'TRN001',
description: 'Patient-level SQL analytics transformation',
since: '2025',
status: 'In Progress',
narrative: 'Leading transformation from practice-level data to patient-level SQL analytics, enabling targeted interventions and a self-serve model for the wider team. This foundational change will unlock previously impossible analysis at population scale.',
linkedConsultations: ['interim-head-2025', 'deputy-head-2024'],
},
{
id: 'prob-data-literacy',
code: 'LEA001',
description: 'Team data literacy programme',
since: 'Jul 2024',
status: 'In Progress',
narrative: 'Educating colleagues on data interpretation and analytics best practices, improving data fluency across the team through training, documentation, and self-serve tools. Ongoing initiative to build sustainable analytical capability.',
linkedConsultations: ['deputy-head-2024'],
},
{
id: 'prob-efficiency',
code: 'EFF001',
description: 'Manual prescribing analysis inefficiency',
resolved: 'Oct 2025',
status: 'Resolved',
outcome: 'Python algorithm: 14,000 pts, £2.6M/yr',
narrative: 'Built Python-based switching algorithm using real-world GP prescribing data to automatically identify patients on expensive drugs suitable for cost-effective alternatives. Compressed months of manual analysis into 3 days. Identified 14,000 patients and £2.6M in annual savings, with £2M on target for delivery this financial year.',
linkedConsultations: ['interim-head-2025'],
},
{
id: 'prob-efficiency-target',
code: 'EFF002',
description: '£14.6M efficiency target identification and delivery',
resolved: 'Oct 2025',
status: 'Resolved',
outcome: 'Over-target performance achieved',
narrative: 'Identified and prioritised a £14.6M efficiency programme through comprehensive data analysis. Achieved over-target performance by October 2025 through targeted, evidence-based interventions across the integrated care system.',
linkedConsultations: ['interim-head-2025'],
},
{
id: 'prob-blueteq-backlog',
code: 'AUT001',
description: 'Blueteq form creation backlog',
resolved: '2023',
status: 'Resolved',
outcome: '70% reduction, 200hrs saved',
narrative: 'Developed software automating Blueteq prior approval form creation. Achieved 70% reduction in required forms, 200 hours immediate savings, and ongoing 78 hours weekly efficiency gains.',
linkedConsultations: ['high-cost-drugs-2022'],
},
{
id: 'prob-asthma-screening',
code: 'INN001',
description: 'Asthma screening scalability',
resolved: '2019',
status: 'Resolved',
outcome: 'National rollout: ~300 branches, ~£1M',
narrative: 'Identified and shared an asthma screening process that was adopted nationally across the Tesco pharmacy estate (~300 branches). Reduced pharmacist time from approximately 60 hours to 6 hours per store per month, enabling the network to claim approximately £1M in revenue.',
linkedConsultations: ['pharmacy-manager-2017'],
},
{
id: 'prob-incentive-calc',
code: 'AUT002',
description: 'Incentive scheme manual calculation',
resolved: '2025',
status: 'Resolved',
outcome: 'Automated: 50% Rx reduction in 2 months',
narrative: 'Automated incentive scheme analysis, improving accuracy and targeting precision whilst enabling a novel GP payment system linking rewards to delivered savings. Achieved 50% reduction in targeted prescribing within the first two months of deployment.',
linkedConsultations: ['interim-head-2025'],
},
{
id: 'prob-hcd-tracking',
code: 'DAT001',
description: 'High-cost drug spend tracking gaps',
resolved: '2023',
status: 'Resolved',
outcome: 'Blueteq-secondary care data integration',
narrative: 'Integrated Blueteq data with secondary care activity databases, resolving critical data-matching limitations and enabling accurate high-cost drug spend tracking across the system.',
linkedConsultations: ['high-cost-drugs-2022'],
},
{
id: 'prob-pathway-opacity',
code: 'VIS001',
description: 'Patient pathway opacity',
resolved: '2023',
status: 'Resolved',
outcome: 'Sankey chart analysis tool',
narrative: 'Created Python-based Sankey chart analysis tool visualising patient journeys through high-cost drug pathways, enabling trusts to audit compliance and identify improvement opportunities.',
linkedConsultations: ['high-cost-drugs-2022'],
},
{
id: 'prob-opioid-monitoring',
code: 'MON001',
description: 'Population opioid exposure monitoring',
resolved: '2024',
status: 'Resolved',
outcome: 'CD monitoring system: OME tracking',
narrative: 'Developed Python-based controlled drug monitoring system calculating oral morphine equivalents across all opioid prescriptions to track patient-level exposure over time, identifying high-risk patients and potential diversion—enabling previously impossible patient safety analysis at population scale.',
linkedConsultations: ['deputy-head-2024'],
},
]
+61
View File
@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react'
type Breakpoint = 'mobile' | 'tablet' | 'desktop'
interface BreakpointState {
breakpoint: Breakpoint
isMobile: boolean
isTablet: boolean
isDesktop: boolean
}
export function useBreakpoint(): BreakpointState {
const [state, setState] = useState<BreakpointState>(() => {
if (typeof window === 'undefined') {
return { breakpoint: 'desktop', isMobile: false, isTablet: false, isDesktop: true }
}
const width = window.innerWidth
if (width < 768) {
return { breakpoint: 'mobile', isMobile: true, isTablet: false, isDesktop: false }
}
if (width < 1024) {
return { breakpoint: 'tablet', isMobile: false, isTablet: true, isDesktop: false }
}
return { breakpoint: 'desktop', isMobile: false, isTablet: false, isDesktop: true }
})
useEffect(() => {
const handleResize = () => {
const width = window.innerWidth
let breakpoint: Breakpoint
let isMobile: boolean
let isTablet: boolean
let isDesktop: boolean
if (width < 768) {
breakpoint = 'mobile'
isMobile = true
isTablet = false
isDesktop = false
} else if (width < 1024) {
breakpoint = 'tablet'
isMobile = false
isTablet = true
isDesktop = false
} else {
breakpoint = 'desktop'
isMobile = false
isTablet = false
isDesktop = true
}
setState({ breakpoint, isMobile, isTablet, isDesktop })
}
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return state
}
+35
View File
@@ -0,0 +1,35 @@
import { useState, useEffect, useRef } from 'react'
interface UseScrollCondensationOptions {
threshold?: number
}
export function useScrollCondensation(options: UseScrollCondensationOptions = {}) {
const { threshold = 100 } = options
const [isCondensed, setIsCondensed] = useState(false)
const sentinelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries
setIsCondensed(!entry.isIntersecting)
},
{
rootMargin: `-${threshold}px 0px 0px 0px`,
threshold: 0,
}
)
observer.observe(sentinel)
return () => {
observer.disconnect()
}
}, [threshold])
return { isCondensed, sentinelRef }
}
+43
View File
@@ -3,6 +3,7 @@
@tailwind utilities;
:root {
/* Original design system tokens (for boot/ECG phases) */
--bg: #FFFFFF;
--text: #334155;
--heading: #0F172A;
@@ -20,6 +21,28 @@
--radius: 16px;
--font-primary: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-secondary: 'Inter Tight', system-ui, sans-serif;
/* PMR-specific tokens */
--pmr-content: #F5F7FA;
--pmr-card: #FFFFFF;
--pmr-sidebar: #1E293B;
--pmr-banner: #334155;
--pmr-nhs-blue: #005EB8;
--pmr-green: #22C55E;
--pmr-amber: #F59E0B;
--pmr-red: #EF4444;
--pmr-text-primary: #111827;
--pmr-text-secondary: #6B7280;
--pmr-border: #E5E7EB;
--pmr-border-dark: #D1D5DB;
--pmr-selected: #EFF6FF;
--pmr-alert-bg: #FEF3C7;
--pmr-alert-border: #F59E0B;
--pmr-alert-text: #92400E;
--pmr-radius: 4px;
--pmr-radius-login: 12px;
--font-inter: 'Inter', system-ui, sans-serif;
--font-geist-mono: 'Geist Mono', 'Fira Code', monospace;
}
* {
@@ -46,6 +69,17 @@ body {
.font-mono {
font-family: 'Fira Code', monospace;
}
.font-inter {
font-family: var(--font-inter);
}
.font-geist-mono {
font-family: var(--font-geist-mono);
}
.pmr-theme {
background-color: var(--pmr-content);
color: var(--pmr-text-primary);
font-family: var(--font-inter);
}
}
@keyframes blink {
@@ -66,6 +100,15 @@ body {
animation: seedPulse 0.6s ease-in-out infinite;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeIn {
animation: fadeIn 200ms ease-out forwards;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
+1 -1
View File
@@ -33,7 +33,7 @@ export interface ContactItem {
href?: string
}
export type Phase = 'boot' | 'ecg' | 'content'
export type Phase = 'boot' | 'ecg' | 'login' | 'pmr'
export interface BootLine {
html: string
+115
View File
@@ -0,0 +1,115 @@
export interface CodedEntry {
code: string
description: string
}
export interface Consultation {
id: string
date: string
organization: string
orgColor: string
role: string
duration: string
isCurrent: boolean
history: string
examination: string[]
plan: string[]
codedEntries: CodedEntry[]
}
export interface PrescribingHistoryEntry {
year: number
description: string
}
export interface Medication {
id: string
name: string
dose: number
frequency: 'Daily' | 'Weekly' | 'Monthly' | 'As needed'
startYear: number
status: 'Active' | 'Historical'
category: 'Active' | 'Clinical' | 'PRN'
prescribingHistory: PrescribingHistoryEntry[]
}
export interface Problem {
id: string
code: string
description: string
since?: string
resolved?: string
status: 'Active' | 'In Progress' | 'Resolved'
outcome?: string
narrative?: string
linkedConsultations?: string[]
}
export interface InvestigationResult {
label: string
value: string
}
export interface Investigation {
id: string
name: string
requestedYear: number
reportedYear?: number
status: 'Complete' | 'Ongoing' | 'Live'
resultSummary: string
requestingClinician: string
methodology: string
results: string[]
techStack: string[]
externalUrl?: string
}
export type DocumentType = 'Certificate' | 'Registration' | 'Results' | 'Research'
export interface Document {
id: string
type: DocumentType
title: string
date: string
source: string
classification?: string
institution?: string
duration?: string
researchDetail?: string
researchGrade?: string
notes?: string
}
export interface Patient {
name: string
displayName: string
dob: string
nhsNumber: string
nhsNumberTooltip: string
address: string
phone: string
email: string
linkedin: string
status: string
badge: string
qualification: string
university: string
registrationYear: string
}
export type ViewId = 'summary' | 'consultations' | 'medications' | 'problems' | 'investigations' | 'documents' | 'referrals'
export interface NavItem {
id: ViewId
label: string
icon: string
}
export interface ReferralFormData {
priority: 'Urgent' | 'Routine' | 'Two-Week Wait'
referrerName: string
referrerEmail: string
referrerOrg?: string
reason: string
contactMethod: 'Email' | 'Phone' | 'LinkedIn'
}
+25 -1
View File
@@ -33,19 +33,43 @@ export default {
dim: '#3a6b45',
grey: '#666666',
},
pmr: {
sidebar: '#1E293B',
banner: '#334155',
content: '#F5F7FA',
card: '#FFFFFF',
nhsblue: '#005EB8',
green: '#22C55E',
amber: '#F59E0B',
red: '#EF4444',
'text-primary': '#111827',
'text-secondary': '#6B7280',
'text-on-dark': '#FFFFFF',
'text-on-dark-secondary': '#94A3B8',
'border': '#E5E7EB',
'border-dark': '#D1D5DB',
'selected-row': '#EFF6FF',
'alert-bg': '#FEF3C7',
'alert-border': '#F59E0B',
'alert-text': '#92400E',
},
},
fontFamily: {
primary: ['Plus Jakarta Sans', 'system-ui', 'sans-serif'],
secondary: ['Inter Tight', 'system-ui', 'sans-serif'],
mono: ['Fira Code', 'monospace'],
inter: ['Inter', 'system-ui', 'sans-serif'],
geist: ['Geist Mono', 'Fira Code', 'monospace'],
},
boxShadow: {
'sm': '0 1px 3px rgba(0,0,0,0.06)',
'md': '0 4px 12px rgba(0,0,0,0.08)',
'lg': '0 8px 24px rgba(0,0,0,0.1)',
'pmr': '0 1px 2px rgba(0,0,0,0.03)',
},
borderRadius: {
'card': '16px',
'card': '4px',
'login': '12px',
},
},
},