Merge branch 'v143/labs' into release/v1.43.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:06:51 +02:00
commit 74fa4e10b6
96 files changed
+6992 -626

No files matched your search

@@ -18,6 +18,7 @@ vi.mock("@/components/links/use-link-picker-pages", async (importOriginal) => ({
setTerm: vi.fn(),
searchPending: false,
filterLocally: true,
period: null,
hasOlder: false,
hasNewer: false,
loadingOlder: false,
@@ -26,6 +26,7 @@
*/
import { useCalendarDate } from "@/hooks/use-calendar-date";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { FlaskConical, Loader2, ScanSearch } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
@@ -36,7 +37,11 @@ import { Checkbox } from "@/components/ui/checkbox";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { MarkerMatchField } from "@/components/labs/marker-match-field";
import type { BiomarkerListResponse } from "@/components/labs/types";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type {
ConditionFactData,
@@ -62,6 +67,8 @@ function commitFailureKey(reason: string): string {
return "documents.review.failUnitRequired";
case "observation.unitMismatch":
return "documents.review.failUnitMismatch";
case "observation.unknownBiomarker":
return "documents.review.failUnknownBiomarker";
default:
return "documents.review.failGeneric";
}
@@ -203,6 +210,9 @@ function FactRow({
checked,
onCheckedChange,
failure,
chosenBiomarkerId,
onChooseBiomarker,
markerName,
}: {
documentId: string;
fact: ExtractedFactDto;
@@ -210,6 +220,11 @@ function FactRow({
onCheckedChange: (checked: boolean) => void;
/** A per-fact commit refusal from the last confirm attempt, or null. */
failure: string | null;
/** v1.43 — the suggestion the person picked for a lab value, or null. */
chosenBiomarkerId: string | null;
onChooseBiomarker: (biomarkerId: string | null) => void;
/** The account's marker name by id, for the matched marker. */
markerName: (biomarkerId: string) => string | null;
}) {
const { t } = useTranslations();
const calendarDate = useCalendarDate();
@@ -265,6 +280,25 @@ function FactRow({
{typeLabel}
{date ? `, ${calendarDate(date)}` : ""}
</p>
{isObservation &&
(fact.biomarkerId !== undefined ||
(fact.suggestions?.length ?? 0) > 0) ? (
<MarkerMatchField
matchedName={
fact.biomarkerId
? (markerName(fact.biomarkerId) ??
(fact.data as ObservationFactData).label)
: null
}
matchKind={fact.matchKind ?? null}
suggestions={fact.suggestions ?? []}
chosenId={chosenBiomarkerId}
onChoose={(suggestion) =>
onChooseBiomarker(suggestion?.biomarkerId ?? null)
}
slot="document-fact-marker"
/>
) : null}
{isObservation && (fact.data as ObservationFactData).referenceText ? (
<p className="text-muted-foreground text-xs break-words">
{t("documents.review.statedRange", {
@@ -461,6 +495,18 @@ export function DocumentFactsSection({
// until it is verified (which checks it).
const [included, setIncluded] = useState<Record<string, boolean>>({});
const [failures, setFailures] = useState<Record<string, string>>({});
// v1.43 — the marker the person picked per lab value, keyed by fact.
const [chosen, setChosen] = useState<Record<string, string | null>>({});
const hasObservations = doc.facts.some(
(fact) => fact.factType === "OBSERVATION" && fact.status === "PENDING",
);
const markers = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
enabled: canManage && hasObservations && open,
});
const markerName = (id: string) =>
markers.data?.biomarkers.find((m) => m.id === id)?.name ?? null;
const pending = doc.facts.filter((fact) => fact.status === "PENDING");
const approvedCount = doc.facts.filter(
@@ -483,6 +529,7 @@ export function DocumentFactsSection({
const decisions = decidable.map((fact) => ({
factId: fact.id,
action: isChecked(fact) ? ("approve" as const) : ("reject" as const),
...(chosen[fact.id] ? { biomarkerId: chosen[fact.id] as string } : {}),
}));
confirmFacts.mutate(
{ documentId: doc.id, decisions },
@@ -567,6 +614,11 @@ export function DocumentFactsSection({
checked={isChecked(fact)}
onCheckedChange={(checked) => setChecked(fact.id, checked)}
failure={failures[fact.id] ?? null}
chosenBiomarkerId={chosen[fact.id] ?? null}
onChooseBiomarker={(id) =>
setChosen((prev) => ({ ...prev, [fact.id]: id }))
}
markerName={markerName}
/>
))}
</ul>
@@ -114,7 +114,12 @@ export function useConfirmFacts() {
Error,
{
documentId: string;
decisions: { factId: string; action: "approve" | "reject" }[];
decisions: {
factId: string;
action: "approve" | "reject";
/** v1.43 — the marker the person chose for a lab value. */
biomarkerId?: string;
}[];
}
>({
mutationFn: ({ documentId, decisions }) =>
@@ -0,0 +1,116 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManageDomain: () => true }),
}));
import { BiomarkerAliases } from "../biomarker-aliases";
import { BiomarkerMergeSheet } from "../biomarker-merge-sheet";
import type { BiomarkerDto } from "../types";
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
children,
title,
footer,
}: {
children: React.ReactNode;
title: string;
footer?: React.ReactNode;
}) => (
<div>
<h2>{title}</h2>
{children}
{footer}
</div>
),
}));
function wrap(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
const marker = (overrides: Partial<BiomarkerDto>): BiomarkerDto => ({
id: "bm-1",
name: "Alkaline Phosphatase",
unit: "U/L",
lowerBound: null,
upperBound: null,
panel: null,
analyteKey: null,
hasContext: false,
context: null,
hidden: false,
aliases: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
});
describe("<BiomarkerAliases>", () => {
it("shows the other spellings as chips, removable for a manager", () => {
const html = wrap(
<BiomarkerAliases
marker={marker({ aliases: ["Alk.P", "AP"] })}
canEdit
/>,
);
expect(html).toContain("Also written as");
expect(html).toContain("Alk.P");
expect(html).toContain('aria-label="Remove AP"');
expect(html).toContain("Another name for this biomarker");
});
it("is read-only without the manage right, and absent when there is nothing to show", () => {
const readOnly = wrap(
<BiomarkerAliases
marker={marker({ aliases: ["Alk.P"] })}
canEdit={false}
/>,
);
expect(readOnly).toContain("Alk.P");
expect(readOnly).not.toContain("Remove Alk.P");
expect(readOnly).not.toContain("<form");
expect(wrap(<BiomarkerAliases marker={marker({})} canEdit={false} />)).toBe(
"",
);
});
it("speaks German too", () => {
expect(
wrap(<BiomarkerAliases marker={marker({})} canEdit />, "de"),
).toContain("Auch geschrieben als");
});
});
describe("<BiomarkerMergeSheet>", () => {
it("opens on the pick step with every marker and no preview yet", () => {
const html = wrap(
<BiomarkerMergeSheet
open
onOpenChange={() => {}}
markers={[
marker({ id: "a", name: "Alkaline Phosphatase" }),
marker({ id: "b", name: "Alk.P" }),
]}
/>,
);
expect(html).toContain("Merge biomarkers");
expect(html).toContain('data-slot="biomarker-merge-select"');
expect(html).toContain("Alk.P");
expect(html).toContain("Select at least two biomarkers.");
// The preview button waits for a selection.
expect(html).toMatch(
/<button[^>]*data-slot="biomarker-merge-preview"[^>]*disabled=""/,
);
expect(html).not.toContain('data-slot="biomarker-merge-confirm"');
});
});
@@ -26,7 +26,9 @@ vi.mock("@/hooks/use-ai-capability", () => ({
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
ApiError: (await importOriginal<typeof import("@/lib/api/api-fetch")>())
.ApiError,
apiGet: () => new Promise(() => {}),
apiDelete: vi.fn(),
apiPut: vi.fn(),
@@ -0,0 +1,139 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr";
import {
chooseMarker,
seedReviewRows,
type OcrReviewRow,
} from "../ocr-review-types";
import { toCommitRow } from "../ocr-review-validation";
import { OcrRowEditor } from "../ocr-row-editor";
const dto: OcrExtractedRowDto = {
analyte: "Bilirubin ges.",
value: 0.8,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: null,
referenceText: null,
takenAt: "2026-06-01",
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "new",
markerUnit: null,
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: null,
matchKind: null,
suggestions: [
{
biomarkerId: "bm-bili",
name: "Bilirubin (Total)",
unit: "µmol/L",
matchKind: "similar",
},
],
};
const lookup = (id: string) =>
id === "bm-bili"
? { name: "Bilirubin (Total)", analyteKey: "bilirubin-total" }
: id === "bm-alp"
? { name: "Alkaline Phosphatase", analyteKey: null }
: null;
function render(row: OcrReviewRow, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OcrRowEditor row={row} onChange={() => {}} markerLookup={lookup} />
</I18nProvider>,
);
}
describe("the scan review's marker choice (D1223)", () => {
it("seeds the match and the suggestions, with nothing chosen", () => {
const [row] = seedReviewRows([dto], null);
expect(row).toMatchObject({
biomarkerId: null,
matchKind: null,
chosenBiomarkerId: null,
suggestions: dto.suggestions,
});
});
it("reads a scan from before the fields existed as no match", () => {
const legacy = { ...dto } as Partial<OcrExtractedRowDto>;
delete legacy.biomarkerId;
delete legacy.matchKind;
delete legacy.suggestions;
const [row] = seedReviewRows([legacy as OcrExtractedRowDto], null);
expect(row.suggestions).toEqual([]);
expect(row.biomarkerId).toBeNull();
});
it("offers the suggestions and a new marker, new by default", () => {
const [row] = seedReviewRows([dto], null);
const html = render(row);
expect(html).toContain('data-slot="ocr-row-marker-choice"');
expect(html).toContain("Bilirubin (Total) (µmol/L)");
expect(html).toContain("A new biomarker");
expect(html).toMatch(/<option value="" selected="">/);
});
it("names the matched marker and how it was found", () => {
const [row] = seedReviewRows(
[
{
...dto,
analyte: "Alk.P",
biomarkerId: "bm-alp",
matchKind: "catalog",
suggestions: [],
biomarkerMatch: "existing",
markerUnit: "U/L",
},
],
null,
);
const html = render(row);
expect(html).toContain("Links to existing Alkaline Phosphatase");
expect(html).toContain("recognised as a common abbreviation");
expect(html).not.toContain('data-slot="ocr-row-marker-choice"');
expect(render(row, "de")).toContain("als übliche Abkürzung erkannt");
});
it("files a chosen row under the marker, judged in its unit", () => {
const [row] = seedReviewRows([dto], null);
const chosen = chooseMarker(
row,
dto.suggestions[0],
(id) => lookup(id)?.analyteKey ?? null,
);
expect(chosen).toMatchObject({
chosenBiomarkerId: "bm-bili",
biomarkerMatch: "existing",
markerUnit: "µmol/L",
markerAnalyteKey: "bilirubin-total",
});
expect(toCommitRow(chosen)).toMatchObject({ biomarkerId: "bm-bili" });
const back = chooseMarker(chosen, null, () => null);
expect(back).toMatchObject({
chosenBiomarkerId: null,
biomarkerMatch: "new",
markerUnit: null,
});
expect(toCommitRow(back)).not.toHaveProperty("biomarkerId");
});
it("sends no marker for an automatic match", () => {
const [row] = seedReviewRows(
[{ ...dto, biomarkerId: "bm-alp", matchKind: "alias", suggestions: [] }],
null,
);
expect(toCommitRow(row)).not.toHaveProperty("biomarkerId");
});
});
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -76,17 +77,20 @@ function render(
consentRequired?: boolean;
} = {},
): string {
// The dialog reads the account's markers once rows are on screen (v1.43).
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OcrReviewDialog
open
onOpenChange={() => {}}
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
consentRequired={options.consentRequired}
onCommitted={() => {}}
/>
</I18nProvider>,
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>
<OcrReviewDialog
open
onOpenChange={() => {}}
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
consentRequired={options.consentRequired}
onCommitted={() => {}}
/>
</I18nProvider>
</QueryClientProvider>,
);
}
@@ -28,6 +28,10 @@ function row(overrides: Partial<OcrReviewRow> = {}): OcrReviewRow {
markerUnit: "mmol/L",
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: "bm-ldl",
matchKind: "name",
suggestions: [],
chosenBiomarkerId: null,
confirmed: true,
...overrides,
};
@@ -205,6 +209,9 @@ describe("a reading in another unit than its marker's", () => {
markerUnit,
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: "bm-glucose",
matchKind: "name" as const,
suggestions: [],
});
const [same, other, fresh] = seedReviewRows(
[dto("mmol/L", "mmol/L"), dto("mmol/L", "mg/dL"), dto("mmol/L", null)],
@@ -271,6 +278,9 @@ describe("a reading in another unit that can be converted", () => {
markerUnit: "mg/dL",
markerAnalyteKey: "fasting-glucose",
duplicateOf: null,
biomarkerId: "bm-glucose",
matchKind: "name" as const,
suggestions: [],
};
const [converts, blocked] = seedReviewRows(
[dto, { ...dto, markerAnalyteKey: null }],
@@ -21,6 +21,10 @@ const baseRow: OcrReviewRow = {
markerUnit: "mmol/L",
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: "bm-ldl",
matchKind: "name",
suggestions: [],
chosenBiomarkerId: null,
confirmed: true,
};
+154
View File
@@ -0,0 +1,154 @@
"use client";
/**
* v1.43 (D1223) — the other spellings a marker answers to.
*
* Shown as chips on the marker; with the labs manage right they can be added
* and removed. Every change sends the whole set (`PUT /api/biomarkers/{id}`
* with `aliases`), which is how the server reads it. A spelling another marker
* already answers to is refused with `labs.biomarker.alias.taken`; the
* sentence names the spelling so the person knows which one to drop.
*/
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { TagChip } from "@/components/ui/tag-chip";
import { ApiError, apiPut } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BiomarkerDto } from "./types";
/** Longest alias the server stores. */
const ALIAS_MAX = 120;
export function BiomarkerAliases({
marker,
canEdit,
}: {
marker: Pick<BiomarkerDto, "id" | "name" | "aliases">;
canEdit: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const aliases = marker.aliases ?? [];
const save = useMutation({
mutationFn: (next: string[]) =>
apiPut<BiomarkerDto>(`/api/biomarkers/${marker.id}`, { aliases: next }),
onSuccess: () => {
setError(null);
setDraft("");
queryClient.invalidateQueries({
queryKey: queryKeys.biomarkerDetail(marker.id),
});
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
},
onError: (err) => {
const alias =
err instanceof ApiError &&
err.meta?.errorCode === "labs.biomarker.alias.taken" &&
typeof err.meta.alias === "string"
? err.meta.alias
: null;
setError(
alias
? t("labs.aliases.taken", { alias })
: t("labs.aliases.saveError"),
);
},
});
if (aliases.length === 0 && !canEdit) return null;
const add = () => {
const name = draft.trim();
if (!name) return;
if (aliases.some((a) => a.toLowerCase() === name.toLowerCase())) {
setDraft("");
return;
}
save.mutate([...aliases, name]);
};
return (
<div className="space-y-2" data-slot="biomarker-aliases">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("labs.aliases.title")}
</p>
{aliases.length > 0 ? (
<ul className="flex flex-wrap gap-1.5">
{aliases.map((alias) => (
<li key={alias}>
<TagChip className="inline-flex items-center gap-1">
{alias}
{canEdit ? (
<button
type="button"
className="hover:text-foreground text-muted-foreground focus-visible:ring-ring/50 -my-1 -me-1 inline-flex size-6 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
onClick={() =>
save.mutate(aliases.filter((a) => a !== alias))
}
disabled={save.isPending}
aria-label={t("labs.aliases.remove", { alias })}
>
<X className="size-3" aria-hidden />
</button>
) : null}
</TagChip>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground text-xs">
{t("labs.aliases.empty", { name: marker.name })}
</p>
)}
{canEdit ? (
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
add();
}}
>
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
maxLength={ALIAS_MAX}
placeholder={t("labs.aliases.placeholder")}
aria-label={t("labs.aliases.addLabel")}
className="min-w-0 flex-1"
/>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
disabled={save.isPending || draft.trim() === ""}
>
{save.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Plus className="size-4" aria-hidden />
)}
{t("labs.aliases.add")}
</Button>
</form>
) : null}
{error ? (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
) : null}
</div>
);
}
+38 -1
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, EyeOff, FlaskConical, Pencil, Plus } from "lucide-react";
import { Eye, EyeOff, FlaskConical, Merge, Pencil, Plus } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
@@ -18,7 +18,10 @@ import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { BiomarkerForm } from "./biomarker-form";
import { BiomarkerMergeSheet } from "./biomarker-merge-sheet";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import { useLabNumber } from "./use-lab-format";
@@ -49,6 +52,10 @@ export function BiomarkerManager() {
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<BiomarkerDto | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
// v1.43 (D1223) — merging is a manage-level act, like edit and delete.
const { canManageDomain } = useRecordCapabilities();
const canMerge = canManageDomain("labs");
const [mergeOpen, setMergeOpen] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.biomarkers(),
@@ -125,6 +132,16 @@ export function BiomarkerManager() {
: t("labs.biomarker.noRange")}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
{marker.aliases && marker.aliases.length > 0 ? (
<p
className="text-muted-foreground truncate text-xs"
data-slot="biomarker-row-aliases"
>
{t("labs.aliases.alsoWritten", {
aliases: marker.aliases.join(", "),
})}
</p>
) : null}
</div>
<Button
size="icon"
@@ -216,6 +233,18 @@ export function BiomarkerManager() {
state's own button, so the card never offers the same action twice. */}
{!isLoading && !isError && markers.length > 0 ? (
<SettingsCardActions>
{canMerge && markers.length >= 2 ? (
<Button
size="sm"
variant="outline"
onClick={() => setMergeOpen(true)}
className="min-h-11 sm:min-h-9"
data-slot="biomarker-merge-open"
>
<Merge className="h-4 w-4" />
{t("labs.merge.open")}
</Button>
) : null}
<Button size="sm" onClick={openNew} className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("labs.biomarker.define")}
@@ -223,6 +252,14 @@ export function BiomarkerManager() {
</SettingsCardActions>
) : null}
{canMerge ? (
<BiomarkerMergeSheet
open={mergeOpen}
onOpenChange={setMergeOpen}
markers={markers}
/>
) : null}
<ResponsiveSheet
open={formOpen}
onOpenChange={setFormOpen}
@@ -0,0 +1,284 @@
"use client";
/**
* v1.43 (D1223) — merge markers that are one analyte written two ways.
*
* Three steps in one sheet: pick the markers, pick the one that stays, read
* the preview, then confirm. The preview is the server's own dry run
* (`POST /api/biomarkers/{id}/merge` with `dryRun: true`): how many readings
* move, which are converted into the kept marker's unit, which names become
* its aliases, and any marker whose unit cannot be converted, which blocks the
* merge. A merge cannot be undone, and the sheet says so before the button.
* Nothing is computed here; every number is the server's.
*/
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { TagChip } from "@/components/ui/tag-chip";
import { apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types";
/** The most sources one merge takes, matching the server. */
const MAX_SOURCES = 10;
export function BiomarkerMergeSheet({
open,
onOpenChange,
markers,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
markers: readonly BiomarkerDto[];
}) {
const { t, tCount } = useTranslations();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const [targetId, setTargetId] = useState<string | null>(null);
const [preview, setPreview] = useState<BiomarkerMergeResultDto | null>(null);
const target = targetId && selected.includes(targetId) ? targetId : null;
const keepId = target ?? selected[0] ?? null;
const sourceIds = useMemo(
() => selected.filter((id) => id !== keepId),
[selected, keepId],
);
const byId = useMemo(() => new Map(markers.map((m) => [m.id, m])), [markers]);
const reset = () => {
setSelected([]);
setTargetId(null);
setPreview(null);
};
const run = useMutation({
mutationFn: (dryRun: boolean) =>
apiPost<BiomarkerMergeResultDto>(`/api/biomarkers/${keepId}/merge`, {
sourceIds,
dryRun,
}),
onSuccess: (result) => {
if (result.dryRun) {
setPreview(result);
return;
}
toastWrittenOutcome(
"success",
tCount("labs.merge.doneToast", result.movedReadings, {
name: result.target.name,
}),
);
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
reset();
onOpenChange(false);
},
onError: () => toast.error(t("labs.merge.error")),
});
const toggle = (id: string, checked: boolean) => {
setPreview(null);
setSelected((prev) =>
checked
? prev.length > MAX_SOURCES
? prev
: [...prev, id]
: prev.filter((x) => x !== id),
);
};
const refused = preview?.sources.filter((s) => s.conversion === "refused");
const canPreview = keepId !== null && sourceIds.length > 0;
return (
<ResponsiveSheet
open={open}
onOpenChange={(next) => {
if (!next) reset();
onOpenChange(next);
}}
title={t("labs.merge.title")}
description={t("labs.merge.description")}
footer={
<div className="flex w-full flex-wrap justify-end gap-2">
{preview ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setPreview(null)}
disabled={run.isPending}
>
{t("labs.merge.back")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="biomarker-merge-confirm"
onClick={() => run.mutate(false)}
disabled={run.isPending || (refused?.length ?? 0) > 0}
>
{run.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("labs.merge.confirm")}
</Button>
</>
) : (
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="biomarker-merge-preview"
onClick={() => run.mutate(true)}
disabled={!canPreview || run.isPending}
>
{run.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("labs.merge.showPreview")}
</Button>
)}
</div>
}
>
{preview ? (
<div className="space-y-4" data-slot="biomarker-merge-preview-body">
<p className="text-sm">
{tCount("labs.merge.previewSummary", preview.movedReadings, {
name: preview.target.name,
unit: preview.target.unit,
})}
</p>
<ul className="space-y-2">
{preview.sources.map((source) => (
<li
key={source.id}
className="border-border space-y-0.5 rounded-lg border p-3"
>
<p className="text-sm font-medium break-words">
{source.name}{" "}
<span className="text-muted-foreground text-xs font-normal">
{source.unit}
</span>
</p>
<p
className={
source.conversion === "refused"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{source.conversion === "refused"
? t("labs.merge.refused", {
unit: source.unit,
targetUnit: preview.target.unit,
})
: source.conversion === "converted"
? tCount("labs.merge.converted", source.readingCount, {
unit: source.unit,
targetUnit: preview.target.unit,
})
: tCount("labs.merge.moves", source.readingCount)}
</p>
</li>
))}
</ul>
{preview.aliasesAdded.length > 0 ? (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs">
{t("labs.merge.aliasesAdded")}
</p>
<div className="flex flex-wrap gap-1.5">
{preview.aliasesAdded.map((alias) => (
<TagChip key={alias}>{alias}</TagChip>
))}
</div>
</div>
) : null}
{(refused?.length ?? 0) > 0 ? (
<p role="alert" className="text-destructive text-sm">
{t("labs.merge.blocked")}
</p>
) : (
<p className="text-sm font-medium">{t("labs.merge.noUndo")}</p>
)}
</div>
) : (
<div className="space-y-4" data-slot="biomarker-merge-select">
<ul className="space-y-1">
{markers.map((marker) => {
const id = `merge-pick-${marker.id}`;
const checked = selected.includes(marker.id);
return (
<li key={marker.id}>
<label
htmlFor={id}
className="hover:bg-muted/50 flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-2 py-1.5"
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={(state) =>
toggle(marker.id, state === true)
}
disabled={!checked && selected.length > MAX_SOURCES}
/>
<span className="min-w-0 flex-1 truncate text-sm">
{marker.name}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{marker.unit}
</span>
</label>
</li>
);
})}
</ul>
{selected.length >= 2 ? (
<div className="space-y-1.5">
<Label htmlFor="merge-keep">{t("labs.merge.keepLabel")}</Label>
<NativeSelect
id="merge-keep"
value={keepId ?? ""}
onChange={(event) => setTargetId(event.target.value)}
>
{selected.map((id) => (
<option key={id} value={id}>
{byId.get(id)?.name ?? id}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("labs.merge.keepHint")}
</p>
</div>
) : (
<p className="text-muted-foreground text-xs">
{t("labs.merge.pickHint")}
</p>
)}
</div>
)}
</ResponsiveSheet>
);
}
+26 -2
View File
@@ -34,7 +34,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status";
import { useMounted } from "@/hooks/use-mounted";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { ApiError, apiDelete, apiGet } from "@/lib/api/api-fetch";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { useTranslations } from "@/lib/i18n/context";
@@ -52,6 +52,7 @@ import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { importWithRetry } from "@/lib/retry-import";
import { BiomarkerAliases } from "./biomarker-aliases";
import { BiomarkerForm } from "./biomarker-form";
import { LabForm } from "./lab-form";
import type {
@@ -158,12 +159,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const {
data: marker,
isError: markerError,
error: markerFailure,
refetch: refetchMarker,
} = useQuery({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
});
// v1.43 (D1223) — a marker merged into another answers 404 with the
// marker it went into; a bookmark or a stale screen follows it there.
const mergedInto =
markerFailure instanceof ApiError &&
markerFailure.status === 404 &&
typeof markerFailure.meta?.mergedInto === "string"
? markerFailure.meta.mergedInto
: null;
useEffect(() => {
if (!mergedInto) return;
queryClient.removeQueries({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
});
router.replace(`/labs/${mergedInto}`);
}, [mergedInto, biomarkerId, queryClient, router]);
// v1.25 — offset-paginated reading feed. The page no longer fetches a single
// `limit=500` window (which silently truncated a marker once it crossed 500
// readings); it loads a page at a time and a "Load more" control reveals the
@@ -305,7 +323,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
// v1.25.1 (H2-M2) — the error state keeps the heading + description and adds a
// retry, mirroring the insights `SubPageShell` recovery affordance rather than
// collapsing to a bare line with no context and no way back.
if (markerError || listError) {
// A merged marker is on its way to its target (see above), not failed.
if ((markerError && !mergedInto) || listError) {
return (
<div className="space-y-6">
<PageHeader
@@ -409,6 +428,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</p>
) : null}
{/* v1.43 (D1223) — the other spellings this marker answers to. */}
{marker ? (
<BiomarkerAliases marker={marker} canEdit={canManageLabs} />
) : null}
{/* Bridge to the source report PDFs. There is no per-value document
relation in the data model yet, so this links to the vault filtered
to lab-result documents rather than the exact scan behind one value.
+124
View File
@@ -0,0 +1,124 @@
"use client";
/**
* v1.43 (D1223) — which marker a reviewed lab value goes to.
*
* Shared by the scan review and the document review. The server resolves a
* printed name to one of the account's markers (its name, an alias, or a
* catalog abbreviation such as "T.Bili") and, when nothing resolves, offers up
* to three markers to choose from. This renders that resolution: the marker a
* value was matched to, or a choice between the suggestions and a new marker.
* A choice is remembered by the server as another spelling of the marker, so
* the next report from the same lab matches on its own.
*
* Nothing is computed here: the match, its kind and the suggestions all come
* from the review DTO.
*/
import { useId } from "react";
import { FilePlus2, Link2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useTranslations } from "@/lib/i18n/context";
export interface MarkerSuggestionDto {
biomarkerId: string;
name: string;
unit: string;
matchKind: "catalog" | "similar";
}
/** The value for "a new marker" in the choice. */
const NEW_MARKER = "";
export function MarkerMatchField({
matchedName,
matchKind,
suggestions,
chosenId,
onChoose,
slot,
}: {
/** The marker the server matched, by name; null when nothing matched. */
matchedName: string | null;
matchKind: "name" | "alias" | "catalog" | null;
suggestions: readonly MarkerSuggestionDto[];
/** The suggestion the person picked, or null for a new marker. */
chosenId: string | null;
onChoose: (suggestion: MarkerSuggestionDto | null) => void;
/** `data-slot` prefix for tests. */
slot: string;
}) {
const { t } = useTranslations();
const selectId = useId();
if (matchedName !== null) {
return (
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant="outline"
className="text-muted-foreground max-w-full"
title={t("labs.ocr.linksExisting", { name: matchedName })}
data-slot={`${slot}-matched`}
>
<Link2 aria-hidden />
<span className="truncate">
{t("labs.ocr.linksExisting", { name: matchedName })}
</span>
</Badge>
{matchKind === "alias" || matchKind === "catalog" ? (
<span className="text-muted-foreground text-xs">
{matchKind === "alias"
? t("labs.match.viaAlias")
: t("labs.match.viaCatalog")}
</span>
) : null}
</div>
);
}
if (suggestions.length === 0) {
return (
<Badge
variant="outline"
className="text-muted-foreground"
data-slot={`${slot}-new`}
>
<FilePlus2 aria-hidden />
{t("labs.ocr.newBiomarker")}
</Badge>
);
}
return (
<div className="space-y-1.5" data-slot={`${slot}-choice`}>
<Label htmlFor={selectId} className="text-xs">
{t("labs.match.chooseLabel")}
</Label>
<NativeSelect
id={selectId}
value={chosenId ?? NEW_MARKER}
onChange={(event) =>
onChoose(
suggestions.find((s) => s.biomarkerId === event.target.value) ??
null,
)
}
>
{suggestions.map((suggestion) => (
<option key={suggestion.biomarkerId} value={suggestion.biomarkerId}>
{t("labs.match.suggestionOption", {
name: suggestion.name,
unit: suggestion.unit,
})}
</option>
))}
<option value={NEW_MARKER}>{t("labs.match.newMarkerOption")}</option>
</NativeSelect>
<p className="text-muted-foreground text-xs">
{chosenId ? t("labs.match.rememberHint") : t("labs.match.chooseHint")}
</p>
</div>
);
}
+24 -1
View File
@@ -18,12 +18,16 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { ApiError } from "@/lib/api/api-fetch";
import { useQuery } from "@tanstack/react-query";
import { ApiError, apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { OcrSkippedRowDto } from "@/lib/validations/labs-ocr";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useTranslations } from "@/lib/i18n/context";
import { OcrRowEditor } from "./ocr-row-editor";
import type { BiomarkerListResponse } from "./types";
import { seedReviewRows, type OcrReviewRow } from "./ocr-review-types";
import { collectRowErrors, planSave } from "./ocr-review-validation";
import {
@@ -176,6 +180,24 @@ export function OcrReviewDialog({
const textExtract = useOcrTextExtract();
const extract = mode === "text" ? textExtract : visionExtract;
const commit = useOcrCommit();
// v1.43 — the account's markers, so a matched row names its marker and a
// chosen suggestion brings the analyte it converts with. Only once rows are
// on screen; the review works without it (names fall back to the printed
// analyte).
const markers = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
enabled: rows.length > 0,
});
const markerLookup = useMemo(() => {
const byId = new Map(
(markers.data?.biomarkers ?? []).map((m) => [
m.id,
{ name: m.name, analyteKey: m.analyteKey },
]),
);
return (id: string) => byId.get(id) ?? null;
}, [markers.data]);
// Text mode never accepts PDFs (tesseract.js can't read them).
const allowPdf = mode === "vision" && pdfSupported;
@@ -439,6 +461,7 @@ export function OcrReviewDialog({
<OcrRowEditor
key={row.key}
row={row}
markerLookup={markerLookup}
errors={showErrors ? rowErrors.get(row.key) : undefined}
onChange={(next) =>
setRows((prev) =>
+43
View File
@@ -34,6 +34,16 @@ export interface OcrReviewRow {
markerAnalyteKey: string | null;
/** Non-null when the server flagged a likely duplicate of a live reading. */
duplicateOf: string | null;
/**
* v1.43 (D1223) — the marker the server matched the name to (its name, an
* alias, or a catalog abbreviation), and how; null when nothing matched.
*/
biomarkerId: string | null;
matchKind: "name" | "alias" | "catalog" | null;
/** Up to three markers to choose from when nothing matched. */
suggestions: OcrExtractedRowDto["suggestions"];
/** The suggestion the person picked; null keeps the automatic outcome. */
chosenBiomarkerId: string | null;
/** Whether the row is selected for saving. Defaults false for duplicates. */
confirmed: boolean;
}
@@ -58,6 +68,11 @@ export function seedReviewRows(
markerUnit: row.markerUnit,
markerAnalyteKey: row.markerAnalyteKey,
duplicateOf: row.duplicateOf,
// A scan read before v1.43 carries none of the three.
biomarkerId: row.biomarkerId ?? null,
matchKind: row.matchKind ?? null,
suggestions: row.suggestions ?? [],
chosenBiomarkerId: null,
// A flagged duplicate starts unchecked, and so does a reading in another
// unit than its marker's that cannot be converted (it carries a badge
// saying why, and ticking it marks the unit field until it is fixed); a
@@ -74,3 +89,31 @@ export function seedReviewRows(
}),
}));
}
/**
* Point a row at a suggestion the person picked, or back at a new marker. The
* marker's unit and analyte come with the choice, so the unit check and the
* conversion badge judge the row against the marker it will be filed under.
*/
export function chooseMarker(
row: OcrReviewRow,
choice: { biomarkerId: string; unit: string } | null,
analyteKeyOf: (biomarkerId: string) => string | null,
): OcrReviewRow {
if (choice === null) {
return {
...row,
chosenBiomarkerId: null,
biomarkerMatch: "new",
markerUnit: null,
markerAnalyteKey: null,
};
}
return {
...row,
chosenBiomarkerId: choice.biomarkerId,
biomarkerMatch: "existing",
markerUnit: choice.unit,
markerAnalyteKey: analyteKeyOf(choice.biomarkerId),
};
}
+12 -1
View File
@@ -129,7 +129,12 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
if (isQualitativeRow(row)) {
const valueText = (row.valueText ?? "").trim();
if (!valueText) return null;
return { analyte, valueText, takenAt: takenAt.toISOString() };
return {
analyte,
valueText,
takenAt: takenAt.toISOString(),
...chosen(row),
};
}
if (row.value === null || !Number.isFinite(row.value)) return null;
@@ -144,9 +149,15 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
...(row.referenceLow !== null ? { referenceLow: row.referenceLow } : {}),
...(row.referenceHigh !== null ? { referenceHigh: row.referenceHigh } : {}),
...(row.referenceText ? { referenceText: row.referenceText } : {}),
...chosen(row),
};
}
/** The marker the person picked, which the commit then files the row under. */
function chosen(row: OcrReviewRow): { biomarkerId?: string } {
return row.chosenBiomarkerId ? { biomarkerId: row.chosenBiomarkerId } : {};
}
/** Blocking fields per CONFIRMED row, keyed by the row's key; writable rows are absent. */
export function collectRowErrors(
rows: readonly OcrReviewRow[],
+34 -27
View File
@@ -15,13 +15,7 @@
*/
import { useId } from "react";
import {
AlertCircle,
FilePlus2,
Link2,
Ruler,
TriangleAlert,
} from "lucide-react";
import { AlertCircle, Ruler, TriangleAlert } from "lucide-react";
import { FieldError } from "@/components/forms/field-error";
import { Badge } from "@/components/ui/badge";
@@ -31,7 +25,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import type { OcrReviewRow } from "./ocr-review-types";
import { MarkerMatchField } from "./marker-match-field";
import { chooseMarker, type OcrReviewRow } from "./ocr-review-types";
import {
readingConversion,
readingUnitBlocked,
@@ -55,9 +50,18 @@ export function OcrRowEditor({
row,
onChange,
errors,
markerLookup = () => null,
}: {
row: OcrReviewRow;
onChange: (next: OcrReviewRow) => void;
/**
* The account's marker by id, for the matched marker's name and the
* analyte a chosen suggestion converts with. Absent: names fall back to the
* printed analyte.
*/
markerLookup?: (
biomarkerId: string,
) => { name: string; analyteKey: string | null } | null;
/** The blocking fields of this row, once a save has been attempted. */
errors?: OcrRowErrors;
}) {
@@ -151,26 +155,29 @@ export function OcrRowEditor({
className="font-medium"
/>
{fieldError("analyte", "labs.ocr.errorAnalyte")}
{/* v1.43 (D1223) — the marker this value goes to: the server's
match, or a choice between its suggestions and a new one. */}
<MarkerMatchField
matchedName={
row.biomarkerId !== null
? (markerLookup(row.biomarkerId)?.name ?? row.analyte)
: null
}
matchKind={row.matchKind}
suggestions={row.suggestions}
chosenId={row.chosenBiomarkerId}
onChoose={(suggestion) =>
onChange(
chooseMarker(
row,
suggestion,
(id) => markerLookup(id)?.analyteKey ?? null,
),
)
}
slot="ocr-row-marker"
/>
<div className="flex flex-wrap items-center gap-1.5">
{row.biomarkerMatch === "existing" ? (
// The chip repeats the analyte, so a long panel name ran past
// the row on a phone; it truncates inside the row instead.
<Badge
variant="outline"
className="text-muted-foreground max-w-full"
title={t("labs.ocr.linksExisting", { name: row.analyte })}
>
<Link2 aria-hidden />
<span className="truncate">
{t("labs.ocr.linksExisting", { name: row.analyte })}
</span>
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
<FilePlus2 aria-hidden />
{t("labs.ocr.newBiomarker")}
</Badge>
)}
{row.duplicateOf ? (
<Badge variant="secondary">
<TriangleAlert aria-hidden />
+23
View File
@@ -95,6 +95,11 @@ export interface BiomarkerDto {
analyteKey: string | null;
hasContext: boolean;
context: string | null;
/**
* v1.43 — other spellings that resolve to this marker, sorted, without its
* own name. Absent from an older server.
*/
aliases?: string[];
/** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */
hidden: boolean;
createdAt: string;
@@ -104,3 +109,21 @@ export interface BiomarkerDto {
export interface BiomarkerListResponse {
biomarkers: BiomarkerDto[];
}
/** v1.43 — `POST /api/biomarkers/{id}/merge`, for the preview and the result. */
export interface BiomarkerMergeResultDto {
dryRun: boolean;
target: BiomarkerDto;
sources: Array<{
id: string;
name: string;
unit: string;
readingCount: number;
conversion: "none" | "converted" | "refused";
refusal: "unknown_analyte" | "unknown_unit" | "not_finite" | null;
}>;
movedReadings: number;
convertedReadings: number;
aliasesAdded: string[];
mergeId: string | null;
}
+5
View File
@@ -51,6 +51,11 @@ export interface OcrCommitRowInput {
referenceHigh?: number;
referenceText?: string;
takenAt: string;
/**
* v1.43 — the marker the person chose on the review screen; the server then
* remembers the analyte as its alias. Absent for an automatic match.
*/
biomarkerId?: string;
}
export interface OcrCommitResult extends OcrCommitResponseDto {
@@ -125,6 +125,41 @@ describe("paged link picker", () => {
expect(html).toContain('data-slot="labs-add"');
});
it("names the period a date term searches, and does not filter its answer by the digits", () => {
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server({
term: "03.2019",
filterLocally: true,
period: {
year: 2019,
month: 3,
fromDay: "2019-03-01",
toDay: "2019-03-31",
},
})}
/>,
);
expect(html).toContain('data-slot="labs-period"');
expect(html).toContain("Showing March 2019");
expect(html).toContain('data-slot="labs-option"');
});
it("shows no period line for a plain term", () => {
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server({ term: "metabolic" })}
/>,
);
expect(html).not.toContain('data-slot="labs-period"');
});
it("does not filter a server answer again by the term", () => {
// The route matched on the panel name; the option does not render it.
const html = render(
@@ -0,0 +1,155 @@
/**
* A picker term that names a year or a month becomes a date window; anything
* else stays a text search. Every accepted form is checked in every UI
* locale, and the near misses that must stay text are checked too.
*/
import { describe, expect, it } from "vitest";
import { formatPeriod, parsePeriodQuery } from "../period-query";
const NOW = new Date("2026-10-15T12:00:00.000Z");
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
function window(term: string, locale: string) {
const parsed = parsePeriodQuery(term, locale, NOW);
return parsed ? `${parsed.fromDay}..${parsed.toDay}` : null;
}
const MARCH = "2019-03-01..2019-03-31";
const YEAR = "2019-01-01..2019-12-31";
describe("parsePeriodQuery", () => {
describe.each(LOCALES)("%s", (locale) => {
it("reads a year", () => {
expect(window("2019", locale)).toBe(YEAR);
expect(window(" 2019 ", locale)).toBe(YEAR);
});
it.each([
"03.2019",
"3.2019",
"03/2019",
"3/2019",
"03-2019",
"2019-03",
"2019/03",
"2019.03",
"2019.3",
" 03 . 2019 ",
])("reads the numeric month %j", (term) => {
expect(window(term, locale)).toBe(MARCH);
});
it.each(["LDL", "2019abc", "13.2019", "00.2019", "1850", "1899", "2028"])(
"leaves %j as text",
(term) => {
expect(window(term, locale)).toBeNull();
},
);
it.each(["", " ", "2019 2020", "03.2019.04", "LDL 2019", "3 2019 x"])(
"leaves %j as text",
(term) => {
expect(window(term, locale)).toBeNull();
},
);
});
it.each([
["de", ["März 2019", "märz 2019", "MÄRZ 2019", "Maerz 2019", "Marz 2019"]],
["de", ["mär 2019", "Mär. 2019", "2019 März", "März, 2019"]],
["en", ["March 2019", "march 2019", "Mar 2019", "Mar. 2019", "2019 March"]],
["es", ["marzo 2019", "Marzo de 2019", "mar 2019", "mar. 2019"]],
["fr", ["mars 2019", "Mars 2019", "mar 2019", "2019 mars"]],
["it", ["marzo 2019", "Marzo 2019", "mar 2019", "marzo del 2019"]],
["pl", ["marzec 2019", "Marzec 2019", "marca 2019", "mar 2019"]],
["ko", ["2019년 3월", "2019년3월", "2019년 03월", "3월 2019", "2019. 3."]],
] as const)("reads March 2019 in %s", (locale, terms) => {
for (const term of terms) {
expect(window(term, locale), term).toBe(MARCH);
}
});
it.each([
["de", "Dezember 2019", "2019-12-01..2019-12-31"],
["de", "Okt 2019", "2019-10-01..2019-10-31"],
["de", "Februar 2020", "2020-02-01..2020-02-29"],
["en", "Sept 2019", "2019-09-01..2019-09-30"],
["en", "Sep 2019", "2019-09-01..2019-09-30"],
["en", "May 2019", "2019-05-01..2019-05-31"],
["es", "septiembre 2019", "2019-09-01..2019-09-30"],
["es", "ene 2019", "2019-01-01..2019-01-31"],
["es", "dic 2019", "2019-12-01..2019-12-31"],
["fr", "février 2019", "2019-02-01..2019-02-28"],
["fr", "fevrier 2019", "2019-02-01..2019-02-28"],
["fr", "févr. 2019", "2019-02-01..2019-02-28"],
["fr", "août 2019", "2019-08-01..2019-08-31"],
["fr", "déc 2019", "2019-12-01..2019-12-31"],
["fr", "juil. 2019", "2019-07-01..2019-07-31"],
["it", "gennaio 2019", "2019-01-01..2019-01-31"],
["it", "giu 2019", "2019-06-01..2019-06-30"],
["it", "set 2019", "2019-09-01..2019-09-30"],
["pl", "październik 2019", "2019-10-01..2019-10-31"],
["pl", "paź 2019", "2019-10-01..2019-10-31"],
["pl", "października 2019", "2019-10-01..2019-10-31"],
["pl", "luty 2019", "2019-02-01..2019-02-28"],
["pl", "styczeń 2019", "2019-01-01..2019-01-31"],
["ko", "2019년 12월", "2019-12-01..2019-12-31"],
["ko", "2019년", YEAR],
// Austrian German calls January "Jänner", and Intl knows it.
["de-AT", "Jänner 2019", "2019-01-01..2019-01-31"],
["de-AT", "März 2019", MARCH],
] as const)("%s %j", (locale, term, expected) => {
expect(window(term, locale)).toBe(expected);
});
it.each([
// "jui" is both juin and juillet; "ma" is too short to name one month.
["fr", "jui 2019"],
["en", "ma 2019"],
["en", "Ju 2019"],
// A month name from another language is text in this one.
["en", "März 2019"],
["de", "March 2019"],
// A month without a year is not a period.
["en", "March"],
["de", "03"],
// Two months, or a month name with digits glued on.
["en", "March May"],
["en", "March2019"],
] as const)("%s %j stays text", (locale, term) => {
expect(window(term, locale)).toBeNull();
});
it("accepts next year and refuses the one after", () => {
expect(window("2027", "en")).toBe("2027-01-01..2027-12-31");
expect(window("12.2027", "en")).toBe("2027-12-01..2027-12-31");
expect(window("2028", "en")).toBeNull();
expect(window("1900", "en")).toBe("1900-01-01..1900-12-31");
});
it("names the year and the month", () => {
expect(parsePeriodQuery("2019", "de", NOW)).toEqual({
year: 2019,
month: null,
fromDay: "2019-01-01",
toDay: "2019-12-31",
});
expect(parsePeriodQuery("Maerz 2019", "de", NOW)).toMatchObject({
year: 2019,
month: 3,
});
});
});
describe("formatPeriod", () => {
it("names a period the way the locale writes it", () => {
const march = parsePeriodQuery("03.2019", "de", NOW)!;
expect(formatPeriod(march, "de")).toBe("März 2019");
expect(formatPeriod(march, "en")).toBe("March 2019");
expect(formatPeriod(march, "ko")).toBe("2019년 3월");
expect(formatPeriod(parsePeriodQuery("2019", "en", NOW)!, "en")).toBe(
"2019",
);
});
});
@@ -28,15 +28,24 @@ import {
} from "../use-link-picker-pages";
const pivot = pickerPivot("2019-05-10T10:00:00.000Z");
const ctx = { locale: "en", timeZone: "UTC" } as const;
const REQUESTS: PickerPageRequest[] = [
{ direction: "older", pivot, term: "", cursor: null },
{ direction: "older", pivot, term: "", cursor: "200" },
{ direction: "older", pivot: null, term: "", cursor: null },
{ direction: "newer", pivot, term: "", cursor: null },
{ direction: "newer", pivot, term: "", cursor: "100" },
{ direction: "search", pivot: null, term: "ferritin", cursor: null },
{ direction: "search", pivot: null, term: "ferritin", cursor: "100" },
{ direction: "older", pivot, term: "", cursor: null, ...ctx },
{ direction: "older", pivot, term: "", cursor: "200", ...ctx },
{ direction: "older", pivot: null, term: "", cursor: null, ...ctx },
{ direction: "newer", pivot, term: "", cursor: null, ...ctx },
{ direction: "newer", pivot, term: "", cursor: "100", ...ctx },
{ direction: "search", pivot: null, term: "ferritin", cursor: null, ...ctx },
{ direction: "search", pivot: null, term: "ferritin", cursor: "100", ...ctx },
{ direction: "search", pivot: null, term: "2019", cursor: null, ...ctx },
{
direction: "search",
pivot: null,
term: "March 2019",
cursor: "100",
...ctx,
},
];
function params(url: string): Record<string, string> {
@@ -67,10 +76,22 @@ describe("documents picker", () => {
it("splits the day-granular window at the pivot's day", () => {
const older = params(
documentPickerUrl({ direction: "older", pivot, term: "", cursor: null }),
documentPickerUrl({
direction: "older",
pivot,
term: "",
cursor: null,
...ctx,
}),
);
const newer = params(
documentPickerUrl({ direction: "newer", pivot, term: "", cursor: null }),
documentPickerUrl({
direction: "newer",
pivot,
term: "",
cursor: null,
...ctx,
}),
);
expect(older.to).toBe("2019-05-17");
expect(newer.from).toBe("2019-05-18");
@@ -88,10 +109,22 @@ describe("the windows around a record's date", () => {
it("older reads up to the split newest first, newer after it oldest first", () => {
const older = params(
labPickerUrl({ direction: "older", pivot, term: "", cursor: null }),
labPickerUrl({
direction: "older",
pivot,
term: "",
cursor: null,
...ctx,
}),
);
const newer = params(
labPickerUrl({ direction: "newer", pivot, term: "", cursor: null }),
labPickerUrl({
direction: "newer",
pivot,
term: "",
cursor: null,
...ctx,
}),
);
expect(older).toMatchObject({ to: pivot, sortDir: "desc", offset: "0" });
expect(newer).toMatchObject({
@@ -107,6 +140,7 @@ describe("the windows around a record's date", () => {
pivot: null,
term: "ferritin",
cursor: null,
...ctx,
}),
);
expect(search.q).toBe("ferritin");
@@ -120,3 +154,88 @@ describe("the windows around a record's date", () => {
expect(nextOffset("100", 3)).toBeNull();
});
});
describe("a search term that names a period", () => {
const search = (
term: string,
locale = "en",
timeZone = "UTC",
): PickerPageRequest => ({
direction: "search",
pivot: null,
term,
cursor: null,
locale,
timeZone,
});
it.each([
["labs", labPickerUrl, "sortDir"],
["episodes", episodePickerUrl, "sortDir"],
] as const)(
"%s: a year is the year's instant window, newest first, with no q",
(_name, build, sortKey) => {
const sent = params(build(search("2019")));
expect(sent.q).toBeUndefined();
expect(sent.from).toBe("2019-01-01T00:00:00.000Z");
expect(sent.to).toBe("2019-12-31T23:59:59.999Z");
expect(sent[sortKey]).toBe("desc");
},
);
it("labs: a month in the person's language is that month", () => {
const sent = params(labPickerUrl(search("März 2019", "de")));
expect(sent.q).toBeUndefined();
expect(sent.from).toBe("2019-03-01T00:00:00.000Z");
expect(sent.to).toBe("2019-03-31T23:59:59.999Z");
});
it("the window follows the person's timezone, DST included", () => {
// Berlin: March 2019 starts at UTC+1 and ends at UTC+2.
const sent = params(
episodePickerUrl(search("03.2019", "de", "Europe/Berlin")),
);
expect(sent.from).toBe("2019-02-28T23:00:00.000Z");
expect(sent.to).toBe("2019-03-31T21:59:59.999Z");
});
it("documents: the day-granular window is the period's two calendar days", () => {
const sent = params(documentPickerUrl(search("2019-02")));
expect(sent.q).toBeUndefined();
expect(sent.from).toBe("2019-02-01");
expect(sent.to).toBe("2019-02-28");
expect(sent.order).toBe("desc");
});
it("encounters: the route without text search still gets the period", () => {
const sent = params(encounterPickerUrl(search("Mar 2019")));
expect(sent.q).toBeUndefined();
expect(sent.from).toBe("2019-03-01T00:00:00.000Z");
expect(sent.to).toBe("2019-03-31T23:59:59.999Z");
});
it.each([labPickerUrl, episodePickerUrl, documentPickerUrl])(
"a plain term stays a text search (%#)",
(build) => {
for (const term of ["LDL", "2019abc", "13.2019", "1850"]) {
const sent = params(build(search(term)));
expect(sent.q).toBe(term);
expect(sent.from).toBeUndefined();
expect(sent.to).toBeUndefined();
}
},
);
it("a month name is read in the UI locale only", () => {
// "März" is not an English month, so it stays text.
expect(params(labPickerUrl(search("März 2019", "en"))).q).toBe("März 2019");
});
it("the older and newer windows never read the term", () => {
const older = params(
labPickerUrl({ ...search("2019"), direction: "older", pivot }),
);
expect(older.to).toBe(pivot);
expect(older.from).toBeUndefined();
});
});
@@ -113,6 +113,8 @@ describe("fetchVaultPage", () => {
pivot: "2026-05-17T23:59:59.999Z",
term: "",
cursor: "z",
locale: "en",
timeZone: "UTC",
});
expect(page.items.map((d) => d.id)).toEqual(["a"]);
expect(page.next).toBe("a");
+22 -2
View File
@@ -53,6 +53,8 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { formatPeriod, type PeriodQuery } from "./period-query";
export interface EntityLinkOption {
id: string;
label: string;
@@ -110,6 +112,8 @@ export interface EntityLinkServerPaging {
* here instead of being sent.
*/
filterLocally?: boolean;
/** The term names a year or a month, and the list is that period. */
period?: PeriodQuery | null;
}
/**
@@ -127,6 +131,7 @@ export function serverPagingFrom(pages: {
loadOlder: () => void;
loadNewer: () => void;
filterLocally: boolean;
period: PeriodQuery | null;
}): EntityLinkServerPaging {
return {
term: pages.term,
@@ -139,6 +144,7 @@ export function serverPagingFrom(pages: {
onOlder: pages.loadOlder,
onNewer: pages.loadNewer,
filterLocally: pages.filterLocally,
period: pages.period,
};
}
@@ -278,7 +284,7 @@ export function EntityLinkPicker({
*/
knownOptions?: EntityLinkOption[];
}) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const [open, setOpen] = useState(false);
const [localTerm, setLocalTerm] = useState("");
const term = server ? server.term : localTerm;
@@ -308,7 +314,10 @@ export function EntityLinkPicker({
// again here would drop a match on a field the option does not render.
const groups = useMemo(() => {
const visible =
server && !server.filterLocally ? options : filterOptions(options, term);
// A period is answered by the route's date window, never by the digits.
server && (!server.filterLocally || server.period)
? options
: filterOptions(options, term);
// Linked records the loaded pages do not hold stay reachable in the
// sheet, on top, so they can be unticked where they are listed.
const loaded = new Set(options.map((option) => option.id));
@@ -486,6 +495,17 @@ export function EntityLinkPicker({
/>
</div>
{server?.period ? (
<p
className="text-muted-foreground px-1 text-xs"
data-slot={`${slot}-period`}
>
{t("links.picker.period.showing", {
period: formatPeriod(server.period, locale),
})}
</p>
) : null}
{server?.searchPending && groups.length === 0 ? (
<div
className="flex justify-center py-6"
+207
View File
@@ -0,0 +1,207 @@
/**
* A picker search term that names a calendar period: a year, or a month of a
* year.
*
* A person looking for "the lab panel from March 2019" types the date, not an
* analyte. The list routes match `q` against names and titles only, so "2019"
* or "03.2019" found nothing, or only whatever happened to carry those digits
* in its name. A term that parses here is sent as the route's own `from` /
* `to` window instead, so the answer is every record in that period, however
* far back it sits.
*
* Accepted, after folding case, accents and the umlaut spellings "ae", "oe",
* "ue" (so "März", "Maerz" and "mär" agree):
*
* - a year: "2019" (Korean "2019년");
* - a numeric month and year, either order, separated by ".", "/", "-" or a
* space: "03.2019", "3.2019", "03/2019", "2019-03", "2019/03", "2019.03",
* Korean "2019년 3월" / "2019. 3.";
* - a month name and a year, either order, in the person's UI locale: the
* full name, the short name, the genitive form where the language has one
* (Polish "marca"), or any prefix of at least three letters that names one
* month only ("sept", "paź"; "jui" is both juin and juillet, so it is not
* a period). Spanish "de" may join the two ("marzo de 2019").
*
* The month names come from `Intl.DateTimeFormat` for the locale rather than a
* table kept by hand, so they read the way the app's own dates are rendered.
*
* A year before {@link MIN_PERIOD_YEAR} or more than one year after the
* current one is not a period ("1850" stays a text search), and neither is a
* month outside 1 to 12 ("13.2019") or a term with anything else in it
* ("2019abc", "LDL 2019").
*
* Pure: no provider, no clock unless the caller omits `now`.
*/
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import { shiftDateKey } from "@/lib/tz/format";
/** The earliest year a period search accepts. */
export const MIN_PERIOD_YEAR = 1900;
export interface PeriodQuery {
year: number;
/** 1 to 12, or null for the whole year. */
month: number | null;
/** First calendar day of the period, `YYYY-MM-DD`. */
fromDay: string;
/** Last calendar day of the period, `YYYY-MM-DD` (inclusive). */
toDay: string;
}
/** Words that may stand between a month name and its year. */
const CONNECTORS: Record<string, readonly string[]> = {
es: ["de", "del"],
it: ["del"],
};
const nameCache = new Map<string, ReadonlyArray<[string, number]>>();
/** Spell German umlauts the way a keyboard without them does. */
function umlautSpelling(lower: string): string {
return lower.replace(/ä/gu, "ae").replace(/ö/gu, "oe").replace(/ü/gu, "ue");
}
/**
* Every folded spelling of every month in `locale`, paired with its month
* number: long, short and (where it differs) genitive, each also with its
* umlauts spelled out.
*/
function monthNames(locale: string): ReadonlyArray<[string, number]> {
const cached = nameCache.get(locale);
if (cached) return cached;
const names: Array<[string, number]> = [];
const add = (raw: string, month: number) => {
const lower = raw.toLowerCase().replace(/\./gu, "");
for (const spelling of [lower, umlautSpelling(lower)]) {
const folded = foldForMatch(spelling);
if (folded && !names.some(([n, m]) => n === folded && m === month)) {
names.push([folded, month]);
}
}
};
let long: Intl.DateTimeFormat;
let short: Intl.DateTimeFormat;
let genitive: Intl.DateTimeFormat;
try {
long = new Intl.DateTimeFormat(locale, { month: "long", timeZone: "UTC" });
short = new Intl.DateTimeFormat(locale, {
month: "short",
timeZone: "UTC",
});
genitive = new Intl.DateTimeFormat(locale, {
month: "long",
day: "numeric",
timeZone: "UTC",
});
} catch {
nameCache.set(locale, names);
return names;
}
for (let month = 1; month <= 12; month += 1) {
const day = new Date(Date.UTC(2019, month - 1, 15));
add(long.format(day), month);
add(short.format(day), month);
const part = genitive
.formatToParts(day)
.find((p) => p.type === "month")?.value;
if (part) add(part, month);
}
nameCache.set(locale, names);
return names;
}
/** The month a folded word names in `locale`, or null. */
function monthFromName(word: string, locale: string): number | null {
const names = monthNames(locale);
const exact = names.find(([name]) => name === word);
if (exact) return exact[1];
if (word.length < 3 || !/^\p{L}+$/u.test(word)) return null;
const months = new Set(
names.filter(([name]) => name.startsWith(word)).map(([, month]) => month),
);
return months.size === 1 ? [...months][0] : null;
}
function plausibleYear(word: string, now: Date): number | null {
if (!/^\d{4}$/u.test(word)) return null;
const year = Number(word);
if (year < MIN_PERIOD_YEAR || year > now.getUTCFullYear() + 1) return null;
return year;
}
function numericMonth(word: string): number | null {
if (!/^\d{1,2}$/u.test(word)) return null;
const month = Number(word);
return month >= 1 && month <= 12 ? month : null;
}
function pad(value: number): string {
return String(value).padStart(2, "0");
}
function period(year: number, month: number | null): PeriodQuery {
if (month === null) {
return { year, month, fromDay: `${year}-01-01`, toDay: `${year}-12-31` };
}
const fromDay = `${year}-${pad(month)}-01`;
const nextMonth =
month === 12 ? `${year + 1}-01-01` : `${year}-${pad(month + 1)}-01`;
return { year, month, fromDay, toDay: shiftDateKey(nextMonth, -1) };
}
/** The base language of a locale tag: "de-AT" → "de". */
function language(locale: string): string {
return locale.toLowerCase().split(/[-_]/u)[0] ?? "";
}
/**
* The period `term` names in `locale`, or null when it is not one (and the
* picker keeps its text search).
*/
export function parsePeriodQuery(
term: string,
locale: string,
now: Date = new Date(),
): PeriodQuery | null {
const lang = language(locale);
let text = term.toLowerCase();
// Korean writes the units after the numbers: "2019년 3월".
if (lang === "ko") {
text = text.replace(/(\d)\s*년/gu, "$1 ").replace(/(\d)\s*월/gu, "$1 ");
}
const connectors = CONNECTORS[lang] ?? [];
const words = foldForMatch(text.replace(/[.,]/gu, " "))
.split(" ")
.filter((word) => word.length > 0 && !connectors.includes(word));
if (words.length === 1) {
const year = plausibleYear(words[0], now);
return year === null ? null : period(year, null);
}
if (words.length !== 2) return null;
for (const [first, second] of [
[words[0], words[1]],
[words[1], words[0]],
]) {
const year = plausibleYear(second, now);
if (year === null) continue;
const month = numericMonth(first) ?? monthFromName(first, locale);
if (month !== null) return period(year, month);
}
return null;
}
/** The period as the app names it in `locale`: "2019", "März 2019". */
export function formatPeriod(value: PeriodQuery, locale: string): string {
if (value.month === null) return String(value.year);
try {
return new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
timeZone: "UTC",
}).format(new Date(Date.UTC(value.year, value.month - 1, 15)));
} catch {
return `${pad(value.month)}.${value.year}`;
}
}
+74 -6
View File
@@ -11,7 +11,28 @@
* `search` the term across the whole history. The cursor is the family's own:
* an offset for the routes that page by offset, the last row's id for the
* document route's keyset.
*
* A search term that names a year or a month ("2019", "03.2019", "März 2019",
* see {@link parsePeriodQuery}) is not sent as `q`: it becomes the route's
* own `from` / `to` window over that period, newest first, so the answer is
* every record dated in it rather than the records whose name contains the
* digits. The visit route, which has no text search at all, gets the same
* window.
*
* The period is a calendar period as the person reads it. The routes over an
* instant (lab draw, condition onset, visit) get the first instant of the
* period's first day and the last instant of its last day in the person's
* timezone, the zone every date in the picker is rendered in, so a visit at
* 00:30 on 1 March is in March for the person who sees it dated 1 March. A
* date-only lab reading is stored at noon UTC, which reads as its own date in
* every zone from UTC-11 to UTC+11. The document route's window is already
* day-granular and inclusive over a stored calendar date, so it gets the two
* day keys as they are.
*/
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { shiftDateKey } from "@/lib/tz/format";
import { parsePeriodQuery, type PeriodQuery } from "./period-query";
import {
PICKER_PAGE_SIZE,
afterPivot,
@@ -22,6 +43,44 @@ function withParams(path: string, params: URLSearchParams): string {
return `${path}?${params.toString()}`;
}
/** The period a search request names, or null outside a period search. */
export function requestPeriod(request: PickerPageRequest): PeriodQuery | null {
if (request.direction !== "search") return null;
return parsePeriodQuery(request.term, request.locale);
}
/**
* The instant window of a period in `timeZone`: from the first instant of its
* first day to the last millisecond before the day after its last, both
* inclusive as the instant routes read them.
*/
export function periodInstants(
period: PeriodQuery,
timeZone: string,
): { from: string; to: string } {
const from = startOfLocalDayKey(period.fromDay, timeZone);
const next = startOfLocalDayKey(shiftDateKey(period.toDay, 1), timeZone);
return {
from: from.toISOString(),
to: new Date(next.getTime() - 1).toISOString(),
};
}
/** `q`, or the period's instant window, for a search on an instant route. */
function setInstantSearch(
params: URLSearchParams,
request: PickerPageRequest,
): void {
const period = requestPeriod(request);
if (period) {
const window = periodInstants(period, request.timeZone);
params.set("from", window.from);
params.set("to", window.to);
} else {
params.set("q", request.term);
}
}
/** `GET /api/labs` — offset paging, `from` / `to` on the draw instant. */
export function labPickerUrl(request: PickerPageRequest): string {
const params = new URLSearchParams({
@@ -29,7 +88,7 @@ export function labPickerUrl(request: PickerPageRequest): string {
offset: request.cursor ?? "0",
sortDir: request.direction === "newer" ? "asc" : "desc",
});
if (request.direction === "search") params.set("q", request.term);
if (request.direction === "search") setInstantSearch(params, request);
else if (request.pivot !== null) {
if (request.direction === "older") params.set("to", request.pivot);
else params.set("from", afterPivot(request.pivot));
@@ -45,7 +104,7 @@ export function episodePickerUrl(request: PickerPageRequest): string {
offset: request.cursor ?? "0",
sortDir: request.direction === "newer" ? "asc" : "desc",
});
if (request.direction === "search") params.set("q", request.term);
if (request.direction === "search") setInstantSearch(params, request);
else if (request.pivot !== null) {
if (request.direction === "older") params.set("to", request.pivot);
else params.set("from", afterPivot(request.pivot));
@@ -65,7 +124,11 @@ export function documentPickerUrl(request: PickerPageRequest): string {
limit: String(PICKER_PAGE_SIZE),
});
if (request.cursor) params.set("cursor", request.cursor);
if (request.direction === "search") params.set("q", request.term);
const period = requestPeriod(request);
if (period) {
params.set("from", period.fromDay);
params.set("to", period.toDay);
} else if (request.direction === "search") params.set("q", request.term);
else if (request.pivot !== null) {
if (request.direction === "older") {
params.set("to", request.pivot.slice(0, 10));
@@ -80,15 +143,20 @@ export function documentPickerUrl(request: PickerPageRequest): string {
* `GET /api/encounters` — offset paging inside a window on the visit instant.
* The route answers in two arms (upcoming soonest first, past newest first),
* each paged by the same offset; the picker merges and orders them itself.
* The visit route has no text search, so `search` reads the whole history
* and the picker filters what it has loaded.
* The visit route has no text search, so a plain term never reaches it (the
* picker filters what it has loaded); a period search reads that period.
*/
export function encounterPickerUrl(request: PickerPageRequest): string {
const params = new URLSearchParams({
limit: String(PICKER_PAGE_SIZE),
offset: request.cursor ?? "0",
});
if (request.direction !== "search" && request.pivot !== null) {
const period = requestPeriod(request);
if (period) {
const window = periodInstants(period, request.timeZone);
params.set("from", window.from);
params.set("to", window.to);
} else if (request.direction !== "search" && request.pivot !== null) {
if (request.direction === "older") params.set("to", request.pivot);
else params.set("from", afterPivot(request.pivot));
}
+63 -10
View File
@@ -22,8 +22,11 @@
* paged further forward from there;
*
* and a typed term switches to `search`, which asks the list route itself
* (`q`) so a match is found wherever it sits in the history. With no date
* yet the picker opens on the newest records and pages back.
* (`q`) so a match is found wherever it sits in the history. A term that names
* a year or a month ("2019", "03.2019", "März 2019") searches that period
* instead, through the route's date window, on every family including the
* visit list that has no text search (`parsePeriodQuery`). With no date yet
* the picker opens on the newest records and pages back.
*
* Every page comes from the family's own list route through `fetchPage`, so
* paging, the filter and the ownership check stay server-side.
@@ -33,6 +36,9 @@ import { useInfiniteQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
import { parsePeriodQuery, type PeriodQuery } from "./period-query";
export type PickerDirection = "older" | "newer" | "search";
@@ -50,6 +56,10 @@ export interface PickerPageRequest {
term: string;
/** The family's own cursor (an offset or a row id), null for page one. */
cursor: string | null;
/** The UI locale, which names the months a period search understands. */
locale: string;
/** The person's timezone, where a period's days begin and end. */
timeZone: string;
}
/** Records per page, the same for every family. */
@@ -106,17 +116,41 @@ export function useLinkPickerPages<T extends { id: string }>({
*/
serverSearch?: boolean;
}) {
const { locale } = useTranslations();
const timeZone = useDisplayTimezone();
const [rawTerm, setTerm] = useState("");
const debounced = useDebouncedValue(rawTerm.trim(), SEARCH_DEBOUNCE_MS);
const term = serverSearch ? debounced : "";
// A period goes to the route as a date window, which every family's route
// has, so it is searched on the server even where text is not.
const period: PeriodQuery | null = useMemo(
() => parsePeriodQuery(debounced, locale),
[debounced, locale],
);
const typedPeriod = useMemo(
() => parsePeriodQuery(rawTerm.trim(), locale) !== null,
[rawTerm, locale],
);
const term = serverSearch || period !== null ? debounced : "";
const searching = term.length > 0;
// "März 2019" and "03.2019" are one request; the key says which period, in
// which zone, rather than how it was typed.
const searchKey = period
? `period:${period.fromDay}..${period.toDay}@${timeZone}`
: term;
const older = useInfiniteQuery({
queryKey: queryKey("older", pivot, ""),
enabled: enabled && !searching,
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
fetchPage({ direction: "older", pivot, term: "", cursor: pageParam }),
fetchPage({
direction: "older",
pivot,
term: "",
cursor: pageParam,
locale,
timeZone,
}),
getNextPageParam: (last) => last.next ?? undefined,
});
@@ -131,16 +165,30 @@ export function useLinkPickerPages<T extends { id: string }>({
enabled: enabled && !searching && newerRequested,
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
fetchPage({ direction: "newer", pivot, term: "", cursor: pageParam }),
fetchPage({
direction: "newer",
pivot,
term: "",
cursor: pageParam,
locale,
timeZone,
}),
getNextPageParam: (last) => last.next ?? undefined,
});
const search = useInfiniteQuery({
queryKey: queryKey("search", null, term),
queryKey: queryKey("search", null, searchKey),
enabled: enabled && searching,
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
fetchPage({ direction: "search", pivot: null, term, cursor: pageParam }),
fetchPage({
direction: "search",
pivot: null,
term,
cursor: pageParam,
locale,
timeZone,
}),
getNextPageParam: (last) => last.next ?? undefined,
});
@@ -171,10 +219,15 @@ export function useLinkPickerPages<T extends { id: string }>({
setTerm,
/** A search is in flight (typed, or its first page not back yet). */
searchPending:
serverSearch &&
(serverSearch || typedPeriod || period !== null) &&
(rawTerm.trim() !== term || (searching && search.isPending)),
/** The picker filters the loaded options by the term itself. */
filterLocally: !serverSearch,
/**
* The picker filters the loaded options by the term itself: a route
* without text search, and a term that is not a period.
*/
filterLocally: !serverSearch && !typedPeriod,
/** The period the list is showing, when the term names one. */
period: searching ? period : null,
hasOlder: searching ? search.hasNextPage : older.hasNextPage,
// Unknown until asked: offered once for every dated record, then only
// while the server reports another page.