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(
-