feat(search): widen the clinical read for the in-app search and cover the palette end to end

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 12:59:51 +02:00
1 parent 1b5225ba36
commit a36e414895
5 files changed
+140 -16

No files matched your search

+108 -1
View File
@@ -15,7 +15,11 @@ import { expect, test } from "./setup/test";
* - the top bar carries a magnifier at every width, named "Search",
* with a tooltip that gives the shortcut; on a phone it opens a
* full-height sheet with the field focused;
* - the open palette passes axe.
* - the open palette passes axe;
* - a hit from the record search ("In your records", answered by a route
* mock so the shared account's records never move) lists after the local
* results, Enter on it opens its page, and a visit lands on the visits
* tab; on a phone the hit fits the width.
*
* Stable hooks only: `command-palette` (+ `data-variant`),
* `command-palette-input`, `command-palette-trigger`,
@@ -26,6 +30,49 @@ import { expect, test } from "./setup/test";
test.use({ storageState: STORAGE_STATE_PATH });
const palette = (page: Page) => page.locator('[data-slot="command-palette"]');
/** Answer `GET /api/search` with one visit hit, and count the requests. */
async function mockRecordSearch(page: Page): Promise<string[]> {
const queries: string[] = [];
await page.route("**/api/search?*", async (route) => {
queries.push(new URL(route.request().url()).searchParams.get("q") ?? "");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
groups: [
{
kind: "visit",
more: false,
hits: [
{
id: "visit:e2e-visit",
kind: "visit",
title: { text: "Arthroscopy", marks: [] },
context: [
{ text: "Shoulder (left)", marks: [[0, 8]] },
{ text: "A practitioner with a long name", marks: [] },
],
snippet: {
text: "Follow-up for the shoulder after the operation, physiotherapy twice a week",
marks: [[18, 26]],
},
day: "2026-03-02",
path: "/checkups?visit=e2e-visit",
source: null,
},
],
},
],
truncated: false,
},
error: null,
}),
});
});
return queries;
}
const input = (page: Page) =>
page.locator('[data-slot="command-palette-input"]');
@@ -110,6 +157,47 @@ test.describe("command palette", () => {
await expect(input(page)).toHaveValue("timeline");
});
test("a hit from the record opens its page", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
const queries = await mockRecordSearch(page);
// Start on the page the hit opens, on another tab: the link has to move
// the open page, not only a fresh one.
await page.goto("/checkups");
await shellReady(page);
await expect(
page.locator('[data-testid="checkups-tab-visits"]'),
).toHaveAttribute("aria-selected", "false");
await page.keyboard.press("ControlOrMeta+k");
await input(page).fill("shoulder");
const hit = page.locator('[data-entry="record:visit:e2e-visit"]');
await expect(hit).toBeVisible();
// Asked once the typing paused, not once per letter.
expect(queries).toEqual(["shoulder"]);
await expect(
page.locator('[data-group="records"] mark').first(),
).toHaveText("Shoulder");
const axe = await new AxeBuilder({ page })
.include('[data-slot="command-palette"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations).toEqual([]);
// The arrows reach it after the local options, and Enter opens it.
for (let i = 0; i < 40; i++) {
const active = await input(page).getAttribute("aria-activedescendant");
if (active && (await hit.getAttribute("id")) === active) break;
await page.keyboard.press("ArrowDown");
}
await expect(hit).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/checkups\?visit=e2e-visit$/);
await expect(palette(page)).toHaveCount(0);
await expect(
page.locator('[data-testid="checkups-tab-visits"]'),
).toHaveAttribute("aria-selected", "true");
});
test("the top bar: a magnifier on a wide desktop, with the shortcut in its tooltip", async ({
page,
}) => {
@@ -161,4 +249,23 @@ test.describe("command palette", () => {
await input(page).fill("weight");
await expect(page.locator('[data-entry="insights:weight"]')).toBeVisible();
});
test("on a phone: a hit from the record fits the width", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockRecordSearch(page);
await page.goto("/");
await shellReady(page);
await page.locator('[data-slot="command-palette-trigger"]').click();
await input(page).fill("shoulder");
const hit = page.locator('[data-entry="record:visit:e2e-visit"]');
await expect(hit).toBeVisible();
const box = await hit.boundingBox();
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390);
const overflow = await page
.locator('[data-slot="command-palette-results"]')
.evaluate((el) => el.scrollWidth - el.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
await hit.click();
await expect(page).toHaveURL(/\/checkups\?visit=e2e-visit$/);
});
});
@@ -569,7 +569,7 @@ function RecordSection({
}}
onClick={() => onOpen(hit.path)}
className={cn(
"flex min-h-11 cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-sm",
"group flex min-h-11 cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-sm",
selected && "bg-accent text-accent-foreground",
)}
>
@@ -82,7 +82,9 @@ export function RecordHitContent({ hit }: { hit: SearchHit }) {
{hit.title ? <Marked value={hit.title} /> : label}
</span>
<span className="block truncate text-xs">
<span className="text-muted-foreground">
{/* Muted reads too faint on the highlighted row's accent fill, so
the highlighted option sets it in the accent's own ink. */}
<span className="text-muted-foreground group-aria-selected:text-accent-foreground">
{hit.title ? label : null}
{hit.title && hit.day ? " · " : null}
{hit.day ? fmt.dateShortSmartCalendar(hit.day) : null}
+5 -1
View File
@@ -788,6 +788,8 @@ export interface RecordSearchOptions {
* reading. Only the person's own in-app search sets it; MCP never does.
*/
includeAiDeferredText?: boolean;
/** Newest rows read per kind; defaults to `MAX_ROWS_PER_KIND`. */
rowsPerKind?: number;
}
/**
@@ -802,7 +804,9 @@ export async function loadRecordCandidates(
): Promise<SearchCandidate[]> {
const words = queryWords(query);
const empty = normalizeSearchText(query).length === 0;
const take = empty ? EMPTY_QUERY_PER_KIND : MAX_ROWS_PER_KIND;
const take = empty
? EMPTY_QUERY_PER_KIND
: (options.rowsPerKind ?? MAX_ROWS_PER_KIND);
const [kinds, owner] = await Promise.all([
options.kinds ? Promise.resolve(options.kinds) : readableKinds(userId),
loadOwner(userId, options.displayLocale),
+23 -12
View File
@@ -43,11 +43,7 @@ import {
import { normalizeSearchText } from "@/lib/encounters/procedures";
import type { Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
loadRecordCandidates,
MAX_ROWS_PER_KIND,
type RecordKind,
} from "@/lib/mcp/record-search";
import { loadRecordCandidates, type RecordKind } from "@/lib/mcp/record-search";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import type { ShareScope } from "@/lib/sharing/scope";
@@ -83,7 +79,13 @@ import {
/** Rows one batched source reads per round trip. */
export const SCAN_BATCH = 250;
/** Rows one batched source reads at most, per search. */
export const MAX_SCAN_ROWS_PER_SOURCE = 2000;
export const MAX_SCAN_ROWS_PER_SOURCE = 5000;
/**
* Newest rows of each clinical kind (visits, conditions, documents,
* vaccinations) one search reads. Wider than the MCP bound: a person's own
* search is asked about the operation of ten years ago.
*/
export const RECORD_ROWS_PER_KIND = 2000;
/** Wall-clock budget one search shares across its sources. */
export const SEARCH_TIME_BUDGET_MS = 1500;
/** Sources that read at the same time. */
@@ -452,11 +454,19 @@ async function symptomNoteCandidates(
});
}
/** "stairClimber" → "stair climber". */
function sportWords(sportType: string): string {
return sportType
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.toLowerCase();
}
/** The sport's name in the display language, else its words. */
function sportLabel(sportType: string, locale: Locale): string {
const key = `insights.workouts.sport.${sportType}`;
const label = getServerTranslator(locale).t(key);
return label === key ? sportType.toLowerCase().replace(/_/g, " ") : label;
return label === key ? sportWords(sportType) : label;
}
async function workoutCandidates(
@@ -483,7 +493,7 @@ async function workoutCandidates(
text: [
sportLabel(sportType, ctx.display),
sportLabel(sportType, "en"),
sportType.toLowerCase().replace(/_/g, " "),
sportWords(sportType),
].join(" "),
weight: WEIGHT.primary,
},
@@ -512,7 +522,7 @@ async function workoutCandidates(
text: [
label,
sportLabel(row.sportType, "en"),
row.sportType.toLowerCase().replace(/_/g, " "),
sportWords(row.sportType),
].join(" "),
weight: WEIGHT.primary,
},
@@ -866,18 +876,19 @@ export async function searchRecords(
kinds: recordKinds,
displayLocale: args.displayLocale,
includeAiDeferredText: true,
rowsPerKind: RECORD_ROWS_PER_KIND,
},
);
budget.rows += candidates.length;
// Each kind reads its newest `MAX_ROWS_PER_KIND`; a kind that filled
// the read may have older rows the search did not see.
// Each kind reads its newest `RECORD_ROWS_PER_KIND`; a kind that
// filled the read may have older rows the search did not see.
const perKind = new Map<string, number>();
for (const candidate of candidates) {
const kind = candidate.hit?.kind ?? "";
perKind.set(kind, (perKind.get(kind) ?? 0) + 1);
if (keepIfHit(ctx, candidate)) collected.push(candidate);
}
if ([...perKind.values()].some((n) => n >= MAX_ROWS_PER_KIND)) {
if ([...perKind.values()].some((n) => n >= RECORD_ROWS_PER_KIND)) {
budget.truncated = true;
}
});