From 9e47b47b004c6d54861f130ec0eac005206d772e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 05:54:05 +0200 Subject: [PATCH] fix: honour the chosen glucose unit in the measurements list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list renders a reading through the metric/imperial transform registry, and glucose is not in it, so the list kept saying 95 mg/dL while the dashboard tile said 5.3 mmol/L for the same reading — correct on both counts and disagreeing anyway. The edit sheet is the half that could have done damage. Its label follows the same resolution as the row, so on mmol/L the number in the input is in mmol/L and has to come back the other way before the PATCH. Row, edit seed and inversion move together; converting the display alone would rewrite a 95 mg/dL reading as 5.3 the first time somebody corrected a typo. An mg/dL account edits in the unit the column already holds and stays on the identity path, deliberately: the converter rounds, and sending an mg/dL edit through it would quantise a stored 95.48 the moment somebody opened the sheet to fix a note. A row whose stored unit is not the type's canonical one still shows verbatim, as before. --- .../measurement-list-glucose-unit.test.tsx | 117 ++++++++++++++++++ .../measurements/measurement-list.tsx | 45 ++++++- 2 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx diff --git a/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx b/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx new file mode 100644 index 000000000..97765257f --- /dev/null +++ b/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx @@ -0,0 +1,117 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * The measurements list and the blood-glucose display unit. + * + * Glucose is stored in mg/dL and rendered in the unit the account chose. + * The list is not part of the metric/imperial transform registry, so + * without a branch of its own a reader on mmol/L would see the dashboard + * tile say 5.3 and this list say 95 for the same reading. + * + * The edit sheet is the half that could do damage: its label follows the + * same resolution, so the number in the input is in the displayed unit and + * has to be inverted before the PATCH. Display and inversion are asserted + * together on purpose — converting one without the other rewrites a + * 95 mg/dL reading as 5.3 the first time somebody corrects a typo. + */ + +const rows = [ + { + id: "m1", + type: "BLOOD_GLUCOSE", + value: 95, + unit: "mg/dL", + source: "MANUAL", + measuredAt: "2026-05-15T10:00:00.000Z", + notes: null, + }, +]; + +let glucoseUnit: string | null = null; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { measurements: rows, meta: { total: 1 } }, + isLoading: false, + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/measurements", + useSearchParams: () => new URLSearchParams(""), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "testuser", role: "USER", glucoseUnit }, + isAuthenticated: true, + isLoading: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MeasurementList } from "../measurement-list"; + +const listSource = readFileSync( + join(process.cwd(), "src/components/measurements/measurement-list.tsx"), + "utf8", +); + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("MeasurementList — blood-glucose display unit", () => { + it("shows the stored mg/dL reading as it stands for an mg/dL account", () => { + glucoseUnit = null; + const html = render(); + expect(html).toContain("95"); + expect(html).toContain("mg/dL"); + expect(html).not.toContain("mmol/L"); + }); + + it("converts the same reading for an mmol/L account", () => { + glucoseUnit = "mmol/L"; + const html = render(); + // 95 mg/dL ÷ 18.0182 = 5.3 mmol/L. + expect(html).toContain("5.3"); + expect(html).toContain("mmol/L"); + expect(html).not.toContain("95 mg/dL"); + }); + + it("inverts an edited glucose value back to mg/dL before the save", () => { + expect(listSource).toMatch( + /editsConvertedGlucose\(editing\)[\s\S]{0,400}?canonicalValue = toCanonicalMgdl\(parsedValue, "mmol\/L"\)/, + ); + }); + + it("seeds the edit input in the unit its own label names", () => { + expect(listSource).toMatch( + /const seedValue = editsConvertedGlucose\(measurement\)\s*\?\s*rd\.value/, + ); + }); + + it("leaves an mg/dL edit on the identity path so a note fix cannot round the value", () => { + // `toCanonicalMgdl(v, "mg/dL")` rounds, so routing the mg/dL branch + // through it would quantise a stored 95.48 on an unrelated edit. + expect(listSource).toMatch( + /editsConvertedGlucose[\s\S]{0,300}?glucoseUnit === "mmol\/L"/, + ); + }); +}); diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index b9667f9e0..4433ceae1 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -72,6 +72,11 @@ import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum" import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform"; import { getUnitForType } from "@/lib/validations/measurement"; import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { + convertGlucose, + resolveGlucoseUnit, + toCanonicalMgdl, +} from "@/lib/glucose"; import { invalidateKeys, measurementDependentKeys, @@ -303,17 +308,40 @@ export function MeasurementList({ // transform; a legacy non-canonical row (an old MCP write whose `unit` ≠ the // type's canonical unit) is shown verbatim so its stored unit is never // silently relabelled. Metric users take the identity path — unchanged. + // Glucose sits on its own display axis: stored mg/dL, rendered in whichever + // of mg/dL and mmol/L the account chose. It is not part of the + // metric/imperial registry, so it needs its own branch or a reader on + // mmol/L would see the dashboard tile and this list disagree about the + // same reading. + const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit); + // An edit whose input is NOT in the stored unit. Only the mmol/L branch + // qualifies: an mg/dL account edits in the unit the column holds, and + // routing it through the converter anyway would quantise a stored 95.48 to + // 95 the moment somebody opened the sheet to fix a note. + const editsConvertedGlucose = useCallback( + (m: { type: string; unit: string }) => + m.type === "BLOOD_GLUCOSE" && + glucoseUnit === "mmol/L" && + m.unit === getUnitForType(m.type), + [glucoseUnit], + ); const rowDisplay = useCallback( (m: { type: string; value: number; unit: string }) => { if (m.unit !== getUnitForType(m.type)) { return { value: m.value, unit: m.unit }; } + if (m.type === "BLOOD_GLUCOSE") { + return { + value: convertGlucose(m.value, glucoseUnit), + unit: glucoseUnit, + }; + } return { value: unitDisplay.toDisplay(m.type, m.value), unit: unitDisplay.unitFor(m.type), }; }, - [unitDisplay], + [unitDisplay, glucoseUnit], ); // v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH // render (only CSS `display` hid one), so a dense cumulative (up to ~5000 @@ -791,10 +819,11 @@ export function MeasurementList({ // stored value, unchanged. Round the converted imperial seed so the input // shows "210" rather than a long float. const rd = rowDisplay(measurement); - const seedValue = - unitDisplay.preference === "imperial" && - unitDisplay.isTransformed(measurement.type) && - measurement.unit === getUnitForType(measurement.type) + const seedValue = editsConvertedGlucose(measurement) + ? rd.value + : unitDisplay.preference === "imperial" && + unitDisplay.isTransformed(measurement.type) && + measurement.unit === getUnitForType(measurement.type) ? Math.round(rd.value * 100) / 100 : measurement.value; const seed = { @@ -852,7 +881,11 @@ export function MeasurementList({ // the PATCH so an imperial edit persists in the stored unit. Metric users // and legacy non-canonical rows are the identity path — value unchanged. let canonicalValue = parsedValue; - if ( + if (editsConvertedGlucose(editing)) { + // The label above the input said mmol/L, so the number in it is + // mmol/L. Sending it on would rewrite a 95 mg/dL reading as 5.3. + canonicalValue = toCanonicalMgdl(parsedValue, "mmol/L"); + } else if ( unitDisplay.isTransformed(editing.type) && editing.unit === getUnitForType(editing.type) ) {