diff --git a/scripts/build-cross-mode-differentials-index.mjs b/scripts/build-cross-mode-differentials-index.mjs index c2d96f11d..b24299422 100644 --- a/scripts/build-cross-mode-differentials-index.mjs +++ b/scripts/build-cross-mode-differentials-index.mjs @@ -20,26 +20,51 @@ import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const source = join(root, "data", "differentials-snapshot.json"); const target = join(root, "src", "data", "cross-mode-differentials-index.json"); +const presentationDisplaySource = join(root, "src", "data", "differential-presentation-display-metadata.json"); const checkOnly = process.argv.includes("--check"); const snapshot = JSON.parse(readFileSync(source, "utf8")); +const presentationDisplayMetadata = JSON.parse(readFileSync(presentationDisplaySource, "utf8")).presentations; // Bare-number aliases (e.g. a field-weight "1.1" leaked from snapshot template // metadata) would match unrelated records by substring — drop them, mirroring // differentialSearchAliases() in src/lib/differentials.ts. const isBareNumber = (value) => /^\d+(\.\d+)?$/.test(value.trim()); +const distinctTerms = (values) => { + const seen = new Set(); + return values.flatMap((value) => { + const trimmed = value?.trim(); + if (!trimmed) return []; + const key = trimmed.toLocaleLowerCase("en-AU"); + if (seen.has(key)) return []; + seen.add(key); + return [trimmed]; + }); +}; + const catalog = { diagnoses: snapshot.diagnoses.map((diagnosis) => ({ slug: diagnosis.slug, title: diagnosis.title, clinicalHinge: diagnosis.clinicalHinge, })), - presentations: snapshot.presentations.map((presentation) => ({ - id: presentation.id, - title: presentation.title, - subtitle: presentation.subtitle, - })), + presentations: snapshot.presentations.map((presentation) => { + const metadata = presentationDisplayMetadata[presentation.id]; + const titleAliases = metadata + ? distinctTerms([ + presentation.sourceTitle ?? presentation.title, + ...metadata.aliases, + ...(presentation.titleAliases ?? []), + ]) + : presentation.titleAliases; + return { + id: presentation.id, + title: metadata?.title ?? presentation.title, + subtitle: presentation.subtitle, + ...(titleAliases?.length ? { titleAliases } : {}), + }; + }), aliases: Object.fromEntries( Object.entries(snapshot.searchAliases) .map(([token, aliases]) => [token, aliases.filter((alias) => !isBareNumber(alias))]) diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 6b1edb5b3..f9642aca3 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -9,7 +9,6 @@ import { BrainCircuit, Check, ChevronRight, - CircleHelp, FlaskConical, GitCompareArrows, HeartPulse, @@ -74,6 +73,7 @@ type DifferentialResult = { id: string; kind: "presentation" | "diagnosis"; title: string; + scopeLabel?: string; subtitle: string; href: string; status: DifferentialRecord["status"]; @@ -214,11 +214,28 @@ function tagText(value: string) { return cleaned.toLowerCase(); } +function clinicalCueTexts(values: string[]) { + const seen = new Set(); + return values.flatMap((value) => + value + .split(/\s*\/\s*/) + .map(tagText) + .flatMap((cue) => { + if (!cue) return []; + const key = cue.toLocaleLowerCase("en-AU"); + if (seen.has(key)) return []; + seen.add(key); + return [cue]; + }), + ); +} + function toDifferentialResult(item: DifferentialSearchResultItem, query: string): DifferentialResult { return { id: item.id, kind: item.kind, title: item.title, + scopeLabel: item.scopeLabel, subtitle: item.subtitle, // Presentation comparisons now own the shared mode bar. Preserve the // originating search query so its Search tab can return to these results. @@ -227,13 +244,31 @@ function toDifferentialResult(item: DifferentialSearchResultItem, query: string) selected: false, matchLabel: item.matchLabel, tags: item.tags.map(tagText), - clinicalCues: item.clinicalCues.map(tagText), + clinicalCues: clinicalCueTexts(item.clinicalCues), nextSteps: item.nextSteps, icon: resultIcon(item.kind, item.slug), safety: item.safety, }; } +function ResultTypeBadge({ kind }: { kind: DifferentialResult["kind"] }) { + const Icon = kind === "presentation" ? BrainCircuit : Stethoscope; + return ( + + + {kind === "presentation" ? "Presentation" : "Differential"} + + ); +} + function StatusBadge({ status, className }: { status: DifferentialRecord["status"]; className?: string }) { return ( + {children} ); @@ -344,14 +374,14 @@ function DesktopResultRow({ >
{result.title} - - {result.kind === "presentation" ? "Presentation" : "Diagnosis"} - +
+ {result.scopeLabel ? ( +

+ {result.scopeLabel} +

+ ) : null}

{result.subtitle}

