From 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:54:23 +0800 Subject: [PATCH 01/14] docs: align readiness runtime version --- docs/production-readiness-checklist.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/production-readiness-checklist.md b/docs/production-readiness-checklist.md index e38e98c7f..1596dc639 100644 --- a/docs/production-readiness-checklist.md +++ b/docs/production-readiness-checklist.md @@ -2,9 +2,9 @@ This is the runbook to make the app publishable in one focused pass. -Last reviewed: 2026-07-04. Applies to any feature branch or release candidate. +Last reviewed: 2026-07-10. Applies to any feature branch or release candidate. -- Runtime target: Next.js 16.2.9, Node 24.x, npm 11.x. +- Runtime target: Next.js 16.2.10, Node 24.x, npm 11.x. - Supabase target: `sjrfecxgysukkwxsowpy` (`Clinical KB Database`). ## Immediate completion targets From 4249b3c0598fd552515a07b32493da40168cd20c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:29:19 +0800 Subject: [PATCH 02/14] feat(differentials): add detail-page helpers, server context, shared clipboard util - src/lib/differential-detail.ts: pure client-safe helpers (item cleaning, tone-aware visible items, honest safety facts, copy text, presentation grouping, tab ids) with vitest coverage - getDifferentialDetailContext in src/lib/differentials.ts: server-side catalog lookups (validated related slugs, overlap title links, compare presentation mapping, snapshot governance) passed as a small prop so the client never bundles the snapshot JSON - shared copyTextToClipboard with legacy fallback, adopted by the service detail page and CopyAfterReviewButton - saved-registry storage key for locally saved differentials Co-Authored-By: Claude Fable 5 --- .../differentials/diagnoses/[slug]/page.tsx | 10 +- .../differential-diagnosis-page-client.tsx | 13 +- .../differential-presentation-actions.tsx | 3 +- .../services/service-detail-page.tsx | 28 +-- src/lib/copy-to-clipboard.ts | 28 +++ src/lib/differential-detail.ts | 212 +++++++++++++++++ src/lib/differentials.ts | 54 +++++ src/lib/saved-registry-storage.ts | 9 +- tests/differential-detail.test.ts | 213 ++++++++++++++++++ 9 files changed, 538 insertions(+), 32 deletions(-) create mode 100644 src/lib/copy-to-clipboard.ts create mode 100644 src/lib/differential-detail.ts create mode 100644 tests/differential-detail.test.ts 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/differentials/differential-diagnosis-page-client.tsx b/src/components/differentials/differential-diagnosis-page-client.tsx index 428a69a06..c04be07b0 100644 --- a/src/components/differentials/differential-diagnosis-page-client.tsx +++ b/src/components/differentials/differential-diagnosis-page-client.tsx @@ -2,16 +2,25 @@ 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 { status, record, governance } = useDifferentialRecord(slug); const resolvedRecord = status === "ready" && record ? record : fallbackRecord; - return ; + 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..d3f0fd9e2 --- /dev/null +++ b/src/lib/copy-to-clipboard.ts @@ -0,0 +1,28 @@ +/** 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 { + const copied = document.execCommand?.("copy"); + if (copied === false) 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..05e1216ea --- /dev/null +++ b/src/lib/differential-detail.ts @@ -0,0 +1,212 @@ +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 === "action" && record.immediateActions.length > 0 + ? record.immediateActions + : 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..2a0edc58e 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,58 @@ export function presentationStaticParams() { return differentialPresentations().map((presentation) => ({ slug: presentation.id })); } +let diagnosisTitleToSlug: Map | null = null; +function diagnosisTitleSlugMap() { + if (!diagnosisTitleToSlug) { + diagnosisTitleToSlug = new Map(); + for (const record of differentialRecords) { + const key = cleanDifferentialItem(record.title).toLowerCase(); + if (key && !diagnosisTitleToSlug.has(key)) diagnosisTitleToSlug.set(key, record.slug); + } + } + return diagnosisTitleToSlug; +} + +/** 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): DifferentialDetailContext { + const catalogSlugs = new Set(differentialRecords.map((entry) => entry.slug)); + const knownRelatedSlugs = [...new Set(record.related.map((node) => node.id).filter((id) => catalogSlugs.has(id)))]; + + const overlapLinks: Record = {}; + const titleMap = diagnosisTitleSlugMap(); + 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) overlapLinks[cleaned] = slug; + } + } + + const presentation = + differentialPresentations().find((workflow) => + 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/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/differential-detail.test.ts b/tests/differential-detail.test.ts new file mode 100644 index 000000000..dd86a2257 --- /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("sources action sections from record.immediateActions", () => { + const section = buildSection({ id: "immediate-action", tone: "action", items: ["Truncated copy"] }); + const record = buildRecord({ sections: [section], immediateActions: ["Step one", "Step two", "Step one"] }); + expect(visibleSectionItems(section, record)).toEqual(["Step one", "Step two"]); + }); + + 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(); + } + } + }); +}); From fb2aceb962747867578ee9e1d5b0269a73e8591b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:30:28 +0800 Subject: [PATCH 03/14] feat(differentials): interactive diagnosis detail page - Section rows become controlled native
disclosures revealing the previously unrendered section items, with tone-specific bodies (numbered immediate actions, danger-tinted must-not-miss list, linked mimic chips), cleaned count badges, empty-section static fallback, and expand/collapse all - Safety snapshot: status-aware theming (danger/warning/neutral instead of always red), honest facts (curated for delirium, derived counts elsewhere), "Watch for" chip relabel, and a review-must-not-miss CTA that opens and scrolls to the section - Dead controls wired: Copy after review (real clipboard + Copied feedback), Compare buttons switch to the compare tab, bookmark persists locally with aria-pressed, header "+" starts a new differentials search; dead overflow button removed - Compare tab reworked into an honest panel linking related diagnoses and the matching comparison workspace; Related rows become links with notes; Current presentation renders hinge callouts; Source tab shows real snapshot governance (live values when hydrated) - Tabs gain roving-tabindex arrow-key navigation and ?tab= deep links via history.replaceState; map panel "Open diagnosis" now routes to the selected related node Co-Authored-By: Claude Fable 5 --- .../differentials/diagnosis-map-panel.tsx | 20 +- .../differential-detail-page.tsx | 969 ++++++++++++++---- 2 files changed, 762 insertions(+), 227 deletions(-) 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 6517ebabd..6a232c38f 100644 --- a/src/components/differentials/differential-detail-page.tsx +++ b/src/components/differentials/differential-detail-page.tsx @@ -1,32 +1,58 @@ "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 { + 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 +71,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 +104,392 @@ 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, + container: "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)]/50", + 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)]/50", + 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)]/60", + 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} - - ))} -
+ {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,142 +499,261 @@ 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, onCompare }: { record: DifferentialRecord; onCompare: () => void }) { return (
      - +
      ); } @@ -397,27 +778,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 +805,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 (