mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +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
+11
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.",
|
||||
};
|
||||
|
||||
@@ -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