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
9 changes: 7 additions & 2 deletions src/components/therapy-compass/screens/search-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { pageContainer } from "@/components/ui-primitives";

import { useTcBindings } from "../bindings";
import { matchesAvailability, matchesTopics } from "../data/select";
import { hasSearchableTherapyQuery } from "@/lib/therapy-ranking";
import { LoadingState } from "../ui";
import { ResultCard } from "../therapy-card";
import { TherapyReviewNotice } from "../therapy-review-notice";
Expand All @@ -36,6 +37,10 @@ export function SearchScreen() {
const q = b.search.query;
const results = b.searchResults;
const shown = results.slice(0, MAX_CARDS);
// An empty query gives every record the same baseline score and then sorts
// alphabetically, so only call out a best match when the scorer had real
// query evidence to rank. Facets narrow the list but do not rank it.
const hasRankedQuery = hasSearchableTherapyQuery(q);
const topics = useMemo(() => new Set(b.search.tags), [b.search.tags]);
// Filters only — the query is deliberately excluded. It counts toward the
// sheet's Clear all (see below), because `clearSearch` resets it, but the
Expand Down Expand Up @@ -236,8 +241,8 @@ export function SearchScreen() {
/>
) : (
<div className="flex flex-col gap-3.5">
{shown.map((t) => (
<ResultCard key={t.slug} therapy={t} />
{shown.map((t, index) => (
<ResultCard key={t.slug} therapy={t} featured={hasRankedQuery && index === 0} />
))}
</div>
)}
Expand Down
14 changes: 12 additions & 2 deletions src/components/therapy-compass/therapy-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
FileText,
Heart,
Scale,
Sparkles,
Target,
TriangleAlert,
type LucideIcon,
Expand All @@ -27,8 +28,8 @@ import { useTherapyFavourite } from "./use-therapy-favourite";

/**
* Search-result cards spend all three actions as `secondary` so the page keeps
* the one filled `--command` slot (COMPONENTS.md §9.1). Recommend's featured
* top match is that page-level primary, so only `featured` promotes Open.
* the one filled `--command` slot (COMPONENTS.md §9.1). A featured top match
* is that page-level primary, so only `featured` promotes Open.
*/
const cardActionButton = "min-w-0 px-2 text-xs sm:px-4 sm:text-sm-minus";

Expand Down Expand Up @@ -109,6 +110,15 @@ export function ResultCard({
</Button>
<div className="grid grid-cols-1 items-start gap-3 px-4 pt-3.5 md:grid-cols-[minmax(240px,1fr)_minmax(320px,1.35fr)] md:gap-4 md:px-5 md:py-4 md:pr-[calc(1rem+var(--spacing-tap)+0.75rem)]">
<div data-therapy-result-copy className="min-w-0 pr-[calc(var(--spacing-tap)+0.5rem)] md:pr-0">
{featured ? (
<div
data-therapy-result-highlight
className="mb-2 inline-flex items-center gap-1.5 rounded-full border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-2.5 py-1 text-2xs font-extrabold tracking-wide text-[color:var(--clinical-accent)] uppercase forced-colors:border-[CanvasText] forced-colors:text-[CanvasText]"
>
<Sparkles aria-hidden="true" size={13} strokeWidth={2.1} />
Best match
</div>
) : null}
<div className="flex items-start gap-2.5">
{rank != null ? (
<span className="inline-flex h-7 min-w-7 flex-none items-center justify-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-xs font-extrabold nums text-[color:var(--text-heading)]">
Expand Down
5 changes: 5 additions & 0 deletions src/lib/therapy-ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const normalize = (value: string | null | undefined) =>
.replace(/\s+/g, " ")
.trim();

/** True only when the scorer can derive at least one searchable token. */
export function hasSearchableTherapyQuery(query: string | null | undefined): boolean {
return Boolean(normalize(query));
}

/**
* The single Therapy ranking contract used by both the dedicated catalogue and
* universal discovery. Keeping one scorer prevents a search handoff from
Expand Down
3 changes: 2 additions & 1 deletion tests/recommend-screen.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import type { Therapy } from "@/components/therapy-compass/data/types";
Expand Down Expand Up @@ -83,6 +83,7 @@ describe("Recommend screen", () => {
const cards = document.querySelectorAll("[data-therapy-result-card]");
expect(cards).toHaveLength(2);
expect(cards[0]).toHaveAttribute("data-therapy-result-featured");
expect(within(cards[0] as HTMLElement).getByText("Best match")).toBeInTheDocument();
const featuredOpen = cards[0]?.querySelector('[aria-label="Open record"]');
expect(featuredOpen).toHaveClass("bg-[color:var(--command)]");
expect(screen.getAllByText("WHY MATCHED").length).toBeGreaterThan(0);
Expand Down
52 changes: 51 additions & 1 deletion tests/therapy-compass-search-filters.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";

Expand Down Expand Up @@ -50,6 +50,18 @@ vi.mock("@/components/therapy-compass/bindings", () => ({
toggleBriefOnly,
clearSearch,
clearSearchFilters,
isInCompare: () => false,
open: vi.fn(),
openSheet: vi.fn(),
toggleCompare: vi.fn(),
}),
}));

vi.mock("@/components/account-data-provider", () => ({
useAccountData: () => ({
isAuthenticated: false,
isSaved: () => false,
setFavourite: vi.fn(),
}),
}));

Expand Down Expand Up @@ -183,4 +195,42 @@ describe("therapy-compass search filter contract adoption", () => {
await user.click(screen.getByTestId("therapy-filter-trigger-phone"));
expect(screen.queryByTestId("therapy-filter-panel-clear")).not.toBeInTheDocument();
});

it("visually identifies only the first ranked result as the best match", () => {
bindingsState.query = "anxiety";
bindingsState.queryMatches = [therapy()];
bindingsState.searchResults = [
therapy({ slug: "first", name: "First ranked therapy" }),
therapy({ slug: "second", name: "Second ranked therapy" }),
];

render(<SearchScreen />);

const cards = document.querySelectorAll("[data-therapy-result-card]");
expect(cards).toHaveLength(2);
expect(cards[0]).toHaveAttribute("data-therapy-result-featured");
expect(cards[1]).not.toHaveAttribute("data-therapy-result-featured");
expect(within(cards[0] as HTMLElement).getByText("Best match")).toBeInTheDocument();
expect(within(cards[1] as HTMLElement).queryByText("Best match")).not.toBeInTheDocument();
});

it("does not imply a best match when the unqueried catalogue is alphabetical", () => {
bindingsState.searchResults = [therapy({ slug: "first" })];

render(<SearchScreen />);

expect(document.querySelector("[data-therapy-result-featured]")).toBeNull();
expect(screen.queryByText("Best match")).not.toBeInTheDocument();
});

it("does not imply a best match for a punctuation-only query", () => {
bindingsState.query = "!!!";
bindingsState.queryMatches = [therapy({ slug: "first" }), therapy({ slug: "second" })];
bindingsState.searchResults = [therapy({ slug: "first" }), therapy({ slug: "second" })];

render(<SearchScreen />);

expect(document.querySelector("[data-therapy-result-featured]")).toBeNull();
expect(screen.queryByText("Best match")).not.toBeInTheDocument();
});
});
14 changes: 11 additions & 3 deletions tests/ui-route-coverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,13 +327,17 @@ test.describe("previously uncovered production routes", () => {

const card = page.locator("[data-therapy-result-card]").first();
await expect(card).toBeVisible({ timeout: 30_000 });
await expect(card).toHaveAttribute("data-therapy-result-featured", "");
await expect(card.getByText("Best match", { exact: true })).toBeVisible();
await expect(page.locator("[data-therapy-result-highlight]")).toHaveCount(1);

for (const width of [320, 390, 639, 768, 1440, 1920]) {
await page.setViewportSize({ width, height: width < 768 ? 844 : 900 });
await expectNoHorizontalOverflow(page);

const layout = await card.evaluate((element) => {
const bounds = element.getBoundingClientRect();
const borderLeft = Number.parseFloat(getComputedStyle(element).borderLeftWidth);
const copy = element.querySelector<HTMLElement>("[data-therapy-result-copy]")!.getBoundingClientRect();
const evidence = element.querySelector<HTMLElement>("[data-therapy-result-evidence]")!.getBoundingClientRect();
const actions = element.querySelector<HTMLElement>("[data-therapy-result-actions]")!;
Expand All @@ -349,16 +353,20 @@ test.describe("previously uncovered production routes", () => {
});
return {
card: { left: bounds.left, right: bounds.right },
borderLeft,
copyLeft: copy.left,
evidence: { left: evidence.left, right: evidence.right },
buttons,
};
});

if (width < 640) {
expect(Math.abs(layout.evidence.left - layout.card.left), `${width}px evidence left edge`).toBeLessThanOrEqual(
1,
);
// Featured cards carry the intentional 3px best-match accent edge.
// The evidence panel remains full-bleed inside that border.
expect(
Math.abs(layout.evidence.left - (layout.card.left + layout.borderLeft)),
`${width}px evidence left edge`,
).toBeLessThanOrEqual(1);
expect(
Math.abs(layout.card.right - layout.evidence.right),
`${width}px evidence right edge`,
Expand Down
Loading