diff --git a/src/components/day/day-gki-tile.tsx b/src/components/day/day-gki-tile.tsx index 1dd807099..ed29214d2 100644 --- a/src/components/day/day-gki-tile.tsx +++ b/src/components/day/day-gki-tile.tsx @@ -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[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, })} diff --git a/src/components/insights/ketones/gki-card.tsx b/src/components/insights/ketones/gki-card.tsx index ec1f99a80..f5e9c2bba 100644 --- a/src/components/insights/ketones/gki-card.tsx +++ b/src/components/insights/ketones/gki-card.tsx @@ -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), })} diff --git a/src/lib/measurements/__tests__/glucose-ketone-index.test.ts b/src/lib/measurements/__tests__/glucose-ketone-index.test.ts index 27ead05d2..ab2804fd1 100644 --- a/src/lib/measurements/__tests__/glucose-ketone-index.test.ts +++ b/src/lib/measurements/__tests__/glucose-ketone-index.test.ts @@ -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, }); }); }); diff --git a/src/lib/measurements/glucose-ketone-index.ts b/src/lib/measurements/glucose-ketone-index.ts index 50e130994..366639fa9 100644 --- a/src/lib/measurements/glucose-ketone-index.ts +++ b/src/lib/measurements/glucose-ketone-index.ts @@ -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; diff --git a/tests/integration/blood-ketones-gki.test.ts b/tests/integration/blood-ketones-gki.test.ts index fccf01eda..f350bb146 100644 --- a/tests/integration/blood-ketones-gki.test.ts +++ b/tests/integration/blood-ketones-gki.test.ts @@ -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(