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
106 changes: 72 additions & 34 deletions src/components/clinical-dashboard/differentials-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,21 @@ function statusLabel(status: DifferentialRecord["status"]) {
}

function statusTone(status: DifferentialRecord["status"]) {
if (status === "emergent")
return "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
if (status === "emergent") return "border-transparent bg-[color:var(--danger)] text-white";
if (status === "urgent") {
return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]";
}
return "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]";
}

function resultTypeTabCounts(results: DifferentialResult[]) {
return {
all: results.length,
presentations: results.filter((result) => result.href.includes("/presentations")).length,
diagnoses: results.filter((result) => result.href.includes("/diagnoses")).length,
};
}

function recordIcon(record: DifferentialRecord) {
return candidateIconBySlug.find(([fragment]) => record.slug.includes(fragment))?.[1] ?? BrainCircuit;
}
Expand Down Expand Up @@ -230,17 +237,75 @@ function buildDifferentialResults(): DifferentialResult[] {
function StatusBadge({ status, className }: { status: DifferentialRecord["status"]; className?: string }) {
return (
<span
data-testid="differential-status-badge"
className={cn(
"inline-flex min-h-5 items-center rounded-md border px-1.5 text-3xs font-extrabold uppercase leading-none tracking-normal",
"inline-flex h-6 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 text-2xs font-extrabold uppercase leading-tight tracking-normal",
statusTone(status),
className,
)}
>
{status === "emergent" ? (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-white/90" aria-hidden />
) : null}
{statusLabel(status)}
</span>
);
}

function ResultTypeTabs({ results }: { results: DifferentialResult[] }) {
const counts = resultTypeTabCounts(results);
const tabs = [
{ key: "all", label: "All", count: counts.all },
{ key: "presentations", label: "Presentations", count: counts.presentations },
{ key: "diagnoses", label: "Diagnoses", count: counts.diagnoses },
] as const;

return (
<div
data-testid="differential-result-type-tabs"
className="polished-scroll flex max-w-full items-center gap-1 overflow-x-auto rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] p-1 shadow-[var(--shadow-inset)]"
>
{tabs.map((tab, index) => {
const active = index === 0;
return (
<button
key={tab.key}
type="button"
aria-pressed={active}
aria-label={`${tab.label} (${tab.count})`}
className={cn(
"inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border px-2.5 text-xs font-bold min-[390px]:text-sm",
active
? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]"
: "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)]",
)}
>
{tab.label}
<span
className={cn(
"nums rounded-full px-1.5 text-3xs leading-tight",
active
? "bg-[color:var(--clinical-accent-contrast)]/15 text-[color:var(--clinical-accent-contrast)]"
: "bg-[color:var(--surface-subtle)] text-[color:var(--text-soft)]",
)}
>
{tab.count}
</span>
</button>
);
})}
<button
type="button"
aria-label="Filters"
className="inline-flex h-10 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 text-xs font-bold text-[color:var(--text-heading)] min-[390px]:text-sm"
>
<ListFilter className="h-4 w-4 shrink-0" aria-hidden />
<span className="hidden min-[430px]:inline">Filters</span>
</button>
</div>
);
}

