diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts index 26d24eac5..b6ba08930 100644 --- a/e2e/command-palette.spec.ts +++ b/e2e/command-palette.spec.ts @@ -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 { + 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$/); + }); }); diff --git a/src/components/command-palette/command-palette.tsx b/src/components/command-palette/command-palette.tsx index bb284242c..04564b64b 100644 --- a/src/components/command-palette/command-palette.tsx +++ b/src/components/command-palette/command-palette.tsx @@ -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", )} > diff --git a/src/components/command-palette/record-hit.tsx b/src/components/command-palette/record-hit.tsx index cc7d769a8..5f2316300 100644 --- a/src/components/command-palette/record-hit.tsx +++ b/src/components/command-palette/record-hit.tsx @@ -82,7 +82,9 @@ export function RecordHitContent({ hit }: { hit: SearchHit }) { {hit.title ? : label} - + {/* Muted reads too faint on the highlighted row's accent fill, so + the highlighted option sets it in the accent's own ink. */} + {hit.title ? label : null} {hit.title && hit.day ? " · " : null} {hit.day ? fmt.dateShortSmartCalendar(hit.day) : null} diff --git a/src/lib/mcp/record-search.ts b/src/lib/mcp/record-search.ts index d41b3efe2..3cbd2d8f7 100644 --- a/src/lib/mcp/record-search.ts +++ b/src/lib/mcp/record-search.ts @@ -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 { 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), diff --git a/src/lib/search/search-records.ts b/src/lib/search/search-records.ts index 6bfecc0dc..6a61f84a1 100644 --- a/src/lib/search/search-records.ts +++ b/src/lib/search/search-records.ts @@ -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(); 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; } });