Skip to content

Session 31: Full EHR View Drawer

Context

You are working on the Curaway Health Navigator frontend (curaway-health-navigator repo, curaway-ai/curaway-frontend on GitHub). This is a React + Vite + TypeScript + Tailwind + shadcn/ui app deployed on Vercel at app.curaway.ai. Backend is FastAPI at services.curaway.ai.

Read CLAUDE.md in both repos before starting. Read the existing src/components/panels/EHRPanel.tsx to understand the current EHR panel implementation.

Branch

git checkout main && git pull origin main
git checkout -b feature/full-ehr-view

What You're Building

A full-width EHR viewer drawer that opens from a CTA on the existing EHR panel. The drawer slides in from the right, covering the conversation area but preserving the 56px icon rail. It displays the complete patient health record in a structured, grouped, two-column layout with 15 collapsible sections — including structured lab results with LOINC codes and abnormal flags, SNOMED/ICD-10 codes with AI confidence scores, and data provenance tags showing whether each piece of information came from an uploaded document or the intake conversation.

The existing EHR panel (300px slide-out) is the quick glance. This drawer is the deep dive.

No backend changes needed — this uses the existing GET /api/v1/cases/{case_id}/ehr endpoint.


PART A: Full EHR Drawer Component

A1: Create src/components/ehr/FullEHRDrawer.tsx

The main drawer container.

Props:

interface FullEHRDrawerProps {
  isOpen: boolean;
  onClose: () => void;
  caseId: string;
  patientName: string;
  caseNumber: string;
}

Layout: - Overlay: fixed position, inset-0, z-50, bg-black/30, click to close - Drawer: fixed position, top-0 right-0 bottom-0, width calc(100vw - 56px) (preserves icon rail), white background - Entry animation: transform transition-transform duration-300 ease-outtranslate-x-full when closed, translate-x-0 when open - Internal structure: sticky header (64px) + scrollable content area

