diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 37ad86ccc9..ed5d70f878 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -20,6 +20,7 @@ import { demoRecentQueryOwnerId, loadRecentQueries, } from "@/components/clinical-dashboard/recent-query-storage"; +import { PatientProfileProvider } from "@/components/clinical-dashboard/patient-profile-context"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; import { @@ -134,18 +135,23 @@ function GlobalSearchShellClient(props: GlobalSearchShellProps) { const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch); - if (shouldRenderClinicalDashboard) { - return ( - - ); - } - - return ; + // Wrap both render paths so the patient-considerations profile is shared + // between the prescribing workspace (ClinicalDashboard) and the medication + // detail pages (standalone shell), backed by sessionStorage across navigation. + return ( + + {shouldRenderClinicalDashboard ? ( + + ) : ( + + )} + + ); } function GlobalStandaloneSearchShellClient({ diff --git a/src/components/clinical-dashboard/medication-considerations.tsx b/src/components/clinical-dashboard/medication-considerations.tsx new file mode 100644 index 0000000000..d72c1da6bb --- /dev/null +++ b/src/components/clinical-dashboard/medication-considerations.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { ClipboardList } from "lucide-react"; +import { useMemo } from "react"; + +import { BadgeCluster, type ClinicalBadgeItem } from "@/components/clinical-dashboard/clinical-badge"; +import { usePatientProfile } from "@/components/clinical-dashboard/patient-profile-context"; +import { + evaluatePatientAlerts, + noticeToneForSemanticTone, + type MedicationConsideration, +} from "@/lib/medication-patient-alerts"; +import type { MedicationRecord } from "@/lib/medications"; +import type { SemanticTone } from "@/lib/semantic-tone"; +import { cn, InlineNotice } from "@/components/ui-primitives"; + +/** Badge for a result row summarising how many considerations apply. */ +export function considerationSummaryBadge(count: number, highestTone: SemanticTone | null): ClinicalBadgeItem | null { + if (!count || !highestTone) return null; + return { + id: "patient-alerts", + label: `${count} alert${count === 1 ? "" : "s"}`, + tone: highestTone, + }; +} + +function considerationBadges(consideration: MedicationConsideration): ClinicalBadgeItem[] { + return [ + ...consideration.factorLabels.map((label, index) => ({ + id: `${consideration.id}-factor-${index}`, + label, + tone: consideration.tone, + })), + ...consideration.reasons.map((reason, index) => ({ + id: `${consideration.id}-reason-${index}`, + label: reason, + tone: "neutral" as const, + })), + ]; +} + +/** + * Detail-page block: evaluates the entered profile against a single medication + * and renders the applicable considerations, an all-clear when none apply, and a + * hint for any contraindication gate the profile did not supply. + */ +export function MedicationConsiderations({ record, className }: { record: MedicationRecord; className?: string }) { + const { profile, isEmpty } = usePatientProfile(); + const result = useMemo(() => evaluatePatientAlerts(record, profile), [record, profile]); + + return ( +
+
+
+ + {isEmpty ? ( +
+ Enter patient details above to surface dosing, safety, and contraindication considerations for this + medication. +
+ ) : result.considerations.length === 0 ? ( + + No matching considerations for the entered patient profile. Always confirm against source. + + ) : ( +
+ {result.considerations.map((consideration) => ( + +
+ + {consideration.note ? ( +

{consideration.note}

+ ) : null} +
+
+ ))} +
+ )} + + {!isEmpty && result.unassessed.length > 0 ? ( + + Enter {result.unassessed.join(", ")} to fully assess this medication’s contraindications. + + ) : null} +
+ ); +} diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 8c8287680a..a11372c9fa 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -22,8 +22,12 @@ import { useMemo, useState } from "react"; import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { considerationSummaryBadge } from "@/components/clinical-dashboard/medication-considerations"; +import { usePatientProfile } from "@/components/clinical-dashboard/patient-profile-context"; +import { PatientProfilePanel } from "@/components/clinical-dashboard/patient-profile-panel"; import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; +import { evaluatePatientAlerts } from "@/lib/medication-patient-alerts"; import { BadgeCluster, ClinicalBadge, @@ -372,13 +376,21 @@ function MedicationResults({ >) { const command = useSearchCommand(); const catalog = useMedicationCatalog(query); + const { profile, isEmpty: profileEmpty } = usePatientProfile(); const [activeFilter, setActiveFilter] = useState("best"); const { rows, counts, totalAvailable } = useMemo(() => { const governance = catalog.data?.governance; - const toRow = (result: MedicationResult, medication?: MedicationRecord): MedicationRow => ({ - result, - badges: medication ? medicationIdentityBadges(medication, governance?.[medication.slug]) : [], - }); + const toRow = (result: MedicationResult, medication?: MedicationRecord): MedicationRow => { + const badges = medication ? medicationIdentityBadges(medication, governance?.[medication.slug]) : []; + // Prepend a per-patient alert badge so the highest-severity consideration + // surfaces first in the row's badge cluster (priority-sorted by tone). + if (medication && !profileEmpty) { + const alerts = evaluatePatientAlerts(medication, profile); + const alertBadge = considerationSummaryBadge(alerts.considerations.length, alerts.highestTone); + if (alertBadge) return { result, badges: [alertBadge, ...badges] }; + } + return { result, badges }; + }; const sourceRows = catalog.data?.matches?.map((match) => toRow(match.result, match.medication)) ?? (catalog.data?.records ?? []).slice(0, 12).map((record) => @@ -413,7 +425,7 @@ function MedicationResults({ counts: filterCounts, totalAvailable: scoped.length, }; - }, [activeFilter, catalog.data, command?.commandScopes]); + }, [activeFilter, catalog.data, command?.commandScopes, profile, profileEmpty]); const resultCount = rows.length; // The match-quality badge only earns its slot when it differentiates: hide it on // "Exact clinical fit" rows when every visible row says the same thing. @@ -437,6 +449,8 @@ function MedicationResults({ + + {catalog.loading ? ( diff --git a/src/components/clinical-dashboard/medication-record-page.tsx b/src/components/clinical-dashboard/medication-record-page.tsx index 8c38be1e3c..7e2bf80efa 100644 --- a/src/components/clinical-dashboard/medication-record-page.tsx +++ b/src/components/clinical-dashboard/medication-record-page.tsx @@ -19,6 +19,8 @@ import Link from "next/link"; import { useMemo, useState } from "react"; import { BadgeCluster, clinicalBadgeToneClass } from "@/components/clinical-dashboard/clinical-badge"; +import { MedicationConsiderations } from "@/components/clinical-dashboard/medication-considerations"; +import { PatientProfilePanel } from "@/components/clinical-dashboard/patient-profile-panel"; import { useMedicationDetail } from "@/components/clinical-dashboard/use-medication-catalog"; import { medicationAccessBadges, @@ -215,6 +217,11 @@ function MedicationRecordDetail({ ))} +
+ + +
+
{( [ diff --git a/src/components/clinical-dashboard/patient-profile-context.tsx b/src/components/clinical-dashboard/patient-profile-context.tsx new file mode 100644 index 0000000000..63cecb90da --- /dev/null +++ b/src/components/clinical-dashboard/patient-profile-context.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { createContext, useCallback, useContext, useMemo, useSyncExternalStore } from "react"; + +import { isProfileEmpty, type AllergyClass, type PatientProfile } from "@/lib/medication-patient-alerts"; +import { + EMPTY_PATIENT_PROFILE, + getPatientProfileSnapshot, + getServerPatientProfileSnapshot, + subscribePatientProfile, + writePatientProfile, +} from "@/lib/patient-profile-storage"; + +export type PatientProfileContextValue = { + profile: PatientProfile; + updateField: (key: K, value: PatientProfile[K]) => void; + toggleAllergy: (allergy: AllergyClass) => void; + clear: () => void; + isEmpty: boolean; +}; + +const PatientProfileContext = createContext(null); + +export function PatientProfileProvider({ children }: { children: React.ReactNode }) { + // Read from the sessionStorage-backed external store so the profile is shared + // across the prescribing workspace and detail pages with no hydration mismatch. + const profile = useSyncExternalStore( + subscribePatientProfile, + getPatientProfileSnapshot, + getServerPatientProfileSnapshot, + ); + + const updateField = useCallback((key, value) => { + writePatientProfile({ ...getPatientProfileSnapshot(), [key]: value }); + }, []); + + const toggleAllergy = useCallback((allergy: AllergyClass) => { + const current = getPatientProfileSnapshot(); + const allergies = current.allergies ?? []; + const next = allergies.includes(allergy) ? allergies.filter((item) => item !== allergy) : [...allergies, allergy]; + writePatientProfile({ ...current, allergies: next }); + }, []); + + const clear = useCallback(() => { + writePatientProfile({ ...EMPTY_PATIENT_PROFILE }); + }, []); + + const value = useMemo( + () => ({ profile, updateField, toggleAllergy, clear, isEmpty: isProfileEmpty(profile) }), + [profile, updateField, toggleAllergy, clear], + ); + + return {children}; +} + +export function usePatientProfile(): PatientProfileContextValue { + const value = useContext(PatientProfileContext); + if (!value) { + throw new Error("usePatientProfile must be used within a PatientProfileProvider"); + } + return value; +} diff --git a/src/components/clinical-dashboard/patient-profile-panel.tsx b/src/components/clinical-dashboard/patient-profile-panel.tsx new file mode 100644 index 0000000000..ecb12f9d17 --- /dev/null +++ b/src/components/clinical-dashboard/patient-profile-panel.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { Eraser, UserRound } from "lucide-react"; +import { useId, useState } from "react"; + +import { usePatientProfile } from "@/components/clinical-dashboard/patient-profile-context"; +import { cn, fieldControlPlain, fieldLabel, ToggleSwitch } from "@/components/ui-primitives"; +import type { AllergyClass, HepaticSeverity, ScrUnit } from "@/lib/medication-patient-alerts"; + +const HEPATIC_OPTIONS: { value: HepaticSeverity; label: string }[] = [ + { value: "none", label: "None" }, + { value: "mild", label: "Mild" }, + { value: "moderate", label: "Moderate" }, + { value: "severe", label: "Severe" }, +]; + +const SCR_UNIT_OPTIONS: { value: ScrUnit; label: string }[] = [ + { value: "umol/L", label: "µmol/L" }, + { value: "mg/dL", label: "mg/dL" }, +]; + +const ALLERGY_OPTIONS: { value: AllergyClass; label: string }[] = [ + { value: "penicillin", label: "Penicillin" }, + { value: "sulfa", label: "Sulfa" }, + { value: "nsaid", label: "NSAID" }, + { value: "cephalosporin", label: "Cephalosporin" }, + { value: "macrolide", label: "Macrolide" }, + { value: "fluoroquinolone", label: "Fluoroquinolone" }, +]; + +function parseNumber(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +const segmentBase = + "min-h-tap rounded-lg border px-2.5 text-2xs font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:text-xs"; +const segmentActive = + "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"; +const segmentIdle = + "border-[color:var(--border)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text-heading)]"; + +function NumberField({ + label, + unit, + value, + onChange, + testId, +}: { + label: string; + unit?: string; + value: number | null | undefined; + onChange: (value: number | null) => void; + testId?: string; +}) { + const id = useId(); + return ( +
+ + onChange(parseNumber(event.target.value))} + className={cn(fieldControlPlain, "nums")} + data-testid={testId} + /> +
+ ); +} + +export function PatientProfilePanel({ + variant = "full", + className, +}: { + variant?: "full" | "compact"; + className?: string; +}) { + const { profile, updateField, toggleAllergy, clear, isEmpty } = usePatientProfile(); + const [open, setOpen] = useState(variant === "full"); + const allergies = new Set(profile.allergies ?? []); + + return ( +
setOpen((event.currentTarget as HTMLDetailsElement).open)} + data-testid="patient-profile-panel" + className={cn( + "group overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] shadow-[var(--shadow-inset)]", + className, + )} + > + + + + + {open ? "Hide" : "Edit"} + + + +
+
+ updateField("ageYears", value)} + testId="patient-age" + /> + updateField("egfr", value)} + testId="patient-egfr" + /> + updateField("crcl", value)} + testId="patient-crcl" + /> + updateField("qtc", value)} + testId="patient-qtc" + /> +
+ updateField("scr", value)} + testId="patient-scr" + /> +
+
+ Creatinine unit +
+ {SCR_UNIT_OPTIONS.map((option) => { + const active = (profile.scrUnit ?? "umol/L") === option.value; + return ( + + ); + })} +
+
+
+ +
+ Hepatic impairment +
+ {HEPATIC_OPTIONS.map((option) => { + const active = (profile.hepatic ?? "none") === option.value; + return ( + + ); + })} +
+
+ +
+ Allergies +
+ {ALLERGY_OPTIONS.map((option) => { + const active = allergies.has(option.value); + return ( + + ); + })} +
+
+ +
+ + updateField("pregnant", !profile.pregnant)} + aria-label="Pregnancy" + /> + Pregnancy + + + updateField("breastfeeding", !profile.breastfeeding)} + aria-label="Breastfeeding" + /> + Breastfeeding + + +
+ +

+ Anonymous values only — no patient‑identifying information is stored. Cleared when the tab closes. Decision + support, not medical advice. +

+
+
+ ); +} diff --git a/src/lib/medication-patient-alerts.ts b/src/lib/medication-patient-alerts.ts new file mode 100644 index 0000000000..6d04960332 --- /dev/null +++ b/src/lib/medication-patient-alerts.ts @@ -0,0 +1,371 @@ +// Patient-info → medication considerations engine. +// +// The medication catalogue (exported from the Medications app into +// `data/medications-snapshot.json`) already carries per-row patient-matching +// metadata (`MedicationSectionRow.patient`): `factors`, `action`, `severity`, +// a structured `match` object, and a source-backed `note`. Until now that data +// was only surfaced as static badges (`patientBadges` in `medication-badges.ts`). +// +// This module is the missing evaluation layer: given a medication record and a +// clinician-entered `PatientProfile`, it returns the considerations that apply +// to *this* patient, each with a human-readable reason and a resolved semantic +// tone. It is intentionally framework-free (imports only `@/lib/semantic-tone` +// and types from `@/lib/medications`) so it can be unit-tested and reused by +// both the medication detail page and the prescribing search workspace. +// +// Firing semantics are derived from the shape of the snapshot data. The three +// heuristic thresholds below (renal impairment, QTc prolongation, and the +// elderly/paediatric age cut-offs used only when a row carries a bare `factor` +// with no numeric `match`) are the one place where the exact rule cannot be read +// off the data. They are named constants so they can be re-tuned in one edit if +// the source Medications app uses different cut-offs. + +import { SEMANTIC_TONE_PRIORITY, type SemanticTone } from "@/lib/semantic-tone"; +import type { MedicationPatientMetadata, MedicationRecord, MedicationSectionRow } from "@/lib/medications"; + +export type AllergyClass = "penicillin" | "sulfa" | "nsaid" | "cephalosporin" | "macrolide" | "fluoroquinolone"; + +export type HepaticSeverity = "none" | "mild" | "moderate" | "severe"; + +export type ScrUnit = "umol/L" | "mg/dL"; + +export type PatientProfile = { + ageYears?: number | null; + egfr?: number | null; + crcl?: number | null; + scr?: number | null; + scrUnit?: ScrUnit; + hepatic?: HepaticSeverity | null; + qtc?: number | null; + pregnant?: boolean; + breastfeeding?: boolean; + allergies?: AllergyClass[]; +}; + +export type MedicationConsideration = { + /** Stable id `${sectionType}:${rowKey}` for React keys and test ids. */ + id: string; + sectionType: string; + rowKey: string; + action?: string; + severity?: string; + tone: SemanticTone; + /** Source-backed rationale (patient.note), falling back to the row text. */ + note: string; + /** Human-readable trigger reasons, e.g. ["eGFR 22 < 30 mL/min"]. */ + reasons: string[]; + /** Display labels for the row's factors, e.g. ["Renal"]. */ + factorLabels: string[]; +}; + +export type PatientAlertResult = { + considerations: MedicationConsideration[]; + counts: Record; + highestTone: SemanticTone | null; + /** + * Distinct inputs (e.g. ["eGFR", "hepatic status"]) referenced by a + * contraindication on this medication that could not be evaluated because the + * profile did not supply them — so a blank field is never read as an + * all-clear. Rendered as a single hint, not per row. + */ + unassessed: string[]; +}; + +// --------------------------------------------------------------------------- +// Heuristic constants (⚠️ confirm against the BigSimmo/Medications reference). +// Only used when a row lists a numeric factor with no covering `match` key. +// --------------------------------------------------------------------------- +export const RENAL_IMPAIRMENT_EGFR = 60; // mL/min; CKD stage 3+. +export const QTC_PROLONGED_MS = 450; // ms; sex-agnostic conservative threshold. +export const ELDERLY_AGE_YEARS = 65; +export const PAEDIATRIC_AGE_YEARS = 18; +export const SCR_UMOL_PER_MGDL = 88.4; // serum creatinine unit conversion factor. + +export const MEDICATION_FACTOR_LABELS: Record = { + renal: "Renal", + hepatic: "Hepatic", + pregnancy: "Pregnancy", + lactation: "Breastfeeding", + elderly: "Elderly", + paediatric: "Paediatric", + qtc: "QTc", + "allergy-nsaid": "NSAID allergy", + "allergy-pcn": "Penicillin allergy", + "allergy-sulfa": "Sulfa allergy", + "allergy-ceph": "Cephalosporin allergy", + "allergy-macrolide": "Macrolide allergy", + "allergy-fluoro": "Fluoroquinolone allergy", +}; + +const ALLERGY_FACTOR_BY_CLASS: Record = { + penicillin: "allergy-pcn", + sulfa: "allergy-sulfa", + nsaid: "allergy-nsaid", + cephalosporin: "allergy-ceph", + macrolide: "allergy-macrolide", + fluoroquinolone: "allergy-fluoro", +}; + +const ALLERGY_CLASS_BY_FACTOR = Object.fromEntries( + Object.entries(ALLERGY_FACTOR_BY_CLASS).map(([cls, factor]) => [factor, cls as AllergyClass]), +) as Record; + +// Section types that carry patient blocks, in display priority order. Used for a +// stable secondary sort within a tone. +const SECTION_ORDER: Record = { contra: 0, risk: 1, mon: 2, dose: 3, spec: 4 }; + +const EMPTY_COUNTS: Record = { + danger: 0, + warning: 0, + clinical: 0, + success: 0, + neutral: 0, + info: 0, +}; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function numberField(value: number | null | undefined): number | null { + return isFiniteNumber(value) ? value : null; +} + +function matchOperator(match: Record, key: string): Record | null { + const value = match[key]; + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +} + +function op(operator: Record, name: string): number | null { + return isFiniteNumber(operator[name]) ? (operator[name] as number) : null; +} + +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function capitalize(value: string): string { + return value ? value.charAt(0).toUpperCase() + value.slice(1) : value; +} + +function normalizedScr(profile: PatientProfile): number | null { + const scr = numberField(profile.scr); + if (scr === null) return null; + return profile.scrUnit === "mg/dL" ? scr * SCR_UMOL_PER_MGDL : scr; +} + +/** True when the profile carries no information any criterion could match. */ +export function isProfileEmpty(profile: PatientProfile | null | undefined): boolean { + if (!profile) return true; + if ( + numberField(profile.ageYears) !== null || + numberField(profile.egfr) !== null || + numberField(profile.crcl) !== null || + numberField(profile.scr) !== null || + numberField(profile.qtc) !== null + ) { + return false; + } + if (profile.pregnant || profile.breastfeeding) return false; + if (profile.hepatic && profile.hepatic !== "none") return false; + if (profile.allergies && profile.allergies.length > 0) return false; + return true; +} + +/** Map an engine tone to an `InlineNotice` tone (no "clinical" notice tone). */ +export function noticeToneForSemanticTone(tone: SemanticTone): "success" | "warning" | "danger" | "info" | "neutral" { + return tone === "clinical" ? "info" : tone; +} + +// Mirror of the per-factor tone logic in `patientBadges` (medication-badges.ts) +// so the always-on badges and the live considerations agree on colour. +function considerationTone(action?: string, severity?: string): SemanticTone { + if (action === "contraindication") return "danger"; + if (action === "monitor" || action === "dose-adjust") return "clinical"; + if (severity === "danger") return "danger"; + if (action === "caution" || severity === "caution") return "warning"; + if (action === "info" || severity === "info") return "info"; + return "warning"; +} + +type RowEvaluation = { + reasons: string[]; + /** Numeric/categorical firing gates whose input was not supplied. */ + missingGates: string[]; +}; + +// A bare factor is only evaluated when no `match` key of the same domain already +// covers it, so the numeric gate and the factor never double-report one row. +function evaluateRow(patient: MedicationPatientMetadata, profile: PatientProfile): RowEvaluation { + const reasons: string[] = []; + const missingGates: string[] = []; + const match = (patient.match ?? {}) as Record; + const factors = patient.factors ?? []; + + const coversRenal = "egfr" in match || "crcl" in match || "scr" in match; + const coversHepatic = "hepatic" in match; + const coversAge = "age" in match; + const coversQtc = "qtc" in match; + + // --- Structured match gates (precise, OR across keys) --- + const age = numberField(profile.ageYears); + const ageOp = matchOperator(match, "age"); + if (ageOp) { + if (age === null) { + missingGates.push("age"); + } else { + const gte = op(ageOp, "gte"); + const gt = op(ageOp, "gt"); + const lt = op(ageOp, "lt"); + if (gte !== null && age >= gte) reasons.push(`Age ${age} ≥ ${gte}`); + if (gt !== null && age > gt) reasons.push(`Age ${age} > ${gt}`); + if (lt !== null && age < lt) reasons.push(`Age ${age} < ${lt}`); + } + } + + const egfrOp = matchOperator(match, "egfr"); + if (egfrOp) { + const egfr = numberField(profile.egfr); + const lt = op(egfrOp, "lt"); + if (egfr === null) missingGates.push("eGFR"); + else if (lt !== null && egfr < lt) reasons.push(`eGFR ${egfr} < ${lt} mL/min`); + } + + const crclOp = matchOperator(match, "crcl"); + if (crclOp) { + const crcl = numberField(profile.crcl); + const lt = op(crclOp, "lt"); + const lte = op(crclOp, "lte"); + if (crcl === null) missingGates.push("CrCl"); + else { + if (lt !== null && crcl < lt) reasons.push(`CrCl ${crcl} < ${lt} mL/min`); + if (lte !== null && crcl <= lte) reasons.push(`CrCl ${crcl} ≤ ${lte} mL/min`); + } + } + + const scrOp = matchOperator(match, "scr"); + if (scrOp) { + const scr = normalizedScr(profile); + const gt = op(scrOp, "gt"); + if (scr === null) missingGates.push("serum creatinine"); + else if (gt !== null && scr > gt) reasons.push(`SCr ${Math.round(scr)} > ${gt} µmol/L`); + } + + const qtcOp = matchOperator(match, "qtc"); + if (qtcOp) { + const qtc = numberField(profile.qtc); + const gte = op(qtcOp, "gte"); + if (qtc === null) missingGates.push("QTc"); + else if (gte !== null && qtc >= gte) reasons.push(`QTc ${qtc} ≥ ${gte} ms`); + } + + if ("hepatic" in match) { + const levels = stringList(match.hepatic); + const hepatic = profile.hepatic; + if (!hepatic) missingGates.push("hepatic status"); + else if (hepatic !== "none" && levels.includes(hepatic)) { + reasons.push(`${capitalize(hepatic)} hepatic impairment`); + } + } + + // --- Factor triggers (booleans / allergy classes / derived numerics) --- + const allergies = new Set(profile.allergies ?? []); + for (const factor of factors) { + if (factor === "pregnancy") { + if (profile.pregnant) reasons.push("Pregnancy"); + } else if (factor === "lactation") { + if (profile.breastfeeding) reasons.push("Breastfeeding"); + } else if (factor in ALLERGY_CLASS_BY_FACTOR) { + const cls = ALLERGY_CLASS_BY_FACTOR[factor]; + if (allergies.has(cls)) reasons.push(MEDICATION_FACTOR_LABELS[factor] ?? `${capitalize(cls)} allergy`); + } else if (factor === "renal" && !coversRenal) { + const egfr = numberField(profile.egfr); + const crcl = numberField(profile.crcl); + if (egfr !== null && egfr < RENAL_IMPAIRMENT_EGFR) reasons.push(`Renal impairment (eGFR ${egfr})`); + else if (crcl !== null && crcl < RENAL_IMPAIRMENT_EGFR) reasons.push(`Renal impairment (CrCl ${crcl})`); + else if (egfr === null && crcl === null) missingGates.push("eGFR or CrCl"); + } else if (factor === "hepatic" && !coversHepatic) { + if (!profile.hepatic) missingGates.push("hepatic status"); + else if (profile.hepatic !== "none") reasons.push(`${capitalize(profile.hepatic)} hepatic impairment`); + } else if (factor === "elderly" && !coversAge) { + if (age === null) missingGates.push("age"); + else if (age >= ELDERLY_AGE_YEARS) reasons.push(`Age ${age} ≥ ${ELDERLY_AGE_YEARS}`); + } else if (factor === "paediatric" && !coversAge) { + if (age === null) missingGates.push("age"); + else if (age < PAEDIATRIC_AGE_YEARS) reasons.push(`Age ${age} < ${PAEDIATRIC_AGE_YEARS}`); + } else if (factor === "qtc" && !coversQtc) { + const qtc = numberField(profile.qtc); + if (qtc === null) missingGates.push("QTc"); + else if (qtc >= QTC_PROLONGED_MS) reasons.push(`QTc ${qtc} ≥ ${QTC_PROLONGED_MS} ms`); + } + } + + return { reasons: dedupe(reasons), missingGates: dedupe(missingGates) }; +} + +function dedupe(values: string[]): string[] { + return Array.from(new Set(values)); +} + +function factorLabelsFor(patient: MedicationPatientMetadata): string[] { + return dedupe((patient.factors ?? []).map((factor) => MEDICATION_FACTOR_LABELS[factor] ?? capitalize(factor))); +} + +/** + * Evaluate every patient-tagged row of a medication against the entered profile. + * Only fields present in the profile are tested (partial profiles are normal). + * Contraindication rows that fire on a numeric/categorical gate the clinician + * did not supply are surfaced separately as `unassessed`, so a missing input is + * never read as an all-clear. + */ +export function evaluatePatientAlerts(record: MedicationRecord, profile: PatientProfile): PatientAlertResult { + const considerations: MedicationConsideration[] = []; + const unassessed = new Set(); + + for (const section of record.sections ?? []) { + for (const row of section.rows ?? []) { + const patient = rowPatient(row); + if (!patient) continue; + + const { reasons, missingGates } = evaluateRow(patient, profile); + + if (reasons.length > 0) { + considerations.push({ + id: `${section.type}:${row.key}`, + sectionType: section.type, + rowKey: row.key, + action: patient.action, + severity: patient.severity, + tone: considerationTone(patient.action, patient.severity), + note: (patient.note ?? row.val ?? "").replace(/\*\*/g, "").trim(), + reasons, + factorLabels: factorLabelsFor(patient), + }); + } else if (patient.action === "contraindication") { + for (const gate of missingGates) unassessed.add(gate); + } + } + } + + considerations.sort((a, b) => { + const byTone = SEMANTIC_TONE_PRIORITY[b.tone] - SEMANTIC_TONE_PRIORITY[a.tone]; + if (byTone !== 0) return byTone; + const bySection = (SECTION_ORDER[a.sectionType] ?? 99) - (SECTION_ORDER[b.sectionType] ?? 99); + if (bySection !== 0) return bySection; + return a.rowKey.localeCompare(b.rowKey); + }); + + const counts: Record = { ...EMPTY_COUNTS }; + for (const consideration of considerations) counts[consideration.tone] += 1; + + return { + considerations, + counts, + highestTone: considerations[0]?.tone ?? null, + unassessed: Array.from(unassessed).sort(), + }; +} + +function rowPatient(row: MedicationSectionRow): MedicationPatientMetadata | null { + return row.patient && typeof row.patient === "object" ? row.patient : null; +} diff --git a/src/lib/patient-profile-storage.ts b/src/lib/patient-profile-storage.ts new file mode 100644 index 0000000000..bfb79a8486 --- /dev/null +++ b/src/lib/patient-profile-storage.ts @@ -0,0 +1,114 @@ +// Session-scoped persistence for the patient-considerations profile. +// +// The profile is anonymous physiology (age, renal/hepatic function, QTc, +// pregnancy/lactation, allergy classes) — deliberately NOT PHI. It is kept in +// `sessionStorage` so it survives navigation between the prescribing search and +// a medication detail page within a tab session, but clears when the tab closes +// (appropriate for transient patient context on a shared workstation). +// +// Exposed as an external store (snapshot + subscribe + write) so React can read +// it via `useSyncExternalStore` — the same pattern as `use-theme.ts` / +// `use-sidebar-collapsed.ts` — which shares state across the prescribing +// workspace and detail pages without a hydration mismatch or setState-in-effect. + +import type { AllergyClass, HepaticSeverity, PatientProfile, ScrUnit } from "@/lib/medication-patient-alerts"; + +export const PATIENT_PROFILE_STORAGE_KEY = "clinical-kb-patient-profile"; +const PATIENT_PROFILE_CHANGE_EVENT = "clinical-kb-patient-profile-change"; + +export const EMPTY_PATIENT_PROFILE: PatientProfile = { + ageYears: null, + egfr: null, + crcl: null, + scr: null, + scrUnit: "umol/L", + hepatic: null, + qtc: null, + pregnant: false, + breastfeeding: false, + allergies: [], +}; + +const SCR_UNITS: ScrUnit[] = ["umol/L", "mg/dL"]; +const HEPATIC_LEVELS: HepaticSeverity[] = ["none", "mild", "moderate", "severe"]; +const ALLERGY_CLASSES: AllergyClass[] = [ + "penicillin", + "sulfa", + "nsaid", + "cephalosporin", + "macrolide", + "fluoroquinolone", +]; + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function sanitize(raw: unknown): PatientProfile { + if (!raw || typeof raw !== "object") return { ...EMPTY_PATIENT_PROFILE }; + const value = raw as Record; + const hepatic = HEPATIC_LEVELS.includes(value.hepatic as HepaticSeverity) ? (value.hepatic as HepaticSeverity) : null; + const scrUnit = SCR_UNITS.includes(value.scrUnit as ScrUnit) ? (value.scrUnit as ScrUnit) : "umol/L"; + const allergies = Array.isArray(value.allergies) + ? value.allergies.filter((item): item is AllergyClass => ALLERGY_CLASSES.includes(item as AllergyClass)) + : []; + return { + ageYears: numberOrNull(value.ageYears), + egfr: numberOrNull(value.egfr), + crcl: numberOrNull(value.crcl), + scr: numberOrNull(value.scr), + scrUnit, + hepatic, + qtc: numberOrNull(value.qtc), + pregnant: value.pregnant === true, + breastfeeding: value.breastfeeding === true, + allergies, + }; +} + +// Cache the parsed snapshot keyed by the raw string so `useSyncExternalStore` +// receives a stable reference until the stored value actually changes. +let cachedRaw: string | null = null; +let cachedProfile: PatientProfile = EMPTY_PATIENT_PROFILE; + +export function getPatientProfileSnapshot(): PatientProfile { + if (typeof window === "undefined") return EMPTY_PATIENT_PROFILE; + let raw: string | null = null; + try { + raw = window.sessionStorage.getItem(PATIENT_PROFILE_STORAGE_KEY); + } catch { + raw = null; + } + if (raw === cachedRaw) return cachedProfile; + cachedRaw = raw; + try { + cachedProfile = raw ? sanitize(JSON.parse(raw)) : { ...EMPTY_PATIENT_PROFILE }; + } catch { + cachedProfile = { ...EMPTY_PATIENT_PROFILE }; + } + return cachedProfile; +} + +export function getServerPatientProfileSnapshot(): PatientProfile { + return EMPTY_PATIENT_PROFILE; +} + +export function subscribePatientProfile(onChange: () => void): () => void { + if (typeof window === "undefined") return () => undefined; + window.addEventListener("storage", onChange); + window.addEventListener(PATIENT_PROFILE_CHANGE_EVENT, onChange); + return () => { + window.removeEventListener("storage", onChange); + window.removeEventListener(PATIENT_PROFILE_CHANGE_EVENT, onChange); + }; +} + +export function writePatientProfile(profile: PatientProfile): void { + if (typeof window === "undefined") return; + try { + window.sessionStorage.setItem(PATIENT_PROFILE_STORAGE_KEY, JSON.stringify(profile)); + } catch { + // Persistence is a convenience only; ignore quota/availability errors. + } + window.dispatchEvent(new Event(PATIENT_PROFILE_CHANGE_EVENT)); +} diff --git a/tests/medication-patient-alerts.test.ts b/tests/medication-patient-alerts.test.ts new file mode 100644 index 0000000000..8420752d73 --- /dev/null +++ b/tests/medication-patient-alerts.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest"; + +import { getMedicationRecord, loadMedicationSnapshot } from "@/lib/medication-snapshot"; +import { + evaluatePatientAlerts, + isProfileEmpty, + noticeToneForSemanticTone, + type MedicationConsideration, + type PatientProfile, +} from "@/lib/medication-patient-alerts"; +import type { MedicationPatientMetadata, MedicationRecord } from "@/lib/medications"; + +function recordWith(patient: MedicationPatientMetadata, sectionType = "contra", key = "Test"): MedicationRecord { + return { + slug: "test-med", + name: "Test Med", + class: "", + subclass: "", + category: "", + accent: "#0f766e", + tag: "", + schedule: "", + stats: [], + quick: [], + sections: [{ title: "Test section", type: sectionType, rows: [{ key, val: "row text", patient }] }], + }; +} + +function reasons(considerations: MedicationConsideration[]): string[] { + return considerations.flatMap((consideration) => consideration.reasons); +} + +describe("evaluatePatientAlerts — match-key gates", () => { + it("age gte fires at the boundary and clears below it", () => { + const record = recordWith({ + factors: ["elderly"], + action: "caution", + severity: "danger", + match: { age: { gte: 65 } }, + }); + expect(reasons(evaluatePatientAlerts(record, { ageYears: 65 }).considerations)).toContain("Age 65 ≥ 65"); + expect(evaluatePatientAlerts(record, { ageYears: 64 }).considerations).toHaveLength(0); + }); + + it("age gt is strict and age lt catches paediatric", () => { + const gt = recordWith({ factors: ["elderly"], action: "caution", match: { age: { gt: 65 } } }); + expect(evaluatePatientAlerts(gt, { ageYears: 65 }).considerations).toHaveLength(0); + expect(reasons(evaluatePatientAlerts(gt, { ageYears: 66 }).considerations)).toContain("Age 66 > 65"); + + const lt = recordWith({ factors: ["paediatric"], action: "contraindication", match: { age: { lt: 18 } } }); + expect(reasons(evaluatePatientAlerts(lt, { ageYears: 17 }).considerations)).toContain("Age 17 < 18"); + expect(evaluatePatientAlerts(lt, { ageYears: 18 }).considerations).toHaveLength(0); + }); + + it("egfr lt fires below the threshold only", () => { + const record = recordWith({ factors: ["renal"], action: "caution", match: { egfr: { lt: 30 } } }); + expect(reasons(evaluatePatientAlerts(record, { egfr: 29 }).considerations)).toContain("eGFR 29 < 30 mL/min"); + expect(evaluatePatientAlerts(record, { egfr: 30 }).considerations).toHaveLength(0); + }); + + it("crcl lt is strict while lte includes the edge", () => { + const lt = recordWith({ factors: ["renal"], action: "dose-adjust", match: { crcl: { lt: 10 } } }); + expect(evaluatePatientAlerts(lt, { crcl: 10 }).considerations).toHaveLength(0); + expect(reasons(evaluatePatientAlerts(lt, { crcl: 9 }).considerations)).toContain("CrCl 9 < 10 mL/min"); + + const lte = recordWith({ factors: ["renal"], action: "dose-adjust", match: { crcl: { lte: 50 } } }); + expect(reasons(evaluatePatientAlerts(lte, { crcl: 50 }).considerations)).toContain("CrCl 50 ≤ 50 mL/min"); + expect(evaluatePatientAlerts(lte, { crcl: 51 }).considerations).toHaveLength(0); + }); + + it("scr gt respects the µmol/L default and converts mg/dL", () => { + const record = recordWith({ factors: ["renal"], action: "contraindication", match: { scr: { gt: 120 } } }); + expect(reasons(evaluatePatientAlerts(record, { scr: 121 }).considerations)).toContain("SCr 121 > 120 µmol/L"); + expect(evaluatePatientAlerts(record, { scr: 120 }).considerations).toHaveLength(0); + // 1.5 mg/dL × 88.4 = 132.6 µmol/L > 120 + const converted = evaluatePatientAlerts(record, { scr: 1.5, scrUnit: "mg/dL" }); + expect(reasons(converted.considerations)).toContain("SCr 133 > 120 µmol/L"); + // 1.3 mg/dL × 88.4 = 114.9 µmol/L < 120 + expect(evaluatePatientAlerts(record, { scr: 1.3, scrUnit: "mg/dL" }).considerations).toHaveLength(0); + }); + + it("qtc gte includes the boundary", () => { + const record = recordWith({ factors: ["qtc"], action: "monitor", match: { qtc: { gte: 450 } } }); + expect(reasons(evaluatePatientAlerts(record, { qtc: 450 }).considerations)).toContain("QTc 450 ≥ 450 ms"); + expect(evaluatePatientAlerts(record, { qtc: 449 }).considerations).toHaveLength(0); + }); + + it("hepatic matches only the listed severities and ignores none", () => { + const record = recordWith({ factors: ["hepatic"], action: "contraindication", match: { hepatic: ["severe"] } }); + expect(reasons(evaluatePatientAlerts(record, { hepatic: "severe" }).considerations)).toContain( + "Severe hepatic impairment", + ); + expect(evaluatePatientAlerts(record, { hepatic: "moderate" }).considerations).toHaveLength(0); + expect(evaluatePatientAlerts(record, { hepatic: "none" }).considerations).toHaveLength(0); + }); + + it("multiple match keys fire on OR (either criterion)", () => { + const record = recordWith({ + factors: ["renal", "qtc"], + action: "contraindication", + match: { egfr: { lt: 15 }, qtc: { gte: 450 } }, + }); + expect(evaluatePatientAlerts(record, { egfr: 10 }).considerations).toHaveLength(1); + expect(evaluatePatientAlerts(record, { qtc: 480 }).considerations).toHaveLength(1); + expect(reasons(evaluatePatientAlerts(record, { egfr: 10, qtc: 480 }).considerations)).toEqual( + expect.arrayContaining(["eGFR 10 < 15 mL/min", "QTc 480 ≥ 450 ms"]), + ); + }); +}); + +describe("evaluatePatientAlerts — factor triggers", () => { + it("pregnancy and lactation fire from profile booleans", () => { + const preg = recordWith({ factors: ["pregnancy", "lactation"], action: "contraindication", match: {} }); + expect(reasons(evaluatePatientAlerts(preg, { pregnant: true }).considerations)).toContain("Pregnancy"); + expect(reasons(evaluatePatientAlerts(preg, { breastfeeding: true }).considerations)).toContain("Breastfeeding"); + expect(evaluatePatientAlerts(preg, { pregnant: false, breastfeeding: false }).considerations).toHaveLength(0); + }); + + it("allergy factors fire only when the matching class is selected", () => { + const record = recordWith({ factors: ["allergy-sulfa"], action: "contraindication", match: {} }); + expect(reasons(evaluatePatientAlerts(record, { allergies: ["sulfa"] }).considerations)).toContain("Sulfa allergy"); + expect(evaluatePatientAlerts(record, { allergies: ["penicillin"] }).considerations).toHaveLength(0); + }); + + it("derives renal/qtc/elderly/paediatric factors when no match covers them", () => { + const renal = recordWith({ factors: ["renal"], action: "dose-adjust", match: {} }); + expect(reasons(evaluatePatientAlerts(renal, { egfr: 45 }).considerations)).toContain("Renal impairment (eGFR 45)"); + expect(evaluatePatientAlerts(renal, { egfr: 80 }).considerations).toHaveLength(0); + + const qtc = recordWith({ factors: ["qtc"], action: "monitor", match: {} }); + expect(reasons(evaluatePatientAlerts(qtc, { qtc: 470 }).considerations)).toContain("QTc 470 ≥ 450 ms"); + + const elderly = recordWith({ factors: ["elderly"], action: "caution", match: {} }); + expect(reasons(evaluatePatientAlerts(elderly, { ageYears: 80 }).considerations)).toContain("Age 80 ≥ 65"); + + const paed = recordWith({ factors: ["paediatric"], action: "caution", match: {} }); + expect(reasons(evaluatePatientAlerts(paed, { ageYears: 5 }).considerations)).toContain("Age 5 < 18"); + }); + + it("does not double-report a factor already covered by a match key", () => { + // acamprosate "Absolute" row: factors:["renal"] + match:{scr:{gt:120}} + const record = getMedicationRecord("acamprosate"); + expect(record).toBeTruthy(); + const result = evaluatePatientAlerts(record!, { scr: 150 }); + const absolute = result.considerations.filter((c) => c.rowKey === "Absolute"); + expect(absolute).toHaveLength(1); + expect(absolute[0].reasons).toEqual(["SCr 150 > 120 µmol/L"]); + expect(reasons(result.considerations).some((r) => r.startsWith("Renal impairment"))).toBe(false); + }); +}); + +describe("evaluatePatientAlerts — partial / empty profile and unassessed", () => { + it("only evaluates supplied fields", () => { + const record = recordWith({ factors: ["renal"], action: "caution", match: { egfr: { lt: 30 } } }); + // eGFR not supplied → caution row simply does not fire, and (not a + // contraindication) does not surface as unassessed. + const result = evaluatePatientAlerts(record, { ageYears: 40 }); + expect(result.considerations).toHaveLength(0); + expect(result.unassessed).toHaveLength(0); + }); + + it("flags a contraindication gate that could not be assessed", () => { + const record = recordWith({ factors: ["renal"], action: "contraindication", match: { egfr: { lt: 30 } } }); + // Some input present (so profile is non-empty) but eGFR missing. + const result = evaluatePatientAlerts(record, { pregnant: true }); + expect(result.considerations).toHaveLength(0); + expect(result.unassessed).toContain("eGFR"); + // Once eGFR is supplied and safe, it is neither a consideration nor unassessed. + const assessed = evaluatePatientAlerts(record, { egfr: 50 }); + expect(assessed.considerations).toHaveLength(0); + expect(assessed.unassessed).toHaveLength(0); + }); + + it("treats an empty profile as empty and surfaces no considerations", () => { + expect(isProfileEmpty({})).toBe(true); + expect(isProfileEmpty({ scrUnit: "umol/L", allergies: [] })).toBe(true); + expect(isProfileEmpty({ pregnant: true })).toBe(false); + expect(isProfileEmpty({ egfr: 40 })).toBe(false); + const record = getMedicationRecord("acamprosate"); + expect(evaluatePatientAlerts(record!, {}).considerations).toHaveLength(0); + }); +}); + +describe("evaluatePatientAlerts — real records", () => { + it("flags celecoxib for both NSAID and sulfa allergy", () => { + const record = getMedicationRecord("celecoxib"); + expect(record).toBeTruthy(); + const nsaid = evaluatePatientAlerts(record!, { allergies: ["nsaid"] }); + expect(reasons(nsaid.considerations)).toContain("NSAID allergy"); + const sulfa = evaluatePatientAlerts(record!, { allergies: ["sulfa"] }); + expect(reasons(sulfa.considerations)).toContain("Sulfa allergy"); + expect(sulfa.considerations.some((c) => c.tone === "danger")).toBe(true); + }); + + it("orders considerations by severity, danger first", () => { + const record = getMedicationRecord("acamprosate"); + const result = evaluatePatientAlerts(record!, { scr: 200, ageYears: 80, hepatic: "severe", pregnant: true }); + expect(result.considerations.length).toBeGreaterThan(1); + const priority = { danger: 6, warning: 5, clinical: 4, success: 3, neutral: 2, info: 1 } as const; + for (let i = 1; i < result.considerations.length; i += 1) { + expect(priority[result.considerations[i - 1].tone]).toBeGreaterThanOrEqual( + priority[result.considerations[i].tone], + ); + } + expect(result.highestTone).toBe("danger"); + }); + + it("evaluates the whole corpus with a full profile without throwing", () => { + const records = loadMedicationSnapshot(); + const profile: PatientProfile = { + ageYears: 82, + egfr: 20, + crcl: 18, + scr: 200, + hepatic: "severe", + qtc: 520, + pregnant: true, + breastfeeding: true, + allergies: ["penicillin", "sulfa", "nsaid", "cephalosporin", "macrolide", "fluoroquinolone"], + }; + for (const record of records) { + const result = evaluatePatientAlerts(record, profile); + for (const consideration of result.considerations) { + expect(consideration.reasons.length).toBeGreaterThan(0); + expect(consideration.tone).toBeTruthy(); + } + } + // A broadly-affected drug should surface at least one consideration. + const acamprosate = evaluatePatientAlerts(getMedicationRecord("acamprosate")!, profile); + expect(acamprosate.considerations.length).toBeGreaterThan(0); + }); +}); + +describe("noticeToneForSemanticTone", () => { + it("maps clinical to info and passes the rest through", () => { + expect(noticeToneForSemanticTone("clinical")).toBe("info"); + expect(noticeToneForSemanticTone("danger")).toBe("danger"); + expect(noticeToneForSemanticTone("warning")).toBe("warning"); + expect(noticeToneForSemanticTone("success")).toBe("success"); + expect(noticeToneForSemanticTone("info")).toBe("info"); + expect(noticeToneForSemanticTone("neutral")).toBe("neutral"); + }); +});