mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(search): widen the clinical read for the in-app search and cover the palette end to end
This commit is contained in:
1 parent
1b5225ba36
commit
a36e414895
5 files changed
+140
-16
No files matched your search
+108
-1
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user