feat(labs): suggest panels in the marker form and let an edit clear fields

The panel field offers the catalog panels in the page language and the
person's own panels, which GET /api/biomarkers now lists as
customPanels (one spelling per folded form). The field stays free
text; a catalog panel typed in any language lands in the same category.

An edit used to omit an emptied panel, context or bound, so the stored
value survived the save. The form now sends null for a field that held
a value and was emptied. A new marker still omits empty fields, and a
field that was empty when the edit began stays out of the body, so a
context the list could not decrypt is never wiped by an unrelated save.

Refs #1349
This commit is contained in:
Marc-André Bombeck committed 2026-11-06 17:51:34 +01:00
1 parent 92638ec089
commit 910f28dcfe
22 files changed
+279 -29

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 344,
"total": 111568,
"total": 111553,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -130,7 +130,7 @@
"components/insights/sleep-stage-stacked-bar.tsx :: SleepStageStackedBar": 354,
"components/insights/sub-page-shell.tsx :: SubPageShell": 266,
"components/insights/trends-row.tsx :: TrendsRow": 215,
"components/labs/biomarker-form.tsx :: BiomarkerForm": 244,
"components/labs/biomarker-form.tsx :: BiomarkerForm": 229,
"components/labs/biomarker-manager.tsx :: BiomarkerManager": 242,
"components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 250,
"components/labs/lab-biomarker-chart.tsx :: LabBiomarkerChart": 370,
+4
View File
@@ -15,6 +15,7 @@ import { aliasNamesByMarker } from "@/lib/labs/biomarker-aliases";
import { serialiseBiomarker } from "@/lib/labs/biomarker-dto";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import { customPanelSuggestions } from "@/lib/labs/marker-category";
import { annotate } from "@/lib/logging/context";
import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
@@ -64,6 +65,9 @@ export const GET = apiHandler(async () => {
biomarkers: rows.map((row) =>
serialiseBiomarker(row, aliases.get(row.id) ?? [], { soft: true }),
),
// The person's own panel spellings, for the panel suggestions in the
// marker form; catalog panels are offered there from the bundle.
customPanels: customPanelSuggestions(rows.map((row) => row.panel)),
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { buildBiomarkerBody } from "../biomarker-form-body";
const empty = {
name: " LDL ",
unit: "mg/dL",
lower: null,
upper: null,
panel: " ",
context: "",
};
describe("buildBiomarkerBody", () => {
it("omits every empty field on a new marker", () => {
expect(buildBiomarkerBody(empty, null)).toEqual({
name: "LDL",
unit: "mg/dL",
});
});
it("sends the filled fields trimmed", () => {
expect(
buildBiomarkerBody(
{ ...empty, lower: 0, upper: 116, panel: " Lipids ", context: " x " },
null,
),
).toEqual({
name: "LDL",
unit: "mg/dL",
lowerBound: 0,
upperBound: 116,
panel: "Lipids",
context: "x",
});
});
it("sends null for every field an edit emptied", () => {
expect(
buildBiomarkerBody(empty, {
lowerBound: 10,
upperBound: 0,
panel: "Lipids",
context: "note",
}),
).toEqual({
name: "LDL",
unit: "mg/dL",
lowerBound: null,
upperBound: null,
panel: null,
context: null,
});
});
it("leaves a field out that was empty when the edit began", () => {
expect(
buildBiomarkerBody(empty, {
lowerBound: null,
upperBound: null,
panel: null,
context: null,
}),
).toEqual({ name: "LDL", unit: "mg/dL" });
});
});
@@ -1,9 +1,11 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { ApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { queryKeys } from "@/lib/query-keys";
import en from "../../../../messages/en.json";
/**
@@ -36,9 +38,18 @@ const MARKER: BiomarkerDto = {
updatedAt: "2026-01-01T00:00:00.000Z",
};
function render(node: React.ReactNode) {
function render(node: React.ReactNode, customPanels: string[] = []) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
queryClient.setQueryData(queryKeys.biomarkers(), {
biomarkers: [],
customPanels,
});
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
@@ -79,3 +90,20 @@ describe("<BiomarkerForm> unit lock", () => {
);
});
});
describe("<BiomarkerForm> panel suggestions", () => {
it("suggests every catalog panel and the person's own panels", () => {
const html = render(<BiomarkerForm />, ["Annual check"]);
const list = html.match(
/<datalist[^>]*data-slot="biomarker-panel-suggestions"[^>]*>(.*?)<\/datalist>/,
);
expect(list).not.toBeNull();
const values = [...list![1].matchAll(/value="([^"]*)"/g)].map((m) => m[1]);
expect(values).toContain("Lipids");
expect(values).toContain("Blood count");
expect(values).toContain("Annual check");
expect(values).toHaveLength(11);
const id = /<datalist[^>]*id="([^"]+)"/.exec(html)![1];
expect(html).toContain(`list="${id}"`);
});
});
@@ -1,4 +1,5 @@
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
@@ -22,8 +23,14 @@ import { OcrRowEditor } from "../ocr-row-editor";
*/
function render(node: React.ReactNode): string {
// The marker form reads the biomarker list for its panel suggestions.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, enabled: false } },
});
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
@@ -24,7 +24,10 @@ function render(prefs: Partial<ModuleListPrefs>) {
queryKeys.moduleListLayout(),
resolveModuleListLayout({ version: 1, labs: prefs }),
);
queryClient.setQueryData(queryKeys.biomarkers(), { biomarkers: [] });
queryClient.setQueryData(queryKeys.biomarkers(), {
biomarkers: [],
customPanels: [],
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
@@ -0,0 +1,57 @@
/**
* The request body the marker form sends.
*
* A new marker omits every empty field: the create schema has no "clear",
* and an omitted field simply stays unset. An edit omits a field that was
* empty and still is, so it stays as stored, and sends `null` for a field the
* person emptied: panel, context or a bound that held a value and now holds
* none is cleared, which an omitted key never did. Only a field that held a
* value when the form opened can be emptied, so a context the list could not
* decrypt (shown empty) is never wiped by saving another field.
*/
export interface BiomarkerFormFields {
name: string;
unit: string;
lower: number | null;
upper: number | null;
panel: string;
context: string;
}
export interface BiomarkerFormInitial {
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
context: string | null;
}
export type BiomarkerBody = {
name: string;
unit: string;
lowerBound?: number | null;
upperBound?: number | null;
panel?: string | null;
context?: string | null;
};
export function buildBiomarkerBody(
fields: BiomarkerFormFields,
initial: BiomarkerFormInitial | null,
): BiomarkerBody {
const panel = fields.panel.trim();
const context = fields.context.trim();
const body: BiomarkerBody = {
name: fields.name.trim(),
unit: fields.unit.trim(),
};
if (fields.lower !== null) body.lowerBound = fields.lower;
else if (initial?.lowerBound != null) body.lowerBound = null;
if (fields.upper !== null) body.upperBound = fields.upper;
else if (initial?.upperBound != null) body.upperBound = null;
if (panel) body.panel = panel;
else if (initial?.panel) body.panel = null;
if (context) body.context = context;
else if (initial?.context) body.context = null;
return body;
}
+7 -20
View File
@@ -26,6 +26,8 @@ import {
} from "@/lib/labs/biomarker-catalog";
import { useTranslations } from "@/lib/i18n/context";
import { buildBiomarkerBody } from "./biomarker-form-body";
import { BiomarkerPanelField } from "./biomarker-panel-field";
import { LabUnitInput } from "./lab-unit-input";
import type { BiomarkerDto } from "./types";
@@ -113,14 +115,10 @@ export function BiomarkerForm({
return;
}
const body = {
name: name.trim(),
unit: unit.trim(),
...(low !== null ? { lowerBound: low } : {}),
...(high !== null ? { upperBound: high } : {}),
...(panel.trim() ? { panel: panel.trim() } : {}),
...(context.trim() ? { context: context.trim() } : {}),
};
const body = buildBiomarkerBody(
{ name, unit, lower: low, upper: high, panel, context },
existing ?? null,
);
setSubmitting(true);
try {
@@ -238,18 +236,7 @@ export function BiomarkerForm({
</p>
) : null}
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-panel">
{t("labs.biomarker.form.panel")}
</Label>
<Input
id="biomarker-panel"
value={panel}
onChange={(e) => setPanel(e.target.value)}
placeholder={t("labs.biomarker.form.panelPlaceholder")}
maxLength={120}
/>
</div>
<BiomarkerPanelField value={panel} onChange={setPanel} />
<div className="space-y-1.5">
<Label htmlFor="biomarker-lower">
{t("labs.biomarker.form.lowerBound")}
@@ -0,0 +1,65 @@
"use client";
/**
* The marker form's panel field: free text with suggestions. The catalog
* panels come from the bundle in the page language; the person's own panels
* come from `customPanels` on `GET /api/biomarkers`, the same read the
* pickers around the form already hold. A catalog panel typed in any
* language lands in the same category, so the suggestions only spare typing.
*/
import { useId } from "react";
import { useQuery } from "@tanstack/react-query";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { BIOMARKER_PANELS } from "@/lib/labs/biomarker-catalog";
import { queryKeys } from "@/lib/query-keys";
import type { BiomarkerListResponse } from "./types";
export function BiomarkerPanelField({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslations();
const listId = `${useId()}-panels`;
const hintId = `${listId}-hint`;
const { data } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
});
const suggestions = [
...new Set([
...BIOMARKER_PANELS.map((key) => t(`labs.catalog.panel.${key}`)),
...(data?.customPanels ?? []),
]),
];
return (
<div className="space-y-1.5">
<Label htmlFor="biomarker-panel">{t("labs.biomarker.form.panel")}</Label>
<Input
id="biomarker-panel"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t("labs.biomarker.form.panelPlaceholder")}
maxLength={120}
list={listId}
aria-describedby={hintId}
autoComplete="off"
/>
<datalist id={listId} data-slot="biomarker-panel-suggestions">
{suggestions.map((panel) => (
<option key={panel} value={panel} />
))}
</datalist>
<p id={hintId} className="text-muted-foreground text-xs">
{t("labs.biomarker.form.panelHint")}
</p>
</div>
);
}
+5
View File
@@ -161,6 +161,11 @@ export type BiomarkerTrendDto =
export interface BiomarkerListResponse {
biomarkers: BiomarkerDto[];
/**
* The person's own panels that are no catalog panel, one spelling each,
* for the panel suggestions.
*/
customPanels: string[];
}
/** v1.43 — `POST /api/biomarkers/{id}/merge`, for the preview and the result. */
+13 -3
View File
@@ -152,8 +152,15 @@ const biomarkerDetail = biomarkerRow
})
.meta({ id: "BiomarkerDetail" });
const listResponse = z
.object({ biomarkers: z.array(biomarkerRow) })
export const biomarkerListResponse = z
.object({
biomarkers: z.array(biomarkerRow),
customPanels: z
.array(z.string())
.describe(
"The caller's panels that are no catalog panel in any language, one spelling per folded form (the most frequent, ties to the alphabetically first), sorted. Suggestions for the panel field; catalog panels are not repeated here.",
),
})
.meta({ id: "ListBiomarkersResponse" });
const conflict = {
@@ -243,7 +250,10 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Biomarker catalog.",
content: {
"application/json": {
schema: dataEnvelope(listResponse, "ListBiomarkersEnvelope"),
schema: dataEnvelope(
biomarkerListResponse,
"ListBiomarkersEnvelope",
),
},
},
},