feat(palette): list hits from the record under the local results

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 12:39:54 +02:00
1 parent 9b86ec2e10
commit 1644acf443
18 files changed
+1136 -34

No files matched your search

+26 -3
View File
@@ -12560,8 +12560,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",
@@ -12575,7 +12575,8 @@
"insights": "Insights",
"settings": "Einstellungen",
"modules": "Module",
"actions": "Aktionen"
"actions": "Aktionen",
"records": "In deinen Einträgen"
},
"actions": {
"capture": "Neuer Eintrag",
@@ -12583,6 +12584,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": {
+26 -3
View File
@@ -12560,8 +12560,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",
@@ -12575,7 +12575,8 @@
"insights": "Insights",
"settings": "Settings",
"modules": "Modules",
"actions": "Actions"
"actions": "Actions",
"records": "In your records"
},
"actions": {
"capture": "New entry",
@@ -12583,6 +12584,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": {
+26 -3
View File
@@ -12560,8 +12560,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",
@@ -12575,7 +12575,8 @@
"insights": "Insights",
"settings": "Ajustes",
"modules": "Módulos",
"actions": "Acciones"
"actions": "Acciones",
"records": "En tus registros"
},
"actions": {
"capture": "Nueva entrada",
@@ -12583,6 +12584,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": {
+26 -3
View File
@@ -12560,8 +12560,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é",
@@ -12575,7 +12575,8 @@
"insights": "Insights",
"settings": "Paramètres",
"modules": "Modules",
"actions": "Actions"
"actions": "Actions",
"records": "Dans votre dossier"
},
"actions": {
"capture": "Nouvelle saisie",
@@ -12583,6 +12584,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": {
+26 -3
View File
@@ -12560,8 +12560,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",
@@ -12575,7 +12575,8 @@
"insights": "Insights",
"settings": "Impostazioni",
"modules": "Moduli",
"actions": "Azioni"
"actions": "Azioni",
"records": "Nei tuoi dati"
},
"actions": {
"capture": "Nuova voce",
@@ -12583,6 +12584,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": {
+26 -3
View File
@@ -12560,8 +12560,8 @@
},
"palette": {
"title": "검색",
"description": "페이지, 설정, 모듈 또는 작업을 찾습니다.",
"placeholder": "페이지, 설정, 모듈 검색…",
"description": "페이지, 설정, 모듈, 작업 또는 내 기록의 항목을 찾습니다.",
"placeholder": "페이지, 설정, 내 기록 검색…",
"results": "결과",
"noResults": "“{query}”에 대한 결과가 없습니다.",
"moduleOn": "켜짐",
@@ -12575,7 +12575,8 @@
"insights": "인사이트",
"settings": "설정",
"modules": "모듈",
"actions": "작업"
"actions": "작업",
"records": "내 기록에서"
},
"actions": {
"capture": "새 항목",
@@ -12583,6 +12584,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": {
+26 -3
View File
@@ -12560,8 +12560,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ł.",
@@ -12575,7 +12575,8 @@
"insights": "Insights",
"settings": "Ustawienia",
"modules": "Moduły",
"actions": "Akcje"
"actions": "Akcje",
"records": "W twoich wpisach"
},
"actions": {
"capture": "Nowy wpis",
@@ -12583,6 +12584,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": {
+8
View File
@@ -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(
() =>
@@ -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("<PaletteBody>", () => {
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("<PaletteBody>", () => {
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 <b>SCHULTER</b> zwickt", marks: [[7, 15]] },
day: "2026-03-05",
path: "/mood?day=2026-03-05",
source: "mood",
},
],
},
],
truncated: true,
};
describe("<PaletteBody> 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("<mark");
expect(html).toContain(">Schulter</mark> (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("<b>");
expect(html).toContain("&lt;b&gt;");
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"');
});
});
@@ -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<HTMLInputElement>) {
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)) ? (
<p
data-slot="command-palette-empty"
className="text-muted-foreground px-3 py-8 text-center text-sm"
@@ -414,6 +445,27 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) {
);
})
)}
{showRecords &&
!(
sections.length === 0 &&
!records.loading &&
!records.error &&
shownHits.length === 0
) ? (
<RecordSection
headingId={`${listId}-group-records`}
hits={shownHits}
firstIndex={flat.length}
activeIndex={activeIndex}
optionId={optionId}
loading={records.loading}
error={records.error}
truncated={records.data?.truncated === true}
query={records.query ?? ""}
onHover={setActive}
onOpen={openRecord}
/>
) : null}
</div>
{isMobile ? null : (
@@ -444,3 +496,104 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) {
</div>
);
}
/**
* "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<SearchHit>;
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 (
<div
role="group"
aria-labelledby={headingId}
aria-busy={loading}
data-group="records"
className="pb-1"
>
<div
id={headingId}
className="text-muted-foreground px-3 pt-3 pb-1 text-xs font-medium tracking-wide uppercase"
>
{t("palette.groups.records")}
</div>
{hits.map((hit, n) => {
const i = firstIndex + n;
const selected = i === activeIndex;
return (
<div
key={hit.id}
id={optionId(i)}
role="option"
aria-selected={selected}
data-entry={`record:${hit.id}`}
data-kind={hit.kind}
onMouseMove={() => {
if (!selected) onHover(i);
}}
onMouseDown={(event) => {
// Keep focus in the field; the click opens the record.
event.preventDefault();
}}
onClick={() => onOpen(hit.path)}
className={cn(
"flex min-h-11 cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-sm",
selected && "bg-accent text-accent-foreground",
)}
>
<RecordHitContent hit={hit} />
</div>
);
})}
{status ? (
<p
data-slot="command-palette-records-status"
aria-live="polite"
className="text-muted-foreground px-3 py-2 text-xs"
>
{status}
</p>
) : null}
{truncated && !error ? (
<p
data-slot="command-palette-records-truncated"
className="text-muted-foreground px-3 pt-1 pb-2 text-xs"
>
{t("palette.records.truncated")}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,105 @@
"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<SearchHitKind, LucideIcon> = {
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 `<mark>`. 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(
<mark
key={start}
className="bg-primary/15 text-foreground rounded-[2px] font-medium"
>
{value.text.slice(start, end)}
</mark>,
);
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 (
<>
<Icon
className="text-muted-foreground mt-0.5 size-4 shrink-0 self-start"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate">
{hit.title ? <Marked value={hit.title} /> : label}
</span>
<span className="block truncate text-xs">
<span className="text-muted-foreground">
{hit.title ? label : null}
{hit.title && hit.day ? " · " : null}
{hit.day ? fmt.dateShortSmartCalendar(hit.day) : null}
</span>
{hit.context.map((part, i) => (
<span key={i}>
{hit.title || hit.day || i > 0 ? " · " : null}
<Marked value={part} />
</span>
))}
</span>
{hit.snippet ? (
<span className="line-clamp-2 block text-xs break-words">
<Marked value={hit.snippet} />
</span>
) : null}
</span>
</>
);
}
@@ -0,0 +1,77 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
SEARCH_QUERY_MAX,
SEARCH_QUERY_MIN,
type SearchResponse,
} from "@/lib/search/contract";
/** Hits per group the palette asks for. */
export const PALETTE_RECORD_LIMIT = 4;
/** Pause in typing before the record search runs. */
const DEBOUNCE_MS = 200;
export interface RecordSearchState {
/** The query the shown answer belongs to, or null while none applies. */
query: string | null;
data: SearchResponse | undefined;
/**
* The answer for the typed text is not in yet (still typing, or the
* request in flight). `data` may hold the previous query's answer.
*/
loading: boolean;
error: boolean;
}
/**
* The palette's search over the record, after the local results.
*
* Runs once the typing pauses, for a query of at least two characters, and
* only where the server answers it: on the person's own record, or on a
* record shared whole. A grant over some sections never opens the search, so
* it is not asked. The request carries the query's abort signal: when the
* next keystroke changes the key, the previous query loses its observer and
* its request is cancelled rather than left to land late.
*/
export function useRecordSearch(rawQuery: string): RecordSearchState {
const { inSharedRecord, sections } = useRecordCapabilities();
const available = !inSharedRecord || sections === null;
const typed = rawQuery.trim().slice(0, SEARCH_QUERY_MAX);
const query = useDebouncedValue(typed, DEBOUNCE_MS);
const wanted = available && typed.length >= SEARCH_QUERY_MIN;
const enabled = wanted && query.length >= SEARCH_QUERY_MIN;
const result = useQuery({
queryKey: queryKeys.recordSearch(query, PALETTE_RECORD_LIMIT),
queryFn: ({ signal }) =>
apiGet<SearchResponse>(
`/api/search?${new URLSearchParams({
q: query,
limit: String(PALETTE_RECORD_LIMIT),
})}`,
{ signal },
),
enabled,
// The previous answer stays while the next one loads, so typing does not
// flash the section empty between keystrokes.
placeholderData: keepPreviousData,
staleTime: 30_000,
gcTime: 60_000,
retry: false,
});
// Still typing, or the answer for the typed text not in yet.
const current = typed === query && !result.isPlaceholderData;
return {
query: wanted ? typed : null,
data: wanted && enabled ? result.data : undefined,
loading: wanted && (!current || result.isFetching),
error: wanted && current && result.isError,
};
}
@@ -27,10 +27,18 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { EncounterSheet } from "./encounter-sheet";
import { VisitCard } from "./visit-card";
/** One visit by id, for a link to a visit older than the list reads. */
function fetchVisit(id: string): Promise<Encounter | null> {
return apiGet<Encounter>(`/api/encounters/${encodeURIComponent(id)}`).catch(
() => null,
);
}
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
@@ -60,6 +68,9 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
records: list.data ? [...upcoming, ...past] : undefined,
anchorAttribute: "data-encounter-id",
open: canManageVisits ? openSheet : undefined,
// The list reads the newest hundred of each arm; a search hit can name an
// older visit, which is read on its own.
resolve: fetchVisit,
});
const empty = upcoming.length === 0 && past.length === 0;
// The last visit that took place: the start of the visit preparation.
@@ -46,6 +46,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
import { TagChip } from "@/components/ui/tag-chip";
import { QuickEntrySheets } from "@/components/dashboard/quick-entry-sheets";
import { useAuth } from "@/hooks/use-auth";
import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
@@ -369,6 +370,15 @@ export function SymptomsSection() {
const hidden = all.filter((d) => !d.isActive);
const days = groupEventsByDay(events.data?.events ?? [], timezone);
// `?symptom=<id>` — a search hit names the symptom. Scroll to it, and open
// it for someone who may edit it.
useDeepLinkedRecord({
param: "symptom",
records: definitions.data ? all : undefined,
anchorAttribute: "data-symptom-id",
open: canManage ? setEditingDefinition : undefined,
});
if (definitions.isLoading) return null;
if (definitions.isError || events.isError) {
return (
@@ -413,6 +423,7 @@ export function SymptomsSection() {
return (
<li
key={definition.id}
data-symptom-id={definition.id}
className="flex items-center justify-between gap-3 py-2.5"
>
<div className="flex min-w-0 items-center gap-2">
@@ -449,6 +460,7 @@ export function SymptomsSection() {
{hidden.map((definition) => (
<li
key={definition.id}
data-symptom-id={definition.id}
className="flex items-center justify-between gap-3 py-1.5"
>
<span className="text-muted-foreground truncate text-sm">
+33 -4
View File
@@ -14,14 +14,17 @@ import { useSearchParams } from "next/navigation";
* sheet, or nothing for a reader who may not edit). Each id is acted on once,
* so closing the sheet does not reopen it on the next render.
*
* An id the list does not hold (deleted since, or outside what this grant
* shows) is ignored: the page simply renders as it would without the link.
* An id the list does not hold (deleted since, outside what this grant shows,
* or older than the bounded list reads) is handed to `resolve` when the page
* gives one, and the record it returns is opened; without `resolve`, or when
* it finds nothing, the page simply renders as it would without the link.
*/
export function useDeepLinkedRecord<T extends { id: string }>({
param,
records,
anchorAttribute,
open,
resolve,
}: {
/** The query parameter carrying the id. */
param: string;
@@ -31,20 +34,46 @@ export function useDeepLinkedRecord<T extends { id: string }>({
anchorAttribute: string;
/** Called once with the linked record; omit to only scroll. */
open?: (record: T) => void;
/**
* Read one record the list does not hold, by its id, or null. Only asked
* when `open` is given: a record that is not on the page cannot be
* scrolled to.
*/
resolve?: (id: string) => Promise<T | null>;
}): void {
// Null outside the app router (a component rendered on its own, as the
// unit tests do): no link, nothing to open.
const wanted = useSearchParams()?.get(param) ?? null;
const handled = useRef<string | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
useEffect(() => {
if (!wanted || !records || handled.current === wanted) return;
handled.current = wanted;
const record = records.find((entry) => entry.id === wanted);
if (!record) return;
if (!record) {
if (!resolve || !open) return;
void resolve(wanted)
.then((found) => {
// Still on the page, and still the link it was asked for.
if (found && mounted.current && handled.current === wanted) {
open(found);
}
})
.catch(() => {
// Gone or refused: the page stays as it would without the link.
});
return;
}
document
.querySelector(`[${anchorAttribute}="${CSS.escape(wanted)}"]`)
?.scrollIntoView({ block: "center" });
open?.(record);
}, [wanted, records, anchorAttribute, open]);
}, [wanted, records, anchorAttribute, open, resolve]);
}
+2
View File
@@ -43,6 +43,7 @@ import { recordSettingsKeys } from "./record-settings";
import { profileKeys } from "./profile";
import { settingsKeys } from "./settings";
import { timelineKeys } from "./timeline";
import { searchKeys } from "./search";
import { sharingKeys } from "./sharing";
import { vaccinationKeys } from "./vaccinations";
import { workoutKeys } from "./workouts";
@@ -80,6 +81,7 @@ export const queryKeys = {
...aiRunKeys,
...dayKeys,
...timelineKeys,
...searchKeys,
};
export { recordSettingsKeys } from "./record-settings";
+15
View File
@@ -0,0 +1,15 @@
/**
* Query keys — the in-app record search (`GET /api/search`, v1.42.2).
* Part of the centralized factory; aggregated in `./index.ts`.
*
* The query text is part of the key, which keeps it in memory only: the
* `["search"]` head is not on the persister's allowlist, so neither the
* query nor its hits are written to disk.
*/
export const searchKeys = {
/** Root prefix. */
searchRoot: () => ["search"] as const,
/** `GET /api/search?q=&limit=`. */
recordSearch: (query: string, limit: number) =>
["search", "records", query, limit] as const,
};
+413
View File
@@ -0,0 +1,413 @@
/**
* v1.42.2 (#1117) — `GET /api/search` against real Postgres.
*
* What the unit tests cannot show: that encrypted text is found after the
* decrypt whichever way the umlaut is typed, that the reads are narrowed to
* the record the session acts for (two accounts hold the same words here),
* that a switched-off module's rows are not read, that the per-source row cap
* really bounds a large record and says so, and that a delegate reads the
* owner's record only through a whole-record grant.
*
* Mutation checks (each run, each seen red):
* - drop `userId: ctx.recordId` from the mood-note read → "never returns the
* other account's records" goes red;
* - drop the `search:moodNote` surface entry → "a switched-off module
* contributes nothing" goes red;
* - raise `MAX_SCAN_ROWS_PER_SOURCE` above the seeded count → "stops at the
* scan cap" goes red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptNote } from "@/lib/crypto/note-cipher";
import type { SearchHit, SearchResponse } from "@/lib/search/contract";
import { MAX_SCAN_ROWS_PER_SOURCE } from "@/lib/search/search-records";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
let counter = 0;
async function makeUser(label: string, modules: Record<string, boolean> = {}) {
const suffix = `${label}-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `rs-${suffix}`,
email: `rs-${suffix}@example.test`,
displayName: `RS ${suffix}`,
role: "USER",
timezone: "Europe/Berlin",
locale: "de",
modulePreferencesJson: modules,
},
});
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
async function search(q: string, limit?: number): Promise<Response> {
const { GET } = await import("@/app/api/search/route");
const params = new URLSearchParams({ q });
if (limit) params.set("limit", String(limit));
return (
GET as unknown as (
req: NextRequest,
ctx: { params: Promise<object> },
) => Promise<Response>
)(new NextRequest(`http://localhost/api/search?${params}`), {
params: Promise.resolve({}),
});
}
async function hits(q: string, limit?: number): Promise<SearchResponse> {
const res = await search(q, limit);
expect(res.status).toBe(200);
return ((await res.json()) as { data: SearchResponse }).data;
}
function all(result: SearchResponse): SearchHit[] {
return result.groups.flatMap((g) => g.hits);
}
/** One account's record holding "Schulter" in every source that can hold it. */
async function seedRecord(userId: string, tag: string) {
const db = getPrismaClient();
const visit = await db.encounter.create({
data: {
userId,
occurredAt: new Date("2026-03-02T09:00:00Z"),
kind: "PROCEDURE",
reasonEncrypted: encryptToBytes(`Arthroskopie ${tag}`),
bodySiteEncrypted: encryptToBytes("Schulter"),
laterality: "LEFT",
},
});
const condition = await db.illnessEpisode.create({
data: {
userId,
label: `Schulterprellung ${tag}`,
type: "INJURY",
onsetAt: new Date("2026-02-20T09:00:00Z"),
},
});
const mood = await db.moodEntry.create({
data: {
userId,
date: "2026-03-05",
mood: "OKAY",
score: 3,
moodLoggedAt: new Date("2026-03-05T18:00:00Z"),
noteEncrypted: encryptNote(`Die SCHULTER zwickt ${tag}`),
},
});
const reading = await db.measurement.create({
data: {
userId,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date("2026-03-06T07:00:00Z"),
notesEncrypted: encryptNote(`nach der Schulter-OP ${tag}`),
},
});
const medication = await db.medication.create({
data: { userId, name: `Ibuprofen ${tag}`, dose: "400 mg" },
});
const lab = await db.labResult.create({
data: {
userId,
analyte: "Hämoglobin",
value: 14,
unit: "g/dL",
takenAt: new Date("2026-03-01T08:00:00Z"),
},
});
const symptom = await db.symptomDefinition.create({
data: { userId, labelEncrypted: encryptToBytes(`Schulterschmerz ${tag}`) },
});
const document = await db.inboundDocument.create({
data: {
userId,
title: `MRT Schulter ${tag}`,
mimeType: "application/pdf",
byteSize: 4,
contentEncrypted: encryptToBytes("%PDF"),
kind: "IMAGING",
},
});
return {
visit,
condition,
mood,
reading,
medication,
lab,
symptom,
document,
};
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("the record search", () => {
it("finds encrypted and plaintext records, case and umlaut blind, each with a path that opens it", async () => {
const owner = await makeUser("owner");
const mine = await seedRecord(owner.id, "eigen");
await signIn(owner.id);
const result = await hits("schulter", 20);
const byId = new Map(all(result).map((h) => [h.id, h]));
expect(byId.get(`visit:${mine.visit.id}`)?.path).toBe(
`/checkups?visit=${mine.visit.id}`,
);
expect(byId.get(`condition:${mine.condition.id}`)?.path).toBe(
`/illness/${mine.condition.id}`,
);
expect(byId.get(`document:${mine.document.id}`)?.path).toBe(
`/documents?doc=${mine.document.id}`,
);
expect(byId.get(`moodNote:${mine.mood.id}`)?.path).toBe(
"/mood?day=2026-03-05",
);
expect(byId.get(`measurementNote:${mine.reading.id}`)?.path).toBe(
"/measurements?day=2026-03-06",
);
expect(byId.get(`symptom:${mine.symptom.id}`)?.path).toBe(
`/illness?symptom=${mine.symptom.id}`,
);
// The note's snippet marks the word as the person wrote it.
const mood = byId.get(`moodNote:${mine.mood.id}`)!;
expect(mood.source).toBe("mood");
const [[start, end]] = mood.snippet!.marks;
expect(mood.snippet!.text.slice(start, end)).toBe("SCHULTER");
// The visit names its site in context, in the reader's language.
const visit = byId.get(`visit:${mine.visit.id}`)!;
expect(visit.title?.text).toBe("Arthroskopie eigen");
expect(visit.context.map((c) => c.text).join(" ")).toContain("Schulter");
// Umlauts typed either way, in either direction.
const lab = all(await hits("haemoglobin"));
expect(lab.map((h) => h.path)).toContain(
`/labs?analyte=${encodeURIComponent("Hämoglobin")}`,
);
expect(all(await hits("HÄMOGLOBIN")).length).toBeGreaterThan(0);
const meds = all(await hits("ibuprofen"));
expect(meds.map((h) => h.path)).toEqual([
`/medications/${mine.medication.id}`,
]);
});
it("never returns the other account's records", async () => {
const owner = await makeUser("owner");
const other = await makeUser("other");
await seedRecord(owner.id, "eigen");
const theirs = await seedRecord(other.id, "fremd");
await signIn(owner.id);
const found = all(await hits("schulter", 20));
expect(found.length).toBeGreaterThan(5);
const theirIds = new Set(
Object.values(theirs).map((row) => (row as { id: string }).id),
);
for (const hit of found) {
expect(theirIds.has(hit.id.slice(hit.id.indexOf(":") + 1))).toBe(false);
expect(JSON.stringify(hit)).not.toContain("fremd");
}
expect(all(await hits("fremd"))).toEqual([]);
});
it("a switched-off module contributes nothing", async () => {
const owner = await makeUser("owner", {
mood: false,
illness: false,
medications: false,
});
const mine = await seedRecord(owner.id, "eigen");
await signIn(owner.id);
const found = all(await hits("schulter", 20));
const ids = found.map((h) => h.id);
expect(ids).toContain(`visit:${mine.visit.id}`);
expect(ids).not.toContain(`moodNote:${mine.mood.id}`);
expect(ids).not.toContain(`condition:${mine.condition.id}`);
expect(ids).not.toContain(`symptom:${mine.symptom.id}`);
expect(found.map((h) => h.kind)).not.toContain("condition");
expect(all(await hits("ibuprofen"))).toEqual([]);
});
it("stops at the scan cap on a large record and says so", async () => {
const db = getPrismaClient();
const owner = await makeUser("owner");
// The one matching note is the oldest; newer than it sit more noted
// readings than one search reads.
await db.measurement.create({
data: {
userId: owner.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date("2020-01-01T07:00:00Z"),
notesEncrypted: encryptNote("Schulter"),
},
});
const filler = Array.from(
{ length: MAX_SCAN_ROWS_PER_SOURCE + 10 },
(_, i) => ({
userId: owner.id,
type: "WEIGHT" as const,
value: 80,
unit: "kg",
measuredAt: new Date(Date.UTC(2021, 0, 1) + i * 3_600_000),
notesEncrypted: encryptNote(`Waage ${i}`),
}),
);
await db.measurement.createMany({ data: filler });
await signIn(owner.id);
const capped = await hits("schulter");
expect(capped.truncated).toBe(true);
expect(all(capped)).toEqual([]);
// A newer match is found inside the cap, and the answer is not cut short
// when there is no more to read.
expect(all(await hits("Waage", 3)).length).toBe(3);
});
it("caps each group and says when there is more", async () => {
const db = getPrismaClient();
const owner = await makeUser("owner");
for (let i = 0; i < 4; i++) {
await db.moodEntry.create({
data: {
userId: owner.id,
date: `2026-01-0${i + 1}`,
mood: "OKAY",
score: 3,
moodLoggedAt: new Date(`2026-01-0${i + 1}T18:00:00Z`),
noteEncrypted: encryptNote(`Kopfweh ${i}`),
},
});
}
await signIn(owner.id);
const result = await hits("kopfweh", 2);
const notes = result.groups.find((g) => g.kind === "note")!;
expect(notes.hits).toHaveLength(2);
expect(notes.more).toBe(true);
// Newest first on a tie.
expect(notes.hits[0].day).toBe("2026-01-04");
});
it("a delegate searches the owner's record through a whole-record grant only", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const mine = await seedRecord(owner.id, "eigen");
await seedRecord(delegate.id, "selbst");
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const whole = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
access: "READ",
scope: null,
});
await acceptGrant({ grantId: whole.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
await switchSessionTo(session.id, owner.id);
const found = all(await hits("schulter", 20));
expect(found.map((h) => h.id)).toContain(`visit:${mine.visit.id}`);
expect(JSON.stringify(found)).not.toContain("selbst");
});
it("a grant scoped to some sections does not open it", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedRecord(owner.id, "eigen");
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const scoped = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
access: "READ",
scope: ["profile"],
});
await acceptGrant({ grantId: scoped.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
await switchSessionTo(session.id, owner.id);
const res = await search("schulter");
expect(res.status).toBe(403);
expect(await res.text()).not.toContain("Schulter");
});
it("refuses a query too short to search", async () => {
const owner = await makeUser("owner");
await signIn(owner.id);
expect((await search("s")).status).toBe(422);
});
it("reads the noted readings through the partial index", async () => {
const db = getPrismaClient();
const owner = await makeUser("owner");
await db.measurement.createMany({
data: Array.from({ length: 2000 }, (_, i) => ({
userId: owner.id,
type: "PULSE" as const,
value: 60,
unit: "bpm",
measuredAt: new Date(Date.UTC(2024, 0, 1) + i * 60_000),
})),
});
await db.$executeRawUnsafe("ANALYZE measurements");
const plan = await db.$queryRawUnsafe<Array<{ "QUERY PLAN": string }>>(
`EXPLAIN SELECT id FROM measurements
WHERE user_id = $1 AND deleted_at IS NULL
AND (notes_encrypted IS NOT NULL OR notes IS NOT NULL)
ORDER BY measured_at DESC, id DESC LIMIT 250`,
owner.id,
);
expect(plan.map((r) => r["QUERY PLAN"]).join("\n")).toContain(
"measurements_noted_live_idx",
);
});
});