fix(measurements): read the GKI's glucose from the ketone side

The correlated EXISTS over a time range had no equality to drive it from the ketones, so a person with years of sensor glucose walked every glucose row in the window. Each ketone reading now reads its glucose through a LATERAL index range. A full tie between two glucose readings resolves by id, the day tile names the unit, and the empty text says a ketone reading above zero.
This commit is contained in:
Marc-André Bombeck committed 2026-11-10 19:53:47 +01:00
1 parent c14cc9ab47
commit 69ba8bec91
16 files changed
+67 -44

No files matched your search

@@ -151,8 +151,6 @@ const ALLOWED_SQL: Record<string, string> = {
"Selects from `unnest` of the type list; `measurements` is read only inside COUNT scalar subqueries, before the fold window for a type the DAY rollups cover.",
"lib/measurements/read-glucose-ketone-index.ts::1":
"Ketone readings in the window: typed off a fingerstick meter, a few a day at most. Each one is paired with a glucose reading, so the rows themselves are needed.",
"lib/measurements/read-glucose-ketone-index.ts::2":
"Glucose rows only within 15 minutes of one of those ketone readings (`EXISTS`): a handful per fingerstick even under a sensor, never the window's glucose series.",
"lib/dashboard/summary-reads.ts::2":
"Selects from `generate_series` of the streak window's local days; `measurements` is read inside `EXISTS`, one row per day at most.",
};
+2
View File
@@ -4,6 +4,7 @@ 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";
type DayGki = NonNullable<DayResponse["glucoseKetoneIndex"]>[number];
@@ -38,6 +39,7 @@ export function DayGkiTile({ points }: { points: readonly DayGki[] }) {
: t("day.gkiPair", {
glucose: fmt.number(latest.glucoseMmol, 1),
ketones: fmt.number(latest.ketonesMmol, 1),
unit: GKI_COMPONENT_UNIT,
})}
</span>
</Link>
@@ -115,6 +115,18 @@ describe("pairGlucoseKetoneIndex", () => {
expect(byTime?.glucoseId).toBe("g-earlier");
});
it("gives the same pair for a full tie whatever order the rows came in", () => {
const a = reading("g-a", 0, 90, "APPLE_HEALTH");
const b = reading("g-b", 0, 110, "NIGHTSCOUT");
for (const glucose of [
[a, b],
[b, a],
]) {
const [point] = pairGlucoseKetoneIndex([reading("k1", 0, 1)], glucose);
expect(point?.glucoseId).toBe("g-a");
}
});
it("answers in ketone order whatever order the readings came in", () => {
const points = pairGlucoseKetoneIndex(
[reading("k2", 60, 2), reading("k1", 0, 1)],
+5 -1
View File
@@ -106,8 +106,12 @@ export function pairGlucoseKetoneIndex(
ketones: readonly GkiReading[],
glucose: readonly GkiReading[],
): GlucoseKetoneIndexPoint[] {
// The id breaks the last tie, so two glucose readings alike in time and
// source always give the same pair, whatever order the rows came in.
const sortedGlucose = [...glucose].sort(
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
(a, b) =>
a.measuredAt.getTime() - b.measuredAt.getTime() ||
a.id.localeCompare(b.id),
);
const sortedKetones = [...ketones].sort(
(a, b) =>
@@ -15,7 +15,6 @@ import { prisma } from "@/lib/db";
import {
GKI_PAIRING_WINDOW_MINUTES,
GKI_PAIRING_WINDOW_MS,
pairGlucoseKetoneIndex,
type GkiReading,
type GlucoseKetoneIndexPoint,
@@ -62,25 +61,24 @@ export async function readGlucoseKetoneIndex(args: {
if (ketones.length === 0) return [];
// An interval between two stored instants: no zone enters the arithmetic.
const reach = `${GKI_PAIRING_WINDOW_MINUTES} minutes`;
// Driven from the ketone side: one index range per ketone reading on
// (user_id, type, measured_at), so a person with years of CGM glucose
// reads only the glucose around their fingersticks, never the series. A
// correlated EXISTS over a range had no equality to drive it the other
// way and walked every glucose row in the window.
const at = ketones.map((k) => k.measured_at);
const glucose = await prisma.$queryRaw<Row[]>`
SELECT g."id", g."value", g."measured_at", g."source"::text AS "source"
FROM measurements g
WHERE g."user_id" = ${userId}
AND g."type" = 'BLOOD_GLUCOSE'::"measurement_type"
AND g."deleted_at" IS NULL
AND g."measured_at" >= ${new Date(from.getTime() - GKI_PAIRING_WINDOW_MS)}
AND g."measured_at" <= ${new Date(to.getTime() + GKI_PAIRING_WINDOW_MS)}
AND EXISTS (
SELECT 1 FROM measurements k
WHERE k."user_id" = ${userId}
AND k."type" = 'BLOOD_KETONES'::"measurement_type"
AND k."deleted_at" IS NULL
AND k."measured_at" >= ${from}
AND k."measured_at" < ${to}
AND k."value" > 0
AND k."measured_at" BETWEEN g."measured_at" - ${reach}::interval
AND g."measured_at" + ${reach}::interval
)
SELECT DISTINCT g."id", g."value", g."measured_at", g."source"
FROM unnest(${at}::timestamp(3)[]) AS k("at")
CROSS JOIN LATERAL (
SELECT m."id", m."value", m."measured_at", m."source"::text AS "source"
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type"
AND m."deleted_at" IS NULL
AND m."measured_at" BETWEEN k."at" - ${reach}::interval
AND k."at" + ${reach}::interval
) g
`;
return pairGlucoseKetoneIndex(ketones.map(toReading), glucose.map(toReading));
}