Merge branch 'v1422/search' into release/v1.42.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:06:07 +02:00
commit 334cb4e979
40 files changed
+3913 -326

No files matched your search

@@ -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(
"group flex min-h-11 cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-sm",
selected && "bg-accent text-accent-foreground",
)}
>
<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,107 @@
"use client";
import {
Activity,
Dumbbell,
FileText,
FlaskConical,
NotebookPen,
Pill,
Stethoscope,
Syringe,
Thermometer,
type LucideIcon,
} from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type {
SearchHit,
SearchHitKind,
SearchMarkedText,
} from "@/lib/search/contract";
export const RECORD_HIT_ICON: Record<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">
{/* Muted reads too faint on the highlighted row's accent fill, so
the highlighted option sets it in the accent's own ink. */}
<span className="text-muted-foreground group-aria-selected:text-accent-foreground">
{hit.title ? label : null}
{hit.title && hit.day ? " · " : null}
{hit.day ? fmt.dateShortSmartCalendar(hit.day) : null}
</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">