fix(labs): keep a reading's conversion record unless its value actually changes

The single-reading edit cleared sourceValue and sourceUnit whenever the body
carried a value, and the web edit sheet always sends one, so a date or note
edit erased the "converted from" hint. It is now cleared only when the value
differs from the stored one. The sheet also opened a converted reading at its
stored precision (100.90191999999999); it now starts from the value as the lab
surfaces show it and leaves an untouched value out of the request, so the
stored full-precision number is not replaced by its rounded display.
This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:15:33 +02:00
1 parent 47a91d6922
commit cd1449e63e
6 files changed
+118 -9

No files matched your search

+1 -1
View File
@@ -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`.
+8 -2
View File
@@ -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;
}
+15 -4
View File
@@ -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())) {
@@ -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);
});
});
+29
View File
@@ -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;
}
+17 -2
View File
@@ -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<Dto>("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<Dto>(
"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 () => {