mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(palette): list hits from the record under the local results
This commit is contained in:
1 parent
9b86ec2e10
commit
1644acf443
18 files changed
+1136
-34
No files matched your search
+26
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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("<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"');
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user