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( + /]*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 = /]*id="([^"]+)"/.exec(html)![1]; + expect(html).toContain(`list="${id}"`); + }); +}); diff --git a/src/components/labs/__tests__/lab-unit-input.test.tsx b/src/components/labs/__tests__/lab-unit-input.test.tsx index ebc06b31c..b54122518 100644 --- a/src/components/labs/__tests__/lab-unit-input.test.tsx +++ b/src/components/labs/__tests__/lab-unit-input.test.tsx @@ -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( - {node}, + + {node} + , ); } diff --git a/src/components/labs/__tests__/labs-settings-groups.test.tsx b/src/components/labs/__tests__/labs-settings-groups.test.tsx index b578d0793..b08f243e8 100644 --- a/src/components/labs/__tests__/labs-settings-groups.test.tsx +++ b/src/components/labs/__tests__/labs-settings-groups.test.tsx @@ -24,7 +24,10 @@ function render(prefs: Partial) { queryKeys.moduleListLayout(), resolveModuleListLayout({ version: 1, labs: prefs }), ); - queryClient.setQueryData(queryKeys.biomarkers(), { biomarkers: [] }); + queryClient.setQueryData(queryKeys.biomarkers(), { + biomarkers: [], + customPanels: [], + }); return renderToStaticMarkup( diff --git a/src/components/labs/biomarker-form-body.ts b/src/components/labs/biomarker-form-body.ts new file mode 100644 index 000000000..fa3203921 --- /dev/null +++ b/src/components/labs/biomarker-form-body.ts @@ -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; +} diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx index 168e68bad..7a7d07c8e 100644 --- a/src/components/labs/biomarker-form.tsx +++ b/src/components/labs/biomarker-form.tsx @@ -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({

) : null} -
- - setPanel(e.target.value)} - placeholder={t("labs.biomarker.form.panelPlaceholder")} - maxLength={120} - /> -
+