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
14 changes: 9 additions & 5 deletions src/app/specifiers/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { notFound } from "next/navigation";

import { SpecifierRecordPage } from "@/components/specifiers/specifier-record-page";
import { findSpecifier, specifierRecords } from "@/lib/specifiers";

type SpecifierDetailRouteProps = {
params: Promise<{ slug: string }>;
};

export function generateStaticParams() {
return specifierRecords.map((record) => ({ slug: record.slug }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
export async function generateMetadata({ params }: SpecifierDetailRouteProps): Promise<Metadata> {
const { slug } = await params;
const record = findSpecifier(slug);
if (!record) return {};
if (!record) return { title: "Specifier not found - Clinical KB" };

return {
title: record.name,
title: `${record.name} - Psychiatric specifier - Clinical KB`,
description: record.summary,
};
}

export default async function SpecifierDetailRoute({ params }: { params: Promise<{ slug: string }> }) {
export default async function SpecifierDetailRoute({ params }: SpecifierDetailRouteProps) {
const { slug } = await params;
const record = findSpecifier(slug);
if (!record) notFound();
Expand Down
15 changes: 6 additions & 9 deletions src/app/specifiers/compare/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,14 @@ export async function generateMetadata({ searchParams }: CompareRouteProps): Pro
const params = searchParams ? await searchParams : {};
const left = findSpecifier(first(params.a) ?? "");
const right = findSpecifier(first(params.b) ?? "");

if (left && right) {
return {
title: `Compare ${left.shortName} and ${right.shortName}`,
description: `Side-by-side comparison of ${left.name} and ${right.name} specifiers.`,
};
}
const names = [left?.shortName, right?.shortName].filter(Boolean);

return {
title: "Compare specifiers",
description: "Compare psychiatric specifiers side by side to understand the deciding features.",
title: `${names.length ? `Compare ${names.join(" and ")}` : "Compare psychiatric specifiers"} - Clinical KB`,
description:
left && right
? `Compare ${left.name} and ${right.name} side by side, including clinical signals, timing, and cautions.`
: "Compare psychiatric specifiers side by side, including clinical signals, timing, and cautions.",
};
}

Expand Down
12 changes: 6 additions & 6 deletions src/app/specifiers/map/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ import type { Metadata } from "next";

import { SpecifierMapPage } from "@/components/specifiers/specifier-map-page";

type MapRouteProps = {
searchParams?: Promise<{ selected?: string | string[] }>;
};

export const metadata: Metadata = {
title: "Specifier map",
title: "Psychiatric specifier map - Clinical KB",
description:
"Browse psychiatric specifiers by diagnostic architecture: episode features, course and onset, severity and remission.",
};

type MapRouteProps = {
searchParams?: Promise<{ selected?: string | string[] }>;
};

export default async function SpecifierMapRoute({ searchParams }: MapRouteProps) {
const params = searchParams ? await searchParams : {};
const selected = Array.isArray(params.selected) ? params.selected[0] : params.selected;
return <SpecifierMapPage initialSlug={selected} />;
return <SpecifierMapPage key={selected ?? "default"} initialSlug={selected} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -726,16 +726,16 @@ export function UniversalSearchCommandSurface({
? "favourites"
: modeId === "differentials"
? "differentials"
: modeId === "dsm"
? "dsm"
: modeId === "answer"
? "answer"
: modeId === "tools"
? "tools"
: modeId === "specifiers"
? "specifiers"
: modeId === "formulation"
? "formulation"
: modeId === "specifiers"
? "specifiers"
: modeId === "formulation"
? "formulation"
: modeId === "dsm"
? "dsm"
: modeId === "answer"
? "answer"
: modeId === "tools"
? "tools"
: null;

if (actionSetId) {
Expand Down
29 changes: 10 additions & 19 deletions src/components/specifiers/specifier-builder-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,26 +43,17 @@ function wordingSegment(record: SpecifierRecord) {
}

export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecifiers?: string[] }) {
const normalizedInitial = normalizeSpecifierSelection(initialSpecifiers);

// Determine the best initial diagnosis from the deep-linked specifiers
const inferredDiagnosis =
normalizedInitial.length > 0
? (diagnosisPresets.find((preset) =>
normalizedInitial.every((slug) => {
const record = specifierRecords.find((candidate) => candidate.slug === slug);
return record ? specifierAppliesToBuilderDiagnosis(record, preset.id) : false;
}),
) ?? diagnosisPresets[0])
: diagnosisPresets[0];

const validInitial = normalizedInitial.filter((slug) => {
const record = specifierRecords.find((candidate) => candidate.slug === slug);
return record ? specifierAppliesToBuilderDiagnosis(record, inferredDiagnosis.id) : false;
});
const [diagnosisId, setDiagnosisId] = useState<SpecifierBuilderDiagnosis>(inferredDiagnosis.id);
const validInitial = normalizeSpecifierSelection(initialSpecifiers);
const initialDiagnosis =
diagnosisPresets.find((preset) =>
validInitial.every((slug) => {
const record = specifierRecords.find((candidate) => candidate.slug === slug);
return record ? specifierAppliesToBuilderDiagnosis(record, preset.id) : false;
}),
) ?? diagnosisPresets[0];
const [diagnosisId, setDiagnosisId] = useState<SpecifierBuilderDiagnosis>(initialDiagnosis.id);
const [selected, setSelected] = useState<string[]>(validInitial);
const diagnosis = diagnosisPresets.find((preset) => preset.id === diagnosisId) ?? inferredDiagnosis;
const diagnosis = diagnosisPresets.find((preset) => preset.id === diagnosisId) ?? initialDiagnosis;
const selectedRecords = useMemo(
() =>
selected
Expand Down
19 changes: 1 addition & 18 deletions src/components/specifiers/specifier-map-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,9 @@ import { cn, eyebrowText } from "@/components/ui-primitives";
import { findSpecifier, specifierFamilies, specifierRecords } from "@/lib/specifiers";

export function SpecifierMapPage({ initialSlug }: { initialSlug?: string }) {
// Derive valid initial slug from props
const validInitialSlug = findSpecifier(initialSlug ?? "")?.slug ?? specifierRecords[0].slug;

// Track selected slug with derived initial state pattern (useState with function)
const [state, setState] = useState({ selectedSlug: validInitialSlug, lastInitialSlug: initialSlug });

// Derive state: if initialSlug prop changed, reset to new initial; otherwise keep current selection
const selectedSlug = state.lastInitialSlug !== initialSlug ? validInitialSlug : state.selectedSlug;

// Update state if derived slug differs from stored state
if (selectedSlug !== state.selectedSlug || state.lastInitialSlug !== initialSlug) {
setState({ selectedSlug, lastInitialSlug: initialSlug });
}

const [selectedSlug, setSelectedSlug] = useState(findSpecifier(initialSlug ?? "")?.slug ?? specifierRecords[0].slug);
const selected = findSpecifier(selectedSlug) ?? specifierRecords[0];

const setSelectedSlug = (slug: string) => {
setState({ selectedSlug: slug, lastInitialSlug: initialSlug });
};

return (
<SpecifierPageShell>
<div className="grid gap-3">
Expand Down
10 changes: 5 additions & 5 deletions src/lib/search-command-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,14 @@ const searchCommandSurfaceByMode: Partial<Record<AppModeId, SearchCommandSurface
crossModes: ["documents", "prescribing", "services", "forms", "differentials"],
},
specifiers: {
examples: ["depressed with racing thoughts", "mood episode after birth", "better but not fully recovered"],
examples: ["depressed but racing thoughts", "returns every winter", "much better but not fully recovered"],
suggestions: [
{ text: "with anxious distress", meta: "Episode feature" },
{ text: "with seasonal pattern", meta: "Course and onset" },
{ text: "in partial remission", meta: "Severity and remission" },
{ text: "depressed but racing thoughts", meta: "Episode features" },
{ text: "returns every winter", meta: "Course and onset" },
{ text: "much better but not fully recovered", meta: "Severity and remission" },
],
scopes: [],
crossModes: ["dsm", "differentials", "formulation"],
crossModes: ["dsm", "differentials", "formulation", "documents"],
},
formulation: {
examples: ["avoidance after panic", "rumination after rejection", "dissociation under threat"],
Expand Down
29 changes: 16 additions & 13 deletions tests/ui-specifiers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,16 @@ test("searches clinical language without provenance fields and carries a result

await expect(page).toHaveURL(/\/specifiers\?.*q=depressed(?:\+|%20)but(?:\+|%20)racing(?:\+|%20)thoughts.*run=1/);
await expect(page.getByRole("heading", { name: /Matches for “depressed but racing thoughts”/ })).toBeVisible();
await expect(page.getByText(/Results ranked by text relevance/i)).toBeVisible();
await expect(page.getByText("Top match", { exact: true })).toBeVisible();
await expect(page.getByText("Best fit", { exact: true })).toHaveCount(0);
await expect(page.getByText(/clinical fit/i)).toHaveCount(0);
await expect(page.getByRole("link", { name: "With mixed features", exact: true })).toBeVisible();
await expect(page.getByText("Source status", { exact: true })).toHaveCount(0);
await expect(page.getByText("Source", { exact: true })).toHaveCount(0);

await page.getByRole("link", { name: "Open With mixed features" }).click();
await expect(page).toHaveURL(/\/specifiers\/with-mixed-features$/);
await expect(page).toHaveURL(/\/specifiers\/with-mixed-features$/, { timeout: 30_000 });
await expect(page.getByRole("heading", { name: "With mixed features", exact: true })).toBeVisible();
await expect(page.getByText("What matters now", { exact: true })).toBeVisible();

Expand Down Expand Up @@ -139,22 +143,21 @@ test("blocks incompatible specifiers and preserves severe psychotic-features wor
await expect(rapidCycling).toBeChecked();
});

test("infers the correct diagnosis from a non-MDD deep link", async ({ page }) => {
await gotoApp(page, "/specifiers/builder?specifier=with-rapid-cycling");
test("infers a compatible diagnosis for non-MDD builder deep links", async ({ page }) => {
await gotoApp(page, "/specifiers/builder?specifier=with-rapid-cycling&specifier=with-psychotic-features");

const rapidCycling = page.getByRole("checkbox", { name: /Rapid cycling/ });
await expect(rapidCycling).toBeChecked();
await expect(page.getByRole("combobox", { name: "Diagnostic phrase" })).toHaveValue("bipolar-i-depressed");
await expect(rapidCycling).toBeEnabled();
await expect(rapidCycling).toBeChecked();
await expect(
page.getByText("Bipolar I disorder, current episode depressed, with rapid cycling", { exact: true }),
page.getByText(
"Bipolar I disorder, current episode depressed, severe with psychotic features, with rapid cycling",
{ exact: true },
),
).toBeVisible();
});

test("labels search results using text-relevance language instead of clinical-fit language", async ({ page }) => {
await gotoApp(page, "/specifiers?q=racing+thoughts&run=1");

await expect(page.getByRole("heading", { name: /Matches for “racing thoughts”/ })).toBeVisible();
await expect(page.getByText("Top match", { exact: true })).toBeVisible();
await expect(page.getByText(/Results ranked by text relevance/i)).toBeVisible();
await expect(page.getByText(/clinical fit/i)).toHaveCount(0);
await page.getByRole("combobox", { name: "Diagnostic phrase" }).selectOption("bipolar-i-manic");
await expect(rapidCycling).toBeEnabled();
await expect(rapidCycling).toBeChecked();
});