From 910f28dcfe6a68cb9f4c8381194d1340817564f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 6 Nov 2026 17:51:34 +0100
Subject: [PATCH] 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
---
docs/api/openapi.yaml | 8 +++
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/ko.json | 1 +
messages/nl.json | 1 +
messages/pl.json | 1 +
messages/pt-BR.json | 1 +
messages/ru.json | 1 +
.../baselines/source-function-lines.json | 4 +-
src/app/api/biomarkers/route.ts | 4 ++
.../__tests__/biomarker-form-body.test.ts | 66 +++++++++++++++++++
.../labs/__tests__/biomarker-form.test.tsx | 32 ++++++++-
.../labs/__tests__/lab-unit-input.test.tsx | 9 ++-
.../__tests__/labs-settings-groups.test.tsx | 5 +-
src/components/labs/biomarker-form-body.ts | 57 ++++++++++++++++
src/components/labs/biomarker-form.tsx | 27 ++------
src/components/labs/biomarker-panel-field.tsx | 65 ++++++++++++++++++
src/components/labs/types.ts | 5 ++
src/lib/openapi/routes/biomarkers.ts | 16 ++++-
22 files changed, 279 insertions(+), 29 deletions(-)
create mode 100644 src/components/labs/__tests__/biomarker-form-body.test.ts
create mode 100644 src/components/labs/biomarker-form-body.ts
create mode 100644 src/components/labs/biomarker-panel-field.tsx
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 63591a0c4..615a13b0b 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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
diff --git a/messages/de.json b/messages/de.json
index 62b9f18f8..ce094622f 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index d19458fd1..4691baf80 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/es.json b/messages/es.json
index bbafebc0f..3df9d94d7 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index 723697b62..a61b5944f 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index df8ff7e41..b1c7e66af 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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",
diff --git a/messages/ko.json b/messages/ko.json
index 5cbc33397..3834a66ac 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -10322,6 +10322,7 @@
"unitLockedHint": "이 마커에 기록된 값이 있으면 단위는 그대로 유지돼요. 그렇지 않으면 저장된 값이 변환되지 않은 채 새 단위로 표시돼요.",
"panel": "패널",
"panelPlaceholder": "예: 지질",
+ "panelHint": "제안에서 고르거나 직접 입력하세요. 카탈로그 패널은 모든 언어에서 인식돼요.",
"lowerBound": "참고 하한",
"upperBound": "참고 상한",
"lowerPlaceholder": "하한값",
diff --git a/messages/nl.json b/messages/nl.json
index d1d713304..c0c3eeda5 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -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",
diff --git a/messages/pl.json b/messages/pl.json
index 377c44cb9..5d10dd0f0 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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",
diff --git a/messages/pt-BR.json b/messages/pt-BR.json
index cb9f463a2..ddd915acd 100644
--- a/messages/pt-BR.json
+++ b/messages/pt-BR.json
@@ -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",
diff --git a/messages/ru.json b/messages/ru.json
index b2bcb4b9a..7be6be626 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -10322,6 +10322,7 @@
"unitLockedHint": "Когда у показателя есть измерения, его единица остаётся прежней. Иначе сохранённые значения показывались бы в новой единице без пересчёта.",
"panel": "Панель",
"panelPlaceholder": "например Липиды",
+ "panelHint": "Выберите подсказку или введите свою. Панели из каталога распознаются на любом языке.",
"lowerBound": "Нижняя граница",
"upperBound": "Верхняя граница",
"lowerPlaceholder": "Нижняя граница",
diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json
index ad1e29f61..4fa80a682 100644
--- a/src/__tests__/baselines/source-function-lines.json
+++ b/src/__tests__/baselines/source-function-lines.json
@@ -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,
diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts
index 3dca738f1..8b1a75562 100644
--- a/src/app/api/biomarkers/route.ts
+++ b/src/app/api/biomarkers/route.ts
@@ -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)),
});
});
diff --git a/src/components/labs/__tests__/biomarker-form-body.test.ts b/src/components/labs/__tests__/biomarker-form-body.test.ts
new file mode 100644
index 000000000..8b71aefad
--- /dev/null
+++ b/src/components/labs/__tests__/biomarker-form-body.test.ts
@@ -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" });
+ });
+});
diff --git a/src/components/labs/__tests__/biomarker-form.test.tsx b/src/components/labs/__tests__/biomarker-form.test.tsx
index 50333598e..1eb91a737 100644
--- a/src/components/labs/__tests__/biomarker-form.test.tsx
+++ b/src/components/labs/__tests__/biomarker-form.test.tsx
@@ -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(
- {node},
+
+ {node}
+ ,
);
}
@@ -79,3 +90,20 @@ describe(" unit lock", () => {
);
});
});
+
+describe(" panel suggestions", () => {
+ it("suggests every catalog panel and the person's own panels", () => {
+ const html = render(, ["Annual check"]);
+ const list = html.match(
+ /