diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index fe6ba0627..55f6df77b 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -40,3 +40,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-11 | PR #487 / claude/answer-page-design-polish-ffd5a6 | b2c772606126f8323424bc9c0b636bac77c08789 | open-PR review, unresolved comments, and CI | Two findings fixed: expanded weak/unsupported prior answers retain an explicit source-review warning, and cross-mode search actions no longer log an incorrect detail-open telemetry event. Added a persisted prior-turn browser assertion. No additional high-confidence defect was found in the changed scope. | Focused answer-render and cross-mode Vitest (20/20); TypeScript; focused Prettier; `git diff --check`. Browser assertion delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | | 2026-07-11 | PR #469 / claude/response-formatting-cleanup-b57a9c | f1864308e0b287bb83b2a13daca4c3aa2ab95a3e | open-PR review, unresolved comments, and CI | P2 fixed: the OCR bullet sanitizer now preserves line-start O blood values when followed by blood or red-cell noun tails while still stripping non-blood bullets such as `o Negative screen`. No additional high-confidence defect was found in the nine-file diff. | Focused sanitizer/extractive Vitest (75/75); TypeScript; focused Prettier; `git diff --check`. Production-readiness script ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | | 2026-07-11 | PR #466 / claude/search-timeout-failure-s6aiuj | 54d52292eeb9e1c7856b3dad89d1b72e0d49fd53 | open-PR review, unresolved comments, and CI | P2 fixed: SSE progress/token/error emission now tolerates a client cancellation racing an enqueue, so the catch path cannot throw while reporting the original stream error. No additional high-confidence defect was found in the six-file diff. | Focused SSE and search utility Vitest (13/13); TypeScript; focused Prettier. Hosted advisory browser failure was shared stale assertion drift and is rerun after this push. | +| 2026-07-11 | PR #483 / claude/differentials-page-review-a3daaf | 36cca1bf7c13718dcc60a61b75272c7c4fa5cd44 | open-PR review, unresolved comments, and CI | P2 fixed: authenticated diagnosis detail responses now derive related links, overlap links, and comparison presentation from the owner's current diagnosis and presentation rows rather than the bundled snapshot. Added an owner-only catalog regression test. No additional high-confidence defect was found in the changed scope. | Focused differentials route/catalog Vitest (26/26); TypeScript; focused Prettier; `git diff --check`. Production readiness ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | diff --git a/scripts/dev-free-port.mjs b/scripts/dev-free-port.mjs index 6075bd9fa..6c3bbcf66 100644 --- a/scripts/dev-free-port.mjs +++ b/scripts/dev-free-port.mjs @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { appName, stableProjectPort } from "../src/lib/local-server-utils.mjs"; +import { appName, isReservedDevPort, stableProjectPort } from "../src/lib/local-server-utils.mjs"; if (Number(process.versions.node.split(".")[0]) !== 24) { console.error(`Clinical KB local server requires Node 24.x. Current runtime: ${process.versions.node}.`); @@ -104,6 +104,7 @@ async function canListen(port) { async function findFreePort(preferredPort) { for (let port = preferredPort; port <= maxPort; port += 1) { + if (isReservedDevPort(port)) continue; if (await canListen(port)) return port; } throw new Error(`No free development port found from ${preferredPort} to ${maxPort}.`); diff --git a/scripts/ensure-local-server.mjs b/scripts/ensure-local-server.mjs index 67cd699b3..223cbeb1d 100644 --- a/scripts/ensure-local-server.mjs +++ b/scripts/ensure-local-server.mjs @@ -5,7 +5,13 @@ import http from "node:http"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { appName, localProjectId, projectPortEnd, stableProjectPort } from "../src/lib/local-server-utils.mjs"; +import { + appName, + isReservedDevPort, + localProjectId, + projectPortEnd, + stableProjectPort, +} from "../src/lib/local-server-utils.mjs"; if (Number(process.versions.node.split(".")[0]) !== 24) { console.error(`Clinical KB local server requires Node 24.x. Current runtime: ${process.versions.node}.`); @@ -147,6 +153,7 @@ async function findExistingProjectServer(startPort) { async function findStartPort(startPort) { for (let port = startPort; port <= maxPort; port += 1) { + if (isReservedDevPort(port)) continue; if (await isThisProject(port, 1)) return { port, alreadyRunning: true }; if (!(await isPortBusy(port))) return { port, alreadyRunning: false }; } diff --git a/scripts/run-playwright.mjs b/scripts/run-playwright.mjs index 59941e7e0..7171dbca1 100644 --- a/scripts/run-playwright.mjs +++ b/scripts/run-playwright.mjs @@ -4,7 +4,13 @@ import http from "node:http"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { appName, localProjectId, projectPortEnd, stableProjectPort } from "../src/lib/local-server-utils.mjs"; +import { + appName, + isReservedDevPort, + localProjectId, + projectPortEnd, + stableProjectPort, +} from "../src/lib/local-server-utils.mjs"; if (Number(process.versions.node.split(".")[0]) !== 24) { console.error(`Clinical KB Playwright checks require Node 24.x. Current runtime: ${process.versions.node}.`); @@ -64,6 +70,7 @@ async function canListen(port) { async function findFreePort(startPort) { for (let port = startPort; port <= projectPortEnd; port += 1) { + if (isReservedDevPort(port)) continue; if (await canListen(port)) return port; } throw new Error(`No free Playwright server port found from ${startPort} to ${projectPortEnd}.`); diff --git a/src/app/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts index 6689be3fb..b4bf555c3 100644 --- a/src/app/api/differentials/[slug]/route.ts +++ b/src/app/api/differentials/[slug]/route.ts @@ -15,7 +15,7 @@ import { type DifferentialRecordRow, } from "@/lib/differential-records"; import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed"; -import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; +import { getDifferentialDetailContext, getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; @@ -63,6 +63,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (!record) return notFoundResponse(normalizedSlug); return differentialResponse({ record, + detailContext: getDifferentialDetailContext(record), governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, demoMode: true, }); @@ -84,6 +85,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (!record) return notFoundResponse(normalizedSlug); return differentialResponse({ record, + detailContext: getDifferentialDetailContext(record), governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, publicAccess: true, }); @@ -118,6 +120,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (!record) return notFoundResponse(normalizedSlug); return differentialResponse({ record, + detailContext: getDifferentialDetailContext(record), governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, publicAccess: true, }); @@ -158,7 +161,27 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s return differentialResponse({ workflow: rowToPresentationWorkflow(row), governance: rowGovernance(row) }); } - return differentialResponse({ record: rowToDifferentialRecord(row), governance: rowGovernance(row) }); + // Owner rows can drift from the bundled snapshot, so the catalog-derived + // context ships with the record the client will actually render. + const record = rowToDifferentialRecord(row); + const { data: ownerRowsData, error: ownerRowsError } = await supabase + .from("differential_records") + .select("*") + .eq("owner_id", access.ownerId); + if (ownerRowsError) throw new Error(ownerRowsError.message); + const ownerRows = (ownerRowsData as DifferentialRecordRow[] | null) ?? []; + const ownerRecords = ownerRows.filter((entry) => entry.kind === "diagnosis").map(rowToDifferentialRecord); + const ownerPresentations = ownerRows + .filter((entry) => entry.kind === "presentation") + .map(rowToPresentationWorkflow); + return differentialResponse({ + record, + detailContext: getDifferentialDetailContext(record, { + records: ownerRecords, + presentations: ownerPresentations, + }), + governance: rowGovernance(row), + }); } catch (error) { if (error instanceof AuthenticationError) { return unauthorizedResponse(); diff --git a/src/app/differentials/diagnoses/[slug]/page.tsx b/src/app/differentials/diagnoses/[slug]/page.tsx index 012088d99..32b582a46 100644 --- a/src/app/differentials/diagnoses/[slug]/page.tsx +++ b/src/app/differentials/diagnoses/[slug]/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { DifferentialDiagnosisPageClient } from "@/components/differentials/differential-diagnosis-page-client"; -import { differentialStaticParams, getDifferentialRecord } from "@/lib/differentials"; +import { differentialStaticParams, getDifferentialDetailContext, getDifferentialRecord } from "@/lib/differentials"; type DifferentialDiagnosisRouteProps = { params: Promise<{ slug: string }>; @@ -28,5 +28,11 @@ export default async function DifferentialDiagnosisRoute({ params }: Differentia const record = getDifferentialRecord(slug); if (!record) notFound(); - return ; + return ( + + ); } diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index db6685efa..cd68a125d 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -46,7 +46,8 @@ import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; import { appModeIcons } from "@/lib/app-mode-icons"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form"; +type FavouriteType = + "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form" | "Differential"; type ViewMode = FavouritesViewMode; type SortMode = "last-used" | "title" | "type"; @@ -98,6 +99,8 @@ const typeStyles: Record = { Service: "border-[color:var(--type-service-border)] bg-[color:var(--type-service-soft)] text-[color:var(--type-service)]", Form: "border-[color:var(--type-form-border)] bg-[color:var(--type-form-soft)] text-[color:var(--type-form)]", + Differential: + "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", }; const lastUsedByItemId: Record = { @@ -116,6 +119,7 @@ const typeByPrototypeType: Record sources: "Source", services: "Service", forms: "Form", + differentials: "Differential", }; const fallbackIconByType: Record = { @@ -124,6 +128,7 @@ const fallbackIconByType: Record = { sources: Quote, services: appModeIcons.services, forms: FileText, + differentials: appModeIcons.differentials, }; function lastUsedScore(lastUsed: string): number { diff --git a/src/components/clinical-dashboard/favourites-prototype-data.ts b/src/components/clinical-dashboard/favourites-prototype-data.ts index 50d239aa6..a2f0a8f24 100644 --- a/src/components/clinical-dashboard/favourites-prototype-data.ts +++ b/src/components/clinical-dashboard/favourites-prototype-data.ts @@ -1,7 +1,7 @@ -import { ClipboardList, FileText, Folder, LayoutList, Pill, Quote, Search } from "lucide-react"; +import { BrainCircuit, ClipboardList, FileText, Folder, LayoutList, Pill, Quote, Search } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; -export type FavouriteType = "medications" | "documents" | "sources" | "services" | "forms" | "sets"; +export type FavouriteType = "medications" | "documents" | "sources" | "services" | "forms" | "differentials" | "sets"; export type FavouriteTabId = "all" | FavouriteType; export type FavouriteItem = { @@ -33,6 +33,7 @@ export const favouriteTabs: Array<{ }> = [ { id: "all", label: "All", shortLabel: "All", icon: LayoutList }, { id: "medications", label: "Medications", shortLabel: "Meds", icon: Pill }, + { id: "differentials", label: "Differentials", shortLabel: "Diffs", icon: BrainCircuit }, { id: "documents", label: "Documents", shortLabel: "Docs", icon: FileText }, { id: "sources", label: "Sources", shortLabel: "Sources", icon: Quote }, { id: "services", label: "Services", shortLabel: "Services", icon: appModeIcons.services }, diff --git a/src/components/clinical-dashboard/use-differential-catalog.ts b/src/components/clinical-dashboard/use-differential-catalog.ts index 473fcff8d..f0d757881 100644 --- a/src/components/clinical-dashboard/use-differential-catalog.ts +++ b/src/components/clinical-dashboard/use-differential-catalog.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; +import type { DifferentialDetailContext } from "@/lib/differential-detail"; import type { DifferentialSourceStatus, DifferentialValidationStatus } from "@/lib/differential-records"; import type { DifferentialPresentationWorkflow, DifferentialRecord } from "@/lib/differentials"; import { useAuthSession } from "@/lib/supabase/client"; @@ -29,6 +30,9 @@ export type DifferentialRequestStatus = "loading" | "ready" | "unauthorized" | " export type DifferentialRecordState = { status: DifferentialRequestStatus; record: DifferentialRecord | null; + /** Catalog context computed server-side for the returned record (may lag + * older API deployments, so consumers keep an SSR fallback). */ + detailContext: DifferentialDetailContext | null; demoMode: boolean; governance: DifferentialRecordGovernance | null; }; @@ -118,6 +122,7 @@ export function useDifferentialRecord(slug: string): DifferentialRecordState { const [state, setState] = useState({ status: "loading", record: null, + detailContext: null, demoMode: false, governance: null, }); @@ -130,31 +135,35 @@ export function useDifferentialRecord(slug: string): DifferentialRecordState { if (response.status === 401) { if (authStatus === "loading") return; if (authStatus === "authenticated") markSessionExpired(); - setState({ status: "unauthorized", record: null, demoMode: false, governance: null }); + setState({ status: "unauthorized", record: null, detailContext: null, demoMode: false, governance: null }); return; } if (response.status === 404) { - setState({ status: "not_found", record: null, demoMode: false, governance: null }); + setState({ status: "not_found", record: null, detailContext: null, demoMode: false, governance: null }); return; } if (!response.ok) { - setState({ status: "error", record: null, demoMode: false, governance: null }); + setState({ status: "error", record: null, detailContext: null, demoMode: false, governance: null }); return; } const payload = (await response.json()) as { record?: DifferentialRecord; + detailContext?: DifferentialDetailContext; demoMode?: boolean; governance?: DifferentialRecordGovernance; }; setState({ status: payload.record ? "ready" : "not_found", record: payload.record ?? null, + detailContext: payload.detailContext ?? null, demoMode: Boolean(payload.demoMode), governance: payload.governance ?? null, }); }) .catch(() => { - if (active) setState({ status: "error", record: null, demoMode: false, governance: null }); + if (active) { + setState({ status: "error", record: null, detailContext: null, demoMode: false, governance: null }); + } }); return () => { active = false; diff --git a/src/components/clinical-dashboard/use-saved-registry-favourites.ts b/src/components/clinical-dashboard/use-saved-registry-favourites.ts index 5ac0efb0c..e645be8c3 100644 --- a/src/components/clinical-dashboard/use-saved-registry-favourites.ts +++ b/src/components/clinical-dashboard/use-saved-registry-favourites.ts @@ -1,6 +1,6 @@ "use client"; -import { ClipboardList } from "lucide-react"; +import { BrainCircuit, ClipboardList } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; import { useEffect, useMemo, useState } from "react"; @@ -8,6 +8,7 @@ import type { FavouriteItem } from "@/components/clinical-dashboard/favourites-p import type { ServiceRecord } from "@/lib/services"; import { readSavedRegistrySlugs, + savedDifferentialsStorageKey, savedFormsStorageKey, savedServicesStorageKey, subscribeSavedRegistrySlugs, @@ -32,11 +33,13 @@ function recordToFavourite(record: ServiceRecord, type: "services" | "forms"): F export function useSavedRegistryFavourites(): FavouriteItem[] { const [savedServices, setSavedServices] = useState([]); const [savedForms, setSavedForms] = useState([]); + const [savedDifferentials, setSavedDifferentials] = useState([]); useEffect(() => { const refresh = () => { setSavedServices(readSavedRegistrySlugs(savedServicesStorageKey)); setSavedForms(readSavedRegistrySlugs(savedFormsStorageKey)); + setSavedDifferentials(readSavedRegistrySlugs(savedDifferentialsStorageKey)); }; refresh(); return subscribeSavedRegistrySlugs(refresh); @@ -54,6 +57,22 @@ export function useSavedRegistryFavourites(): FavouriteItem[] { const formItems = forms.records .filter((record) => savedFormSet.has(record.slug)) .map((record) => recordToFavourite(record, "forms")); - return [...serviceItems, ...formItems]; - }, [services.records, forms.records, savedServices, savedForms]); + const differentialItems: FavouriteItem[] = savedDifferentials.map((slug) => ({ + id: `differentials:${slug}`, + title: slug + .split("-") + .filter(Boolean) + .map((word) => word[0]?.toUpperCase() + word.slice(1)) + .join(" "), + type: "differentials", + set: "Saved differentials", + meta: "Saved diagnosis", + sourceMeta: "Differential", + primaryAction: "Open", + href: `/differentials/diagnoses/${encodeURIComponent(slug)}`, + icon: BrainCircuit, + keywords: slug.replaceAll("-", " "), + })); + return [...serviceItems, ...formItems, ...differentialItems]; + }, [services.records, forms.records, savedServices, savedForms, savedDifferentials]); } diff --git a/src/components/differentials/diagnosis-map-panel.tsx b/src/components/differentials/diagnosis-map-panel.tsx index 02ce4c026..b3827e540 100644 --- a/src/components/differentials/diagnosis-map-panel.tsx +++ b/src/components/differentials/diagnosis-map-panel.tsx @@ -285,13 +285,21 @@ function NodeDetails({ selected, added, onAdd, + knownRelatedSlugs, }: { record: DifferentialRecord; selected: SelectedNode; added: boolean; onAdd: () => void; + knownRelatedSlugs?: string[]; }) { const selectedIsDiagnosis = selected === "diagnosis"; + // Open the selected related node's own page when it resolves to a real + // diagnosis; otherwise fall back to (re)opening the current record. + const openHref = + !selectedIsDiagnosis && knownRelatedSlugs?.includes(selected.id) + ? `/differentials/diagnoses/${selected.id}` + : `/differentials/diagnoses/${record.slug}`; const title = nodeLabel(selected, record); const likelihood = selectedIsDiagnosis ? "Most likely" : likelihoodLabels[selected.likelihood]; const details = selectedIsDiagnosis ? record.clinicalHinge : selected.note; @@ -341,7 +349,7 @@ function NodeDetails({ {added ? "In compare" : "Add to compare"} - + Open diagnosis @@ -349,7 +357,13 @@ function NodeDetails({ ); } -export function DiagnosisMapPanel({ record }: { record: DifferentialRecord }) { +export function DiagnosisMapPanel({ + record, + knownRelatedSlugs, +}: { + record: DifferentialRecord; + knownRelatedSlugs?: string[]; +}) { const [open, setOpen] = useState(false); const [selected, setSelected] = useState("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) => ( +
  1. + + {index + 1} + + {item} +
  2. + ))} +
