mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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)
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user