diff --git a/messages/de.json b/messages/de.json index 8395f39a5..ac5771b9f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 164ca09ff..da23da37b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 85feaee82..cd7e09945 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index e84f55fb3..491a9c5d8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 6937f4746..702fa739c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index ec79c416d..6d01f8128 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 19bdad46a..2bdd9c0ce 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx index 77a2197cd..e97672d7c 100644 --- a/src/app/checkups/page-client.tsx +++ b/src/app/checkups/page-client.tsx @@ -69,6 +69,14 @@ export default function CheckupsPageClient() { const [view, setView] = useState( linkedVisit ? "visits" : linkedSite ? "body-sites" : "vorsorge", ); + // A link to a visit can arrive while the page is already open (the search + // opens one from wherever the person is): it moves to the visits tab then + // too, rather than only on the first render. + const [observedVisit, setObservedVisit] = useState(linkedVisit); + if (linkedVisit !== observedVisit) { + setObservedVisit(linkedVisit); + if (linkedVisit) setView("visits"); + } const refresh = useCallback( () => diff --git a/src/components/command-palette/__tests__/command-palette.test.tsx b/src/components/command-palette/__tests__/command-palette.test.tsx index cd23ee416..aeeb64b5f 100644 --- a/src/components/command-palette/__tests__/command-palette.test.tsx +++ b/src/components/command-palette/__tests__/command-palette.test.tsx @@ -4,6 +4,7 @@ import { Activity, Moon, Plus, Smile } from "lucide-react"; import { I18nProvider } from "@/lib/i18n/context"; import type { PaletteEntry } from "@/lib/command-palette/build-index"; +import type { SearchResponse } from "@/lib/search/contract"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), @@ -69,6 +70,18 @@ vi.mock("../use-palette-index", () => ({ usePaletteIndex: () => ENTRIES, })); +const recordSearch = vi.hoisted(() => ({ + state: { + query: null as string | null, + data: undefined as SearchResponse | undefined, + loading: false, + error: false, + }, +})); +vi.mock("../use-record-search", () => ({ + useRecordSearch: () => recordSearch.state, +})); + import { PaletteBody, arrangeResults } from "../command-palette"; function render(isMobile = false) { @@ -122,7 +135,9 @@ describe("", () => { const html = render(); expect(html).toContain('role="group"'); expect(html).toContain('aria-label="Search"'); - expect(html).toContain('placeholder="Search pages, settings, modules…"'); + expect(html).toContain( + 'placeholder="Search pages, settings, your records…"', + ); expect(html).toContain(">Actions<"); }); @@ -133,3 +148,104 @@ describe("", () => { expect(phone).toContain(">Cancel<"); }); }); + +const HITS: SearchResponse = { + groups: [ + { + kind: "visit", + more: false, + hits: [ + { + id: "visit:v1", + kind: "visit", + title: { text: "Arthroskopie", marks: [] }, + context: [{ text: "Schulter (links)", marks: [[0, 8]] }], + snippet: null, + day: "2026-03-02", + path: "/checkups?visit=v1", + source: null, + }, + ], + }, + { + kind: "note", + more: false, + hits: [ + { + id: "moodNote:m1", + kind: "note", + title: null, + context: [], + snippet: { text: "Die SCHULTER zwickt", marks: [[7, 15]] }, + day: "2026-03-05", + path: "/mood?day=2026-03-05", + source: "mood", + }, + ], + }, + ], + truncated: true, +}; + +describe(" in your records", () => { + it("lists the record hits after the local results as options", () => { + recordSearch.state = { + query: "schulter", + data: HITS, + loading: false, + error: false, + }; + const html = render(); + expect(html).toContain('data-group="records"'); + expect(html).toContain(">In your records<"); + expect(html).toContain('data-entry="record:visit:v1"'); + expect(html).toContain('data-entry="record:moodNote:m1"'); + // After every local option. + expect(html.indexOf('data-entry="action:today"')).toBeLessThan( + html.indexOf('data-entry="record:visit:v1"'), + ); + // The matched part is marked, as text. + expect(html).toContain("Schulter (links)"); + // A note without a name is called by what it was written on. + expect(html).toContain("Mood note"); + // Record text is never parsed as markup. + expect(html).not.toContain(""); + expect(html).toContain("<b>"); + expect(html).toContain("Only the most recent entries were searched."); + }); + + it("says when it is searching, and when nothing matched", () => { + recordSearch.state = { + query: "schulter", + data: undefined, + loading: true, + error: false, + }; + expect(render()).toContain("Searching your records…"); + recordSearch.state = { + query: "schulter", + data: { groups: [], truncated: false }, + loading: false, + error: false, + }; + expect(render()).toContain("Nothing in your records for “schulter”."); + recordSearch.state = { + query: "schulter", + data: undefined, + loading: false, + error: true, + }; + expect(render()).toContain("Your records could not be searched."); + }); + + it("shows nothing of the records while no search applies", () => { + recordSearch.state = { + query: null, + data: undefined, + loading: false, + error: false, + }; + expect(render()).not.toContain('data-group="records"'); + }); +}); diff --git a/src/components/command-palette/command-palette.tsx b/src/components/command-palette/command-palette.tsx index f3d97849f..bb284242c 100644 --- a/src/components/command-palette/command-palette.tsx +++ b/src/components/command-palette/command-palette.tsx @@ -36,10 +36,13 @@ import { pushRecent, readRecent } from "@/lib/command-palette/recent"; import { scrollToAnchorWhenReady } from "@/lib/dom/scroll-to-anchor"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useTranslations } from "@/lib/i18n/context"; +import type { SearchHit } from "@/lib/search/contract"; import { cn } from "@/lib/utils"; import { setCommandPaletteOpen, useCommandPaletteOpen } from "./palette-store"; +import { RecordHitContent } from "./record-hit"; import { usePaletteIndex } from "./use-palette-index"; +import { useRecordSearch } from "./use-record-search"; /** * The command palette: one field, and below it the places, settings, module @@ -47,6 +50,11 @@ import { usePaletteIndex } from "./use-palette-index"; * Escape closes. A module's entry is a switch: Enter flips it and the palette * stays open. * + * After the local results comes "In your records": the server's search over + * the record (`useRecordSearch`, `GET /api/search`), once the typing pauses. + * Its hits are options like any other, after the local ones, so the arrows + * and Enter work across both; each opens the page that shows the record. + * * Accessibility follows the combobox pattern: the field is the * `role="combobox"` that keeps focus, the results are a `listbox` of grouped * `option`s, and `aria-activedescendant` names the highlighted one. @@ -177,8 +185,15 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { () => sections.flatMap((section) => section.entries), [sections], ); - const activeIndex = - flat.length === 0 ? -1 : Math.min(active, flat.length - 1); + const records = useRecordSearch(query); + const recordHits = useMemo( + () => records.data?.groups.flatMap((group) => group.hits) ?? [], + [records.data], + ); + const showRecords = records.query !== null; + const shownHits = showRecords ? recordHits : []; + const total = flat.length + shownHits.length; + const activeIndex = total === 0 ? -1 : Math.min(active, total - 1); const optionId = (i: number) => `${listId}-option-${i}`; // Keep the highlighted option in view as the arrows move through a list @@ -231,19 +246,33 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { } } + function openRecord(path: string) { + setCommandPaletteOpen(false); + router.push(path); + } + + function runAt(index: number) { + if (index < 0) return; + if (index < flat.length) { + run(flat[index]); + return; + } + const hit = shownHits[index - flat.length]; + if (hit) openRecord(hit.path); + } + function onKeyDown(event: React.KeyboardEvent) { if (event.nativeEvent.isComposing) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); - if (flat.length === 0) return; + if (total === 0) return; const step = event.key === "ArrowDown" ? 1 : -1; - setActive((activeIndex + step + flat.length) % flat.length); + setActive((activeIndex + step + total) % total); return; } if (event.key === "Enter") { event.preventDefault(); - const entry = flat[activeIndex]; - if (entry) run(entry); + runAt(activeIndex); return; } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { @@ -278,7 +307,7 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { autoFocus type="text" role="combobox" - aria-expanded={flat.length > 0} + aria-expanded={total > 0} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={ @@ -319,7 +348,9 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { data-slot="command-palette-results" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2" > - {sections.length === 0 ? ( + {sections.length === 0 && + (!showRecords || + (!records.loading && !records.error && shownHits.length === 0)) ? (

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

+
+ {t("palette.groups.records")} +
+ {hits.map((hit, n) => { + const i = firstIndex + n; + const selected = i === activeIndex; + return ( +
{ + if (!selected) onHover(i); + }} + onMouseDown={(event) => { + // Keep focus in the field; the click opens the record. + event.preventDefault(); + }} + onClick={() => onOpen(hit.path)} + className={cn( + "flex min-h-11 cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-sm", + selected && "bg-accent text-accent-foreground", + )} + > + +
+ ); + })} + {status ? ( +

+ {status} +

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

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

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