Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions docs/branch-review-ledger.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/site-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir
- `/` - Main Clinical KB shell. Source: `src/app/(search-app)/page.tsx`.
- `/calculators` - Route discovered from app directory Source: `src/app/(search-app)/calculators/page.tsx`.
- `/differentials` - Differentials home and search surface. Source: `src/app/(search-app)/differentials/page.tsx`.
- `/differentials/compare` - Compare entry: same-presentation selections redirect into a catalogue workflow; cross-presentation selections render an ad-hoc comparison. Source: `src/app/(search-app)/differentials/compare/page.tsx`.
- `/differentials/compare` - Compare queue: empty state or selected diagnosis ids (Search edit links preserve ids); Open comparison launches a catalogue presentation workflow or an ad-hoc workspace (`workspace=1`). Source: `src/app/(search-app)/differentials/compare/page.tsx`.
- `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/(search-app)/differentials/diagnoses/page.tsx`.
- `/differentials/presentations` - Presentation catalogue stream. Source: `src/app/(search-app)/differentials/presentations/page.tsx`.
- `/differentials/presentations/[slug]` - Presentation comparison workflow. Source: `src/app/(search-app)/differentials/presentations/[slug]/page.tsx`.
Expand Down
2 changes: 1 addition & 1 deletion scripts/generate-site-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const routeDescriptions: Record<string, string> = {
"/applications": "Legacy application launcher redirect to Tools.",
"/differentials": "Differentials home and search surface.",
"/differentials/compare":
"Compare entry: same-presentation selections redirect into a catalogue workflow; cross-presentation selections render an ad-hoc comparison.",
"Compare queue: empty state or selected diagnosis ids (Search edit links preserve ids); Open comparison launches a catalogue presentation workflow or an ad-hoc workspace (`workspace=1`).",
"/differentials/diagnoses": "Diagnosis stream.",
"/differentials/diagnoses/[slug]": "Differential diagnosis detail.",
"/differentials/presentations": "Presentation catalogue stream.",
Expand Down
34 changes: 26 additions & 8 deletions src/app/(search-app)/differentials/compare/page.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";

import { DifferentialCompareQueuePage } from "@/components/differentials/differential-compare-queue-page";
import { DifferentialPresentationWorkflowPage } from "@/components/differentials/differential-presentation-workflow-page";
import { resolveDifferentialCompareHandoff } from "@/lib/differentials";
import {
differentialCompareQueueItems,
resolveDifferentialCompareHandoff,
resolveDifferentialCompareLaunchHref,
} from "@/lib/differentials";

export const metadata: Metadata = {
title: "Compare differentials | Clinical KB",
description: "Compare selected differential diagnoses side by side with safety and bedside context.",
};

type DifferentialCompareRouteProps = {
searchParams?: Promise<{ query?: string | string[]; q?: string | string[]; ids?: string | string[] }>;
searchParams?: Promise<{
query?: string | string[];
q?: string | string[];
ids?: string | string[];
workspace?: string | string[];
}>;
};

function firstSearchParam(value?: string | string[]) {
return Array.isArray(value) ? value[0] : value;
}

/**
* Compare entry page.
*
* Same-presentation selections (and bare/unknown ids) redirect into a catalogue
* presentation workflow. Cross-presentation selections render an ad-hoc compare
* view here so every valid id is preserved. A competing `route.ts` at this path
* is invalid in the App Router — handoff lives in the page instead.
* Compare queue: empty state or selected diagnosis ids. Open comparison launches
* a catalogue presentation workflow, or an ad-hoc workspace (`workspace=1`) when
* selections span presentations.
*/
export default async function DifferentialCompareRoute({ searchParams }: DifferentialCompareRouteProps) {
const resolvedSearchParams = searchParams ? await searchParams : {};
Expand All @@ -32,6 +39,17 @@ export default async function DifferentialCompareRoute({ searchParams }: Differe
.split(",")
.map((value) => value.trim())
.filter(Boolean);
const workspace = firstSearchParam(resolvedSearchParams.workspace)?.trim() === "1";

if (!workspace) {
return (
<DifferentialCompareQueuePage
query={query}
items={differentialCompareQueueItems(selectedIds)}
openComparisonHref={resolveDifferentialCompareLaunchHref(selectedIds, query)}
Comment thread
cursor[bot] marked this conversation as resolved.
/>
);
}

const handoff = resolveDifferentialCompareHandoff(selectedIds, query);
if (handoff.kind === "presentation") {
Expand Down
18 changes: 18 additions & 0 deletions src/components/clinical-dashboard/differentials-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,12 @@ function DesktopResultRow({
>
<span className="line-clamp-1">{result.title}</span>
</Link>
<DesignChip
size="compact"
appearance={{ kind: "information", tone: result.kind === "presentation" ? "accent" : "inset" }}
>
{result.kind === "presentation" ? "Presentation" : "Diagnosis"}
</DesignChip>
<StatusBadge status={result.status} />
</div>
<p className="mt-1 line-clamp-1 text-sm font-medium leading-5 text-[color:var(--text-muted)]">
Expand Down Expand Up @@ -496,6 +502,12 @@ function MobileResultCard({
<span className="line-clamp-2">{result.title}</span>
</Link>
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-2">
<DesignChip
size="compact"
appearance={{ kind: "information", tone: result.kind === "presentation" ? "accent" : "inset" }}
>
{result.kind === "presentation" ? "Presentation" : "Diagnosis"}
</DesignChip>
<StatusBadge status={result.status} />
<MatchBadge label={result.matchLabel} />
</div>
Expand Down Expand Up @@ -576,6 +588,12 @@ function BestAnswerCard({
</Link>
</h2>
<div className="mt-2 flex flex-wrap items-center gap-2">
<DesignChip
size="compact"
appearance={{ kind: "information", tone: best.kind === "presentation" ? "accent" : "inset" }}
>
{best.kind === "presentation" ? "Presentation" : "Diagnosis"}
</DesignChip>
<StatusBadge status={best.status} />
<Link
href={best.href}
Expand Down
145 changes: 145 additions & 0 deletions src/components/differentials/differential-compare-queue-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import Link from "next/link";
import { ArrowLeft, GitCompareArrows, Search, X } from "lucide-react";

import { differentialCompareSearchHref, differentialRouteWithQuery } from "@/lib/differentials-navigation";

export type DifferentialCompareQueueItem = {
slug: string;
title: string;
};

type DifferentialCompareQueuePageProps = {
query?: string;
items: DifferentialCompareQueueItem[];
openComparisonHref: string;
};

/**
* Compare queue (empty or selected diagnosis ids). Server Component so the
* route does not add a client chunk against the repo-wide gzip budget.
*/
export function DifferentialCompareQueuePage({
query = "",
items,
openComparisonHref,
}: DifferentialCompareQueuePageProps) {
const trimmedQuery = query.trim();
const selectedIds = items.map((item) => item.slug);
const editSelectionHref = differentialCompareSearchHref(trimmedQuery, selectedIds);

if (items.length === 0) {
return (
<main
data-testid="differential-compare-empty"
className="min-h-[calc(100dvh-var(--shell-header-h))] bg-[color:var(--background)] px-4 py-10 text-[color:var(--text)] sm:px-6 lg:px-8"
>
<div className="mx-auto grid w-full max-w-3xl gap-6">
<section className="rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] p-5 sm:p-6">
<p className="text-xs font-bold uppercase tracking-eyebrow text-[color:var(--clinical-accent)]">Compare</p>
<h1 className="mt-1 text-3xl font-bold leading-tight text-[color:var(--text-heading)] sm:text-4xl">
Tick diagnoses on Search to build a comparison
</h1>
<p className="mt-3 text-sm leading-7 text-[color:var(--text-muted)]">
The compare queue is empty. Search differentials, select diagnoses, then return here to open a
side-by-side presentation workflow.
</p>
<div className="mt-5 flex flex-wrap gap-2">
<Link
href={differentialCompareSearchHref(trimmedQuery)}
className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent)] px-4 text-sm font-bold text-[color:var(--clinical-accent-contrast)]"
>
<Search className="h-4 w-4" aria-hidden />
{trimmedQuery ? "Back to Search results" : "Open Search"}
</Link>
<Link
href="/differentials/diagnoses"
className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-4 text-sm font-bold text-[color:var(--text-heading)]"
>
Browse diagnoses
</Link>
</div>
</section>
</div>
</main>
);
}

return (
<main
data-testid="differential-compare-queue"
className="min-h-[calc(100dvh-var(--shell-header-h))] bg-[color:var(--background)] px-4 py-10 text-[color:var(--text)] sm:px-6 lg:px-8"
>
<div className="mx-auto grid w-full max-w-3xl gap-6">
<section className="rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-bold uppercase tracking-eyebrow text-[color:var(--clinical-accent)]">
Compare queue
</p>
<h1 className="mt-1 text-3xl font-bold leading-tight text-[color:var(--text-heading)] sm:text-4xl">
{items.length} diagnosis{items.length === 1 ? "" : "es"} selected
</h1>
<p className="mt-3 text-sm leading-7 text-[color:var(--text-muted)]">
Review the queue, remove any diagnosis you do not need, then open the comparison workspace.
</p>
{trimmedQuery ? (
<p className="mt-2 text-sm font-bold text-[color:var(--clinical-accent)]">Query: {trimmedQuery}</p>
) : null}
</div>
<Link
href={editSelectionHref}
className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-sm font-bold text-[color:var(--text-heading)]"
>
<ArrowLeft className="h-4 w-4" aria-hidden />
Search
</Link>
</div>
</section>

<section className="grid gap-2" aria-label="Selected diagnoses">
{items.map((item) => {
const remainingIds = selectedIds.filter((id) => id !== item.slug);
return (
<div
key={item.slug}
className="flex min-h-tap items-center justify-between gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2"
>
<Link
href={`/differentials/diagnoses/${item.slug}`}
className="min-w-0 text-sm font-bold text-[color:var(--text-heading)] hover:text-[color:var(--clinical-accent)]"
>
<span className="line-clamp-2">{item.title}</span>
</Link>
<Link
href={differentialRouteWithQuery("/differentials/compare", trimmedQuery, remainingIds)}
aria-label={`Remove ${item.title} from compare queue`}
className="grid h-tap w-tap shrink-0 place-items-center rounded-md border border-[color:var(--border)] text-[color:var(--text-muted)] hover:border-[color:var(--danger-border)] hover:text-[color:var(--danger)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
>
<X className="h-4 w-4" aria-hidden />
</Link>
</div>
);
})}
</section>

<div className="flex flex-wrap gap-2">
<Link
href={openComparisonHref}
data-testid="differential-compare-open"
className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent)] px-4 text-sm font-bold text-[color:var(--clinical-accent-contrast)]"
>
<GitCompareArrows className="h-4 w-4" aria-hidden />
Open comparison
</Link>
<Link
href={editSelectionHref}
data-testid="differential-compare-edit-selection"
className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-4 text-sm font-bold text-[color:var(--text-heading)]"
>
Edit selection on Search
</Link>
</div>
</div>
</main>
);
}
17 changes: 17 additions & 0 deletions src/lib/differentials-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@
* clinical-dashboard client bundle stays fixture-free.
*/

import { appModeHomeHref } from "@/lib/app-modes";

/** Search home/results href that preserves compare-queue diagnosis ids. */
export function differentialCompareSearchHref(query: string, selectedIds: Iterable<string> = []) {
const trimmedQuery = query.trim();
const base = appModeHomeHref("differentials", {
query: trimmedQuery || undefined,
run: Boolean(trimmedQuery),
focus: true,
});
const ids = Array.from(selectedIds, (id) => id.trim()).filter(Boolean);
if (!ids.length) return base;
const url = new URL(base, "http://differential-compare.local");
url.searchParams.set("ids", ids.join(","));
return `${url.pathname}${url.search}${url.hash}`;
}

export function differentialRouteWithQuery(
path: string,
query: string,
Expand Down
24 changes: 24 additions & 0 deletions src/lib/differentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,30 @@ export function resolveDifferentialCompareHandoff(ids: Iterable<string>, query =
};
}

/**
* Href for the Compare queue "Open comparison" CTA.
* Catalogue-hosted selections open the presentation workflow; cross-presentation
* selections open the ad-hoc workspace on `/differentials/compare`.
*/
export function resolveDifferentialCompareLaunchHref(ids: Iterable<string>, query = ""): string {
const handoff = resolveDifferentialCompareHandoff(ids, query);
if (handoff.kind === "presentation") return handoff.href;
const params = new URLSearchParams();
const trimmedQuery = query.trim();
if (trimmedQuery) params.set("q", trimmedQuery);
if (handoff.selection.diagnosisIds.length) params.set("ids", handoff.selection.diagnosisIds.join(","));
params.set("workspace", "1");
return `/differentials/compare?${params.toString()}`;
}

/** Queue rows for the Compare page (title lookup from the local catalogue). */
export function differentialCompareQueueItems(ids: Iterable<string>): Array<{ slug: string; title: string }> {
return normalizeRequestedDiagnosisIds(ids).map((slug) => ({
slug,
title: getDifferentialRecord(slug)?.title ?? slug.replace(/-/g, " "),
}));
}

export const acuteConfusionPresentationWorkflow: DifferentialPresentationWorkflow =
getPresentationWorkflow("acute-confusion-encephalopathy") ?? differentialPresentations()[0]!;

Expand Down
17 changes: 10 additions & 7 deletions src/lib/mode-secondary-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,9 @@ export function routedModeSecondaryNavigationCount(modeId: AppModeId): number {
export function activeModeSecondaryNavigationId(modeId: AppModeId, pathname: string): string | null {
if (modeId === "differentials") {
if (pathname.startsWith("/differentials/diagnoses")) return "diagnoses";
// Catalogue owns the exact presentations path; workflow slugs are the Compare surface.
if (pathname === "/differentials/presentations" || pathname.startsWith("/differentials/presentations?")) {
return "presentations";
}
if (pathname.startsWith("/differentials/presentations/") || pathname.startsWith("/differentials/compare")) {
// Browse + presentation detail are one Presentations family (symmetric with Diagnoses).
if (pathname.startsWith("/differentials/presentations")) return "presentations";
Comment thread
cursor[bot] marked this conversation as resolved.
if (pathname === "/differentials/compare" || pathname.startsWith("/differentials/compare/")) {
Comment thread
cursor[bot] marked this conversation as resolved.
return "compare";
}
if (pathname === "/differentials" || pathname.startsWith("/differentials?")) return "search";
Expand Down Expand Up @@ -199,7 +197,9 @@ export function isModeSecondaryNavigationRoute(params: {
return (
pathname === "/differentials/diagnoses" ||
pathname === "/differentials/presentations" ||
pathname === "/differentials/compare"
pathname.startsWith("/differentials/presentations/") ||
pathname === "/differentials/compare" ||
pathname.startsWith("/differentials/compare/")
);
}
if (modeId === "dsm") return pathname === "/dsm/search" || pathname === "/dsm/compare";
Expand Down Expand Up @@ -252,7 +252,10 @@ export function modeSecondaryNavigationHref(params: {

if (modeId === "differentials") {
const entries: Array<readonly [string, string]> = query ? [["q", query]] : [];
if (itemId === "search" && currentSearchParams.get("run") === "1") entries.push(["run", "1"]);
// Returning to Search with a carried query must reopen the results view
// (`run=1`), not the empty mode home — even when the previous tab lacked run.
if (itemId === "search" && query) entries.push(["run", "1"]);
else if (itemId === "search" && currentSearchParams.get("run") === "1") entries.push(["run", "1"]);
// Compare (and other in-mode tabs) reuse URL-backed selection so ticks on
// search survive ModeNav handoff without a second client store.
if (currentSearchParams.get("ids")) {
Expand Down
Loading
Loading