("diagnosis");
const [scale, setScale] = useState(1);
@@ -645,6 +659,7 @@ export function DiagnosisMapPanel({ record }: { record: DifferentialRecord }) {
selected={selected}
added={addedIds.includes(selectedId)}
onAdd={toggleCompare}
+ knownRelatedSlugs={knownRelatedSlugs}
/>
{filtered ? (
@@ -664,6 +679,7 @@ export function DiagnosisMapPanel({ record }: { record: DifferentialRecord }) {
selected={selected}
added={addedIds.includes(selectedId)}
onAdd={toggleCompare}
+ knownRelatedSlugs={knownRelatedSlugs}
/>
{filtered ? (
diff --git a/src/components/differentials/differential-detail-page.tsx b/src/components/differentials/differential-detail-page.tsx
index 44f9eef32..cd595a6f4 100644
--- a/src/components/differentials/differential-detail-page.tsx
+++ b/src/components/differentials/differential-detail-page.tsx
@@ -1,32 +1,59 @@
"use client";
-import { useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import Link from "next/link";
import {
Activity,
AlertTriangle,
Bookmark,
+ BookmarkCheck,
BrainCircuit,
CheckCircle2,
ChevronDown,
ChevronRight,
+ ChevronsDownUp,
+ ChevronsUpDown,
CircleHelp,
- ClipboardCopy,
Clock3,
FlaskConical,
GitBranch,
GitCompareArrows,
- MoreVertical,
+ Info,
Plus,
ShieldAlert,
Stethoscope,
+ type LucideIcon,
} from "lucide-react";
+import type { DifferentialRecordGovernance } from "@/components/clinical-dashboard/use-differential-catalog";
import { DiagnosisMapPanel } from "@/components/differentials/diagnosis-map-panel";
-import { cn } from "@/components/ui-primitives";
+import { CopyAfterReviewButton } from "@/components/differentials/differential-presentation-actions";
+import { cn, toneDanger, toneNeutral, toneWarning } from "@/components/ui-primitives";
+import { appModeHomeHref } from "@/lib/app-modes";
+import {
+ cleanDifferentialItem,
+ differentialSourceStatusLabel,
+ differentialStatusLabel,
+ differentialValidationStatusLabel,
+ formatDifferentialCopyText,
+ formatExportedDate,
+ groupCurrentPresentation,
+ isDetailTabId,
+ resolveSafetyFacts,
+ sectionBadgeLabel,
+ visibleSectionItems,
+ type DifferentialDetailContext,
+ type DifferentialDetailTabId,
+ type DifferentialSafetyFact,
+} from "@/lib/differential-detail";
import type { DifferentialRecord, DifferentialSection } from "@/lib/differentials";
+import {
+ readSavedRegistrySlugs,
+ savedDifferentialsStorageKey,
+ writeSavedRegistrySlugs,
+} from "@/lib/saved-registry-storage";
-const sectionIcons: Record = {
+const sectionIcons: Record = {
fit: CheckCircle2,
warning: AlertTriangle,
question: CircleHelp,
@@ -45,31 +72,27 @@ const sectionTone: Record = {
overlap: "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]",
};
-const statusTone: Record = {
- emergent: "border-[color:var(--danger)]/25 bg-[color:var(--danger-soft)] text-[color:var(--danger)]",
- urgent: "border-[color:var(--warning)]/25 bg-[color:var(--warning-soft)] text-[color:var(--warning)]",
- routine: "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]",
+const statusToneClass: Record = {
+ emergent: toneDanger,
+ urgent: toneWarning,
+ routine: toneNeutral,
};
-const rowMeta: Record = {
+const rowMeta: Record = {
fit: {
label: "Key features",
- badgeSuffix: "present",
badgeClassName: "bg-[color:var(--success-soft)] text-[color:var(--success)]",
},
warning: {
label: "High-risk causes",
- badgeSuffix: "possible",
badgeClassName: "bg-[color:var(--warning-soft)] text-[color:var(--warning)]",
},
question: {
label: "Helpful clues",
- badgeSuffix: "positive",
badgeClassName: "bg-[color:var(--info-soft)] text-[color:var(--info)]",
},
action: {
label: "Priority steps",
- badgeSuffix: "pending",
badgeClassName: "bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]",
},
test: {
@@ -82,152 +105,395 @@ const rowMeta: Record> = {
+ fit: CheckCircle2,
+ warning: AlertTriangle,
+ question: CircleHelp,
+ test: FlaskConical,
+};
-/** Maps a related node's likelihood to its own severity tag, mirroring the record-status tones. */
-function likelihoodTag(likelihood: DifferentialRecord["related"][number]["likelihood"]) {
- if (likelihood === "must-not-miss") return { label: "Emergent", className: statusTone.emergent };
- if (likelihood === "possible") return { label: "Urgent", className: statusTone.urgent };
- return { label: "Review", className: statusTone.routine };
+const sectionItemIconClass: Partial> = {
+ fit: "text-[color:var(--success)]",
+ warning: "text-[color:var(--danger)]",
+ question: "text-[color:var(--info)]",
+ test: "text-[color:var(--info)]",
+};
+
+function SectionItems({
+ section,
+ items,
+ overlapLinks,
+}: {
+ section: DifferentialSection;
+ items: string[];
+ overlapLinks: Record;
+}) {
+ if (section.tone === "action") {
+ return (
+
+ {items.map((item, index) => (
+ -
+
+ {index + 1}
+
+ {item}
+
+ ))}
+
+ );
+ }
+
+ if (section.tone === "overlap") {
+ return (
+
+ {items.map((item) => {
+ const slug = overlapLinks[item];
+ return (
+ -
+ {slug ? (
+
+ {item}
+
+
+ ) : (
+
+ {item}
+
+ )}
+
+ );
+ })}
+
+ );
+ }
+
+ const Icon = sectionItemIcons[section.tone] ?? CheckCircle2;
+ return (
+
+ {items.map((item) => (
+ -
+
+ {item}
+
+ ))}
+
+ );
}
-function SectionRow({ section }: { section: DifferentialSection }) {
+function SectionRow({
+ section,
+ record,
+ open,
+ onOpenChange,
+ overlapLinks,
+}: {
+ section: DifferentialSection;
+ record: DifferentialRecord;
+ open: boolean;
+ onOpenChange: (id: string, open: boolean) => void;
+ overlapLinks: Record;
+}) {
const Icon = sectionIcons[section.tone];
const meta = rowMeta[section.tone];
+ const items = useMemo(() => visibleSectionItems(section, record), [section, record]);
+ const badge = sectionBadgeLabel(section, record);
+
+ const iconTile = (
+
+
+
+ );
+
+ if (items.length === 0) {
+ return (
+
+ {iconTile}
+
+
{section.title}
+
+ {section.summary}
+
+
+
+ {meta.label}
+
+
+ );
+ }
+
return (
-
- {
+ // Native toggle also fires for prop-driven and browser-initiated flips
+ // (expand-all, find-in-page auto-expand); sync from the DOM state
+ // instead of inverting so echoes converge instead of looping.
+ const next = event.currentTarget.open;
+ if (next !== open) onOpenChange(section.id, next);
+ }}
+ >
+
+ {iconTile}
+
+
{section.title}
+
+ {section.summary}
+
+
+
+ {meta.label}
+
+
+ {badge}
+
+
+
+
-
-
-
-
{section.title}
-
- {section.summary}
-
+
-
- {meta.label}
-
-
- {sectionBadge(section)}
-
-
-
+
);
}
-function SafetySnapshot({ record }: { record: DifferentialRecord }) {
- const isDelirium = record.slug === "delirium";
- const facts = isDelirium
- ? [
- { label: "High risk", value: "Yes", icon: ShieldAlert },
- { label: "Onset", value: "Acute", icon: Clock3 },
- { label: "Course", value: "Fluctuating", icon: CheckCircle2 },
- { label: "Treatable", value: "Often", icon: Plus },
- ]
- : [
- { label: "High risk", value: record.status === "urgent" ? "Possible" : "Yes", icon: ShieldAlert },
- { label: "Onset", value: "Acute", icon: Clock3 },
- { label: "Course", value: "Variable", icon: CheckCircle2 },
- { label: "Treatable", value: "Often", icon: Plus },
- ];
+type SnapshotTheme = {
+ Icon: LucideIcon;
+ container: string;
+ iconTile: string;
+ heading: string;
+ divider: string;
+ chip: string;
+ accentText: string;
+};
+
+const snapshotThemes: Record
= {
+ emergent: {
+ Icon: ShieldAlert,
+ // Full-opacity soft tokens: an /NN modifier compiles to a color-mix toward
+ // transparent, which renders near-invisible on the near-white soft values
+ // in light mode (see PR #468).
+ container: "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)]",
+ iconTile: "border-[color:var(--danger)]/20 bg-[color:var(--surface)] text-[color:var(--danger)]",
+ heading: "text-[color:var(--danger)]",
+ divider: "border-[color:var(--danger)]/14",
+ chip: "border-[color:var(--danger-border)]/60 bg-[color:var(--danger-soft)] text-[color:var(--danger)]",
+ accentText: "text-[color:var(--danger)]",
+ },
+ urgent: {
+ Icon: AlertTriangle,
+ container: "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)]",
+ iconTile: "border-[color:var(--warning)]/25 bg-[color:var(--surface)] text-[color:var(--warning)]",
+ heading: "text-[color:var(--warning)]",
+ divider: "border-[color:var(--warning)]/20",
+ chip: "border-[color:var(--warning-border)]/60 bg-[color:var(--warning-soft)] text-[color:var(--warning)]",
+ accentText: "text-[color:var(--warning)]",
+ },
+ routine: {
+ Icon: Info,
+ container: "border-[color:var(--border)] bg-[color:var(--surface-subtle)]",
+ iconTile: "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)]",
+ heading: "text-[color:var(--text-heading)]",
+ divider: "border-[color:var(--border)]",
+ chip: "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]",
+ accentText: "text-[color:var(--text-muted)]",
+ },
+};
+
+const factIcons: Record = {
+ "high-risk": ShieldAlert,
+ onset: Clock3,
+ course: Activity,
+ treatable: Plus,
+ causes: AlertTriangle,
+ tests: FlaskConical,
+ actions: Activity,
+ related: GitBranch,
+};
+
+function SafetySnapshot({
+ record,
+ onReviewMustNotMiss,
+}: {
+ record: DifferentialRecord;
+ onReviewMustNotMiss: (() => void) | null;
+}) {
+ const theme = snapshotThemes[record.status];
+ const facts = resolveSafetyFacts(record);
return (
-
+
-
-
+
+
-
- Safety snapshot
-
+ Safety snapshot
- {statusLabel(record.status)}
+ {differentialStatusLabel(record.status)}
{record.safetySnapshot.summary}
-
- {facts.map((fact, index) => {
- const Icon = fact.icon;
- return (
-
0 && "border-l border-[color:var(--danger)]/14 pl-2 sm:pl-4")}
+ {facts.length > 0 ? (
+
+ {facts.map((fact, index) => {
+ const Icon = factIcons[fact.id];
+ return (
+
0 && "sm:border-l sm:pl-4", index > 0 && theme.divider)}
+ >
+
+
+ {fact.label}
+
+
+ {fact.value}
+
+
+ );
+ })}
+
+ ) : null}
+ {record.safetySnapshot.tags.length > 0 ? (
+
+
Watch for
+ {record.safetySnapshot.tags.map((tag) => (
+
-
-
- {fact.label}
-
- {fact.value}
-
- );
- })}
-
-
- Immediate priorities
- {record.safetySnapshot.tags.map((tag) => (
-
- {tag}
-
- ))}
-
+ {cleanDifferentialItem(tag)}
+
+ ))}
+
+ ) : null}
+ {onReviewMustNotMiss ? (
+
+ ) : null}
);
}
-function RelatedDiagnoses({ record }: { record: DifferentialRecord }) {
+function RelatedDiagnoses({ record, knownRelatedSlugs }: { record: DifferentialRecord; knownRelatedSlugs: string[] }) {
+ const known = new Set(knownRelatedSlugs);
return (
);
}
-function FooterStatus() {
+function FooterStatus({
+ source,
+ liveGovernance,
+}: {
+ source: DifferentialDetailContext["source"];
+ liveGovernance: DifferentialRecordGovernance | null;
+}) {
+ const sourceStatus = liveGovernance?.sourceStatus ?? source.sourceStatus;
+ const validationStatus = liveGovernance?.validationStatus ?? source.validationStatus;
+ const sourceToneClass =
+ sourceStatus === "current"
+ ? "text-[color:var(--success)]"
+ : sourceStatus === "outdated"
+ ? "text-[color:var(--danger)]"
+ : "text-[color:var(--warning)]";
+ const validationToneClass =
+ validationStatus === "approved" ? "text-[color:var(--success)]" : "text-[color:var(--warning)]";
+
+ const cards: Array<{ title: string; line: string; lineClassName: string; detail: string }> = [
+ {
+ title: "Source status",
+ line: differentialSourceStatusLabel(sourceStatus),
+ lineClassName: sourceToneClass,
+ detail: source.sourceTitle || source.reviewStatus,
+ },
+ {
+ title: "Review status",
+ line: differentialValidationStatusLabel(validationStatus),
+ lineClassName: validationToneClass,
+ detail: "Use clinical judgement and local protocols.",
+ },
+ {
+ title: "Version",
+ line: `${source.version} | Local content only`,
+ lineClassName: "text-[color:var(--text-heading)]",
+ detail: `Exported ${formatExportedDate(source.exportedAt)}. Data not provided for clinical use.`,
+ },
+ ];
+
return (
- {[
- ["Source status", "Source pending review", "Last updated: Today"],
- ["Review status", "Review before use", "Use clinical judgement and local protocols."],
- ["Version", "v1.0 | Local content only", "Data not provided for clinical use."],
- ].map(([title, line, detail]) => (
+ {cards.map((card) => (
-
{title}
-
{line}
-
{detail}
+
{card.title}
+
{card.line}
+
{card.detail}
))}
);
}
-function TopActions() {
+function TopActions({
+ record,
+ saved,
+ onToggleSaved,
+ onCompare,
+}: {
+ record: DifferentialRecord;
+ saved: boolean;
+ onToggleSaved: () => void;
+ onCompare: () => void;
+}) {
return (
-
-
+
);
}
-function MobilePrimaryActions({ count = 3 }: { count?: number }) {
+function MobilePrimaryActions({
+ record,
+ saved,
+ onToggleSaved,
+ onCompare,
+}: {
+ record: DifferentialRecord;
+ saved: boolean;
+ onToggleSaved: () => void;
+ onCompare: () => void;
+}) {
return (
-
+
+
);
@@ -397,27 +806,26 @@ function HeaderChrome() {
);
}
-type TabId = "overview" | "compare" | "map" | "related" | "source";
-
-const detailTabs: Array<{ id: TabId; label: string }> = [
+const detailTabs: Array<{ id: DifferentialDetailTabId; label: string }> = [
{ id: "overview", label: "Overview" },
{ id: "compare", label: "Compare" },
{ id: "map", label: "Map" },
@@ -425,10 +833,39 @@ const detailTabs: Array<{ id: TabId; label: string }> = [
{ id: "source", label: "Source" },
];
-function Tabs({ active, onChange }: { active: TabId; onChange: (id: TabId) => void }) {
+function Tabs({
+ active,
+ onChange,
+}: {
+ active: DifferentialDetailTabId;
+ onChange: (id: DifferentialDetailTabId) => void;
+}) {
+ const tabRefs = useRef(new Map());
+
+ function handleKeyDown(event: ReactKeyboardEvent) {
+ const order = detailTabs.map((tab) => tab.id);
+ const index = order.indexOf(active);
+ const next =
+ event.key === "ArrowRight"
+ ? order[(index + 1) % order.length]
+ : event.key === "ArrowLeft"
+ ? order[(index - 1 + order.length) % order.length]
+ : event.key === "Home"
+ ? order[0]
+ : event.key === "End"
+ ? order[order.length - 1]
+ : null;
+ if (!next) return;
+ event.preventDefault();
+ if (next === active) return;
+ onChange(next);
+ tabRefs.current.get(next)?.focus();
+ }
+
return (
@@ -500,10 +1016,10 @@ export function DifferentialDetailPage({ record }: { record: DifferentialRecord
-
+
-
+
{activeTab === "overview" ? (
<>
-
+
+
+
+ Clinical review
+
+ {expandableSectionIds.length > 0 ? (
+
+ ) : null}
+
{record.sections.map((section) => (
-
+
))}
>
) : null}
- {activeTab === "compare" ?
: null}
+ {activeTab === "compare" ?
: null}
- {activeTab === "map" ?
: null}
+ {activeTab === "map" ? (
+
+ ) : null}
{activeTab === "related" ? (
<>
-
+
>
) : null}
- {activeTab === "source" ?
: null}
+ {activeTab === "source" ? (
+
+ ) : null}
-
+
Clinical decision support only. Review before use.
diff --git a/src/components/differentials/differential-diagnosis-page-client.tsx b/src/components/differentials/differential-diagnosis-page-client.tsx
index 428a69a06..bce058008 100644
--- a/src/components/differentials/differential-diagnosis-page-client.tsx
+++ b/src/components/differentials/differential-diagnosis-page-client.tsx
@@ -2,16 +2,30 @@
import { DifferentialDetailPage } from "@/components/differentials/differential-detail-page";
import { useDifferentialRecord } from "@/components/clinical-dashboard/use-differential-catalog";
+import type { DifferentialDetailContext } from "@/lib/differential-detail";
import type { DifferentialRecord } from "@/lib/differentials";
export function DifferentialDiagnosisPageClient({
slug,
fallbackRecord,
+ detailContext,
}: {
slug: string;
fallbackRecord: DifferentialRecord;
+ detailContext: DifferentialDetailContext;
}) {
- const { status, record } = useDifferentialRecord(slug);
- const resolvedRecord = status === "ready" && record ? record : fallbackRecord;
- return ;
+ const { status, record, detailContext: liveContext, governance } = useDifferentialRecord(slug);
+ const ready = status === "ready" && record !== null;
+ const resolvedRecord = ready ? record : fallbackRecord;
+ // Prefer the context computed for the live record (owner rows can drift from
+ // the bundled snapshot); fall back to the SSR context when the API predates
+ // the field or the request failed.
+ const resolvedContext = ready && liveContext ? liveContext : detailContext;
+ return (
+
+ );
}
diff --git a/src/components/differentials/differential-presentation-actions.tsx b/src/components/differentials/differential-presentation-actions.tsx
index 37adaaf58..7046aeb03 100644
--- a/src/components/differentials/differential-presentation-actions.tsx
+++ b/src/components/differentials/differential-presentation-actions.tsx
@@ -4,6 +4,7 @@ import { useState } from "react";
import { ClipboardCopy } from "lucide-react";
import { cn } from "@/components/ui-primitives";
+import { copyTextToClipboard } from "@/lib/copy-to-clipboard";
export function CopyAfterReviewButton({
text,
@@ -18,7 +19,7 @@ export function CopyAfterReviewButton({
async function copyText() {
try {
- await navigator.clipboard.writeText(text);
+ await copyTextToClipboard(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx
index 36cc14909..36d7074ea 100644
--- a/src/components/services/service-detail-page.tsx
+++ b/src/components/services/service-detail-page.tsx
@@ -40,6 +40,7 @@ import {
toneWarning,
} from "@/components/ui-primitives";
import { appModeHomeHref } from "@/lib/app-modes";
+import { copyTextToClipboard } from "@/lib/copy-to-clipboard";
import {
serviceNavigatorQuery,
type ServiceContact,
@@ -135,31 +136,6 @@ function hrefIsExternal(href: string | undefined) {
return Boolean(href && /^https?:\/\//i.test(href));
}
-async function copyText(value: string) {
- if (navigator.clipboard?.writeText) {
- try {
- await navigator.clipboard.writeText(value);
- return;
- } catch {
- // Fall through to the legacy selection path for restricted browser contexts.
- }
- }
-
- const textArea = document.createElement("textarea");
- textArea.value = value;
- textArea.setAttribute("readonly", "");
- textArea.style.position = "fixed";
- textArea.style.opacity = "0";
- document.body.appendChild(textArea);
- textArea.select();
- try {
- const copied = document.execCommand?.("copy");
- if (copied === false) throw new Error("copy command rejected");
- } finally {
- document.body.removeChild(textArea);
- }
-}
-
function summaryCardsFor(service: ServiceRecord): ServiceSummaryCard[] {
if (service.summaryCards?.length) return service.summaryCards;
return [
@@ -494,7 +470,7 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) {
}
try {
- await copyText(value.trim());
+ await copyTextToClipboard(value.trim());
setNotice(label);
} catch {
setNotice("Copy failed");
diff --git a/src/lib/copy-to-clipboard.ts b/src/lib/copy-to-clipboard.ts
new file mode 100644
index 000000000..2d764aa52
--- /dev/null
+++ b/src/lib/copy-to-clipboard.ts
@@ -0,0 +1,31 @@
+/** Copies text to the clipboard, falling back to the legacy hidden-textarea
+ * selection path when the async Clipboard API is unavailable or blocked
+ * (restricted browser contexts, older engines). Throws when both paths fail
+ * so callers can surface a copy-failed state. */
+export async function copyTextToClipboard(value: string): Promise {
+ if (navigator.clipboard?.writeText) {
+ try {
+ await navigator.clipboard.writeText(value);
+ return;
+ } catch {
+ // Fall through to the legacy selection path for restricted browser contexts.
+ }
+ }
+
+ const textArea = document.createElement("textarea");
+ textArea.value = value;
+ textArea.setAttribute("readonly", "");
+ textArea.style.position = "fixed";
+ textArea.style.opacity = "0";
+ document.body.appendChild(textArea);
+ textArea.select();
+ try {
+ // execCommand may be absent entirely (an optional call would return
+ // undefined and read as success); only an explicit true means the
+ // fallback actually copied.
+ const copied = typeof document.execCommand === "function" && document.execCommand("copy");
+ if (!copied) throw new Error("copy command rejected");
+ } finally {
+ document.body.removeChild(textArea);
+ }
+}
diff --git a/src/lib/differential-detail.ts b/src/lib/differential-detail.ts
new file mode 100644
index 000000000..a186bfeab
--- /dev/null
+++ b/src/lib/differential-detail.ts
@@ -0,0 +1,207 @@
+import type { DifferentialSourceStatus, DifferentialValidationStatus } from "@/lib/differential-records";
+import type { DifferentialRecord, DifferentialSection } from "@/lib/differential-snapshot";
+
+/** Pure presentation helpers for the differential diagnosis detail page.
+ * Client-safe by design: type-only imports and no catalog/snapshot access —
+ * the generated snapshot JSON must never enter the client bundle. Anything
+ * that needs the full catalog is computed server-side and travels in
+ * DifferentialDetailContext. */
+
+export const DETAIL_TAB_IDS = ["overview", "compare", "map", "related", "source"] as const;
+export type DifferentialDetailTabId = (typeof DETAIL_TAB_IDS)[number];
+
+export function isDetailTabId(value: string | null | undefined): value is DifferentialDetailTabId {
+ return typeof value === "string" && (DETAIL_TAB_IDS as readonly string[]).includes(value);
+}
+
+export type DifferentialDetailContext = {
+ /** related[].id values verified against the diagnosis catalog (safe to link). */
+ knownRelatedSlugs: string[];
+ /** Cleaned mimic/overlap item text -> diagnosis slug (exact title matches only). */
+ overlapLinks: Record;
+ /** First presentation workflow that lists this diagnosis as a candidate. */
+ comparePresentation: { slug: string; title: string } | null;
+ source: {
+ version: string;
+ exportedAt: string;
+ reviewStatus: string;
+ sourceTitle: string;
+ sourceStatus: DifferentialSourceStatus;
+ validationStatus: DifferentialValidationStatus;
+ };
+};
+
+/** Normalizes a generated snapshot item for display: collapses whitespace and
+ * strips the lone trailing full stop the export leaves on short fragments
+ * ("medication toxicity.") without touching real sentences or "e.g.". */
+export function cleanDifferentialItem(value: string): string {
+ const collapsed = value.replace(/\s+/g, " ").trim();
+ if (collapsed.endsWith(".") && collapsed.length <= 64 && !collapsed.slice(0, -1).includes(".")) {
+ return collapsed.slice(0, -1).trimEnd();
+ }
+ return collapsed;
+}
+
+function comparableItemText(value: string) {
+ return value.replace(/\s+/g, " ").trim().replace(/\.$/, "").toLowerCase();
+}
+
+/** Items actually worth rendering inside an expanded section. The generated
+ * export is noisy: `action`/`test` sections carry truncated copies of the
+ * record-level arrays (so prefer those), and many items duplicate the row
+ * summary or the record's clinical hinge. */
+export function visibleSectionItems(section: DifferentialSection, record: DifferentialRecord): string[] {
+ const source = section.tone === "test" && record.investigations.length > 0 ? record.investigations : section.items;
+ const excluded = new Set([section.summary, record.clinicalHinge].map(comparableItemText).filter(Boolean));
+ const seen = new Set();
+ const items: string[] = [];
+ for (const raw of source) {
+ const cleaned = cleanDifferentialItem(raw);
+ if (!cleaned) continue;
+ const key = comparableItemText(cleaned);
+ if (!key || excluded.has(key) || seen.has(key)) continue;
+ seen.add(key);
+ items.push(cleaned);
+ }
+ return items;
+}
+
+const sectionBadgeSuffix: Partial> = {
+ fit: "present",
+ warning: "possible",
+ question: "positive",
+ action: "pending",
+};
+
+/** Count badge text for a section row, using the cleaned item count so the
+ * badge always matches the expanded list; null when there is nothing to show. */
+export function sectionBadgeLabel(section: DifferentialSection, record: DifferentialRecord): string | null {
+ const count = visibleSectionItems(section, record).length;
+ if (count === 0) return null;
+ const suffix = sectionBadgeSuffix[section.tone];
+ return suffix ? `${count} ${suffix}` : String(count);
+}
+
+export function differentialStatusLabel(status: DifferentialRecord["status"]): "Emergent" | "Urgent" | "Routine" {
+ if (status === "emergent") return "Emergent";
+ if (status === "urgent") return "Urgent";
+ return "Routine";
+}
+
+export type DifferentialSafetyFact = {
+ id: "high-risk" | "onset" | "course" | "treatable" | "causes" | "tests" | "actions" | "related";
+ label: string;
+ value: string;
+};
+
+/** Clinically reviewed course facts, keyed by slug. Only records listed here
+ * show qualitative Onset/Course/Treatable facts — every other record falls
+ * back to counts derived from its own data, so the card never fabricates
+ * clinical attributes the snapshot does not carry. */
+const curatedSafetyFacts: Record = {
+ delirium: [
+ { id: "high-risk", label: "High risk", value: "Yes" },
+ { id: "onset", label: "Onset", value: "Acute" },
+ { id: "course", label: "Course", value: "Fluctuating" },
+ { id: "treatable", label: "Treatable", value: "Often" },
+ ],
+};
+
+export function resolveSafetyFacts(record: DifferentialRecord): DifferentialSafetyFact[] {
+ const curated = curatedSafetyFacts[record.slug];
+ if (curated) return curated;
+
+ const facts: DifferentialSafetyFact[] = [];
+ const mustNotMiss = record.sections.find((section) => section.id === "must-not-miss");
+ const causeCount = mustNotMiss ? visibleSectionItems(mustNotMiss, record).length : 0;
+ if (causeCount > 0) facts.push({ id: "causes", label: "High-risk causes", value: String(causeCount) });
+ if (record.investigations.length > 0) {
+ facts.push({ id: "tests", label: "Core tests", value: String(record.investigations.length) });
+ }
+ if (record.immediateActions.length > 0) {
+ facts.push({ id: "actions", label: "Immediate actions", value: String(record.immediateActions.length) });
+ }
+ if (record.related.length > 0) {
+ facts.push({ id: "related", label: "Related differentials", value: String(record.related.length) });
+ }
+ return facts.slice(0, 4);
+}
+
+/** Deterministic plain-text register of the record for the "Copy after
+ * review" action: headline, hinge, safety summary, then actionable lists,
+ * ending with the on-page disclaimer. */
+export function formatDifferentialCopyText(record: DifferentialRecord): string {
+ const lines: string[] = [`${record.title} — ${differentialStatusLabel(record.status)} differential`];
+ if (record.subtitle.trim()) lines.push(record.subtitle.trim());
+ if (record.clinicalHinge.trim()) lines.push("", `Clinical hinge: ${record.clinicalHinge.trim()}`);
+ if (record.safetySnapshot.summary.trim()) {
+ lines.push("", `Must-not-miss: ${record.safetySnapshot.summary.trim()}`);
+ }
+ const actions = record.immediateActions.map(cleanDifferentialItem).filter(Boolean).slice(0, 6);
+ if (actions.length > 0) {
+ lines.push("", "Immediate actions:");
+ for (const action of actions) lines.push(`- ${action}`);
+ }
+ const investigations = record.investigations.map(cleanDifferentialItem).filter(Boolean);
+ if (investigations.length > 0) {
+ lines.push("", "Investigations:");
+ for (const investigation of investigations) lines.push(`- ${investigation}`);
+ }
+ lines.push("", "Clinical decision support only. Review before use.");
+ return lines.join("\n");
+}
+
+const clinicalHingePrefix = /^clinical hinge:\s*/i;
+
+export type CurrentPresentationView =
+ | { kind: "grouped"; groups: Array<{ title: string; candidates: string; hinge: string }> }
+ | { kind: "flat"; items: Array<{ text: string; isHinge: boolean }> };
+
+/** The generated currentPresentation list is usually a strict triplet stream
+ * (presentation title / candidate list / "CLINICAL HINGE: …") but many
+ * records deviate; fall back to a flat list with per-item hinge detection. */
+export function groupCurrentPresentation(items: string[]): CurrentPresentationView {
+ const cleaned = items.map((item) => item.replace(/\s+/g, " ").trim()).filter(Boolean);
+ const isTripletStream =
+ cleaned.length >= 3 &&
+ cleaned.length % 3 === 0 &&
+ cleaned.every((item, index) => clinicalHingePrefix.test(item) === (index % 3 === 2));
+ if (isTripletStream) {
+ const groups: Array<{ title: string; candidates: string; hinge: string }> = [];
+ for (let index = 0; index < cleaned.length; index += 3) {
+ groups.push({
+ title: cleaned[index]!,
+ candidates: cleaned[index + 1]!,
+ hinge: cleaned[index + 2]!.replace(clinicalHingePrefix, ""),
+ });
+ }
+ return { kind: "grouped", groups };
+ }
+ return {
+ kind: "flat",
+ items: cleaned.map((text) => ({
+ text: text.replace(clinicalHingePrefix, ""),
+ isHinge: clinicalHingePrefix.test(text),
+ })),
+ };
+}
+
+export function differentialSourceStatusLabel(status: DifferentialSourceStatus): string {
+ if (status === "current") return "Current";
+ if (status === "review_due") return "Review due";
+ if (status === "outdated") return "Outdated";
+ return "Unknown";
+}
+
+export function differentialValidationStatusLabel(status: DifferentialValidationStatus): string {
+ if (status === "approved") return "Approved";
+ if (status === "locally_reviewed") return "Locally reviewed";
+ return "Unverified";
+}
+
+/** Date-only slice of the snapshot's exportedAt ISO stamp; avoids
+ * locale-dependent formatting that could mismatch between server and client. */
+export function formatExportedDate(exportedAt: string): string {
+ const match = exportedAt.match(/^\d{4}-\d{2}-\d{2}/);
+ return match ? match[0] : exportedAt;
+}
diff --git a/src/lib/differentials.ts b/src/lib/differentials.ts
index e215965c8..4e6ed2290 100644
--- a/src/lib/differentials.ts
+++ b/src/lib/differentials.ts
@@ -1,5 +1,7 @@
import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
+import { cleanDifferentialItem, type DifferentialDetailContext } from "@/lib/differential-detail";
import { loadDifferentialSnapshot } from "@/lib/differential-fixtures";
+import { deriveGovernanceFromSnapshot } from "@/lib/differential-records";
import type {
DifferentialComparisonCandidate,
DifferentialComparisonCriterion,
@@ -104,6 +106,68 @@ export function presentationStaticParams() {
return differentialPresentations().map((presentation) => ({ slug: presentation.id }));
}
+function diagnosisTitleSlugMap(records: DifferentialRecord[]) {
+ const titleToSlug = new Map();
+ for (const record of records) {
+ const key = cleanDifferentialItem(record.title).toLowerCase();
+ if (key && !titleToSlug.has(key)) titleToSlug.set(key, record.slug);
+ }
+ return titleToSlug;
+}
+
+/** Server-computed context for the diagnosis detail page. Everything the page
+ * needs from the full catalog travels in this small serializable payload so
+ * the client component never imports the generated snapshot. */
+export function getDifferentialDetailContext(
+ record: DifferentialRecord,
+ catalog: {
+ records?: DifferentialRecord[];
+ presentations?: DifferentialPresentationWorkflow[];
+ } = {},
+): DifferentialDetailContext {
+ const catalogRecords = catalog.records ?? differentialRecords;
+ const catalogPresentations = catalog.presentations ?? differentialPresentations();
+ const routableDiagnosisSlugs = new Set(differentialRecords.map((entry) => entry.slug));
+ const routablePresentationSlugs = new Set(differentialPresentations().map((entry) => entry.id));
+ const knownRelatedSlugs = [
+ ...new Set(record.related.map((node) => node.id).filter((id) => routableDiagnosisSlugs.has(id))),
+ ];
+
+ const overlapLinks: Record = {};
+ const titleMap = diagnosisTitleSlugMap(catalogRecords);
+ for (const section of record.sections) {
+ if (section.tone !== "overlap") continue;
+ for (const item of section.items) {
+ const cleaned = cleanDifferentialItem(item);
+ const slug = titleMap.get(cleaned.toLowerCase());
+ if (slug && slug !== record.slug && routableDiagnosisSlugs.has(slug)) overlapLinks[cleaned] = slug;
+ }
+ }
+
+ const presentation =
+ catalogPresentations.find(
+ (workflow) =>
+ routablePresentationSlugs.has(workflow.id) &&
+ workflow.candidates.some((candidate) => candidate.slug === record.slug),
+ ) ?? null;
+
+ const snapshot = loadDifferentialSnapshot();
+ const governance = deriveGovernanceFromSnapshot(snapshot);
+ return {
+ knownRelatedSlugs,
+ overlapLinks,
+ comparePresentation: presentation ? { slug: presentation.id, title: presentation.title } : null,
+ source: {
+ version: snapshot.governance.version,
+ exportedAt: snapshot.exportedAt,
+ reviewStatus: snapshot.governance.reviewStatus,
+ sourceTitle: snapshot.governance.sourceTitle,
+ sourceStatus: governance.source_status,
+ validationStatus: governance.validation_status,
+ },
+ };
+}
+
export function differentialStaticParams() {
return differentialRecords.map((record) => ({ slug: record.slug }));
}
diff --git a/src/lib/local-server-utils.mjs b/src/lib/local-server-utils.mjs
index 2133081a8..30eae3ada 100644
--- a/src/lib/local-server-utils.mjs
+++ b/src/lib/local-server-utils.mjs
@@ -5,6 +5,15 @@ export const appName = "Clinical KB";
export const projectPortStart = 3100;
export const projectPortEnd = 4599;
+// Ports Next.js refuses to bind ("Bad port: X is reserved for Y" — Chrome's
+// restricted-port list). Worktree paths can hash onto one of these, which
+// would make every dev/playwright server boot fail for that checkout.
+const reservedDevPorts = new Set([3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 10080]);
+
+export function isReservedDevPort(port) {
+ return reservedDevPorts.has(port);
+}
+
export function normalizeProjectRoot(projectRoot, platform = process.platform) {
const pathApi = platform === "win32" ? path.win32 : path.posix;
const resolvedRoot = pathApi.resolve(projectRoot);
@@ -17,7 +26,11 @@ export function projectHash(projectRoot, platform = process.platform) {
export function stableProjectPort(projectRoot, platform = process.platform) {
const offset = projectHash(projectRoot, platform).readUInt32BE(0) % (projectPortEnd - projectPortStart + 1);
- return projectPortStart + offset;
+ let port = projectPortStart + offset;
+ while (isReservedDevPort(port)) {
+ port = port >= projectPortEnd ? projectPortStart : port + 1;
+ }
+ return port;
}
export function localProjectId(projectRoot, platform = process.platform) {
diff --git a/src/lib/saved-registry-storage.ts b/src/lib/saved-registry-storage.ts
index 8e04c328f..a9a6f2d76 100644
--- a/src/lib/saved-registry-storage.ts
+++ b/src/lib/saved-registry-storage.ts
@@ -1,5 +1,6 @@
export const savedServicesStorageKey = "clinical-kb-saved-services";
export const savedFormsStorageKey = "clinical-kb-saved-forms";
+export const savedDifferentialsStorageKey = "clinical-kb-saved-differentials";
export const savedRegistryStorageChangedEvent = "clinical-kb-saved-registry-changed";
export function readSavedRegistrySlugs(key: string): string[] {
@@ -30,7 +31,13 @@ export function subscribeSavedRegistrySlugs(onChange: () => void) {
if (typeof window === "undefined") return () => undefined;
const handleStorage = (event: StorageEvent) => {
- if (event.key === savedServicesStorageKey || event.key === savedFormsStorageKey) onChange();
+ if (
+ event.key === savedServicesStorageKey ||
+ event.key === savedFormsStorageKey ||
+ event.key === savedDifferentialsStorageKey
+ ) {
+ onChange();
+ }
};
const handleCustom = () => onChange();
diff --git a/tests/copy-to-clipboard.test.ts b/tests/copy-to-clipboard.test.ts
new file mode 100644
index 000000000..327c0878d
--- /dev/null
+++ b/tests/copy-to-clipboard.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { copyTextToClipboard } from "@/lib/copy-to-clipboard";
+
+type FakeTextArea = {
+ value: string;
+ setAttribute: () => void;
+ select: () => void;
+ style: Record;
+};
+
+function stubDocument(execCommand: undefined | ((command: string) => boolean)) {
+ const fakeDocument = {
+ createElement: (): FakeTextArea => ({
+ value: "",
+ setAttribute: () => undefined,
+ select: () => undefined,
+ style: {},
+ }),
+ body: {
+ appendChild: () => undefined,
+ removeChild: () => undefined,
+ },
+ execCommand,
+ };
+ vi.stubGlobal("document", fakeDocument);
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("copyTextToClipboard", () => {
+ it("resolves via the async Clipboard API when available", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
+ await expect(copyTextToClipboard("hello")).resolves.toBeUndefined();
+ expect(writeText).toHaveBeenCalledWith("hello");
+ });
+
+ it("falls back to execCommand and resolves when it reports success", async () => {
+ vi.stubGlobal("navigator", {});
+ const execCommand = vi.fn().mockReturnValue(true);
+ stubDocument(execCommand);
+ await expect(copyTextToClipboard("hello")).resolves.toBeUndefined();
+ expect(execCommand).toHaveBeenCalledWith("copy");
+ });
+
+ it("rejects when the clipboard API is blocked and execCommand is unavailable", async () => {
+ vi.stubGlobal("navigator", { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("blocked")) } });
+ stubDocument(undefined);
+ await expect(copyTextToClipboard("hello")).rejects.toThrow("copy command rejected");
+ });
+
+ it("rejects when execCommand reports failure", async () => {
+ vi.stubGlobal("navigator", {});
+ stubDocument(() => false);
+ await expect(copyTextToClipboard("hello")).rejects.toThrow("copy command rejected");
+ });
+});
diff --git a/tests/differential-detail.test.ts b/tests/differential-detail.test.ts
new file mode 100644
index 000000000..9ff523f3c
--- /dev/null
+++ b/tests/differential-detail.test.ts
@@ -0,0 +1,213 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ cleanDifferentialItem,
+ formatDifferentialCopyText,
+ groupCurrentPresentation,
+ isDetailTabId,
+ resolveSafetyFacts,
+ sectionBadgeLabel,
+ visibleSectionItems,
+} from "@/lib/differential-detail";
+import {
+ differentialRecords,
+ getDifferentialDetailContext,
+ getDifferentialRecord,
+ type DifferentialRecord,
+ type DifferentialSection,
+} from "@/lib/differentials";
+
+function buildSection(overrides: Partial = {}): DifferentialSection {
+ return {
+ id: "why-it-fits",
+ title: "Why it fits",
+ summary: "Summary line",
+ items: [],
+ tone: "fit",
+ ...overrides,
+ };
+}
+
+function buildRecord(overrides: Partial = {}): DifferentialRecord {
+ return {
+ slug: "test-diagnosis",
+ title: "Test diagnosis",
+ status: "urgent",
+ subtitle: "Subtitle line",
+ clinicalHinge: "The clinical hinge.",
+ safetySnapshot: { summary: "Safety summary.", tags: ["Sepsis"] },
+ sections: [],
+ related: [],
+ currentPresentation: [],
+ investigations: [],
+ immediateActions: [],
+ ...overrides,
+ };
+}
+
+describe("cleanDifferentialItem", () => {
+ it("strips the lone trailing full stop from short fragments", () => {
+ expect(cleanDifferentialItem("medication toxicity.")).toBe("medication toxicity");
+ });
+
+ it("keeps sentence punctuation and internal periods", () => {
+ const sentence = "Do vitals, BGL, sats. Then attention testing follows in every unwell patient today.";
+ expect(cleanDifferentialItem(sentence)).toBe(sentence);
+ expect(cleanDifferentialItem("e.g.")).toBe("e.g.");
+ });
+
+ it("collapses whitespace", () => {
+ expect(cleanDifferentialItem(" spaced \n out text ")).toBe("spaced out text");
+ });
+});
+
+describe("visibleSectionItems", () => {
+ it("drops duplicates of the summary and clinical hinge case-insensitively", () => {
+ const section = buildSection({
+ summary: "Fluctuating attention.",
+ items: ["Fluctuating attention.", "FLUCTUATING ATTENTION", "The clinical hinge.", "Unique item", "unique item"],
+ });
+ const record = buildRecord({ sections: [section] });
+ expect(visibleSectionItems(section, record)).toEqual(["Unique item"]);
+ });
+
+ it("keeps action sections diagnosis-scoped", () => {
+ const section = buildSection({ id: "immediate-action", tone: "action", items: ["Diagnosis-specific step"] });
+ const record = buildRecord({ sections: [section], immediateActions: ["Step one", "Step two", "Step one"] });
+ expect(visibleSectionItems(section, record)).toEqual(["Diagnosis-specific step"]);
+ });
+
+ it("sources test sections from record.investigations with fallback to section items", () => {
+ const section = buildSection({ id: "investigations", tone: "test", items: ["ECG"] });
+ expect(visibleSectionItems(section, buildRecord({ investigations: ["Blood glucose"] }))).toEqual(["Blood glucose"]);
+ expect(visibleSectionItems(section, buildRecord({ investigations: [] }))).toEqual(["ECG"]);
+ });
+});
+
+describe("sectionBadgeLabel", () => {
+ it("uses cleaned counts with the tone suffix", () => {
+ const section = buildSection({
+ tone: "warning",
+ summary: "Sepsis summary",
+ items: ["Sepsis", "sepsis", "Hypoxia"],
+ });
+ expect(sectionBadgeLabel(section, buildRecord())).toBe("2 possible");
+ });
+
+ it("returns null when nothing remains after cleaning", () => {
+ const section = buildSection({ summary: "Only item", items: ["Only item."] });
+ expect(sectionBadgeLabel(section, buildRecord())).toBeNull();
+ });
+});
+
+describe("resolveSafetyFacts", () => {
+ it("returns the curated quartet for delirium", () => {
+ const delirium = getDifferentialRecord("delirium");
+ expect(delirium).not.toBeNull();
+ const labels = resolveSafetyFacts(delirium!).map((fact) => fact.label);
+ expect(labels).toEqual(["High risk", "Onset", "Course", "Treatable"]);
+ });
+
+ it("derives only honest counts for non-curated records", () => {
+ const record = buildRecord({
+ sections: [buildSection({ id: "must-not-miss", tone: "warning", summary: "Risks", items: ["Sepsis", "Stroke"] })],
+ investigations: ["Blood glucose"],
+ immediateActions: ["Do vitals"],
+ related: [{ id: "other", label: "Other", likelihood: "possible", note: "" }],
+ });
+ const facts = resolveSafetyFacts(record);
+ expect(facts.map((fact) => fact.label)).toEqual([
+ "High-risk causes",
+ "Core tests",
+ "Immediate actions",
+ "Related differentials",
+ ]);
+ expect(facts.map((fact) => fact.value)).toEqual(["2", "1", "1", "1"]);
+ expect(facts.some((fact) => ["Onset", "Course", "Treatable"].includes(fact.label))).toBe(false);
+ });
+});
+
+describe("formatDifferentialCopyText", () => {
+ it("produces a deterministic register ending with the disclaimer", () => {
+ const record = buildRecord({
+ immediateActions: ["One", "Two", "Three", "Four", "Five", "Six", "Seven"],
+ investigations: ["Blood glucose"],
+ });
+ const text = formatDifferentialCopyText(record);
+ expect(text.startsWith("Test diagnosis — Urgent differential")).toBe(true);
+ expect(text).toContain("Clinical hinge: The clinical hinge.");
+ expect(text).toContain("Must-not-miss: Safety summary.");
+ expect(text).toContain("- Six");
+ expect(text).not.toContain("- Seven");
+ expect(text).toContain("- Blood glucose");
+ expect(text.endsWith("Clinical decision support only. Review before use.")).toBe(true);
+ expect(text).not.toContain("undefined");
+ });
+});
+
+describe("groupCurrentPresentation", () => {
+ it("groups strict title/candidates/hinge triplets", () => {
+ const view = groupCurrentPresentation([
+ "Psychomotor Agitation",
+ "Akathisia, Bipolar mania",
+ "CLINICAL HINGE: Inattention separates delirium.",
+ "Perinatal Acute Psychiatry",
+ "Postpartum psychosis",
+ "CLINICAL HINGE: Abrupt change from baseline.",
+ ]);
+ expect(view.kind).toBe("grouped");
+ if (view.kind !== "grouped") return;
+ expect(view.groups).toHaveLength(2);
+ expect(view.groups[0]).toEqual({
+ title: "Psychomotor Agitation",
+ candidates: "Akathisia, Bipolar mania",
+ hinge: "Inattention separates delirium.",
+ });
+ });
+
+ it("falls back to a flat list with per-item hinge detection", () => {
+ const view = groupCurrentPresentation(["Item one", "CLINICAL HINGE: Key separator", "Item two"]);
+ expect(view.kind).toBe("flat");
+ if (view.kind !== "flat") return;
+ expect(view.items).toEqual([
+ { text: "Item one", isHinge: false },
+ { text: "Key separator", isHinge: true },
+ { text: "Item two", isHinge: false },
+ ]);
+ });
+});
+
+describe("isDetailTabId", () => {
+ it("accepts known tabs and rejects everything else", () => {
+ expect(isDetailTabId("map")).toBe(true);
+ expect(isDetailTabId("overview")).toBe(true);
+ expect(isDetailTabId("bogus")).toBe(false);
+ expect(isDetailTabId(null)).toBe(false);
+ });
+});
+
+describe("getDifferentialDetailContext", () => {
+ it("links delirium to the acute confusion comparison workspace", () => {
+ const delirium = getDifferentialRecord("delirium");
+ expect(delirium).not.toBeNull();
+ const context = getDifferentialDetailContext(delirium!);
+ expect(context.comparePresentation?.slug).toBe("acute-confusion-encephalopathy");
+ expect(context.knownRelatedSlugs).toContain("akathisia");
+ expect(context.source.version.length).toBeGreaterThan(0);
+ expect(context.source.sourceStatus).toBe("review_due");
+ });
+
+ it("produces catalog-consistent context for every record", () => {
+ const catalogSlugs = new Set(differentialRecords.map((record) => record.slug));
+ for (const record of differentialRecords) {
+ const context = getDifferentialDetailContext(record);
+ expect(context.comparePresentation, `${record.slug} should belong to a presentation`).not.toBeNull();
+ for (const slug of context.knownRelatedSlugs) {
+ expect(catalogSlugs.has(slug), `related slug ${slug} on ${record.slug}`).toBe(true);
+ }
+ for (const slug of Object.values(context.overlapLinks)) {
+ expect(getDifferentialRecord(slug), `overlap link ${slug} from ${record.slug}`).not.toBeNull();
+ }
+ }
+ });
+});
diff --git a/tests/differentials-route.test.ts b/tests/differentials-route.test.ts
index e5720b3d8..c77cb7380 100644
--- a/tests/differentials-route.test.ts
+++ b/tests/differentials-route.test.ts
@@ -102,12 +102,75 @@ function request(path: string) {
return new Request(`http://localhost${path}`);
}
+function authenticatedRequest(path: string) {
+ return new Request(`http://localhost${path}`, { headers: { Authorization: `Bearer ${token}` } });
+}
+
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});
describe("differentials API routes", () => {
+ it("builds owner diagnosis detail context from the owner's current catalog rows", async () => {
+ const diagnosis = {
+ slug: "owner-diagnosis",
+ title: "Owner diagnosis",
+ related: [{ id: "owner-related" }],
+ sections: [{ tone: "overlap", items: ["Owner related"] }],
+ };
+ const related = {
+ slug: "owner-related",
+ title: "Owner related",
+ related: [],
+ sections: [],
+ };
+ const presentation = {
+ id: "owner-presentation",
+ title: "Owner presentation",
+ candidates: [{ slug: "owner-diagnosis" }],
+ };
+ const row = (kind: "diagnosis" | "presentation", slug: string, payload: unknown) => ({
+ owner_id: userId,
+ kind,
+ slug,
+ payload,
+ source_status: "current",
+ validation_status: "locally_reviewed",
+ last_reviewed_at: null,
+ review_due_at: null,
+ });
+ const diagnosisRow = row("diagnosis", diagnosis.slug, diagnosis);
+ const ownerRows = [
+ diagnosisRow,
+ row("diagnosis", related.slug, related),
+ row("presentation", presentation.id, presentation),
+ ];
+ const client = createSupabaseMock((call) => {
+ if (call.table === "differential_records" && call.maybeSingle) return ok(diagnosisRow);
+ if (call.table === "differential_records") return ok(ownerRows);
+ return ok([]);
+ });
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/differentials/[slug]/route");
+
+ const response = await GET(authenticatedRequest("/api/differentials/owner-diagnosis?kind=diagnosis"), {
+ params: Promise.resolve({ slug: "owner-diagnosis" }),
+ });
+ const payload = (await response.json()) as {
+ detailContext?: {
+ knownRelatedSlugs?: string[];
+ overlapLinks?: Record;
+ comparePresentation?: { slug: string } | null;
+ };
+ };
+
+ expect(response.status).toBe(200);
+ expect(payload.detailContext?.knownRelatedSlugs).toEqual([]);
+ expect(payload.detailContext?.overlapLinks).toEqual({});
+ expect(payload.detailContext?.comparePresentation).toBeNull();
+ });
+
it("serves delirium from snapshot in demo mode", async () => {
const client = createSupabaseMock();
mockRuntime(client, { demoMode: true });
@@ -116,11 +179,17 @@ describe("differentials API routes", () => {
const response = await GET(request("/api/differentials/delirium?kind=diagnosis"), {
params: Promise.resolve({ slug: "delirium" }),
});
- const payload = (await response.json()) as { record?: { slug: string }; demoMode?: boolean };
+ const payload = (await response.json()) as {
+ record?: { slug: string };
+ detailContext?: { comparePresentation?: { slug: string } | null; knownRelatedSlugs?: string[] };
+ demoMode?: boolean;
+ };
expect(response.status).toBe(200);
expect(payload.demoMode).toBe(true);
expect(payload.record?.slug).toBe("delirium");
+ expect(payload.detailContext?.comparePresentation?.slug).toBe("acute-confusion-encephalopathy");
+ expect(payload.detailContext?.knownRelatedSlugs).toContain("akathisia");
expect(client.from).not.toHaveBeenCalled();
});
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index 1f7dcbc37..ef9ccf6e6 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test, type Page } from "playwright/test";
+import { expect, test, type Locator, type Page } from "playwright/test";
import type { Route } from "playwright-core";
import { acuteConfusionPresentationWorkflow } from "../src/lib/differentials";
import { demoAnswer, demoDocuments } from "../src/lib/demo-data";
@@ -1398,3 +1398,180 @@ test.describe("Responsive layout guards", () => {
expect(balance).toBeLessThan(Math.max(tablet?.topGap ?? 0, tablet?.bottomGap ?? 0) * 1.45);
});
});
+
+test.describe("Clinical KB differential diagnosis detail page", () => {
+ test.describe.configure({ timeout: 90_000 });
+
+ async function expectMinTouchTarget(locator: Locator, minSize = 44) {
+ const box = await locator.boundingBox();
+ expect(box).not.toBeNull();
+ if (!box) return;
+ // 2px tolerance mirrors the ui-smoke helper (sub-pixel rounding in scrolled containers).
+ expect(box.height + 2).toBeGreaterThanOrEqual(minSize);
+ expect(box.width + 2).toBeGreaterThanOrEqual(minSize);
+ }
+
+ // The detail route is not in the Playwright runner's warm-up smoke set, so the
+ // first navigation of a run can pay the dev-server compile; mirror the 30s
+ // first-assertion timeout the presentation-page test uses.
+ async function gotoDetailPage(page: Page, path: string) {
+ await gotoLauncher(page, path);
+ await expect(page.getByTestId("differential-detail-page")).toBeVisible({ timeout: 30_000 });
+ }
+
+ test("delirium detail sections expand, copy, and save at desktop", async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 920 });
+ await gotoDetailPage(page, "/differentials/diagnoses/delirium");
+
+ const detailPage = page.getByTestId("differential-detail-page");
+ await expect(detailPage.getByRole("heading", { level: 1, name: "Delirium" })).toBeVisible();
+ await expect(page.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
+
+ // Safety snapshot keeps the curated delirium facts and honest chip labelling.
+ await expect(detailPage.getByRole("heading", { name: "Safety snapshot" })).toBeVisible();
+ await expect(detailPage.getByText("Watch for")).toBeVisible();
+ await expect(detailPage.getByText("Onset", { exact: true })).toBeVisible();
+
+ // Section rows are real disclosures that reveal their items.
+ const mustNotMiss = page.locator("#differential-section-must-not-miss");
+ await expect(mustNotMiss).toHaveJSProperty("open", false);
+ await expectMinTouchTarget(mustNotMiss.locator("summary"));
+ await mustNotMiss.locator("summary").click();
+ await expect(mustNotMiss).toHaveJSProperty("open", true);
+ await expect(mustNotMiss.getByTestId("differential-section-items")).toBeVisible();
+ await expect(
+ mustNotMiss.getByTestId("differential-section-items").getByText("Sepsis", { exact: true }),
+ ).toBeVisible();
+
+ // Expand all / collapse all toggles every expandable section.
+ const expandAll = page.getByTestId("differential-expand-all");
+ await expect(expandAll).toHaveText(/Expand all/);
+ await expandAll.click();
+ const disclosureRows = page.locator('details[data-testid="differential-section-row"]');
+ const openStates = await disclosureRows.evaluateAll((nodes) =>
+ nodes.map((node) => (node as HTMLDetailsElement).open),
+ );
+ expect(openStates.length).toBeGreaterThan(0);
+ expect(openStates.every(Boolean)).toBe(true);
+ await expect(expandAll).toHaveText(/Collapse all/);
+ await expandAll.click();
+ const closedStates = await disclosureRows.evaluateAll((nodes) =>
+ nodes.map((node) => (node as HTMLDetailsElement).open),
+ );
+ expect(closedStates.every((open) => !open)).toBe(true);
+
+ // Copy after review actually copies and reports success.
+ await detailPage.getByRole("button", { name: "Copy after review" }).click();
+ await expect(detailPage.getByRole("button", { name: "Copied" })).toBeVisible();
+
+ // Bookmark persists across a reload via localStorage.
+ await detailPage.getByRole("button", { name: "Save diagnosis" }).click();
+ await expect(detailPage.getByRole("button", { name: "Remove saved diagnosis" })).toBeVisible();
+ await gotoDetailPage(page, "/differentials/diagnoses/delirium");
+ await expect(detailPage.getByRole("button", { name: "Remove saved diagnosis" })).toBeVisible();
+ await page.goto("/favourites", { waitUntil: "domcontentloaded" });
+ await expect(page.getByText("Delirium", { exact: true }).first()).toBeVisible();
+
+ await expectNoPageHorizontalOverflow(page);
+ });
+
+ test("safety snapshot CTA opens and scrolls to must-not-miss on mobile", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await gotoDetailPage(page, "/differentials/diagnoses/delirium");
+
+ const mustNotMiss = page.locator("#differential-section-must-not-miss");
+ await expect(mustNotMiss).toHaveJSProperty("open", false);
+ await page.getByTestId("differential-safety-cta").click();
+ await expect(mustNotMiss).toHaveJSProperty("open", true);
+ await expect(mustNotMiss.getByTestId("differential-section-items")).toBeVisible();
+ await expect(mustNotMiss).toBeInViewport();
+ await expectNoPageHorizontalOverflow(page);
+ });
+
+ test("detail sections toggle on mobile and stay within narrow viewports", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await gotoDetailPage(page, "/differentials/diagnoses/delirium");
+
+ const detailPage = page.getByTestId("differential-detail-page");
+ await expect(detailPage).toBeVisible();
+
+ const whyItFits = page.locator("#differential-section-why-it-fits");
+ await expectMinTouchTarget(whyItFits.locator("summary"));
+ await whyItFits.locator("summary").click();
+ await expect(whyItFits).toHaveJSProperty("open", true);
+ await expect(whyItFits.getByTestId("differential-section-items")).toBeVisible();
+
+ await expectMinTouchTarget(page.getByRole("tab", { name: "Overview" }));
+ await expect(detailPage.getByRole("button", { name: /Compare \(\d+\)/ })).toBeVisible();
+ // The bookmark must stay reachable below lg where TopActions is hidden.
+ const mobileSave = detailPage.getByRole("button", { name: "Save diagnosis" });
+ await expect(mobileSave).toBeVisible();
+ await expectMinTouchTarget(mobileSave);
+ await expectNoPageHorizontalOverflow(page);
+
+ await page.setViewportSize({ width: 320, height: 700 });
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve())));
+ await expectNoPageHorizontalOverflow(page);
+ });
+
+ test("tabs support keyboard navigation and ?tab= deep links", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await gotoDetailPage(page, "/differentials/diagnoses/delirium");
+
+ const overviewTab = page.getByRole("tab", { name: "Overview" });
+ await overviewTab.click();
+ await overviewTab.press("ArrowRight");
+ await expect(page.getByRole("tab", { name: "Compare" })).toHaveAttribute("aria-selected", "true");
+ await expect(page.getByRole("tab", { name: "Compare" })).toBeFocused();
+ await page.keyboard.press("End");
+ await expect(page.getByRole("tab", { name: "Source" })).toHaveAttribute("aria-selected", "true");
+ await expect(page).toHaveURL(/tab=source/);
+ await page.keyboard.press("Home");
+ await expect(page.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
+
+ await gotoDetailPage(page, "/differentials/diagnoses/delirium?tab=compare");
+ await expect(page.getByRole("tab", { name: "Compare" })).toHaveAttribute("aria-selected", "true");
+ await expect(page.getByTestId("differential-compare-open")).toHaveAttribute(
+ "href",
+ "/differentials/presentations/acute-confusion-encephalopathy",
+ );
+ });
+
+ test("compare, related, and source tabs surface real content", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await gotoDetailPage(page, "/differentials/diagnoses/delirium");
+
+ await page.getByRole("tab", { name: "Compare" }).click();
+ await expect(page.getByRole("heading", { name: "Compare with related diagnoses" })).toBeVisible();
+ await expect(page.getByRole("link", { name: /Akathisia/ })).toHaveAttribute(
+ "href",
+ "/differentials/diagnoses/akathisia",
+ );
+ await expect(page.getByRole("button", { name: "Clear" })).toHaveCount(0);
+
+ await page.getByRole("tab", { name: "Related" }).click();
+ await expect(page.getByTestId("differential-related-row").first()).toHaveAttribute(
+ "href",
+ /\/differentials\/diagnoses\//,
+ );
+ await expect(page.getByRole("heading", { name: "Current presentation" })).toBeVisible();
+
+ await page.getByRole("tab", { name: "Source" }).click();
+ await expect(page.getByText("Review due")).toBeVisible();
+ await expect(page.getByText("Locally reviewed")).toBeVisible();
+ await expect(page.getByText("Use clinical judgement and local protocols.")).toBeVisible();
+ await expect(page.getByText(/^Exported \d{4}-\d{2}-\d{2}/)).toBeVisible();
+ });
+
+ test("safety snapshot derives honest facts for non-curated records", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await gotoDetailPage(page, "/differentials/diagnoses/akathisia");
+
+ const detailPage = page.getByTestId("differential-detail-page");
+ await expect(detailPage.getByRole("heading", { name: "Safety snapshot" })).toBeVisible();
+ // Non-curated records must never show fabricated clinical-course facts.
+ await expect(detailPage.getByText("Onset", { exact: true })).toHaveCount(0);
+ await expect(detailPage.getByText("Treatable", { exact: true })).toHaveCount(0);
+ await expectNoPageHorizontalOverflow(page);
+ });
+});