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

+8
View File
@@ -77763,8 +77763,16 @@ components:
type: array
items:
$ref: "#/components/schemas/Biomarker"
customPanels:
type: array
items:
type: string
description: 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.
required:
- biomarkers
- customPanels
additionalProperties: false
Biomarker:
type: object
+1
View File
@@ -10322,6 +10322,7 @@
"unitLockedHint": "Sobald dieser Marker Werte hat, bleibt seine Einheit, wie sie ist. Sonst würden die gespeicherten Werte in einer neuen Einheit angezeigt, ohne umgerechnet zu werden.",
"panel": "Panel",
"panelPlaceholder": "z. B. Lipide",
"panelHint": "Wähle einen Vorschlag oder gib ein eigenes ein. Panels aus dem Katalog werden in jeder Sprache erkannt.",
"lowerBound": "Referenz unten",
"upperBound": "Referenz oben",
"lowerPlaceholder": "Untere Grenze",
+1
View File
@@ -10322,6 +10322,7 @@
"unitLockedHint": "Once this marker has readings, its unit stays as it is. The stored values would otherwise be shown in a new unit without being converted.",
"panel": "Panel",
"panelPlaceholder": "e.g. Lipids",
"panelHint": "Pick a suggestion or type your own. Catalog panels are recognised in every language.",
"lowerBound": "Reference low",
"upperBound": "Reference high",
"lowerPlaceholder": "Lower bound",
+1
View File
@@ -10322,6 +10322,7 @@
"unitLockedHint": "Cuando este marcador ya tiene valores, su unidad no cambia. De lo contrario, los valores guardados se mostrarían en una unidad nueva sin convertirse.",
"panel": "Panel",
"panelPlaceholder": "p. ej. Lípidos",
"panelHint": "Elige una sugerencia o escribe la tuya. Los paneles del catálogo se reconocen en todos los idiomas.",
"lowerBound": "Referencia mínima",
"upperBound": "Referencia máxima",
"lowerPlaceholder": "Límite inferior",
+1
View File
@@ -10322,6 +10322,7 @@
"unitLockedHint": "Dès que ce marqueur a des valeurs, son unité reste la même. Sinon, les valeurs enregistrées s'afficheraient dans une nouvelle unité sans être converties.",
"panel": "Panel",
"panelPlaceholder": "ex. Lipides",
"panelHint": "Choisissez une suggestion ou saisissez la vôtre. Les panels du catalogue sont reconnus dans toutes les langues.",
"lowerBound": "Référence basse",
"upperBound": "Référence haute",
"lowerPlaceholder": "Borne inférieure",
+1
View File
@@ -10322,6 +10322,7 @@
"unitLockedHint": "Quando questo marcatore ha già dei valori, la sua unità resta invariata. Altrimenti i valori salvati verrebbero mostrati in una nuova unità senza essere convertiti.",
"panel": "Panel",
"panelPlaceholder": "es. Lipidi",
"panelHint": "Scegli un suggerimento o scrivine uno tuo. I panel del catalogo vengono riconosciuti in ogni lingua.",
"lowerBound": "Riferimento minimo",
"upperBound": "Riferimento massimo",
"lowerPlaceholder": "Limite inferiore",
+1
View File
@@ -10322,6 +10322,7 @@
"unitLockedHint": "이 마커에 기록된 값이 있으면 단위는 그대로 유지돼요. 그렇지 않으면 저장된 값이 변환되지 않은 채 새 단위로 표시돼요.",
"panel": "패널",
"panelPlaceholder": "예: 지질",
"panelHint": "제안에서 고르거나 직접 입력하세요. 카탈로그 패널은 모든 언어에서 인식돼요.",
"lowerBound": "참고 하한",
"upperBound": "참고 상한",
"lowerPlaceholder": "하한값",
+1
View File
@@ -10322,6 +10322,7 @@
"unitLockedHint": "Zodra deze marker metingen heeft, blijft de eenheid zoals die is. Anders zouden de opgeslagen waarden zonder omrekening in een nieuwe eenheid worden getoond.",
"panel": "Groep",
"panelPlaceholder": "bijv. Lipiden",
"panelHint": "Kies een suggestie of typ je eigen groep. Groepen uit de catalogus worden in elke taal herkend.",
"lowerBound": "Referentie ondergrens",
"upperBound": "Referentie bovengrens",
"lowerPlaceholder": "Ondergrens",
+1
View File
@@ -10322,6 +10322,7 @@
"unitLockedHint": "Gdy ten marker ma już wyniki, jego jednostka pozostaje bez zmian. W przeciwnym razie zapisane wartości byłyby pokazywane w nowej jednostce bez przeliczenia.",
"panel": "Panel",
"panelPlaceholder": "np. Lipidy",
"panelHint": "Wybierz podpowiedź lub wpisz własny. Panele z katalogu są rozpoznawane w każdym języku.",
"lowerBound": "Dolna granica",
"upperBound": "Górna granica",
"lowerPlaceholder": "Dolna granica",
+1
View File
@@ -10322,6 +10322,7 @@
"unitLockedHint": "Depois que este marcador tiver leituras, sua unidade permanece como está. Caso contrário, os valores armazenados seriam exibidos em uma nova unidade sem serem convertidos.",
"panel": "Painel",
"panelPlaceholder": "ex.: Lipídios",
"panelHint": "Escolha uma sugestão ou digite o seu. Os painéis do catálogo são reconhecidos em todos os idiomas.",
"lowerBound": "Referência baixa",
"upperBound": "Referência alta",
"lowerPlaceholder": "Limite inferior",
+1
View File
@@ -10322,6 +10322,7 @@
"unitLockedHint": "Когда у показателя есть измерения, его единица остаётся прежней. Иначе сохранённые значения показывались бы в новой единице без пересчёта.",
"panel": "Панель",
"panelPlaceholder": "например Липиды",
"panelHint": "Выберите подсказку или введите свою. Панели из каталога распознаются на любом языке.",
"lowerBound": "Нижняя граница",
"upperBound": "Верхняя граница",
"lowerPlaceholder": "Нижняя граница",
@@ -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",
),
},
},
},