fix: honour the chosen glucose unit in the measurements list

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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 06:05:58 +02:00
1 parent 5078867203
commit 9e47b47b00
2 files changed
+156 -6

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">
<MeasurementList />
</I18nProvider>,
);
}
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"/,
);
});
});
@@ -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)
) {