mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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.",
|
||||
};
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user