mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'v143/labs' into release/v1.43.0
This commit is contained in:
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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user