Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -134,18 +135,23 @@ function GlobalSearchShellClient(props: GlobalSearchShellProps) {
const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname);
const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch);

if (shouldRenderClinicalDashboard) {
return (
<ClinicalDashboard
initialSearchMode={resolvedSearchMode}
initialQuery={requestedQuery}
focusSearch={searchParams.get("focus") === "1"}
autoRunSearch={isHomeRoute ? hasSubmittedModeSearch : true}
/>
);
}

return <GlobalStandaloneSearchShellClient {...props} />;
// 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 (
<PatientProfileProvider>
{shouldRenderClinicalDashboard ? (
<ClinicalDashboard
initialSearchMode={resolvedSearchMode}
initialQuery={requestedQuery}
focusSearch={searchParams.get("focus") === "1"}
autoRunSearch={isHomeRoute ? hasSubmittedModeSearch : true}
/>
) : (
<GlobalStandaloneSearchShellClient {...props} />
)}
</PatientProfileProvider>
);
}

function GlobalStandaloneSearchShellClient({
Expand Down
96 changes: 96 additions & 0 deletions src/components/clinical-dashboard/medication-considerations.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-label="Patient considerations" className={cn("space-y-2", className)}>
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-[color:var(--clinical-accent)]" aria-hidden="true" />
<h2 className="text-sm-minus font-semibold text-[color:var(--text-heading)]">
Considerations for this patient
</h2>
{!isEmpty && result.considerations.length > 0 ? (
<span className="rounded-full bg-[color:var(--surface-subtle)] px-2 py-0.5 text-2xs font-semibold text-[color:var(--text-muted)]">
{result.considerations.length}
</span>
) : null}
</div>

{isEmpty ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-subtle)] px-3 py-3 text-sm text-[color:var(--text-muted)]">
Enter patient details above to surface dosing, safety, and contraindication considerations for this
medication.
</div>
) : result.considerations.length === 0 ? (
<InlineNotice tone="success">
No matching considerations for the entered patient profile. Always confirm against source.
</InlineNotice>
) : (
<div className="space-y-2">
{result.considerations.map((consideration) => (
<InlineNotice key={consideration.id} tone={noticeToneForSemanticTone(consideration.tone)}>
<div className="min-w-0 space-y-1.5" data-testid={`patient-consideration-${consideration.id}`}>
<BadgeCluster items={considerationBadges(consideration)} compact />
{consideration.note ? (
<p className="text-xs leading-5 text-[color:var(--text-heading)]">{consideration.note}</p>
) : null}
</div>
</InlineNotice>
))}
</div>
)}

{!isEmpty && result.unassessed.length > 0 ? (
<InlineNotice tone="info">
Enter {result.unassessed.join(", ")} to fully assess this medication&rsquo;s contraindications.
</InlineNotice>
) : null}
</section>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -372,13 +376,21 @@ function MedicationResults({
>) {
const command = useSearchCommand();
const catalog = useMedicationCatalog(query);
const { profile, isEmpty: profileEmpty } = usePatientProfile();
const [activeFilter, setActiveFilter] = useState<MedicationResultFilter>("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) =>
Expand Down Expand Up @@ -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.
Expand All @@ -437,6 +449,8 @@ function MedicationResults({
</div>
</div>

<PatientProfilePanel variant="compact" />

<FilterStrip activeFilter={activeFilter} counts={counts} onFilterChange={setActiveFilter} />

{catalog.loading ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -215,6 +217,11 @@ function MedicationRecordDetail({
))}
</section>

<section className="space-y-2.5">
<PatientProfilePanel />
<MedicationConsiderations record={record} />
</section>

<div className="flex flex-wrap gap-2">
{(
[
Expand Down
62 changes: 62 additions & 0 deletions src/components/clinical-dashboard/patient-profile-context.tsx
Original file line number Diff line number Diff line change
@@ -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: <K extends keyof PatientProfile>(key: K, value: PatientProfile[K]) => void;
toggleAllergy: (allergy: AllergyClass) => void;
clear: () => void;
isEmpty: boolean;
};

const PatientProfileContext = createContext<PatientProfileContextValue | null>(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<PatientProfileContextValue["updateField"]>((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<PatientProfileContextValue>(
() => ({ profile, updateField, toggleAllergy, clear, isEmpty: isProfileEmpty(profile) }),
[profile, updateField, toggleAllergy, clear],
);

return <PatientProfileContext.Provider value={value}>{children}</PatientProfileContext.Provider>;
}

export function usePatientProfile(): PatientProfileContextValue {
const value = useContext(PatientProfileContext);
if (!value) {
throw new Error("usePatientProfile must be used within a PatientProfileProvider");
}
return value;
}
Loading