diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ccc776a9..c5d2e1821 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23728,6 +23728,51 @@ paths: "404": *a69 "422": *a4 "429": *a5 + /api/search: + get: + tags: + - Records + summary: Search the record + description: "One search over the record: visits and procedures, conditions, documents (title, file name and indexed + text), vaccinations, lab results (analyte names and notes), medications, symptoms, workouts by sport, and the + notes on mood entries, readings and illness days. Hits are grouped by kind, the best group first, and each + carries the in-app path that opens the record. Matching ignores case and accents and reads German umlauts typed + as ae, oe, ue or ss. A source whose module the record has switched off is not read. The search reads each source + newest first under a per-source row cap and a time budget; `truncated` says when that cut it short. The query is + never logged. Rate limited per acting account (60 a minute). A grant scoped to some sections of the record does + not open it, like the timeline." + parameters: + - in: query + name: q + schema: + type: string + minLength: 2 + maxLength: 100 + description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue + or ss. + required: true + description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue + or ss. + - in: query + name: limit + schema: + default: 5 + description: Most hits per group. + type: integer + minimum: 1 + maximum: 20 + description: Most hits per group. + responses: + "200": + description: The hits, grouped by kind. + content: + application/json: + schema: + $ref: "#/components/schemas/SearchEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 /api/bugreport: post: security: [] @@ -34447,23 +34492,24 @@ components: profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, profile_claim.taken; record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape, - report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed, - share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid, - share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf, - share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found, - sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, - sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only, - sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, - sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, - sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — - source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — - symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, - symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens — - tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; - vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen, - vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid, - vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found, - vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + report-selection.leaves.unknown; route — route.retired; search — search.invalid; share-link — + share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, + share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, + share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — + sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, + sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, + sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, + sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, + sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, + source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, + symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; + timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached, + tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination — + vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, + vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken, + vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required, + vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, @@ -82317,6 +82363,137 @@ components: - data - error additionalProperties: false + SearchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SearchResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SearchResponse: + type: object + properties: + groups: + type: array + items: + type: object + properties: + kind: + type: string + enum: + - visit + - condition + - document + - vaccination + - lab + - medication + - symptom + - workout + - note + hits: + type: array + items: + $ref: "#/components/schemas/SearchHit" + more: + type: boolean + required: + - kind + - hits + - more + additionalProperties: false + truncated: + type: boolean + description: The search stopped before it had read every entry (a per-kind row cap or the time budget), so older entries + may match and are not listed. + required: + - groups + - truncated + additionalProperties: false + SearchHit: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - visit + - condition + - document + - vaccination + - lab + - medication + - symptom + - workout + - note + title: + anyOf: + - $ref: "#/components/schemas/SearchMarkedText" + - type: "null" + context: + type: array + items: + $ref: "#/components/schemas/SearchMarkedText" + snippet: + anyOf: + - $ref: "#/components/schemas/SearchMarkedText" + - type: "null" + day: + anyOf: + - type: string + - type: "null" + path: + type: string + description: In-app path that opens the record, never an API path. + source: + anyOf: + - type: string + enum: + - mood + - measurement + - illness + - type: "null" + required: + - id + - kind + - title + - context + - snippet + - day + - path + - source + additionalProperties: false + SearchMarkedText: + type: object + properties: + text: + type: string + marks: + type: array + items: + type: array + prefixItems: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Half-open [start, end) character ranges of `text` a query word matched. + required: + - text + - marks + additionalProperties: false __schema0: anyOf: - type: string 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/messages/de.json b/messages/de.json index 1e0b7f437..42e4ed414 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12587,8 +12587,8 @@ }, "palette": { "title": "Suche", - "description": "Finde eine Seite, eine Einstellung, ein Modul oder eine Aktion.", - "placeholder": "Seiten, Einstellungen, Module suchen…", + "description": "Finde eine Seite, eine Einstellung, ein Modul, eine Aktion oder einen Eintrag in deinen Daten.", + "placeholder": "Seiten, Einstellungen, deine Einträge suchen…", "results": "Treffer", "noResults": "Nichts gefunden für „{query}“.", "moduleOn": "An", @@ -12602,7 +12602,8 @@ "insights": "Insights", "settings": "Einstellungen", "modules": "Module", - "actions": "Aktionen" + "actions": "Aktionen", + "records": "In deinen Einträgen" }, "actions": { "capture": "Neuer Eintrag", @@ -12610,6 +12611,28 @@ "backup": "Backup erstellen", "today": "Heute öffnen", "shortcuts": "Tastenkürzel zeigen" + }, + "records": { + "loading": "Deine Einträge werden durchsucht…", + "empty": "Nichts in deinen Einträgen für „{query}“.", + "error": "Deine Einträge konnten nicht durchsucht werden. Versuch es gleich noch einmal.", + "truncated": "Nur die neuesten Einträge wurden durchsucht.", + "kind": { + "visit": "Arztbesuch", + "condition": "Erkrankung", + "document": "Dokument", + "vaccination": "Impfung", + "lab": "Laborwert", + "medication": "Medikament", + "symptom": "Symptom", + "workout": "Training", + "note": "Notiz" + }, + "source": { + "mood": "Stimmungsnotiz", + "measurement": "Notiz zu einem Messwert", + "illness": "Krankheitstagebuch" + } } }, "shortcuts": { diff --git a/messages/en.json b/messages/en.json index b4ade2b97..e275f8e44 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12587,8 +12587,8 @@ }, "palette": { "title": "Search", - "description": "Find a page, a setting, a module or an action.", - "placeholder": "Search pages, settings, modules…", + "description": "Find a page, a setting, a module, an action or an entry in your records.", + "placeholder": "Search pages, settings, your records…", "results": "Results", "noResults": "Nothing found for “{query}”.", "moduleOn": "On", @@ -12602,7 +12602,8 @@ "insights": "Insights", "settings": "Settings", "modules": "Modules", - "actions": "Actions" + "actions": "Actions", + "records": "In your records" }, "actions": { "capture": "New entry", @@ -12610,6 +12611,28 @@ "backup": "Create a backup", "today": "Open today", "shortcuts": "Show keyboard shortcuts" + }, + "records": { + "loading": "Searching your records…", + "empty": "Nothing in your records for “{query}”.", + "error": "Your records could not be searched. Try again in a moment.", + "truncated": "Only the most recent entries were searched.", + "kind": { + "visit": "Visit", + "condition": "Condition", + "document": "Document", + "vaccination": "Vaccination", + "lab": "Lab result", + "medication": "Medication", + "symptom": "Symptom", + "workout": "Workout", + "note": "Note" + }, + "source": { + "mood": "Mood note", + "measurement": "Note on a reading", + "illness": "Illness diary" + } } }, "shortcuts": { diff --git a/messages/es.json b/messages/es.json index 020c42fdb..ee467e577 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12587,8 +12587,8 @@ }, "palette": { "title": "Buscar", - "description": "Encuentra una página, un ajuste, un módulo o una acción.", - "placeholder": "Buscar páginas, ajustes, módulos…", + "description": "Encuentra una página, un ajuste, un módulo, una acción o una entrada de tus registros.", + "placeholder": "Buscar páginas, ajustes, tus registros…", "results": "Resultados", "noResults": "No hay resultados para «{query}».", "moduleOn": "Activado", @@ -12602,7 +12602,8 @@ "insights": "Insights", "settings": "Ajustes", "modules": "Módulos", - "actions": "Acciones" + "actions": "Acciones", + "records": "En tus registros" }, "actions": { "capture": "Nueva entrada", @@ -12610,6 +12611,28 @@ "backup": "Crear una copia de seguridad", "today": "Abrir hoy", "shortcuts": "Mostrar atajos de teclado" + }, + "records": { + "loading": "Buscando en tus registros…", + "empty": "Nada en tus registros para «{query}».", + "error": "No se pudo buscar en tus registros. Inténtalo de nuevo en un momento.", + "truncated": "Solo se buscaron las entradas más recientes.", + "kind": { + "visit": "Consulta", + "condition": "Enfermedad", + "document": "Documento", + "vaccination": "Vacuna", + "lab": "Resultado de laboratorio", + "medication": "Medicamento", + "symptom": "Síntoma", + "workout": "Entrenamiento", + "note": "Nota" + }, + "source": { + "mood": "Nota de ánimo", + "measurement": "Nota de una medición", + "illness": "Diario de enfermedad" + } } }, "shortcuts": { diff --git a/messages/fr.json b/messages/fr.json index 655d07843..0b07d8272 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12587,8 +12587,8 @@ }, "palette": { "title": "Recherche", - "description": "Trouver une page, un réglage, un module ou une action.", - "placeholder": "Rechercher des pages, réglages, modules…", + "description": "Trouver une page, un réglage, un module, une action ou une entrée de votre dossier.", + "placeholder": "Rechercher des pages, réglages, votre dossier…", "results": "Résultats", "noResults": "Aucun résultat pour « {query} ».", "moduleOn": "Activé", @@ -12602,7 +12602,8 @@ "insights": "Insights", "settings": "Paramètres", "modules": "Modules", - "actions": "Actions" + "actions": "Actions", + "records": "Dans votre dossier" }, "actions": { "capture": "Nouvelle saisie", @@ -12610,6 +12611,28 @@ "backup": "Créer une sauvegarde", "today": "Ouvrir aujourd’hui", "shortcuts": "Afficher les raccourcis clavier" + }, + "records": { + "loading": "Recherche dans votre dossier…", + "empty": "Rien dans votre dossier pour « {query} ».", + "error": "Votre dossier n’a pas pu être parcouru. Réessayez dans un instant.", + "truncated": "Seules les entrées les plus récentes ont été parcourues.", + "kind": { + "visit": "Consultation", + "condition": "Maladie", + "document": "Document", + "vaccination": "Vaccination", + "lab": "Résultat d’analyse", + "medication": "Médicament", + "symptom": "Symptôme", + "workout": "Entraînement", + "note": "Note" + }, + "source": { + "mood": "Note d’humeur", + "measurement": "Note sur une mesure", + "illness": "Journal de maladie" + } } }, "shortcuts": { diff --git a/messages/it.json b/messages/it.json index 5cff6c4c9..8de19030c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12587,8 +12587,8 @@ }, "palette": { "title": "Cerca", - "description": "Trova una pagina, un’impostazione, un modulo o un’azione.", - "placeholder": "Cerca pagine, impostazioni, moduli…", + "description": "Trova una pagina, un’impostazione, un modulo, un’azione o una voce dei tuoi dati.", + "placeholder": "Cerca pagine, impostazioni, i tuoi dati…", "results": "Risultati", "noResults": "Nessun risultato per «{query}».", "moduleOn": "Attivo", @@ -12602,7 +12602,8 @@ "insights": "Insights", "settings": "Impostazioni", "modules": "Moduli", - "actions": "Azioni" + "actions": "Azioni", + "records": "Nei tuoi dati" }, "actions": { "capture": "Nuova voce", @@ -12610,6 +12611,28 @@ "backup": "Crea un backup", "today": "Apri oggi", "shortcuts": "Mostra scorciatoie da tastiera" + }, + "records": { + "loading": "Ricerca nei tuoi dati…", + "empty": "Niente nei tuoi dati per «{query}».", + "error": "Non è stato possibile cercare nei tuoi dati. Riprova tra un momento.", + "truncated": "Sono state cercate solo le voci più recenti.", + "kind": { + "visit": "Visita", + "condition": "Malattia", + "document": "Documento", + "vaccination": "Vaccinazione", + "lab": "Esame di laboratorio", + "medication": "Farmaco", + "symptom": "Sintomo", + "workout": "Allenamento", + "note": "Nota" + }, + "source": { + "mood": "Nota sull’umore", + "measurement": "Nota su una misurazione", + "illness": "Diario di malattia" + } } }, "shortcuts": { diff --git a/messages/ko.json b/messages/ko.json index cc0e71c90..a4fc6b89f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12587,8 +12587,8 @@ }, "palette": { "title": "검색", - "description": "페이지, 설정, 모듈 또는 작업을 찾습니다.", - "placeholder": "페이지, 설정, 모듈 검색…", + "description": "페이지, 설정, 모듈, 작업 또는 내 기록의 항목을 찾습니다.", + "placeholder": "페이지, 설정, 내 기록 검색…", "results": "결과", "noResults": "“{query}”에 대한 결과가 없습니다.", "moduleOn": "켜짐", @@ -12602,7 +12602,8 @@ "insights": "인사이트", "settings": "설정", "modules": "모듈", - "actions": "작업" + "actions": "작업", + "records": "내 기록에서" }, "actions": { "capture": "새 항목", @@ -12610,6 +12611,28 @@ "backup": "백업 만들기", "today": "오늘 열기", "shortcuts": "키보드 단축키 보기" + }, + "records": { + "loading": "내 기록을 검색하는 중…", + "empty": "내 기록에서 “{query}”에 대한 결과가 없습니다.", + "error": "내 기록을 검색할 수 없습니다. 잠시 후 다시 시도하세요.", + "truncated": "최근 항목만 검색했습니다.", + "kind": { + "visit": "진료", + "condition": "질환", + "document": "문서", + "vaccination": "예방접종", + "lab": "검사 결과", + "medication": "약", + "symptom": "증상", + "workout": "운동", + "note": "메모" + }, + "source": { + "mood": "기분 메모", + "measurement": "측정값 메모", + "illness": "질병 일지" + } } }, "shortcuts": { diff --git a/messages/pl.json b/messages/pl.json index 0df9ceb8a..9c55a4cc9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12587,8 +12587,8 @@ }, "palette": { "title": "Szukaj", - "description": "Znajdź stronę, ustawienie, moduł lub akcję.", - "placeholder": "Szukaj stron, ustawień, modułów…", + "description": "Znajdź stronę, ustawienie, moduł, akcję lub wpis w swoich danych.", + "placeholder": "Szukaj stron, ustawień, swoich wpisów…", "results": "Wyniki", "noResults": "Brak wyników dla „{query}”.", "moduleOn": "Wł.", @@ -12602,7 +12602,8 @@ "insights": "Insights", "settings": "Ustawienia", "modules": "Moduły", - "actions": "Akcje" + "actions": "Akcje", + "records": "W twoich wpisach" }, "actions": { "capture": "Nowy wpis", @@ -12610,6 +12611,28 @@ "backup": "Utwórz kopię zapasową", "today": "Otwórz dzisiaj", "shortcuts": "Pokaż skróty klawiszowe" + }, + "records": { + "loading": "Przeszukiwanie twoich wpisów…", + "empty": "Brak wyników w twoich wpisach dla „{query}”.", + "error": "Nie udało się przeszukać twoich wpisów. Spróbuj ponownie za chwilę.", + "truncated": "Przeszukano tylko najnowsze wpisy.", + "kind": { + "visit": "Wizyta", + "condition": "Choroba", + "document": "Dokument", + "vaccination": "Szczepienie", + "lab": "Wynik badania", + "medication": "Lek", + "symptom": "Objaw", + "workout": "Trening", + "note": "Notatka" + }, + "source": { + "mood": "Notatka o nastroju", + "measurement": "Notatka do pomiaru", + "illness": "Dziennik choroby" + } } }, "shortcuts": { diff --git a/prisma/migrations/0397_measurements_noted_live_idx/migration.sql b/prisma/migrations/0397_measurements_noted_live_idx/migration.sql new file mode 100644 index 000000000..20711fc33 --- /dev/null +++ b/prisma/migrations/0397_measurements_noted_live_idx/migration.sql @@ -0,0 +1,16 @@ +-- v1.42.2 — the record search reads the notes on readings newest first. +-- +-- A record can hold millions of readings and a handful of notes. Without an +-- index that holds only the noted rows, the search walks the account's whole +-- `(user_id, measured_at)` range to find them. This partial index holds the +-- live rows that carry a note (the encrypted column, or the legacy plaintext +-- one the boot backfill has not migrated yet), so the read is the handful. +-- +-- Additive only, no table rewrite, no data change. Non-concurrent for the +-- reason migration 0243 gives: Prisma Migrate runs a migration file in a +-- transaction, and the entrypoint applies migrations before the app serves +-- traffic. The predicate is tiny, so the build is short. +CREATE INDEX IF NOT EXISTS "measurements_noted_live_idx" + ON "measurements" ("user_id", "measured_at" DESC, "id" DESC) + WHERE "deleted_at" IS NULL + AND ("notes_encrypted" IS NOT NULL OR "notes" IS NOT NULL); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 99eb867c2..9741a7925 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2095,6 +2095,12 @@ model Measurement { // without this one the purge planned a sequential scan and could not finish // inside statement_timeout. Raw SQL only (partial predicate not expressible // in Prisma). + // `measurements_noted_live_idx` is a partial index over `(user_id, + // measured_at DESC, id DESC) WHERE deleted_at IS NULL AND (notes_encrypted + // IS NOT NULL OR notes IS NOT NULL)` — see migration 0397 (v1.42.2). The + // record search reads the notes on readings newest first; without it a + // record of millions of readings with a few notes walks every reading. + // Raw SQL only (partial predicate not expressible in Prisma). @@map("measurements") } diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index b7b768b4b..50035b43b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1045,6 +1045,10 @@ const DELEGABLE_ROUTES: Record = { domain: "record", why: "Counts per timeline lane with an in-app link per gap. Counts only, no record content; scoped to the resolved user and module-gated like the timeline.", }, + "app/api/search/route.ts": { + domain: "record", + why: "One search over the record: hits from every section with an in-app path each. Read-only, scoped to the resolved user, every source narrowed to the record's module switches and to the sections the grant covers, the query never logged.", + }, "app/api/workouts/route.ts": { domain: "measurements", why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", @@ -2094,7 +2098,7 @@ const ACTOR_ROUTES: Record = { * literal (a score's daily course, MANAGE like the derived score itself). * 252 -> 254. */ -const FROZEN_ENTRY_COUNT = 254; +const FROZEN_ENTRY_COUNT = 255; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/search/__tests__/route.test.ts b/src/app/api/search/__tests__/route.test.ts new file mode 100644 index 000000000..82e3ae25f --- /dev/null +++ b/src/app/api/search/__tests__/route.test.ts @@ -0,0 +1,176 @@ +/** + * `GET /api/search` — admission, validation, the rate limit, and what the + * route hands the search and the log. + * + * The search itself is `searchRecords`, covered by its own unit tests and by + * `tests/integration/record-search.test.ts` against Postgres; here it is a + * spy, so the test can assert exactly what the route passes it. + * + * Mutation checks (each run, each seen red): + * - admit on `("read", "profile")` instead of the whole record → "admits + * the whole record only" goes red; + * - drop `.min(SEARCH_QUERY_MIN)` → "refuses a one-letter query" goes red; + * - put the query into the annotation → "never logs the query" goes red; + * - pass `actor.id` as the record → "searches the record the session acts + * for" goes red. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireRecordAuth: vi.fn(), + checkRateLimit: vi.fn(), + searchRecords: vi.fn(), + annotate: vi.fn(), + resolveModuleMap: vi.fn(), + domainVisible: vi.fn(() => true), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (handler: unknown) => handler, + requireRecordAuth: mocks.requireRecordAuth, +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: mocks.annotate, + getEvent: () => undefined, +})); +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit })); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: mocks.resolveModuleMap, +})); +vi.mock("@/lib/sharing/acting-domains", () => ({ + actingDomainVisibility: vi.fn(async () => mocks.domainVisible), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); +vi.mock("@/lib/search/search-records", () => ({ + searchRecords: mocks.searchRecords, +})); + +import { GET } from "../route"; + +const handler = GET as unknown as (req: NextRequest) => Promise; + +function request(query: string): NextRequest { + return new NextRequest(`http://localhost/api/search?${query}`); +} + +const OWNER = { id: "owner-1", locale: "de" }; +const ACTOR = { id: "actor-1", locale: "en" }; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.requireRecordAuth.mockResolvedValue({ + user: OWNER, + actor: ACTOR, + grantId: "grant-1", + }); + mocks.checkRateLimit.mockResolvedValue({ allowed: true }); + mocks.resolveModuleMap.mockResolvedValue({ illness: false }); + mocks.searchRecords.mockResolvedValue({ + groups: [ + { + kind: "visit", + hits: [ + { + id: "visit:v1", + kind: "visit", + title: { text: "Schulter", marks: [[0, 8]] }, + context: [], + snippet: null, + day: "2026-01-02", + path: "/checkups?visit=v1", + source: null, + }, + ], + more: false, + }, + ], + truncated: false, + stats: { sources: 12, rows: 40, candidates: 1, elapsedMs: 3 }, + }); +}); + +describe("GET /api/search", () => { + it("admits the whole record only, at read", async () => { + await handler(request("q=schulter")); + expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "record"); + }); + + it("propagates a refused admission without searching", async () => { + mocks.requireRecordAuth.mockRejectedValueOnce(new Error("denied")); + await expect(handler(request("q=schulter"))).rejects.toThrow("denied"); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("searches the record the session acts for, with its masks", async () => { + const res = await handler(request("q=Schulter&limit=7")); + expect(res.status).toBe(200); + expect(mocks.searchRecords).toHaveBeenCalledTimes(1); + const args = mocks.searchRecords.mock.calls[0][0]; + expect(args.recordId).toBe("owner-1"); + expect(args.query).toBe("Schulter"); + expect(args.limit).toBe(7); + expect(args.modules).toEqual({ illness: false }); + expect(args.domainVisible).toBe(mocks.domainVisible); + expect(args.timezone).toBe("Europe/Berlin"); + // Labels follow the reader, not the record. + expect(args.displayLocale).toBe("en"); + const body = await res.json(); + expect(body.data.groups[0].hits[0].path).toBe("/checkups?visit=v1"); + expect(body.data.truncated).toBe(false); + expect(body.data.stats).toBeUndefined(); + }); + + it("defaults the limit", async () => { + await handler(request("q=knie")); + expect(mocks.searchRecords.mock.calls[0][0].limit).toBe(5); + }); + + it.each([ + ["a one-letter query", "q=a"], + ["a blank query", "q=%20%20%20"], + ["a missing query", "limit=5"], + ["an over-long query", `q=${"x".repeat(101)}`], + ["a zero limit", "q=knie&limit=0"], + ["a limit above the cap", "q=knie&limit=21"], + ["a non-numeric limit", "q=knie&limit=lots"], + ])("refuses %s with 422 and never searches", async (_label, query) => { + const res = await handler(request(query)); + expect(res.status).toBe(422); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("does not echo a refused query", async () => { + const secret = "Psychotherapie".repeat(8); + const res = await handler(request(`q=${secret}`)); + expect(res.status).toBe(422); + expect(await res.text()).not.toContain("Psychotherapie"); + }); + + it("rate limits per actor before reading anything", async () => { + mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false }); + const res = await handler(request("q=schulter")); + expect(res.status).toBe(429); + expect(mocks.checkRateLimit.mock.calls[0][0]).toBe("search:actor-1"); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("never logs the query, only its length and the counts", async () => { + await handler(request("q=Psychotherapie")); + const logged = JSON.stringify(mocks.annotate.mock.calls); + expect(logged).not.toMatch(/psychotherapie/i); + const run = mocks.annotate.mock.calls.find( + ([e]) => e.action.name === "search.query.run", + ); + expect(run?.[0].meta).toMatchObject({ + query_length: 14, + hits: 1, + groups: 1, + truncated: false, + delegated: true, + }); + }); +}); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 000000000..855c9007c --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,101 @@ +/** + * `GET /api/search?q=&limit=` — one search over the record (Discussion #1117): + * visits and procedures, conditions, documents (title and indexed text), + * vaccinations, lab results, medications, symptoms, workouts and the notes on + * mood entries, readings and illness days. Each hit carries the in-app path + * that opens it. The work is `searchRecords` in `src/lib/search/`. + * + * Admission declares the whole record, like the timeline: the search lays + * every section side by side, so a grant scoped to some of them does not open + * it. The sources are narrowed to the grant's sections all the same + * (`actingDomainVisibility`), and to the record's module switches, so a + * switched-off module is not read at all. + * + * The query is health data ("Schulter", a drug, a diagnosis). It never + * reaches a log: the annotation carries its length and the counts only, and a + * refused query is answered without echoing it. + * + * Rate limited per actor: the palette asks once per pause in typing, and a + * manager searching someone's record spends their own allowance. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { annotate } from "@/lib/logging/context"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { searchRecords } from "@/lib/search/search-records"; +import { searchQuerySchema } from "@/lib/search/wire-schemas"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +/** Searches per actor per minute: a few per typed word, with room to spare. */ +const SEARCH_RATE_LIMIT = 60; +const SEARCH_RATE_WINDOW_MS = 60_000; + +function displayLocale(value: string | null | undefined): Locale { + return locales.includes(value as Locale) ? (value as Locale) : defaultLocale; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user, actor, grantId } = await requireRecordAuth("read", "record"); + + const rl = await checkRateLimit( + `search:${actor.id}`, + SEARCH_RATE_LIMIT, + SEARCH_RATE_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many searches. Please retry shortly.", 429); + } + + const params = new URL(request.url).searchParams; + const parsed = searchQuerySchema.safeParse({ + q: params.get("q") ?? undefined, + limit: params.get("limit") ?? undefined, + }); + if (!parsed.success) { + annotate({ + action: { name: "search.query.invalid" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "search.invalid", + }); + } + + const [modules, domainVisible, timezone] = await Promise.all([ + resolveModuleMap(user.id), + actingDomainVisibility(prisma, grantId), + resolveUserTimezone(user.id), + ]); + const result = await searchRecords({ + recordId: user.id, + query: parsed.data.q, + limit: parsed.data.limit, + modules, + domainVisible, + displayLocale: displayLocale(actor.locale), + timezone, + }); + + annotate({ + action: { name: "search.query.run" }, + meta: { + query_length: parsed.data.q.length, + limit: parsed.data.limit, + sources: result.stats.sources, + rows_scanned: result.stats.rows, + matches: result.stats.candidates, + groups: result.groups.length, + hits: result.groups.reduce((n, g) => n + g.hits.length, 0), + truncated: result.truncated, + elapsed_ms: result.stats.elapsedMs, + delegated: grantId !== null, + }, + }); + return apiSuccess({ groups: result.groups, truncated: result.truncated }); +}); diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx index 77a2197cd..e97672d7c 100644 --- a/src/app/checkups/page-client.tsx +++ b/src/app/checkups/page-client.tsx @@ -69,6 +69,14 @@ export default function CheckupsPageClient() { const [view, setView] = useState( linkedVisit ? "visits" : linkedSite ? "body-sites" : "vorsorge", ); + // A link to a visit can arrive while the page is already open (the search + // opens one from wherever the person is): it moves to the visits tab then + // too, rather than only on the first render. + const [observedVisit, setObservedVisit] = useState(linkedVisit); + if (linkedVisit !== observedVisit) { + setObservedVisit(linkedVisit); + if (linkedVisit) setView("visits"); + } const refresh = useCallback( () => diff --git a/src/components/command-palette/__tests__/command-palette.test.tsx b/src/components/command-palette/__tests__/command-palette.test.tsx index cd23ee416..aeeb64b5f 100644 --- a/src/components/command-palette/__tests__/command-palette.test.tsx +++ b/src/components/command-palette/__tests__/command-palette.test.tsx @@ -4,6 +4,7 @@ import { Activity, Moon, Plus, Smile } from "lucide-react"; import { I18nProvider } from "@/lib/i18n/context"; import type { PaletteEntry } from "@/lib/command-palette/build-index"; +import type { SearchResponse } from "@/lib/search/contract"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), @@ -69,6 +70,18 @@ vi.mock("../use-palette-index", () => ({ usePaletteIndex: () => ENTRIES, })); +const recordSearch = vi.hoisted(() => ({ + state: { + query: null as string | null, + data: undefined as SearchResponse | undefined, + loading: false, + error: false, + }, +})); +vi.mock("../use-record-search", () => ({ + useRecordSearch: () => recordSearch.state, +})); + import { PaletteBody, arrangeResults } from "../command-palette"; function render(isMobile = false) { @@ -122,7 +135,9 @@ describe("", () => { const html = render(); expect(html).toContain('role="group"'); expect(html).toContain('aria-label="Search"'); - expect(html).toContain('placeholder="Search pages, settings, modules…"'); + expect(html).toContain( + 'placeholder="Search pages, settings, your records…"', + ); expect(html).toContain(">Actions<"); }); @@ -133,3 +148,104 @@ describe("", () => { expect(phone).toContain(">Cancel<"); }); }); + +const HITS: SearchResponse = { + groups: [ + { + kind: "visit", + more: false, + hits: [ + { + id: "visit:v1", + kind: "visit", + title: { text: "Arthroskopie", marks: [] }, + context: [{ text: "Schulter (links)", marks: [[0, 8]] }], + snippet: null, + day: "2026-03-02", + path: "/checkups?visit=v1", + source: null, + }, + ], + }, + { + kind: "note", + more: false, + hits: [ + { + id: "moodNote:m1", + kind: "note", + title: null, + context: [], + snippet: { text: "Die SCHULTER zwickt", marks: [[7, 15]] }, + day: "2026-03-05", + path: "/mood?day=2026-03-05", + source: "mood", + }, + ], + }, + ], + truncated: true, +}; + +describe(" in your records", () => { + it("lists the record hits after the local results as options", () => { + recordSearch.state = { + query: "schulter", + data: HITS, + loading: false, + error: false, + }; + const html = render(); + expect(html).toContain('data-group="records"'); + expect(html).toContain(">In your records<"); + expect(html).toContain('data-entry="record:visit:v1"'); + expect(html).toContain('data-entry="record:moodNote:m1"'); + // After every local option. + expect(html.indexOf('data-entry="action:today"')).toBeLessThan( + html.indexOf('data-entry="record:visit:v1"'), + ); + // The matched part is marked, as text. + expect(html).toContain("Schulter (links)"); + // A note without a name is called by what it was written on. + expect(html).toContain("Mood note"); + // Record text is never parsed as markup. + expect(html).not.toContain(""); + expect(html).toContain("<b>"); + expect(html).toContain("Only the most recent entries were searched."); + }); + + it("says when it is searching, and when nothing matched", () => { + recordSearch.state = { + query: "schulter", + data: undefined, + loading: true, + error: false, + }; + expect(render()).toContain("Searching your records…"); + recordSearch.state = { + query: "schulter", + data: { groups: [], truncated: false }, + loading: false, + error: false, + }; + expect(render()).toContain("Nothing in your records for “schulter”."); + recordSearch.state = { + query: "schulter", + data: undefined, + loading: false, + error: true, + }; + expect(render()).toContain("Your records could not be searched."); + }); + + it("shows nothing of the records while no search applies", () => { + recordSearch.state = { + query: null, + data: undefined, + loading: false, + error: false, + }; + expect(render()).not.toContain('data-group="records"'); + }); +}); diff --git a/src/components/command-palette/command-palette.tsx b/src/components/command-palette/command-palette.tsx index f3d97849f..04564b64b 100644 --- a/src/components/command-palette/command-palette.tsx +++ b/src/components/command-palette/command-palette.tsx @@ -36,10 +36,13 @@ import { pushRecent, readRecent } from "@/lib/command-palette/recent"; import { scrollToAnchorWhenReady } from "@/lib/dom/scroll-to-anchor"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useTranslations } from "@/lib/i18n/context"; +import type { SearchHit } from "@/lib/search/contract"; import { cn } from "@/lib/utils"; import { setCommandPaletteOpen, useCommandPaletteOpen } from "./palette-store"; +import { RecordHitContent } from "./record-hit"; import { usePaletteIndex } from "./use-palette-index"; +import { useRecordSearch } from "./use-record-search"; /** * The command palette: one field, and below it the places, settings, module @@ -47,6 +50,11 @@ import { usePaletteIndex } from "./use-palette-index"; * Escape closes. A module's entry is a switch: Enter flips it and the palette * stays open. * + * After the local results comes "In your records": the server's search over + * the record (`useRecordSearch`, `GET /api/search`), once the typing pauses. + * Its hits are options like any other, after the local ones, so the arrows + * and Enter work across both; each opens the page that shows the record. + * * Accessibility follows the combobox pattern: the field is the * `role="combobox"` that keeps focus, the results are a `listbox` of grouped * `option`s, and `aria-activedescendant` names the highlighted one. @@ -177,8 +185,15 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { () => sections.flatMap((section) => section.entries), [sections], ); - const activeIndex = - flat.length === 0 ? -1 : Math.min(active, flat.length - 1); + const records = useRecordSearch(query); + const recordHits = useMemo( + () => records.data?.groups.flatMap((group) => group.hits) ?? [], + [records.data], + ); + const showRecords = records.query !== null; + const shownHits = showRecords ? recordHits : []; + const total = flat.length + shownHits.length; + const activeIndex = total === 0 ? -1 : Math.min(active, total - 1); const optionId = (i: number) => `${listId}-option-${i}`; // Keep the highlighted option in view as the arrows move through a list @@ -231,19 +246,33 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { } } + function openRecord(path: string) { + setCommandPaletteOpen(false); + router.push(path); + } + + function runAt(index: number) { + if (index < 0) return; + if (index < flat.length) { + run(flat[index]); + return; + } + const hit = shownHits[index - flat.length]; + if (hit) openRecord(hit.path); + } + function onKeyDown(event: React.KeyboardEvent) { if (event.nativeEvent.isComposing) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); - if (flat.length === 0) return; + if (total === 0) return; const step = event.key === "ArrowDown" ? 1 : -1; - setActive((activeIndex + step + flat.length) % flat.length); + setActive((activeIndex + step + total) % total); return; } if (event.key === "Enter") { event.preventDefault(); - const entry = flat[activeIndex]; - if (entry) run(entry); + runAt(activeIndex); return; } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { @@ -278,7 +307,7 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { autoFocus type="text" role="combobox" - aria-expanded={flat.length > 0} + aria-expanded={total > 0} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={ @@ -319,7 +348,9 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { data-slot="command-palette-results" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2" > - {sections.length === 0 ? ( + {sections.length === 0 && + (!showRecords || + (!records.loading && !records.error && shownHits.length === 0)) ? (

