mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'v1422/search' into release/v1.42.2
This commit is contained in:
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("<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(
|
||||
"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">
|
||||
|
||||
Reference in new issue
Block a user