Header (sticky): - Left: Back arrow button (← icon, gray-500, hover teal) + "Patient Health Record" text (18px Montserrat semi-bold, text-[#004D4D]) - Center: Patient name + case number in a bg-teal-50 text-teal-700 px-3 py-1 rounded-full text-xs pill - Right: Completeness ring (48px SVG circle, teal stroke, percentage text inside) + Close button (×) - Bottom: border-b border-gray-200

Content area: - Padding: p-8 - Two-column grid on desktop: grid grid-cols-[1fr_380px] gap-8 when ≥1024px, single column otherwise - Main column max-width: max-w-[720px]

Behavior: - Fetch EHR data on open (call the existing EHR API hook or service) - Show skeleton loader while loading - Trap focus inside drawer (use useEffect to manage focus) - Close on Escape key press - Prevent body scroll when open (overflow-hidden on body) - Graceful degradation: Every new field (SNOMED codes, LOINC codes, confidence scores, source provenance, fhir_validated, lab_results, auto_detected_conditions) is optional. If a field is null/undefined/missing, the corresponding UI element simply doesn't render — no crashes, no "N/A", no empty sections. The drawer works fully with current ehr_snapshot data and progressively improves as the backend enriches the schema.

A2: Create src/components/ehr/EHRSection.tsx

Reusable collapsible section wrapper.

interface EHRSectionProps {
  title: string;
  icon: React.ReactNode;
  status: 'complete' | 'partial' | 'empty';
  defaultExpanded?: boolean;
  children: React.ReactNode;
  onToggle?: (expanded: boolean) => void;
}

Design: - Header row: 3px teal left border on the section container, 14px Montserrat semi-bold uppercase title in text-[#008B8B], status dot (green/amber/gray), chevron toggle - aria-expanded and aria-controls for accessibility - Smooth height transition: use grid-rows-[0fr]grid-rows-[1fr] pattern for CSS-only animation - When status is 'empty': collapsed by default, show "Not yet captured" in text-gray-400 italic text-sm when expanded

A3: Create Section Components

Each section is its own component inside src/components/ehr/sections/:

PatientDemographics.tsx - Two-column grid of key-value pairs: Name, Age/DOB, Gender, Nationality, Location, Contact Preference - Languages as teal pills (bg-teal-50 text-teal-700 rounded-full px-2 py-0.5 text-xs) - Handle missing fields by not rendering them (never show "N/A")

InsuranceCoverage.tsx - Simple key-value display: Status, Provider, Policy, Pre-auth - If no insurance data, section status is 'empty'

PrimaryCondition.tsx - Condition name prominent (16px semi-bold) - ICD code in a font-mono text-xs bg-gray-100 px-2 py-0.5 rounded pill + SNOMED code in same style beside it - Confidence badges next to each code: bg-teal-50 text-teal-700 for ≥80%, bg-amber-50 text-amber-700 for 50-79%, bg-red-50 text-red-700 for <50% — show percentage value (e.g., "94%") - If fhir_validated is true, show a subtle "FHIR ✓" green checkmark badge - Severity badge: bg-green-100 text-green-700 / bg-amber-100 text-amber-700 / bg-red-100 text-red-700 - Duration, laterality, onset in regular text below - Source provenance tag at bottom: small gray text with icon — document icon + name, or chat bubble icon + "Intake conversation"

ProcedureSought.tsx - Procedure name + CPT code pill (same style as ICD) - Urgency badge: Elective (green), Urgent (amber), Emergency (red) - If fhir_validated is true, show "FHIR Procedure ✓" green badge - Clinical justification in a bg-teal-50 border-l-4 border-teal-500 p-4 rounded-r highlighted block (if present)

Comorbidities.tsx - Table with columns: Condition | ICD-10 | SNOMED | Confidence | Risk Level | Source - ICD and SNOMED as monospace code pills - Confidence as percentage badge (same color scheme as PrimaryCondition) - Risk level as colored badge (low=green, moderate=amber, high=red) - Source column: document icon, chat icon, or flask icon (for lab-detected) - Lab-detected comorbidities (from lab_analyzer.py) show an inline amber callout below the row: "⚠ Detected from HbA1c 6.3%" — uses detection_detail field - Sort by risk level descending (high first) - If empty array, section shows 'empty' state

SurgicalHistory.tsx - Timeline layout: vertical line with dots, each entry shows date (left), procedure name + facility + outcome (right) - Most recent first - Dates formatted with Intl.DateTimeFormat

DiagnosticFindings.tsx - Grouped by source document - Each group: document name as sub-header (linked if document_id exists), extraction date in gray - Findings as key-value pairs - Left border color: border-purple-400 for imaging, border-gray-300 for clinical - Each extracted entity shows FHIR validation: if fhir_resource_ids present, render a subtle text-green-600 text-xs badge "FHIR Observation ✓" or "FHIR Condition ✓" - Source provenance tag below each group: document icon + filename + extraction date - Handle mixed types

LabResults.tsx (new section — renders between DiagnosticFindings and Medications) - Groups lab values by panel_name (e.g., "Complete Blood Count", "Basic Metabolic Panel", "HbA1c", "Lipid Panel" — matching the 13 parameter sets) - Each panel is a sub-card: bg-white border border-gray-200 rounded-lg p-4 mb-4 - Panel header: panel name in 13px semi-bold + source document name (linked) + collection date - Table per panel with columns: - Parameter name (e.g., "Hemoglobin") — 13px regular - Value — 13px semi-bold, color-coded: text-green-700 normal, text-amber-600 low/high, text-red-600 font-bold critical - Unit — 12px gray-500 - Reference range — 12px gray-400 (e.g., "13.5–17.5") - Flag — icon: ✓ green for normal, ↑ red for high, ↓ amber for low, ⚠ red bold for critical - LOINC — font-mono text-[10px] bg-gray-50 px-1.5 py-0.5 rounded text-gray-400 pill (e.g., "718-7"). Only show if loinc_code is present. - Below the table, if auto_detected_conditions exists, render inline callout cards: bg-amber-50 border-l-3 border-amber-400 p-3 rounded-r text-sm — "⚠ {condition}: {triggering_parameter} {triggering_value} ({rule})" — e.g., "⚠ Prediabetes: HbA1c 6.3% (HbA1c 5.7–6.4% → Prediabetes)" - FHIR validation: if individual parameters have fhir_validated: true, show a single "FHIR Observations ✓" badge at panel level (don't repeat per row) - If no lab_results data, section shows 'empty' state

Medications.tsx - Table: Medication | Dosage | Frequency | Duration | Status | Source - Active medications: normal text. Discontinued: text-gray-400 line-through - Source column: small icon + label — document icon "From report", chat icon "From conversation", database icon "FHIR MedicationStatement". Uses source.type field. - Chat-extracted medications (from chat extractor, Session 30) show a bg-blue-50 text-blue-600 text-[10px] px-1.5 rounded "Conversation" tag - If prescribing context exists, show as a tooltip or small text below the row

Allergies.tsx - Cards per allergy (not table): allergy name bold, type pill, severity badge (same colors as comorbidity risk), reaction description below - Sort by severity descending (severe first) - Severe allergies get a subtle border-l-4 border-red-400 accent - Source provenance: small gray text at bottom of each card — "From: [document name]" or "From: Intake conversation"

PatientPreferences.tsx - Budget: formatted with currency symbol and range (e.g., "$15,000 – $25,000 USD") - Destinations: country flags (emoji) + names as pills - Language requirements, dietary, cultural, accommodation: each as a pill group or comma-separated text - Source tags on each preference group: "Stated in conversation" (chat icon) or "From intake form" (form icon) — chat extractor (Session 30) and LLM requirement matcher are the primary sources for preferences - Hide entire subsections that have no data

TravelReadiness.tsx - Simple key-value: passport status, travel companion (yes/no), mobility considerations - Visa requirements: country → status mapping

ClinicalSummary.tsx - The AI-generated narrative in a highlighted card: bg-teal-50 border-l-4 border-[#008B8B] p-6 rounded-r-lg - Text rendered as markdown (use existing markdown renderer if available, or prose class with basic formatting)

RiskAssessment.tsx - Cards per risk factor: factor name, level badge (same color scheme), relevance text, mitigation note (if present) in text-gray-500 text-sm

MissingInformation.tsx - Amber warning cards: bg-amber-50 border border-amber-200 rounded-lg p-3 - Each missing item shows the field name + "Ask patient" button (teal outline, small) - "Ask patient" click: close the drawer, focus the chat input, optionally pre-fill with a prompt asking about that missing info. Use the existing chat send mechanism via a callback prop.

A4: Create src/components/ehr/EHRSidebar.tsx

The right-side sidebar for the two-column layout.

Card 1: Record Completeness - Large SVG ring (80px): stroke-[#008B8B] for filled portion, stroke-gray-200 for unfilled, percentage text centered - Below: mini progress bars per group (Identity, Clinical, Diagnostics, Preferences, AI Analysis) - Calculate group completeness from which fields are populated in the EHR data - Color: bg-green-500 ≥80%, bg-amber-500 50-79%, bg-red-500 <50%

Card 2: Data Sources - List of documents from diagnostic_findings[].source_document + lab_results[].source_document - Deduplicated - Also include a "Conversation" entry if any data has source.type === 'conversation' — shows chat icon + "Intake conversation" + number of data points extracted - Also include a "Lab Analyzer" entry if any comorbidities have detection_method === 'lab_analyzer' — shows flask icon + "Auto-detected from lab values" - Each document shows filename (truncated to 30 chars), upload date (relative), status badge - Click opens document (construct R2 presigned URL from document_id — use existing document viewing logic)

Card 3: Record Timeline - From construction_events[] if present - Compact: small teal dots connected by a thin vertical line - Each event: description + relative timestamp - Most recent at top - If no events data, hide this card entirely

Card 4: Quick Actions - "Download as PDF" — disabled button with tooltip "Coming soon" - "Share with Provider" — disabled button with tooltip "Coming soon" - Styling: opacity-50 cursor-not-allowed with tooltip on hover

All cards: bg-white border border-gray-200 rounded-xl p-5 shadow-sm with 16px gap between them. Sidebar is sticky top-[80px] (below header).


PART B: Integration

B1: Update EHRPanel.tsx

Add the "View Full Record" CTA button at the bottom of the existing EHR panel:

  • Position: below all existing content, above the "Last updated" timestamp
  • Style: w-full h-9 border border-[#008B8B] text-[#008B8B] rounded-lg text-sm font-semibold hover:bg-teal-50 transition-colors flex items-center justify-center gap-2
  • Icon: Maximize/expand icon (use Lucide Maximize2 or ExternalLink)
  • Label: "View Full Record"
  • Only render when EHR data exists (check for non-null/non-empty ehr_snapshot)
  • Feature flag check: only show if full_ehr_view flag is enabled (check Flagsmith, or env var VITE_FULL_EHR_VIEW=true as fallback)
  • onClick: call a callback prop that sets fullEHROpen = true in the parent

B2: Update ConversationApp.tsx (or equivalent layout component)

Add state management for the full EHR drawer:

const [fullEHROpen, setFullEHROpen] = useState(false);

Render <FullEHRDrawer> at the top level of the layout (sibling to the icon rail, not nested inside panels):

<FullEHRDrawer
  isOpen={fullEHROpen}
  onClose={() => setFullEHROpen(false)}
  caseId={activeCase?.id}
  patientName={activeCase?.patientName}
  caseNumber={activeCase?.caseNumber}
/>

Pass onOpenFullEHR={() => setFullEHROpen(true)} down to EHRPanel.

B3: PostHog Analytics

Add tracking events:

// On drawer open
posthog.capture('ehr_full_view_opened', {
  case_id: caseId,
  completeness_score: ehrData?.completeness_score,
  section_count: countPopulatedSections(ehrData)  // 15 possible sections
});

// On drawer close
posthog.capture('ehr_full_view_closed', {
  case_id: caseId,
  time_spent_seconds: Math.floor((Date.now() - openTimestamp) / 1000),
  close_method: closeMethod // 'button' | 'backdrop' | 'escape'
});

// On section toggle
posthog.capture('ehr_section_toggled', {
  case_id: caseId,
  section_name: sectionTitle,
  expanded: isExpanded
});

// On "Ask patient" click
posthog.capture('ehr_missing_info_clicked', {
  case_id: caseId,
  missing_field: fieldName
});

If PostHog is not initialized (e.g., locally), these should no-op gracefully.


PART C: Responsive Behavior

Desktop (>1024px)

  • Two-column layout: main (65%) + sidebar (35%)
  • Drawer width: calc(100vw - 56px)

Tablet (768-1024px)

  • Single column layout
  • Sidebar cards render as a horizontal scroll strip at the top, below the header: flex overflow-x-auto gap-4 pb-4 border-b border-gray-200 mb-6
  • Each sidebar card: min-w-[280px] flex-shrink-0

Mobile (<768px)

  • Full viewport overlay (covers icon rail too): width 100vw
  • Sidebar cards stack vertically below all main sections
  • Back button in header is the only close mechanism (backdrop click disabled on mobile)
  • Respect env(safe-area-inset-bottom) for iOS

PART D: Accessibility

  1. Focus trap: when drawer opens, focus moves to the close button. Tab cycles within the drawer. On close, focus returns to the "View Full Record" button.
  2. role="dialog" and aria-modal="true" on the drawer
  3. aria-label="Patient Health Record" on the drawer
  4. All section headers use <h2> elements
  5. Collapsible sections: aria-expanded, aria-controls, id linking
  6. Status dots have aria-label text (e.g., "Complete", "Partial data", "No data")
  7. All color indicators paired with text labels
  8. Escape key closes drawer
  9. prefers-reduced-motion: disable slide animation, use instant show/hide

FINAL CHECKLIST

After completing all tasks, verify:

Functionality: - [ ] "View Full Record" button appears on EHR panel only when EHR data exists - [ ] Button hidden when full_ehr_view feature flag is disabled - [ ] Drawer opens with slide animation from right - [ ] Drawer preserves icon rail (56px left space) - [ ] Backdrop click closes drawer - [ ] Escape key closes drawer - [ ] Close (×) button closes drawer - [ ] Back arrow closes drawer - [ ] All 15 sections render correctly with TKR demo data (Aisha's case) - [ ] Lab Results section renders with LOINC codes, values, units, reference ranges, and abnormal flags (↑↓✓⚠) - [ ] Auto-detected conditions from lab analyzer display as amber callout cards below triggering values - [ ] ICD-10 and SNOMED codes both display with confidence percentages on conditions and comorbidities - [ ] FHIR validation badges ("FHIR ✓") appear on validated clinical entities - [ ] Source provenance tags ("From: [document]" / "From: Intake conversation") render on medications, allergies, comorbidities, and demographics - [ ] Chat-extracted data (medications, preferences) shows "Conversation" source tag - [ ] Empty/missing sections show "Not yet captured" and collapse - [ ] Sections are collapsible/expandable - [ ] Completeness ring matches EHR panel's percentage - [ ] "Ask patient" on missing info closes drawer and focuses chat input - [ ] Body scroll locked when drawer is open - [ ] Focus trap works correctly

Responsive: - [ ] Two-column layout on desktop (>1024px) - [ ] Single column + horizontal sidebar strip on tablet (768-1024px) - [ ] Full-screen overlay on mobile (<768px) - [ ] iOS safe area respected on mobile

Accessibility: - [ ] role="dialog" and aria-modal="true" present - [ ] aria-expanded on all collapsible sections - [ ] Focus trap works (Tab cycles within drawer) - [ ] Focus returns to trigger button on close - [ ] prefers-reduced-motion respected

Analytics: - [ ] ehr_full_view_opened fires on open - [ ] ehr_full_view_closed fires on close with time spent - [ ] ehr_section_toggled fires on section expand/collapse - [ ] ehr_missing_info_clicked fires on "Ask patient" click - [ ] All PostHog calls no-op gracefully if PostHog not initialized

Code Quality: - [ ] No TypeScript errors - [ ] No console warnings or errors - [ ] No references to "MediMatch" anywhere - [ ] Components use Montserrat font (inherits from app config) - [ ] Colors use Curaway brand: Teal #008B8B, Coral #FF7F50, Deep Ocean #004D4D - [ ] No hardcoded API URLs — use existing API service/config

Testing: - [ ] Existing Playwright E2E tests still pass - [ ] Manual test: full TKR demo flow → open EHR panel → click "View Full Record" → verify all sections → close → continue conversation - [ ] Test with empty EHR (new case, no data yet) — button should not appear

Documentation: - [ ] Update CLAUDE.md (frontend repo): Add Session 31, note Full EHR View drawer feature - [ ] Update CLAUDE.md (frontend repo): Add FullEHRDrawer.tsx and section components to component inventory

Rollback: - Feature flag full_ehr_view can disable the entire feature. The only change to existing code is the CTA button in EHRPanel.tsx which is gated behind the flag. Rollback = disable flag.