+ ) : null} {isMobile ? null : ( @@ -444,3 +496,104 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { ); } + +/** + * "In your records": the server's hits after the local results. A status line + * (searching, nothing found, failed, older entries not read) is plain text, + * not an option, so the arrows skip it. + */ +function RecordSection({ + headingId, + hits, + firstIndex, + activeIndex, + optionId, + loading, + error, + truncated, + query, + onHover, + onOpen, +}: { + headingId: string; + hits: ReadonlyArray; + firstIndex: number; + activeIndex: number; + optionId: (i: number) => string; + loading: boolean; + error: boolean; + truncated: boolean; + query: string; + onHover: (i: number) => void; + onOpen: (path: string) => void; +}) { + const { t } = useTranslations(); + const status = error + ? t("palette.records.error") + : hits.length === 0 + ? loading + ? t("palette.records.loading") + : t("palette.records.empty", { query }) + : null; + return ( +

+
+ {t("palette.groups.records")} +
+ {hits.map((hit, n) => { + const i = firstIndex + n; + const selected = i === activeIndex; + return ( +
{ + if (!selected) onHover(i); + }} + onMouseDown={(event) => { + // Keep focus in the field; the click opens the record. + event.preventDefault(); + }} + onClick={() => onOpen(hit.path)} + className={cn( + "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", + )} + > + +
+ ); + })} + {status ? ( +

+ {status} +

+ ) : null} + {truncated && !error ? ( +

+ {t("palette.records.truncated")} +

+ ) : null} +
+ ); +} diff --git a/src/components/command-palette/record-hit.tsx b/src/components/command-palette/record-hit.tsx new file mode 100644 index 000000000..5f2316300 --- /dev/null +++ b/src/components/command-palette/record-hit.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + Activity, + Dumbbell, + FileText, + FlaskConical, + NotebookPen, + Pill, + Stethoscope, + Syringe, + Thermometer, + type LucideIcon, +} from "lucide-react"; + +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { + SearchHit, + SearchHitKind, + SearchMarkedText, +} from "@/lib/search/contract"; + +export const RECORD_HIT_ICON: Record = { + visit: Stethoscope, + condition: Thermometer, + document: FileText, + vaccination: Syringe, + lab: FlaskConical, + medication: Pill, + symptom: Activity, + workout: Dumbbell, + note: NotebookPen, +}; + +/** + * Text with the ranges the query matched set in ``. Plain React text + * children only: the ranges are character offsets the server computed, and + * nothing here is parsed as markup. + */ +export function Marked({ value }: { value: SearchMarkedText }) { + const parts: React.ReactNode[] = []; + let at = 0; + for (const [start, end] of value.marks) { + if (start < at || end > value.text.length || end <= start) continue; + if (start > at) parts.push(value.text.slice(at, start)); + parts.push( + + {value.text.slice(start, end)} + , + ); + at = end; + } + if (at < value.text.length) parts.push(value.text.slice(at)); + return <>{parts}; +} + +/** What a hit is called when its record has no name of its own. */ +export function useRecordHitLabel(): (hit: SearchHit) => string { + const { t } = useTranslations(); + return (hit) => + hit.source + ? t(`palette.records.source.${hit.source}`) + : t(`palette.records.kind.${hit.kind}`); +} + +/** The body of one record option: title, kind and date, context, snippet. */ +export function RecordHitContent({ hit }: { hit: SearchHit }) { + const fmt = useFormatters(); + const label = useRecordHitLabel()(hit); + const Icon = RECORD_HIT_ICON[hit.kind]; + return ( + <> +