mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
47a91d6922
commit
cd1449e63e
6 files changed
+118
-9
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user