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

+11 -2
View File
@@ -904,7 +904,8 @@ paths:
tags:
- Cycle
summary: Change the calendar subscription's options (#1350)
description: Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content.
description: Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new
content. A change saved while the link is being replaced applies to the link that stays live.
requestBody:
required: true
content:
@@ -927,6 +928,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: The link was replaced twice while the change was being saved (`cycle.feed.exists`); nothing changed, and
sending it again applies it to the link that is live now.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a12
delete:
@@ -959,7 +967,8 @@ paths:
summary: Replace the calendar subscription's link (#1350)
description: "One transaction: the live link is revoked and a new one minted with the same options, the forecast style
included. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing,
the one that loses answers 409 `cycle.feed.exists` and the winner's link stands."
the one that loses answers 409 `cycle.feed.exists` and the winner's link stands; a rotation racing the
subscription being turned off answers 404 `cycle.feed.none` and the subscription stays off."
responses:
"201": *a13
"401": *a14
+2 -2
View File
@@ -4019,7 +4019,7 @@
"gki": {
"description": "Blutzucker geteilt durch Ketone, für jeden Ketonwert mit einem Blutzuckerwert innerhalb von 15 Minuten.",
"emptyTitle": "Noch kein Index",
"emptyDescription": "Er erscheint, sobald zu einem Ketonwert ein Blutzuckerwert zur selben Zeit oder innerhalb von 15 Minuten vorliegt.",
"emptyDescription": "Er erscheint, sobald zu einem Ketonwert über null ein Blutzuckerwert zur selben Zeit oder innerhalb von 15 Minuten vorliegt.",
"latest": "Zuletzt {value}: Blutzucker {glucose} {unit} durch Ketone {ketones} {unit}, {date}."
}
},
@@ -13710,7 +13710,7 @@
"valuesAllFew": "Alle {count} Werte",
"valuesAllOther": "Alle {count} Werte",
"valuesFewer": "Weniger Werte",
"gkiPair": "Blutzucker {glucose} / Ketone {ketones}",
"gkiPair": "Blutzucker {glucose} {unit} / Ketone {ketones} {unit}",
"gkiCountOne": "Letzter von {count}",
"gkiCountFew": "Letzter von {count}",
"gkiCountOther": "Letzter von {count}",
+2 -2
View File
@@ -4019,7 +4019,7 @@
"gki": {
"description": "Glucose divided by ketones, for each ketone reading with a glucose reading within 15 minutes.",
"emptyTitle": "No index yet",
"emptyDescription": "It appears once a ketone reading has a glucose reading taken at the same time or within 15 minutes.",
"emptyDescription": "It appears once a ketone reading above zero has a glucose reading taken at the same time or within 15 minutes.",
"latest": "Latest {value}: glucose {glucose} {unit} over ketones {ketones} {unit}, {date}."
}
},
@@ -13710,7 +13710,7 @@
"valuesAllFew": "All {count} values",
"valuesAllOther": "All {count} values",
"valuesFewer": "Fewer values",
"gkiPair": "Glucose {glucose} / ketones {ketones}",
"gkiPair": "Glucose {glucose} {unit} / ketones {ketones} {unit}",
"gkiCountOne": "Latest of {count}",
"gkiCountFew": "Latest of {count}",
"gkiCountOther": "Latest of {count}",
+2 -2
View File
@@ -4019,7 +4019,7 @@
"gki": {
"description": "Glucosa dividida por cetonas, para cada lectura de cetonas con una de glucosa en un margen de 15 minutos.",
"emptyTitle": "Aún no hay índice",
"emptyDescription": "Aparece cuando una lectura de cetonas tiene una de glucosa tomada a la misma hora o en un margen de 15 minutos.",
"emptyDescription": "Aparece cuando una lectura de cetonas mayor que cero tiene una de glucosa tomada a la misma hora o en un margen de 15 minutos.",
"latest": "Último {value}: glucosa {glucose} {unit} entre cetonas {ketones} {unit}, {date}."
}
},
@@ -13710,7 +13710,7 @@
"valuesAllFew": "Los {count} valores",
"valuesAllOther": "Los {count} valores",
"valuesFewer": "Menos valores",
"gkiPair": "Glucosa {glucose} / cetonas {ketones}",
"gkiPair": "Glucosa {glucose} {unit} / cetonas {ketones} {unit}",
"gkiCountOne": "Último de {count}",
"gkiCountFew": "Último de {count}",
"gkiCountOther": "Último de {count}",
+2 -2
View File
@@ -4019,7 +4019,7 @@
"gki": {
"description": "Glycémie divisée par les cétones, pour chaque relevé de cétones accompagné d’une glycémie à 15 minutes près.",
"emptyTitle": "Pas encore d’indice",
"emptyDescription": "Il apparaît dès qu’un relevé de cétones a une glycémie prise au même moment ou à 15 minutes près.",
"emptyDescription": "Il apparaît dès qu’un relevé de cétones supérieur à zéro a une glycémie prise au même moment ou à 15 minutes près.",
"latest": "Dernier {value} : glycémie {glucose} {unit} sur cétones {ketones} {unit}, {date}."
}
},
@@ -13710,7 +13710,7 @@
"valuesAllFew": "Les {count} valeurs",
"valuesAllOther": "Les {count} valeurs",
"valuesFewer": "Moins de valeurs",
"gkiPair": "Glycémie {glucose} / cétones {ketones}",
"gkiPair": "Glycémie {glucose} {unit} / cétones {ketones} {unit}",
"gkiCountOne": "Dernier sur {count}",
"gkiCountFew": "Dernier sur {count}",
"gkiCountOther": "Dernier sur {count}",
+2 -2
View File
@@ -4019,7 +4019,7 @@
"gki": {
"description": "Glicemia divisa per i chetoni, per ogni lettura di chetoni con una glicemia entro 15 minuti.",
"emptyTitle": "Ancora nessun indice",
"emptyDescription": "Compare quando una lettura di chetoni ha una glicemia presa allo stesso momento o entro 15 minuti.",
"emptyDescription": "Compare quando una lettura di chetoni superiore a zero ha una glicemia presa allo stesso momento o entro 15 minuti.",
"latest": "Ultimo {value}: glicemia {glucose} {unit} su chetoni {ketones} {unit}, {date}."
}
},
@@ -13710,7 +13710,7 @@
"valuesAllFew": "Tutti i {count} valori",
"valuesAllOther": "Tutti i {count} valori",
"valuesFewer": "Meno valori",
"gkiPair": "Glicemia {glucose} / chetoni {ketones}",
"gkiPair": "Glicemia {glucose} {unit} / chetoni {ketones} {unit}",
"gkiCountOne": "Ultimo di {count}",
"gkiCountFew": "Ultimo di {count}",
"gkiCountOther": "Ultimo di {count}",
+2 -2
View File
@@ -4019,7 +4019,7 @@
"gki": {
"description": "15분 이내에 혈당 측정값이 있는 케톤 측정마다 혈당을 케톤으로 나눈 값이에요.",
"emptyTitle": "아직 지수가 없어요",
"emptyDescription": "케톤 측정과 같은 시각이나 15분 이내에 잰 혈당이 있으면 나타나요.",
"emptyDescription": "0보다 큰 케톤 측정과 같은 시각이나 15분 이내에 잰 혈당이 있으면 나타나요.",
"latest": "최근 {value}: 혈당 {glucose} {unit} ÷ 케톤 {ketones} {unit}, {date}."
}
},
@@ -13710,7 +13710,7 @@
"valuesAllFew": "수치 {count}개 모두",
"valuesAllOther": "수치 {count}개 모두",
"valuesFewer": "수치 줄여 보기",
"gkiPair": "혈당 {glucose} / 케톤 {ketones}",
"gkiPair": "혈당 {glucose} {unit} / 케톤 {ketones} {unit}",
"gkiCountOne": "{count}개 중 최근",
"gkiCountFew": "{count}개 중 최근",
"gkiCountOther": "{count}개 중 최근",
+2 -2
View File
@@ -4019,7 +4019,7 @@
"gki": {
"description": "Glucose gedeeld door ketonen, voor elke ketonmeting met een glucosemeting binnen 15 minuten.",
"emptyTitle": "Nog geen index",
"emptyDescription": "Hij verschijnt zodra een ketonmeting een glucosemeting op hetzelfde moment of binnen 15 minuten heeft.",
"emptyDescription": "Hij verschijnt zodra een ketonmeting boven nul een glucosemeting op hetzelfde moment of binnen 15 minuten heeft.",
"latest": "Laatst {value}: glucose {glucose} {unit} gedeeld door ketonen {ketones} {unit}, {date}."
}
},
@@ -13710,7 +13710,7 @@
"valuesAllFew": "Alle {count} waarden",
"valuesAllOther": "Alle {count} waarden",
"valuesFewer": "Minder waarden",
"gkiPair": "Glucose {glucose} / ketonen {ketones}",
"gkiPair": "Glucose {glucose} {unit} / ketonen {ketones} {unit}",
"gkiCountOne": "Laatste van {count}",
"gkiCountFew": "Laatste van {count}",
"gkiCountOther": "Laatste van {count}",
+2 -2
View File
@@ -4019,7 +4019,7 @@
"gki": {
"description": "Glukoza podzielona przez ketony, dla każdego odczytu ketonów z odczytem glukozy w ciągu 15 minut.",
"emptyTitle": "Brak jeszcze indeksu",
"emptyDescription": "Pojawi się, gdy do odczytu ketonów będzie odczyt glukozy z tej samej chwili lub w ciągu 15 minut.",
"emptyDescription": "Pojawi się, gdy do odczytu ketonów powyżej zera będzie odczyt glukozy z tej samej chwili lub w ciągu 15 minut.",
"latest": "Ostatnio {value}: glukoza {glucose} {unit} przez ketony {ketones} {unit}, {date}."
}
},
@@ -13710,7 +13710,7 @@
"valuesAllFew": "Wszystkie {count} wartości",
"valuesAllOther": "Wszystkie {count} wartości",
"valuesFewer": "Mniej wartości",
"gkiPair": "Glukoza {glucose} / ketony {ketones}",
"gkiPair": "Glukoza {glucose} {unit} / ketony {ketones} {unit}",
"gkiCountOne": "Ostatni z {count}",
"gkiCountFew": "Ostatni z {count}",
"gkiCountOther": "Ostatni z {count}",
+2 -2
View File
@@ -4019,7 +4019,7 @@
"gki": {
"description": "Glicose dividida pelas cetonas, para cada leitura de cetonas com uma de glicose em até 15 minutos.",
"emptyTitle": "Ainda não há índice",
"emptyDescription": "Ele aparece quando uma leitura de cetonas tem uma de glicose feita no mesmo horário ou em até 15 minutos.",
"emptyDescription": "Ele aparece quando uma leitura de cetonas acima de zero tem uma de glicose feita no mesmo horário ou em até 15 minutos.",
"latest": "Último {value}: glicose {glucose} {unit} sobre cetonas {ketones} {unit}, {date}."
}
},
@@ -13710,7 +13710,7 @@
"valuesAllFew": "Todos os {count} valores",
"valuesAllOther": "Todos os {count} valores",
"valuesFewer": "Menos valores",
"gkiPair": "Glicose {glucose} / cetonas {ketones}",
"gkiPair": "Glicose {glucose} {unit} / cetonas {ketones} {unit}",
"gkiCountOne": "Último de {count}",
"gkiCountFew": "Último de {count}",
"gkiCountOther": "Último de {count}",
+2 -2
View File
@@ -4019,7 +4019,7 @@
"gki": {
"description": "Глюкоза, делённая на кетоны, для каждого измерения кетонов с измерением глюкозы в пределах 15 минут.",
"emptyTitle": "Индекса пока нет",
"emptyDescription": "Он появится, когда к измерению кетонов будет измерение глюкозы в то же время или в пределах 15 минут.",
"emptyDescription": "Он появится, когда к измерению кетонов выше нуля будет измерение глюкозы в то же время или в пределах 15 минут.",
"latest": "Последний {value}: глюкоза {glucose} {unit} на кетоны {ketones} {unit}, {date}."
}
},
@@ -13710,7 +13710,7 @@
"valuesAllFew": "Все {count} значения",
"valuesAllOther": "Все {count} значений",
"valuesFewer": "Меньше значений",
"gkiPair": "Глюкоза {glucose} / кетоны {ketones}",
"gkiPair": "Глюкоза {glucose} {unit} / кетоны {ketones} {unit}",
"gkiCountOne": "Последний из {count}",
"gkiCountFew": "Последний из {count}",
"gkiCountOther": "Последний из {count}",
@@ -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));
}