fix(gki): build the index from the glucose shown beside it

A reading entered in mmol/L is stored as whole mg/dL, and the index
divided the unrounded conversion, so a 5.2 / 0.3 pair printed beside a
17.4 rather than 17.3. Round the glucose to the decimal it is shown with
first and divide that. The tiles also printed a two-decimal ketone
reading to one decimal; show it as recorded.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 00:44:29 +01:00
1 parent c8b32ccaea
commit 5ebfac1259
5 files changed
+72 -22

No files matched your search

+8 -2
View File
@@ -4,7 +4,10 @@ import Link from "next/link";
import type { DayResponse } from "@/lib/day/contract";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { GKI_COMPONENT_UNIT } from "@/lib/measurements/glucose-ketone-index";
import {
GKI_COMPONENT_UNIT,
ketoneFractionDigits,
} from "@/lib/measurements/glucose-ketone-index";
type DayGki = NonNullable<DayResponse["glucoseKetoneIndex"]>[number];
@@ -38,7 +41,10 @@ export function DayGkiTile({ points }: { points: readonly DayGki[] }) {
? tCount("day.gkiCount", points.length)
: t("day.gkiPair", {
glucose: fmt.number(latest.glucoseMmol, 1),
ketones: fmt.number(latest.ketonesMmol, 1),
ketones: fmt.number(
latest.ketonesMmol,
ketoneFractionDigits(latest.ketonesMmol),
),
unit: GKI_COMPONENT_UNIT,
})}
</span>
+8 -2
View File
@@ -17,7 +17,10 @@ import {
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { GKI_COMPONENT_UNIT } from "@/lib/measurements/glucose-ketone-index";
import {
GKI_COMPONENT_UNIT,
ketoneFractionDigits,
} from "@/lib/measurements/glucose-ketone-index";
import { useGlucoseKetoneIndexSeries } from "@/lib/queries/use-glucose-ketone-index";
import { importWithRetry } from "@/lib/retry-import";
@@ -78,7 +81,10 @@ export function GkiCard() {
{t("insights.bloodKetones.gki.latest", {
value: fmt.number(latest.value, 1),
glucose: fmt.number(latest.gki.glucoseMmol, 1),
ketones: fmt.number(latest.gki.ketonesMmol, 1),
ketones: fmt.number(
latest.gki.ketonesMmol,
ketoneFractionDigits(latest.gki.ketonesMmol),
),
unit: GKI_COMPONENT_UNIT,
date: fmt.dateTime(latest.at),
})}
@@ -4,6 +4,7 @@ import { MGDL_PER_MMOL } from "@/lib/glucose";
import {
GKI_PAIRING_WINDOW_MINUTES,
glucoseKetoneIndex,
ketoneFractionDigits,
pairGlucoseKetoneIndex,
summariseGlucoseKetoneIndex,
type GkiReading,
@@ -49,13 +50,25 @@ describe("pairGlucoseKetoneIndex", () => {
glucoseId: "g1",
at: new Date(T0).toISOString(),
glucoseAt: new Date(T0).toISOString(),
// 81 / 18.0182 = 4.4954 mmol/L; / 1.2 = 3.746 -> 3.7
value: 3.7,
// 81 / 18.0182 = 4.4954 -> 4.5 mmol/L, the figure shown beside the
// index; 4.5 / 1.2 = 3.75 -> 3.8
value: 3.8,
ketonesMmol: 1.2,
glucoseMmol: 4.5,
});
});
it("builds the index from the glucose it prints, not the unrounded one", () => {
// 5.2 mmol/L entered is stored as 94 mg/dL. Unrounded that is 5.217 and
// 17.39 over 0.3, shown as 17.4 beside "5.2 / 0.3"; the pair gives 17.3.
const [point] = pairGlucoseKetoneIndex(
[reading("k1", 0, 0.3)],
[reading("g1", 0, 94)],
);
expect(point?.glucoseMmol).toBe(5.2);
expect(point?.value).toBe(17.3);
});
it("prefers the same instant over a nearer-looking neighbour", () => {
const [point] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1)],
@@ -148,6 +161,16 @@ describe("pairGlucoseKetoneIndex", () => {
});
});
describe("ketoneFractionDigits", () => {
it("shows a ketone reading as recorded, one or two decimals", () => {
expect(ketoneFractionDigits(1.2)).toBe(1);
expect(ketoneFractionDigits(0.3)).toBe(1);
expect(ketoneFractionDigits(2)).toBe(1);
expect(ketoneFractionDigits(0.25)).toBe(2);
expect(ketoneFractionDigits(1.05)).toBe(2);
});
});
describe("summariseGlucoseKetoneIndex", () => {
it("summarises the indices and is null for none", () => {
expect(summariseGlucoseKetoneIndex([])).toBeNull();
@@ -155,14 +178,14 @@ describe("summariseGlucoseKetoneIndex", () => {
[reading("k1", 0, 1), reading("k2", 60, 2), reading("k3", 120, 4)],
[reading("g1", 0, 90), reading("g2", 60, 90), reading("g3", 120, 90)],
);
// 90 mg/dL = 4.995 mmol/L: indices 5.0, 2.5, 1.2.
expect(points.map((p) => p.value)).toEqual([5, 2.5, 1.2]);
// 90 mg/dL = 4.995 -> 5.0 mmol/L: indices 5.0, 2.5, 1.3 (5.0 / 4 = 1.25).
expect(points.map((p) => p.value)).toEqual([5, 2.5, 1.3]);
expect(summariseGlucoseKetoneIndex(points)).toEqual({
count: 3,
mean: 2.9,
min: 1.2,
min: 1.3,
max: 5,
latest: 1.2,
latest: 1.3,
});
});
});
+21 -6
View File
@@ -5,8 +5,9 @@
* GKI = glucose (mmol/L) / beta-hydroxybutyrate (mmol/L)
*
* Glucose is stored in mg/dL and converted with {@link MGDL_PER_MMOL}, the one
* factor the tree uses for that conversion, so the glucose a GKI is built from
* is the same number every glucose surface shows. The index is computed when
* factor the tree uses for that conversion, then rounded to the one decimal
* the index is shown beside, so the glucose a GKI is built from is the same
* number every glucose surface shows. The index is computed when
* it is read and never stored: a stored ratio drifts the moment either reading
* it came from is edited or deleted, which is the failure a hand-typed GKI has.
*
@@ -50,6 +51,16 @@ export const GKI_PAIRING_WINDOW_MINUTES = 15;
export const GKI_COMPONENT_UNIT = "mmol/L";
export const GKI_PAIRING_WINDOW_MS = GKI_PAIRING_WINDOW_MINUTES * 60_000;
/**
* The decimals a ketone reading is shown with beside its index: as recorded,
* one or two. A meter that reads 0.25 must not print as 0.3, or the pair on
* screen no longer divides to the index beside it.
*/
export function ketoneFractionDigits(ketonesMmol: number): 1 | 2 {
const tenths = ketonesMmol * 10;
return Math.abs(tenths - Math.round(tenths)) < 1e-9 ? 1 : 2;
}
/** One stored reading, as the pairing needs it. */
export interface GkiReading {
id: string;
@@ -154,16 +165,20 @@ export function pairGlucoseKetoneIndex(
}
}
if (best === null) continue;
const index = glucoseKetoneIndex(best.value, ketone.value);
if (index === null) continue;
if (glucoseKetoneIndex(best.value, ketone.value) === null) continue;
// The index is built from the glucose as it is shown beside it, so the
// printed pair divides to the printed index. A reading entered in mmol/L
// is stored as whole mg/dL; dividing the unrounded conversion would land
// a tenth away from the pair on screen.
const glucoseMmol = oneDecimal(best.value / MGDL_PER_MMOL);
out.push({
ketoneId: ketone.id,
glucoseId: best.id,
at: ketone.measuredAt.toISOString(),
glucoseAt: best.measuredAt.toISOString(),
value: oneDecimal(index),
value: oneDecimal(glucoseMmol / ketone.value),
ketonesMmol: ketone.value,
glucoseMmol: oneDecimal(best.value / MGDL_PER_MMOL),
glucoseMmol,
});
}
return out;
+6 -6
View File
@@ -207,11 +207,11 @@ describe("glucose-ketone index (#1367)", () => {
glucoseMmol: p.glucoseMmol,
})),
).toEqual([
// 81 mg/dL = 4.5 mmol/L; / 1.2 = 3.7
// 81 mg/dL = 4.5 mmol/L; / 1.2 = 3.75 -> 3.8
{
at: at(0).toISOString(),
glucoseAt: at(0).toISOString(),
value: 3.7,
value: 3.8,
ketonesMmol: 1.2,
glucoseMmol: 4.5,
},
@@ -261,7 +261,7 @@ describe("glucose-ketone index (#1367)", () => {
};
};
expect(gkiBody.data.unit).toBe("ratio");
expect(gkiBody.data.points.map((p) => p.value)).toEqual([3.7, 2.5]);
expect(gkiBody.data.points.map((p) => p.value)).toEqual([3.8, 2.5]);
expect(gkiBody.data.points[1]!.gki).toMatchObject({
glucoseAt: at(130).toISOString(),
glucoseMmol: 5,
@@ -301,7 +301,7 @@ describe("glucose-ketone index (#1367)", () => {
};
};
expect(body.data.glucoseKetoneIndex?.map((p) => p.value)).toEqual([
3.7, 2.5,
3.8, 2.5,
]);
expect(body.data.values.some((v) => v.type === "BLOOD_KETONES")).toBe(true);
});
@@ -324,8 +324,8 @@ describe("glucose-ketone index (#1367)", () => {
count: 2,
latest: 2.5,
min: 2.5,
max: 3.7,
avg: 3.1,
max: 3.8,
avg: 3.2,
});
const ketonesOnly = await collectDoctorReportData(