diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f00512ce5..148febd8d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index c9df357c7..e7bc53513 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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}", diff --git a/messages/en.json b/messages/en.json index 48ed32245..f2d3a970d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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}", diff --git a/messages/es.json b/messages/es.json index 6ea863120..aea9a878c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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}", diff --git a/messages/fr.json b/messages/fr.json index 0f19fac15..8ee397390 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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}", diff --git a/messages/it.json b/messages/it.json index 17b61f5a1..13db50090 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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}", diff --git a/messages/ko.json b/messages/ko.json index 213235474..0e4464586 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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}개 중 최근", diff --git a/messages/nl.json b/messages/nl.json index 57be9591e..c78d5a4ab 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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}", diff --git a/messages/pl.json b/messages/pl.json index 5f4636245..a411d2f18 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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}", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 3d09866b4..754f44ed9 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -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}", diff --git a/messages/ru.json b/messages/ru.json index 9a206fa9f..f3c9d7077 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -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}", diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index 34880beb6..1ddba1dce 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -151,8 +151,6 @@ const ALLOWED_SQL: Record = { "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.", }; diff --git a/src/components/day/day-gki-tile.tsx b/src/components/day/day-gki-tile.tsx index d078a2e8a..1dd807099 100644 --- a/src/components/day/day-gki-tile.tsx +++ b/src/components/day/day-gki-tile.tsx @@ -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[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, })} diff --git a/src/lib/measurements/__tests__/glucose-ketone-index.test.ts b/src/lib/measurements/__tests__/glucose-ketone-index.test.ts index 2c91dee3c..27ead05d2 100644 --- a/src/lib/measurements/__tests__/glucose-ketone-index.test.ts +++ b/src/lib/measurements/__tests__/glucose-ketone-index.test.ts @@ -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)], diff --git a/src/lib/measurements/glucose-ketone-index.ts b/src/lib/measurements/glucose-ketone-index.ts index 46bf54dfa..50e130994 100644 --- a/src/lib/measurements/glucose-ketone-index.ts +++ b/src/lib/measurements/glucose-ketone-index.ts @@ -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) => diff --git a/src/lib/measurements/read-glucose-ketone-index.ts b/src/lib/measurements/read-glucose-ketone-index.ts index cc3afe988..bb17fb7a6 100644 --- a/src/lib/measurements/read-glucose-ketone-index.ts +++ b/src/lib/measurements/read-glucose-ketone-index.ts @@ -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` - 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)); }