diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx
index e9fea8a11..85a5aa68b 100644
--- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx
+++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx
@@ -535,7 +535,7 @@ function MedicationResults({
-
+
{result.indication}
{showMatchBadge || result.match !== "Exact clinical fit" || row.badges.length > 0 ? (
@@ -548,13 +548,13 @@ function MedicationResults({
) : null}
-
+
{result.dose}
- {result.action}
+ {result.action}
{result.href ? (
- {result.indication}
+
+ {result.indication}
+
{showMatchBadge || result.match !== "Exact clinical fit" || row.badges.length > 0 ? (
@@ -627,10 +629,10 @@ function MedicationResults({
) : null}
- {result.dose}
+ {result.dose}
-
+
{result.action}
diff --git a/src/components/clinical-dashboard/medication-record-page.tsx b/src/components/clinical-dashboard/medication-record-page.tsx
index dc1aa5526..1b4f02e85 100644
--- a/src/components/clinical-dashboard/medication-record-page.tsx
+++ b/src/components/clinical-dashboard/medication-record-page.tsx
@@ -14,14 +14,15 @@ import {
Gauge,
Lock,
Pill,
+ ShieldAlert,
ShieldCheck,
- Sparkles,
+ Timer,
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent } from "react";
-import { BadgeCluster, clinicalBadgeToneClass } from "@/components/clinical-dashboard/clinical-badge";
+import { BadgeCluster } 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";
@@ -30,11 +31,27 @@ import {
medicationAccessFields,
medicationIdentityBadges,
medicationRowBadges,
- medicationStatTone,
type MedicationGovernance,
} from "@/lib/medication-badges";
-import { medicationDetailTiles, type MedicationRecord, type MedicationSection } from "@/lib/medications";
-import { cn, EmptyState, LoadingPanel, pageContainer, toneDanger } from "@/components/ui-primitives";
+import {
+ medicationHeroMetrics,
+ medicationIndication,
+ type MedicationHeroMetric,
+ type MedicationQuickRow,
+ type MedicationRecord,
+ type MedicationSection,
+} from "@/lib/medications";
+import type { SemanticTone } from "@/lib/semantic-tone";
+import {
+ cn,
+ EmptyState,
+ LoadingPanel,
+ pageContainer,
+ toneDanger,
+ toneInfo,
+ toneSuccess,
+ toneWarning,
+} from "@/components/ui-primitives";
const sectionIcons: Record = {
dose: CalendarDays,
@@ -74,83 +91,79 @@ function medicationAccentStyle(accent: string | undefined): CSSProperties {
} as CSSProperties;
}
-// Icon + categorical chip per detail tile (index-aligned with medicationDetailTiles:
-// Prescribing answer / Dosing / Dose ceiling / Avoid). The danger tile is toned
-// separately below.
-const detailTileDecor: Array<{ icon: LucideIcon; chip: string }> = [
- {
- icon: Sparkles,
- chip: "border-[color:var(--type-source-border)] bg-[color:var(--type-source-soft)] text-[color:var(--type-source)]",
+// Tone-driven hero metric tile. Colour comes only from the metric's semantic tone
+// (medicationStatTone, via medicationHeroMetrics) so it honours the #659 contract:
+// green = success, amber = caution, red = safety, teal = primary/evidence. The
+// value stays in a high-contrast heading colour on every tone so text never sits
+// on a same-hue wash (the readability lesson from #659) — the border, soft fill,
+// label and icon chip carry the colour.
+const heroToneTile: Record = {
+ clinical: {
+ card: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]",
+ chip: "border-[color:var(--clinical-accent)]/25 bg-[color:var(--surface)] text-[color:var(--clinical-accent)]",
+ label: "text-[color:var(--clinical-accent)]",
+ value: "text-[color:var(--text-heading)]",
},
- {
- icon: CalendarDays,
- chip: "border-[color:var(--type-document-border)] bg-[color:var(--type-document-soft)] text-[color:var(--type-document)]",
+ danger: {
+ card: toneDanger,
+ chip: "border-[color:var(--danger)]/25 bg-[color:var(--surface)] text-[color:var(--danger)]",
+ label: "text-[color:var(--danger)]",
+ value: "text-[color:var(--danger-text)]",
},
- {
- icon: Gauge,
- chip: "border-[color:var(--type-service-border)] bg-[color:var(--type-service-soft)] text-[color:var(--type-service)]",
+ warning: {
+ card: toneWarning,
+ chip: "border-[color:var(--warning)]/25 bg-[color:var(--surface)] text-[color:var(--warning)]",
+ label: "text-[color:var(--warning)]",
+ value: "text-[color:var(--text-heading)]",
},
- { icon: Ban, chip: "border-[color:var(--danger)]/25 bg-[color:var(--danger-soft)] text-[color:var(--danger)]" },
-];
-
-function DetailTile({
- label,
- value,
- meta,
- danger = false,
- icon: Icon,
- chip,
-}: {
- label: string;
- value: string;
- meta?: string;
- danger?: boolean;
- icon: LucideIcon;
- chip: string;
-}) {
+ success: {
+ card: toneSuccess,
+ chip: "border-[color:var(--success)]/25 bg-[color:var(--surface)] text-[color:var(--success)]",
+ label: "text-[color:var(--success)]",
+ value: "text-[color:var(--text-heading)]",
+ },
+ info: {
+ card: toneInfo,
+ chip: "border-[color:var(--info-border)] bg-[color:var(--surface)] text-[color:var(--info)]",
+ label: "text-[color:var(--info)]",
+ value: "text-[color:var(--text-heading)]",
+ },
+ neutral: {
+ card: "border-[color:var(--border)] bg-[color:var(--surface-raised)]",
+ chip: "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]",
+ label: "text-[color:var(--text-muted)]",
+ value: "text-[color:var(--text-heading)]",
+ },
+};
+
+// Icon keyed to the metric's meaning: a gauge for the dose ceiling, a timer for
+// time-based metrics (half-life / onset / duration), and a tone-appropriate shield
+// or alert for everything else (risk & caution flags). Rendered directly (rather
+// than assigning the component to a local) so it stays a static component.
+function HeroMetricIcon({ metric }: { metric: MedicationHeroMetric }) {
+ const label = metric.label.toLowerCase();
+ const iconClass = "h-3.5 w-3.5";
+ if (/dose|ceiling|\bmax\b/.test(label)) return ;
+ if (/half-life|onset|duration|timing|freq/.test(label)) return ;
+ if (metric.tone === "danger") return ;
+ if (metric.tone === "warning") return ;
+ if (metric.tone === "success") return ;
+ return ;
+}
+
+function DetailTile({ metric }: { metric: MedicationHeroMetric }) {
+ const tone = heroToneTile[metric.tone];
return (
-
+
-
-
+
+
-
- {label}
+
+ {metric.label}
-
- {value}
-
- {meta ? (
-
- {meta}
-
- ) : null}
+
{metric.value}
);
}
@@ -277,6 +290,45 @@ function SidebarCard({ title, icon: Icon, children }: { title: string; icon: Luc
);
}
+// Quick-reference values are the verbose ones (≈164 chars median, up to ~560) and
+// would otherwise make the fixed-width sidebar run very tall. Long values collapse
+// to two lines with a chevron affordance and expand in place on tap — the same
+// clamp-on-collapse pattern the differential sections use (line-clamp-2 +
+// group-open:line-clamp-none inside a
) — so nothing is removed, just
+// tucked one tap away. Short values render as a plain row with no toggle.
+function QuickRefRow({ row }: { row: MedicationQuickRow }) {
+ const value = row.value.replace(/\*\*/g, "");
+ const label = (
+ {row.label}
+ );
+
+ if (value.length <= 110) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {label}
+
+
+
+ {value}
+
+
+
+ );
+}
+
function MedicationAccessPanel({ record }: { record: MedicationRecord }) {
const badges = useMemo(() => medicationAccessBadges(record), [record]);
const fields = useMemo(() => medicationAccessFields(record), [record]);
@@ -314,8 +366,9 @@ function MedicationRecordDetail({
record: MedicationRecord;
governance?: MedicationGovernance;
}) {
- const tiles = useMemo(() => medicationDetailTiles(record), [record]);
+ const metrics = useMemo(() => medicationHeroMetrics(record), [record]);
const badges = useMemo(() => medicationIdentityBadges(record, governance), [record, governance]);
+ const indication = useMemo(() => medicationIndication(record), [record]);
const [activeTab, setActiveTab] = useState("summary");
const sectionsByTab = useMemo(() => {
@@ -359,26 +412,22 @@ function MedicationRecordDetail({
>
) : null}
+ {indication ? (
+
+ {indication}
+
+ ) : null}
- {tiles.map((tile, index) => {
- const decor = detailTileDecor[index] ?? detailTileDecor[detailTileDecor.length - 1];
- return (
-
- );
- })}
+ {metrics.map((metric, index) => (
+ // Some records repeat a stat label (e.g. adrenaline has two "Route"
+ // stats), so the label alone is not a unique key — include the index.
+
+ ))}
@@ -414,43 +463,12 @@ function MedicationRecordDetail({
{record.quick.map((row) => (
-
-
- {row.label}
-
-
- {row.value.replace(/\*\*/g, "")}
-
-
+
))}
-
- {record.stats.length ? (
-
-
- {record.stats.map((stat) => {
- const tone = medicationStatTone(stat);
- return (
-
-
- {stat.label}
-
-
{stat.value}
-
- );
- })}
-
-
- ) : null}
diff --git a/src/lib/medications.ts b/src/lib/medications.ts
index d5ad31e2e..7afd5c557 100644
--- a/src/lib/medications.ts
+++ b/src/lib/medications.ts
@@ -1,4 +1,6 @@
import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
+import { medicationStatTone } from "@/lib/medication-badges";
+import type { SemanticTone } from "@/lib/semantic-tone";
export type MedicationPatientMetadata = {
factors?: string[];
@@ -139,12 +141,32 @@ export function medicationSearchText(record: MedicationRecord) {
}
export function medicationIndication(record: MedicationRecord) {
- return (
+ const raw =
firstRowValue(record, "ind", "primary") ||
firstRowValue(record, "summary", "overview") ||
record.subclass ||
- record.category
- );
+ record.category;
+ // Emit a single clinical clause so constrained surfaces (search rows, cross-mode
+ // subtitles, the detail header) get one crisp line; the full indication list
+ // still lives in the record's indication/summary sections.
+ return firstClinicalSentence(raw.replace(/\*\*/g, "")) || raw;
+}
+
+// Shared "short & sharp" length cap for constrained surfaces (hero-metric tiles,
+// search cells): strip markdown bold, then cap on a word boundary with an
+// ellipsis. Generalises the slice(0, N)… idiom already used by
+// formulationShortLabel (medication-badges.ts). It intentionally does NOT split
+// on sentences — stat values are curated tokens that can carry dotted
+// abbreviations ("L.O.T. DRUG", "b.d.") — so callers that want the first clause
+// of prose compose firstClinicalSentence themselves (e.g. medicationIndication,
+// medicationUsualDose). Full text always remains in the sections/reference.
+export function shortValue(text: string, cap = 24): string {
+ const cleaned = (text ?? "").replace(/\*\*/g, "").trim();
+ if (cleaned.length <= cap) return cleaned;
+ const slice = cleaned.slice(0, cap);
+ const boundary = slice.lastIndexOf(" ");
+ const head = (boundary > cap * 0.6 ? slice.slice(0, boundary) : slice).replace(/[\s,;:]+$/, "");
+ return `${head}…`;
}
// Take the first sentence without mangling decimals ("1.5 mg") or common
@@ -159,6 +181,13 @@ export function firstClinicalSentence(value: string): string {
if (next !== undefined && !/\s/.test(next)) continue;
const before = text.slice(0, match.index);
if (/(?:^|[\s(])(?:e\.g|i\.e|etc|vs|approx)$/i.test(before)) continue;
+ // Genus abbreviations ("H. pylori", "E. coli", "C. difficile") are a lone
+ // letter followed by a lowercase species — not a sentence end; splitting
+ // there would drop the rest of the clause. Restrict the skip to that shape
+ // so genuine single-letter sentence ends ("Pregnancy Category D. ...",
+ // "Vitamin C. ...", "Penicillin G. ...") still split.
+ const nextWord = text.slice(match.index + 1).match(/^\s+(\S)/);
+ if (/(?:^|[\s(])[A-Za-z]$/.test(before) && nextWord && /[a-z]/.test(nextWord[1])) continue;
return before.trim();
}
return text;
@@ -234,7 +263,7 @@ export function medicationToSearchResult(match: MedicationSearchMatch): Medicati
name: medication.name,
indication: medicationIndication(medication),
match: score >= 12 ? "Exact clinical fit" : score >= 6 ? "Good clinical fit" : "Related match",
- dose: medicationUsualDose(medication),
+ dose: shortValue(medicationUsualDose(medication), 44),
ceiling: medicationCeiling(medication),
action: action.text,
actionTone: action.tone,
@@ -295,31 +324,63 @@ export function rankMedicationRecords(
export { medicationIdentityBadges } from "@/lib/medication-badges";
-export function medicationDetailTiles(record: MedicationRecord) {
- const usualDose = medicationUsualDose(record);
- const ceiling = medicationCeiling(record);
- const avoid = medicationAction(record);
- return [
- {
- label: "Prescribing answer",
- value: medicationIndication(record).split(".")[0] ?? record.name,
- meta: record.subclass || record.category,
- },
- {
- label: "Dosing",
- value: usualDose,
- meta: record.stats[0]?.label ?? "Usual dose",
- },
- {
- label: "Dose ceiling",
- value: ceiling,
- meta: "MAX",
- },
- {
- label: "Avoid",
- value: avoid?.split(",")[0] ?? "Review contraindications",
- meta: record.schedule === "S8" ? "Controlled" : "Safety",
- danger: true,
- },
+export type MedicationHeroMetric = {
+ label: string;
+ value: string;
+ tone: SemanticTone;
+};
+
+// "Max Dose" (and a bare "Dose") carry flag:"hi" to mark the prescribing
+// ceiling's importance, not a safety stop, so both tone and ordering treat it
+// specially — share one label test so the two stay aligned.
+function isMaxDoseLabel(label: string): boolean {
+ return /^(?:max\s+)?dose$/i.test(label.trim());
+}
+
+// Render Max Dose as the primary/clinical metric so red stays reserved for
+// genuine risk signals (contraindications, toxicity, teratogenicity), matching
+// the #659 colour contract.
+function heroMetricTone(stat: MedicationStat): SemanticTone {
+ if (isMaxDoseLabel(stat.label)) return "clinical";
+ return medicationStatTone(stat);
+}
+
+// The top-of-page metric tiles. Every record already carries a curated ~4-item
+// `stats` array of short, glanceable values (median 8 chars) — Max Dose plus
+// half-life and drug-specific risk/caution flags — so those ARE the "max dose ·
+// duration · cautions, short and sharp". We lead with Max Dose (the most-cited
+// ceiling), cap each value with shortValue, and backfill from crisp derived
+// tokens only for the rare record with fewer than four stats. Verbose derived
+// sentences (indication/dosing/contraindications) are not tiled here — they live
+// in the header subtitle and the detail sections instead.
+export function medicationHeroMetrics(record: MedicationRecord): MedicationHeroMetric[] {
+ const ordered = [
+ ...record.stats.filter((stat) => isMaxDoseLabel(stat.label)),
+ ...record.stats.filter((stat) => !isMaxDoseLabel(stat.label)),
];
+ const metrics: MedicationHeroMetric[] = ordered.slice(0, 4).map((stat) => ({
+ label: stat.label,
+ // Never truncate the prescribing ceiling: multi-variant max doses (e.g.
+ // "Buvidal 160 mg/month; Sublocade 300 mg/month") must stay complete, and the
+ // full value lives nowhere else now that the Key-stats sidebar is gone. Other
+ // stats are short curated tokens, so shortValue is only a safety-net cap.
+ value: isMaxDoseLabel(stat.label) ? stat.value.replace(/\*\*/g, "").trim() : shortValue(stat.value),
+ tone: heroMetricTone(stat),
+ }));
+ if (metrics.length >= 4) return metrics;
+
+ const usualDose = medicationUsualDose(record);
+ const backfills: MedicationHeroMetric[] = [
+ { label: "Usual dose", value: shortValue(usualDose), tone: "clinical" },
+ record.schedule ? { label: "Schedule", value: shortValue(record.schedule), tone: "neutral" } : null,
+ record.category ? { label: "Category", value: shortValue(record.category), tone: "neutral" } : null,
+ ].filter((metric): metric is MedicationHeroMetric => metric !== null);
+
+ for (const fill of backfills) {
+ if (metrics.length >= 4) break;
+ if (metrics.some((metric) => metric.label.toLowerCase() === fill.label.toLowerCase())) continue;
+ if (!fill.value || fill.value === "See dosing" || fill.value === "See reference") continue;
+ metrics.push(fill);
+ }
+ return metrics;
}
diff --git a/tests/medications.test.ts b/tests/medications.test.ts
index 0ac77dcc2..22f075e1d 100644
--- a/tests/medications.test.ts
+++ b/tests/medications.test.ts
@@ -4,8 +4,11 @@ import { getMedicationRecord, loadMedicationSnapshot } from "@/lib/medication-sn
import {
firstClinicalSentence,
medicationActionDetail,
+ medicationHeroMetrics,
+ medicationIndication,
medicationToSearchResult,
rankMedicationRecords,
+ shortValue,
type MedicationRecord,
} from "@/lib/medications";
@@ -208,4 +211,171 @@ describe("medication action tone", () => {
expect(firstClinicalSentence("Rash, nausea, etc. may occur. Stop if severe.")).toBe("Rash, nausea, etc. may occur");
expect(firstClinicalSentence("No trailing period")).toBe("No trailing period");
});
+
+ it("does not cut at single-letter genus abbreviations (lone letter + lowercase species)", () => {
+ expect(firstClinicalSentence("Atypical CAP, H. pylori eradication, severe MAC complex in HIV.")).toBe(
+ "Atypical CAP, H. pylori eradication, severe MAC complex in HIV",
+ );
+ expect(firstClinicalSentence("E. coli UTI. Second line.")).toBe("E. coli UTI");
+ expect(firstClinicalSentence("C. difficile colitis (severe). Oral only.")).toBe("C. difficile colitis (severe)");
+ });
+
+ it("still splits genuine single-letter sentence ends (lone letter + uppercase next word)", () => {
+ expect(firstClinicalSentence("ABSOLUTE — Pregnancy Category D. Associated with malformations.")).toBe(
+ "ABSOLUTE — Pregnancy Category D",
+ );
+ expect(firstClinicalSentence("Vitamin C. Essential antioxidant.")).toBe("Vitamin C");
+ expect(firstClinicalSentence("Depot formulation of Penicillin G. Slowly leaches.")).toBe(
+ "Depot formulation of Penicillin G",
+ );
+ });
+});
+
+describe("shortValue", () => {
+ it("returns short text unchanged and strips markdown bold", () => {
+ expect(shortValue("200 mg/day")).toBe("200 mg/day");
+ expect(shortValue("**6 g/day**")).toBe("6 g/day");
+ expect(shortValue("")).toBe("");
+ });
+
+ it("caps long text on a word boundary with a trailing ellipsis and no dangling punctuation", () => {
+ const out = shortValue("PO 2 tablets (666 mg) three times daily with meals", 24);
+ expect(out.endsWith("…")).toBe(true);
+ expect(out.length).toBeLessThanOrEqual(25); // cap + the ellipsis char
+ expect(out).not.toMatch(/[\s,;:]…$/); // no space/punctuation immediately before the ellipsis
+ });
+
+ it("caps by length only — no sentence-splitting — so dotted abbreviations survive", () => {
+ // Stat values are curated tokens ("L.O.T. DRUG", "b.d."), not prose; splitting
+ // at an internal period would drop clinical label content.
+ expect(shortValue("L.O.T. DRUG")).toBe("L.O.T. DRUG");
+ expect(shortValue("Start 1.5 mg NOCTE. Titrate weekly.", 40)).toBe("Start 1.5 mg NOCTE. Titrate weekly.");
+ });
+});
+
+describe("medicationHeroMetrics", () => {
+ it("leads with Max Dose as the primary/clinical tile and keeps the other stats in order", () => {
+ const record = buildRecord({
+ stats: [
+ { label: "Half-life", value: "26 h", cls: "", flag: "" },
+ { label: "GI Upset", value: "COMMON", cls: "warn", flag: "" },
+ { label: "Max Dose", value: "200 mg/day", cls: "hi", flag: "hi" },
+ { label: "Post-MI Safe", value: "YES", cls: "good", flag: "" },
+ ],
+ });
+ const metrics = medicationHeroMetrics(record);
+ expect(metrics.map((metric) => metric.label)).toEqual(["Max Dose", "Half-life", "GI Upset", "Post-MI Safe"]);
+ // Max Dose's flag:"hi" marks ceiling importance, not danger — keep red reserved.
+ expect(metrics.map((metric) => metric.tone)).toEqual(["clinical", "neutral", "warning", "success"]);
+ });
+
+ it("caps to four metrics; keeps the ceiling full but shortens other over-long values", () => {
+ const record = buildRecord({
+ stats: [
+ { label: "Max Dose", value: "Buvidal 160 mg/month; Sublocade 300 mg/month", cls: "hi", flag: "hi" },
+ { label: "Note", value: "An unusually long caution token that should be capped", cls: "", flag: "" },
+ { label: "Renal Adj.", value: "DOSE RED.", cls: "warn", flag: "warn" },
+ { label: "Efficacy", value: "MODERATE", cls: "warn", flag: "" },
+ { label: "Extra", value: "SHOULD NOT APPEAR", cls: "", flag: "" },
+ ],
+ });
+ const metrics = medicationHeroMetrics(record);
+ expect(metrics).toHaveLength(4);
+ expect(metrics.some((metric) => metric.label === "Extra")).toBe(false);
+ // The prescribing ceiling is never truncated — the full multi-variant value stays.
+ expect(metrics.find((metric) => metric.label === "Max Dose")?.value).toBe(
+ "Buvidal 160 mg/month; Sublocade 300 mg/month",
+ );
+ // ...but other over-long stat values are still capped.
+ const note = metrics.find((metric) => metric.label === "Note");
+ expect(note?.value.endsWith("…")).toBe(true);
+ expect(note?.value.length ?? 0).toBeLessThanOrEqual(25);
+ });
+
+ it("marks genuine hi-risk stats as danger even though Max Dose is not", () => {
+ const record = buildRecord({
+ stats: [
+ { label: "Max Dose", value: "3 g/day", cls: "hi", flag: "hi" },
+ { label: "Lactic Acidosis", value: "CRITICAL", cls: "hi", flag: "warn" },
+ ],
+ });
+ const metrics = medicationHeroMetrics(record);
+ expect(metrics.find((metric) => metric.label === "Max Dose")?.tone).toBe("clinical");
+ expect(metrics.find((metric) => metric.label === "Lactic Acidosis")?.tone).toBe("danger");
+ });
+
+ it("backfills from crisp derived tokens when a record has fewer than four stats", () => {
+ const record = buildRecord({
+ schedule: "S8",
+ category: "Opioid",
+ stats: [{ label: "Max Dose", value: "Titrated", cls: "hi", flag: "hi" }],
+ quick: [{ label: "Usual dose", value: "PO 5 mg BD" }],
+ });
+ const metrics = medicationHeroMetrics(record);
+ expect(metrics.map((metric) => metric.label)).toEqual(["Max Dose", "Usual dose", "Schedule", "Category"]);
+ expect(metrics.find((metric) => metric.label === "Schedule")?.value).toBe("S8");
+ expect(metrics.find((metric) => metric.label === "Usual dose")?.value).toBe("PO 5 mg BD");
+ });
+
+ it("promotes Max Dose first even when the curated array lists it later, from the snapshot", () => {
+ const record = getMedicationRecord("metformin");
+ expect(record).toBeTruthy();
+ const metrics = medicationHeroMetrics(record as MedicationRecord);
+ expect(metrics.length).toBeLessThanOrEqual(4);
+ expect(metrics[0].label).toMatch(/max dose/i);
+ expect(metrics[0].tone).toBe("clinical");
+ });
+
+ it("preserves dotted-abbreviation stat values from the snapshot (L.O.T. DRUG)", () => {
+ const record = getMedicationRecord("lorazepam");
+ expect(record).toBeTruthy();
+ const hepaticSafe = medicationHeroMetrics(record as MedicationRecord).find(
+ (metric) => metric.label === "Hepatic Safe",
+ );
+ expect(hepaticSafe?.value).toBe("L.O.T. DRUG");
+ });
+
+ it("preserves repeated stat labels from the snapshot (adrenaline has two Route metrics)", () => {
+ // Duplicate labels mean the render must key tiles by index, not by label.
+ const record = getMedicationRecord("adrenaline-epinephrine");
+ expect(record).toBeTruthy();
+ const labels = medicationHeroMetrics(record as MedicationRecord).map((metric) => metric.label);
+ expect(labels.filter((label) => label === "Route")).toHaveLength(2);
+ });
+
+ it("keeps a multi-variant max-dose ceiling complete from the snapshot (not truncated)", () => {
+ // The ceiling's full value is not repeated elsewhere on the page, so it must
+ // not be clamped away — both depot brand ceilings must survive.
+ const record = getMedicationRecord("buprenorphine-sl-depot");
+ expect(record).toBeTruthy();
+ const maxDose = medicationHeroMetrics(record as MedicationRecord).find((metric) => /max dose/i.test(metric.label));
+ expect(maxDose?.value).toBe("Buvidal 160 mg/month; Sublocade 300 mg/month");
+ });
+});
+
+describe("medicationIndication", () => {
+ it("crisps the indication to a single clinical clause", () => {
+ const record = buildRecord({
+ sections: [
+ {
+ title: "Indication",
+ type: "ind",
+ rows: [{ key: "Primary", val: "Alcohol dependence maintenance. Adjunct to psychosocial support." }],
+ },
+ ],
+ });
+ expect(medicationIndication(record)).toBe("Alcohol dependence maintenance");
+ });
+
+ it("falls back to taxonomy when no indication content exists", () => {
+ expect(medicationIndication(buildRecord({ subclass: "SSRI" }))).toBe("SSRI");
+ });
+
+ it("keeps genus abbreviations intact from the snapshot (does not truncate H. pylori)", () => {
+ const record = getMedicationRecord("clarithromycin");
+ expect(record).toBeTruthy();
+ const indication = medicationIndication(record as MedicationRecord);
+ expect(indication).toContain("H. pylori");
+ expect(indication).not.toBe("Atypical CAP, H");
+ });
});