function MatchBadge({ label }: { label: string }) {
const tone =
label === "Best match"
Expand Down Expand Up @@ -755,39 +820,12 @@ function SearchResultsView({

<div className="grid gap-2 lg:hidden">
<BestAnswerCard best={best} selected={selectedIds.has(best.id)} onToggle={() => toggleSelected(best.id)} />
<div className="grid grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)_auto] gap-1.5">
{[
{ label: "All (8)", compact: "All" },
{ label: "Diagnosis (6)", compact: "Dx (6)" },
{ label: "Mimics (2)", compact: "Mimics" },
].map((item, index) => (
<button
key={item.label}
type="button"
aria-label={item.label}
className={cn(
"min-h-10 min-w-10 rounded-lg border px-2 text-xs font-bold min-[390px]:text-sm",
index === 0
? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]"
: "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)]",
)}
>
<span className="hidden min-[430px]:inline">{item.label}</span>
<span className="min-[430px]:hidden">{item.compact}</span>
</button>
))}
<button
type="button"
aria-label="Filters"
className="inline-flex min-h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2 text-xs font-bold text-[color:var(--text-heading)] min-[390px]:text-sm"
>
<ListFilter className="h-4 w-4" aria-hidden />
<span className="hidden min-[430px]:inline">Filters</span>
</button>
</div>
<ResultTypeTabs results={results} />
<div className="flex items-center justify-between gap-2 text-sm font-medium text-[color:var(--text-muted)]">
<span>
<strong className="text-[color:var(--text-heading)]">8 results</strong> ·{" "}
<strong className="text-[color:var(--text-heading)]">
{results.length} result{results.length === 1 ? "" : "s"}
</strong> ·{" "}
{hasSourceEvidence ? "Ranked by relevance" : "Guided differential view"}
</span>
<button
Expand Down
96 changes: 96 additions & 0 deletions tests/ui-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,102 @@ test.describe("Clinical KB applications launcher", () => {
await expect(page.getByText("Demonstration ranking").first()).toBeVisible();
});

test("differentials search badges stay single-line on narrow viewport", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });

await page.route(/\/api\/setup-status(?:\?.*)?$/, async (route) => {
await route.fulfill({
json: {
demoMode: true,
checks: [
{ id: "env", label: ".env.local configured", status: "ready", detail: "Test environment ready." },
{ id: "project", label: "[REDACTED] target", status: "ready", detail: "Test project ready." },
{ id: "schema", label: "supabase/schema.sql applied", status: "ready", detail: "Test schema ready." },
{ id: "search", label: "Search RPC and vector indexes", status: "ready", detail: "Test search ready." },
{ id: "openai", label: "OpenAI API key available", status: "ready", detail: "Test OpenAI ready." },
],
},
});
});
await page.route(/\/api\/search(?:\?.*)?$/, async (route) => {
await route.fulfill({
json: {
results: [
{
id: "chunk-acute-confusion",
document_id: "11111111-1111-4111-8111-111111111111",
title: "Acute confusion differential guide",
file_name: "acute-confusion-differentials.pdf",
page_number: 1,
chunk_index: 0,
section_heading: "Differentials",
content: "Acute confusion with inattention should prioritise delirium and other urgent causes.",
image_ids: [],
similarity: 0.91,
hybrid_score: 0.93,
images: [],
},
],
visualEvidence: [],
relatedDocuments: [],
documentMatches: [
{
document_id: "11111111-1111-4111-8111-111111111111",
title: "Acute confusion differential guide",
file_name: "acute-confusion-differentials.pdf",
labels: [],
summarySnippet: "Reviewed acute confusion differential guidance.",
bestPages: [1],
bestChunkIds: ["chunk-acute-confusion"],
imageCount: 0,
tableCount: 0,
matchReason: "Matched indexed passage",
score: 0.93,
},
],
relevance: { verdict: "strong", score: 0.91, directSourceCount: 1, weakSourceCount: 0 },
smartPanel: {},
telemetry: { query_class: "differential_compare", retrieval_strategy: "text_fast_path" },
scope: { queryMode: "compare_guidance" },
sourceGovernanceWarnings: [],
demoMode: true,
},
});
});

await gotoLauncher(page, "/differentials");
await page.locator('input[placeholder="Ask or search a presentation"]:visible').first().fill("acute confusion");
await page.locator('button[aria-label="Search differential presentations"]:visible').click();

await expect(page.getByTestId("differentials-search-results")).toBeVisible();
const tabs = page.getByTestId("differential-result-type-tabs");
await expect(tabs).toBeVisible();
await expect(tabs.getByRole("button", { name: "All (8)" })).toBeVisible();
await expect(tabs.getByRole("button", { name: "Presentations (1)" })).toBeVisible();
await expect(tabs.getByRole("button", { name: "Diagnoses (7)" })).toBeVisible();

const tabMetrics = await tabs.locator("button").evaluateAll((buttons) =>
buttons.map((button) => {
const rect = button.getBoundingClientRect();
return { height: rect.height, scrollHeight: button.scrollHeight };
}),
);
for (const tab of tabMetrics) {
expect(tab.scrollHeight).toBeLessThanOrEqual(tab.height + 1);
}

const emergentBadge = page.getByTestId("differential-status-badge").first();
await expect(emergentBadge).toBeVisible();
await expect(emergentBadge).toHaveText(/Emergent/i);
const badgeMetrics = await emergentBadge.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { height: rect.height, scrollHeight: element.scrollHeight };
});
expect(badgeMetrics.height).toBeGreaterThanOrEqual(22);
expect(badgeMetrics.scrollHeight).toBeLessThanOrEqual(badgeMetrics.height + 1);
await expectNoPageHorizontalOverflow(page);
});

test("differentials presentation comparison page stays wired to differentials mode", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 920 });
await gotoLauncher(page, "/differentials/presentations");
Expand Down