+ ); + } + + 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 (

Related diagnoses

-
    +
      {record.related.map((node) => { const tag = likelihoodTag(node.likelihood); - return ( -
    • - {node.label} - - {tag.label} + const body = ( + <> + + {node.label} + {node.note ? ( + + {node.note} + + ) : null} + + + {tag.label} + + {known.has(node.id) ? ( + + ) : null} + + + ); + return ( +
    • + {known.has(node.id) ? ( + + {body} + + ) : ( +
      + {body} +
      + )}
    • ); })} @@ -237,141 +503,284 @@ function RelatedDiagnoses({ record }: { record: DifferentialRecord }) { } function CurrentPresentation({ record }: { record: DifferentialRecord }) { + const view = groupCurrentPresentation(record.currentPresentation); + const hingeCallout = (text: string) => ( +

      + + + Clinical hinge: {text} + +

      + ); + return (

      Current presentation

      -
        - {record.currentPresentation.map((item) => ( -
      • - - {item} -
      • - ))} -
      + {view.kind === "grouped" ? ( +
      + {view.groups.map((group, index) => ( +
      +

      {group.title}

      +

      {group.candidates}

      + {hingeCallout(group.hinge)} +
      + ))} +
      + ) : ( +
        + {view.items.map((item, index) => + item.isHinge ? ( +
      • {hingeCallout(item.text)}
      • + ) : ( +
      • + + {item.text} +
      • + ), + )} +
      + )}
      ); } -function CompareBasket({ record }: { record: DifferentialRecord }) { - const items = [ - { - id: "self", - label: record.title, - tag: { label: statusLabel(record.status), className: statusTone[record.status] }, - }, - ...record.related - .slice(0, 2) - .map((node) => ({ id: node.id, label: node.label, tag: likelihoodTag(node.likelihood) })), - ]; +function ComparePanel({ + record, + detailContext, +}: { + record: DifferentialRecord; + detailContext: DifferentialDetailContext; +}) { + const known = new Set(detailContext.knownRelatedSlugs); + const compareHref = detailContext.comparePresentation + ? `/differentials/presentations/${detailContext.comparePresentation.slug}` + : "/differentials/presentations"; + const rowClassName = + "flex min-h-12 items-center justify-between gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-xs font-bold text-[color:var(--text-heading)]"; return (
      -
      -

      - Compare basket ({items.length}) -

      - -
      +

      + Compare with related diagnoses +

      +

      + Open a related diagnosis below, or launch the side-by-side comparison workspace for this presentation. +

        - {items.map((item) => ( -
      • - - - {item.label} +
      • + + + {record.title} + + This diagnosis - + + {differentialStatusLabel(record.status)} + +
      • + {record.related.map((node) => { + const tag = likelihoodTag(node.likelihood); + const body = ( + <> + + + {node.label} + + + + {tag.label} + + {known.has(node.id) ? ( + + ) : null} + + + ); + return ( +
      • + {known.has(node.id) ? ( + + {body} + + ) : ( +
        {body}
        )} - > - {item.tag.label} - -
      • - ))} + + ); + })}
      - + Open comparison workspace + + {detailContext.comparePresentation ? ( +

      + Opens “{detailContext.comparePresentation.title}” with this diagnosis in the candidate list. +

      + ) : null}
      ); } -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 (