@@ -388,45 +418,47 @@ function MobileResultCard({ return (
- - {index + 1} - - - {result.title} -
- - {result.kind === "presentation" ? "Presentation" : "Diagnosis"} - - - -
- {result.subtitle ? ( -

- {result.subtitle} -

- ) : null} - - {onToggle ? : } -
+
+ + {index + 1} + + + {result.title} + + {onToggle ? ( + + ) : ( + + )} +
+
+ + + +
+ {result.scopeLabel ? ( +

{result.scopeLabel}

+ ) : null} + {result.subtitle ? ( +

{result.subtitle}

+ ) : null} +

Clinical cues

- {result.clinicalCues.slice(0, 2).map((tag, cueIndex) => ( + {result.clinicalCues.slice(0, 3).map((tag, cueIndex) => ( {tag} ))} - {result.clinicalCues.length > 2 ? {`+${result.clinicalCues.length - 2}`} : null}
@@ -509,20 +541,18 @@ function BestAnswerCard({ "grid items-start gap-x-2.5 gap-y-3 rounded-lg border shadow-[var(--e1)]", "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]/45", compact - ? "grid-cols-[2rem_minmax(0,1fr)_var(--spacing-tap)] p-3" + ? "grid-cols-[minmax(0,1fr)_var(--spacing-tap)] p-3" : "grid-cols-[2.75rem_4.25rem_minmax(0,1fr)_7rem_var(--spacing-tap)] p-3.5", )} > - - {rank} - + {!compact ? ( + + {rank} + + ) : null} {!compact ? (
+ {compact ? ( + + {rank} + + ) : null}

{best.title}

- - {best.kind === "presentation" ? "Presentation" : "Diagnosis"} - +
+ {best.scopeLabel ? ( +

{best.scopeLabel}

+ ) : null} {!compact ? (
) : null} - {onToggle ? : } + {onToggle ? ( + + ) : compact ? ( + + ) : ( + + )} {compact ? ( -
+
) : null} -
+
@@ -623,101 +665,77 @@ function UrgencyCard({ results }: { results: DifferentialResult[] }) { ); } -function SourceStatusCard({ +function SourceStatusBanner({ sourceCount, evidenceState, loading, sourcesChecked, + hasCatalogueResults, onRunSourceSearch, }: { sourceCount: number; evidenceState: DifferentialEvidenceState; loading: boolean; sourcesChecked: boolean; + hasCatalogueResults: boolean; onRunSourceSearch: () => void; }) { const hasSourceEvidence = evidenceState === "source-backed"; - return ( -
-

- Source status -

- {/* The "0 matches" / "No matches" strings below are NOT a failed-request - guard and must not be converted to ErrorState. They render when - `sourcesChecked` is true - the source search ran and legitimately - returned nothing, which is a real, correct, catalogue-only result. - The failure case is the `sourcesChecked === false` branch, which says - "Evidence pending" / "Not yet checked" and offers the run button. An - ErrorState here would tell a clinician the system broke when it did - not. */} -
-

- - - {hasSourceEvidence ? "Source-backed" : "Guided local differential"} - - - {hasSourceEvidence - ? `${sourceCount.toLocaleString()} sources` - : sourcesChecked - ? "0 matches" - : "Evidence pending"} - -

-

- - - {hasSourceEvidence ? "Imported catalogue" : sourcesChecked ? "Sources checked" : "Run source search"} - - - {hasSourceEvidence - ? `${sourceCount.toLocaleString()} source${sourceCount === 1 ? "" : "s"}` +

+
+ {loading ? ( + + ) : hasSourceEvidence ? ( + + ) : ( + + )} +

+ {loading + ? "Checking indexed sources…" + : hasSourceEvidence + ? `${sourceCount.toLocaleString()} indexed source ${sourceCount === 1 ? "match" : "matches"}` : sourcesChecked - ? "No matches" - : "Not yet checked"} - + ? hasCatalogueResults + ? "No indexed source matches — showing reviewed catalogue results" + : "No indexed source matches" + : hasCatalogueResults + ? "Indexed sources have not been checked — showing reviewed catalogue results" + : "Indexed sources have not been checked"}

-

- {hasSourceEvidence - ? "Catalogue matches are ranked from the imported, locally reviewed differentials library." - : sourcesChecked - ? "No indexed documents matched this query. Showing catalogue-only results." - : "Showing reviewed local differential records. Run source search to validate against indexed documents."} -

- {!hasSourceEvidence && !sourcesChecked ? ( + {!loading && !hasSourceEvidence && !sourcesChecked ? ( ) : null}
); } -function InterpretationRail({ - best, - results, - sourceCount, - evidenceState, - loading, - sourcesChecked, - onRunSourceSearch, -}: { - best: DifferentialResult; - results: DifferentialResult[]; - sourceCount: number; - evidenceState: DifferentialEvidenceState; - loading: boolean; - sourcesChecked: boolean; - onRunSourceSearch: () => void; -}) { +function InterpretationRail({ best, results }: { best: DifferentialResult; results: DifferentialResult[] }) { return ( ); } @@ -803,7 +814,7 @@ function SearchResultsView({ results.filter((result) => matchesKind(result, kind) && matchesUrgency(result, urgencyFilter)).length; return (["all", "presentation", "diagnosis"] as const).map((value) => ({ value, - label: value === "all" ? "All" : value === "presentation" ? "Presentations" : "Diagnoses", + label: value === "all" ? "All" : value === "presentation" ? "Presentations" : "Differentials", hint: String(countFor(value)), })); }, [results, urgencyFilter]); @@ -834,7 +845,7 @@ function SearchResultsView({ appliedFilters.push({ id: "result-type", groupLabel: "Show", - valueLabel: kindFilter === "presentation" ? "Presentations" : "Diagnoses", + valueLabel: kindFilter === "presentation" ? "Presentations" : "Differentials", onRemove: () => setKindFilter("all"), }); } @@ -857,10 +868,10 @@ function SearchResultsView({ // explicit `urgencyFilter !== "all"` narrowing. function filteredEmptyHeading(): string { if (kindFilter !== "all" && urgencyFilter !== "all") { - return `No ${kindFilter === "presentation" ? "presentations" : "diagnoses"} at ${statusLabel(urgencyFilter)} priority in this result set`; + return `No ${kindFilter === "presentation" ? "presentations" : "differentials"} at ${statusLabel(urgencyFilter)} priority in this result set`; } if (kindFilter === "presentation") return "No presentations in this result set"; - if (kindFilter === "diagnosis") return "No diagnoses in this result set"; + if (kindFilter === "diagnosis") return "No differentials in this result set"; if (urgencyFilter !== "all") return `No ${statusLabel(urgencyFilter)} results in this result set`; return "No results match your filters"; } @@ -976,7 +987,7 @@ function SearchResultsView({ href={differentialRouteWithQuery("/differentials/diagnoses", query)} className="inline-flex min-h-tap items-center justify-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-xs font-extrabold text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] sm:min-h-10" > - Browse diagnoses + Browse differentials } @@ -1008,6 +1019,14 @@ function SearchResultsView({ /> } /> + 0} + onRunSourceSearch={rerunSearch} + /> {/* Phone-only by construction: the trigger that opens it lives in the ribbon's `mobileControls` slot, which the band hides from `sm` up. */} - Browse diagnoses + Browse differentials
-
- {!hasSourceEvidence && !sourcesChecked ? ( - - ) : sourcesChecked && !hasSourceEvidence ? ( -

- - No indexed source matches -

- ) : null} -
@@ -1171,59 +1168,6 @@ function SearchResultsView({ selected={selectedIds.has(best.id)} onToggle={best.kind === "diagnosis" ? () => toggleSelected(best.id) : undefined} /> - {hasSourceEvidence ? ( -
- -

- {reviewedSourceCount.toLocaleString()} indexed source{" "} - {reviewedSourceCount === 1 ? "match" : "matches"} -

-
- ) : loading ? ( -
- -

- Checking indexed sources… -

-
- ) : !sourcesChecked ? ( -
-
- -

- Sources not checked -

-
- -
- ) : ( -
- -

- No indexed source matches -

-
- )}
@@ -1303,15 +1247,7 @@ function SearchResultsView({ )} - +
)} diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 5da1483cf..f6e053f94 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -890,7 +890,6 @@ function GlobalStandaloneSearchShellBody({ recentQueries={recentQueries} onPickRecent={pickRecentQuery} onCrossModeSearch={crossModeSearch} - headerVariant={isDifferentialPresentationWorkflow ? "workflow" : "default"} mobileSearchPlacement="bottom" mobileHomeComposerPlacement={mobileHomeComposerPlacement} // Every phone dock is the compact single-row pill so content keeps @@ -1041,9 +1040,7 @@ function GlobalStandaloneSearchShellBody({ void; composerFollowUpSuggestionsDisabled?: boolean; - headerVariant?: "default" | "workflow"; /** Keep the product identity stable while `/` retargets between modes. */ sharedHomeIdentity?: boolean; mobileSearchPlacement?: "default" | "bottom"; @@ -376,7 +371,6 @@ export function MasterSearchHeader({ const selectedAppMode = appModeDefinition(searchMode); const selectedSearchable = isSearchableAppMode(searchMode); const isAnswerFooterComposer = searchMode === "answer"; - const isWorkflowHeader = headerVariant === "workflow"; const isServicesMode = searchMode === "services"; const isMobileBottomComposer = searchComposerVisible && mobileSearchPlacement === "bottom" && !isAnswerFooterComposer; const isHeroDesktopComposer = desktopSearchPlacement === "hero" && isMobileBottomComposer; @@ -2249,24 +2243,12 @@ export function MasterSearchHeader({ {...(hideStrategy === "overlay" ? chromeFocusProps : undefined)} >