diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 20b178b71..e5c4ab096 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6152,7 +6152,7 @@ model LabResult { sourceReferenceText String? @map("source_reference_text") /// @pending-consumer(#1095) v1.42 — the value as printed on the source /// report, kept when a write converted it into the biomarker's canonical - /// unit. NULL when no conversion happened. A later edit of `value` clears it. + /// unit. NULL when no conversion happened. An edit that changes `value` clears it. sourceValue Float? @map("source_value") /// @pending-consumer(#1095) v1.42 — the unit the source report printed, /// paired with `sourceValue`. diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index 6ef7f3d77..e8363b6e5 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -195,8 +195,14 @@ export const PUT = apiHandler( if (d.value !== undefined) { data.value = d.value; // A number the person typed is no longer the conversion of what the - // report printed, so the "converted from" record would now be wrong. - if (existing.sourceValue !== null || existing.sourceUnit !== null) { + // report printed, so the "converted from" record would now be wrong. Only + // a CHANGED value says so: a client that sends the whole form back with + // the value untouched (the web edit dialog does) must not erase the + // record. Both sides are plain numbers (`value` is a Float column). + if ( + d.value !== existing.value && + (existing.sourceValue !== null || existing.sourceUnit !== null) + ) { data.sourceValue = null; data.sourceUnit = null; } diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 6e581f259..109d32de6 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -32,6 +32,7 @@ import { localizedApiError } from "@/lib/api/localized-error"; import { formatLabReading } from "@/lib/labs/format-value"; import { LabConvertedNote } from "./lab-converted-note"; import { resolveNoteForUpdate } from "@/lib/labs/note-update"; +import { resolveValueForUpdate, valueEditSeed } from "@/lib/labs/value-update"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -177,7 +178,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { const updateMutation = useMutation({ mutationFn: (body: { id: string; - // Exactly one of value / valueText is set, matching the row's type. + // At most one of value / valueText is set, matching the row's type; + // null leaves the field out of the PUT (an untouched value). value: number | null; valueText: string | null; takenAt: string; @@ -219,7 +221,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { async function openEdit(reading: LabResultDto) { const seed = { - value: reading.value !== null ? String(reading.value) : "", + value: reading.value !== null ? valueEditSeed(reading.value) : "", valueText: reading.valueText ?? "", takenAt: toDateTimeLocal(reading.takenAt), note: "", @@ -275,11 +277,20 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { } qualitativeValue = trimmed; } else { - numericValue = parseDecimal(editValue); - if (numericValue === null) { + const parsed = parseDecimal(editValue); + if (parsed === null) { setEditError(t("labs.form.requiredError")); return; } + // An untouched field is left out of the PUT (null here), so the stored + // full-precision value and a conversion record survive a date or note + // edit. + numericValue = + resolveValueForUpdate({ + seed: editSeed.value, + editorValue: editValue, + parsed, + }) ?? null; } const takenDate = new Date(editTakenAt); if (Number.isNaN(takenDate.getTime())) { diff --git a/src/lib/labs/__tests__/value-update.test.ts b/src/lib/labs/__tests__/value-update.test.ts new file mode 100644 index 000000000..dcc98d4c8 --- /dev/null +++ b/src/lib/labs/__tests__/value-update.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { resolveValueForUpdate, valueEditSeed } from "../value-update"; + +describe("valueEditSeed", () => { + it("opens a converted reading at display precision, not its stored tail", () => { + // 5.6 mmol/L of glucose, stored in mg/dL. + expect(valueEditSeed(100.90191999999999)).toBe("100.9"); + }); + + it("keeps whole numbers bare and at most two decimals", () => { + expect(valueEditSeed(110)).toBe("110"); + expect(valueEditSeed(4.567)).toBe("4.57"); + }); +}); + +describe("resolveValueForUpdate", () => { + it("leaves an untouched value out of the update", () => { + expect( + resolveValueForUpdate({ + seed: "100.9", + editorValue: "100.9", + parsed: 100.9, + }), + ).toBeUndefined(); + }); + + it("sends a changed value", () => { + expect( + resolveValueForUpdate({ + seed: "100.9", + editorValue: "101,5", + parsed: 101.5, + }), + ).toBe(101.5); + }); + + it("sends a value typed back to the same number in another spelling", () => { + // "100,9" is a deliberate re-entry; the stored tail goes, as for any edit. + expect( + resolveValueForUpdate({ + seed: "100.9", + editorValue: "100,9", + parsed: 100.9, + }), + ).toBe(100.9); + }); +}); diff --git a/src/lib/labs/value-update.ts b/src/lib/labs/value-update.ts new file mode 100644 index 000000000..5cbbad305 --- /dev/null +++ b/src/lib/labs/value-update.ts @@ -0,0 +1,29 @@ +import { formatLabValue } from "./format-value"; + +/** + * The numeric value of a lab reading in the edit sheet. + * + * A converted reading is stored at full precision (5.6 mmol/L of glucose is + * 100.90191999999999 mg/dL), and the sheet must not open with that tail. The + * field starts from the value as every lab surface shows it: whole numbers + * bare, at most two decimals. Written with a dot, which the sheet's parser + * reads, rather than in the reader's locale, which an input cannot round-trip. + */ +export function valueEditSeed(value: number): string { + return formatLabValue(value, String); +} + +/** + * What the edit PUT sends for the value. A field the person did not touch is + * omitted (`undefined`), so the stored full-precision number stays as it is and + * a converted reading keeps its "converted from" record: sending the rounded + * start value back would both lose precision and read to the server as a new + * number. A changed field sends the parsed number. + */ +export function resolveValueForUpdate(args: { + seed: string; + editorValue: string; + parsed: number; +}): number | undefined { + return args.editorValue.trim() === args.seed.trim() ? undefined : args.parsed; +} diff --git a/tests/integration/labs-unit-conversion.test.ts b/tests/integration/labs-unit-conversion.test.ts index 9634476ac..89af81ce8 100644 --- a/tests/integration/labs-unit-conversion.test.ts +++ b/tests/integration/labs-unit-conversion.test.ts @@ -14,7 +14,8 @@ * the printed unit is in range after conversion; * - the OCR commit converts, and the same scan committed again is a duplicate * (the stored number is converted, so the printed one is what matches); - * - editing the value of a converted reading clears the conversion record; + * - changing the value of a converted reading clears the conversion record, + * and an edit that sends the value back unchanged keeps it; * - the list returns the fields. */ import { NextRequest } from "next/server"; @@ -338,10 +339,24 @@ describe("editing and reading back", () => { takenAt: "2026-07-02T08:00:00.000Z", }); const id = created.json.data!.id; - const edited = await call("one", "PUT", { note: "fasting" }, id); + const storedValue = created.json.data!.value; + // The web edit dialog sends the form back whole, the unchanged value + // included; only a changed value clears the record. + const edited = await call( + "one", + "PUT", + { + value: storedValue, + takenAt: "2026-07-02T09:00:00.000Z", + note: "fasting", + }, + id, + ); expect(edited.status).toBe(200); + expect(edited.json.data!.value).toBe(storedValue); expect(edited.json.data!.converted).toBe(true); expect(edited.json.data!.sourceValue).toBe(6.1); + expect(edited.json.data!.sourceUnit).toBe("mmol/L"); }); it("returns the fields on the list and on the single reading", async () => {