diff --git a/CHANGELOG.md b/CHANGELOG.md index ece51a626..fd0d97d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,22 @@ with your account and applied by the server, so the app shows the same order. The labs list layout accepts `categoryOrder`, and `GET /api/labs/overview` answers `categories` in that order. (#1349) +- **Blood ketones, and the glucose-ketone index worked out for you.** + Ketones (beta-hydroxybutyrate, mmol/L) are a measurement of their own + now, so a ketogenic diet no longer has to live in a custom metric with + the index typed in by hand beside it. Choose "Blood ketones" when you + add a measurement and type the glucose from the same fingerstick into + the field below: both readings are saved together with one time, or + neither is. The glucose-ketone index is glucose in mmol/L divided by + ketones, taken from the glucose reading at the same time or, failing + that, the nearest one within 15 minutes. It is never stored, so it + follows any reading you edit or delete. It shows under the chart on the + new Ketones page, in the day view, and beside the ketones in the doctor + report and the clinician share; the FHIR export carries the ketone + readings under LOINC 104816-4. Values are shown as recorded, with no + band and no verdict. `GET /api/measurements/series` takes + `kind=ketones` and `kind=glucoseKetoneIndex`, and `GET /api/day/{date}` + carries `glucoseKetoneIndex`. Migration `0496_blood_ketones`. (#1367) ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e4e977459..ab5b2953d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4672,6 +4672,8 @@ paths: - restingHeartRate - heartRateVariability - vo2Max + - ketones + - glucoseKetoneIndex required: true - in: query name: days @@ -4811,6 +4813,7 @@ paths: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES - in: query name: from schema: @@ -5736,6 +5739,7 @@ paths: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES description: The metric. required: true description: The metric. @@ -5866,6 +5870,7 @@ paths: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES description: The metric. required: true description: The metric. @@ -28773,7 +28778,7 @@ components: type: number const: 2 leaves: - maxItems: 111 + maxItems: 112 type: array items: type: string @@ -28835,7 +28840,7 @@ components: type: number const: 2 leaves: - maxItems: 111 + maxItems: 112 type: array items: type: string @@ -29715,6 +29720,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES value: type: number measuredAt: @@ -31449,6 +31455,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES biomarkerId: type: string minLength: 1 @@ -31948,7 +31955,7 @@ components: type: number const: 2 leaves: - maxItems: 111 + maxItems: 112 type: array items: type: string @@ -32593,7 +32600,7 @@ components: - order tiles: minItems: 1 - maxItems: 63 + maxItems: 64 type: array items: type: object @@ -32649,6 +32656,7 @@ components: - audio-events - daylight - blood-glucose + - ketones - skin-temperature - wrist-temperature - mood @@ -33151,6 +33159,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature). InsightsSettingsPutRequest: @@ -37697,6 +37706,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones description: Which chart's overlays are being written. Closed enum. prefs: type: object @@ -37886,6 +37896,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones additionalProperties: type: object properties: @@ -38858,7 +38869,7 @@ components: additionalProperties: false tiles: minItems: 1 - maxItems: 63 + maxItems: 64 type: array items: type: object @@ -38914,6 +38925,7 @@ components: - audio-events - daylight - blood-glucose + - ketones - skin-temperature - wrist-temperature - mood @@ -42723,6 +42735,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES value: type: number valueMin: @@ -43491,6 +43504,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES - type: "null" biomarkerName: anyOf: @@ -46089,6 +46103,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES dateKey: type: string pattern: ^\d{4}-\d{2}-\d{2}$ @@ -47289,6 +47304,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES - type: "null" intervalDays: anyOf: @@ -47515,6 +47531,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES metricSlot: type: - string @@ -51039,10 +51056,12 @@ components: - restingHeartRate - heartRateVariability - vo2Max + - ketones + - glucoseKetoneIndex unit: type: string description: Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` - (per-night time-asleep in hours). + (per-night time-asleep in hours); `ketones` is `mmol/L`; `glucoseKetoneIndex` is `ratio`. points: type: array items: @@ -51089,6 +51108,31 @@ components: type: - number - "null" + gki: + description: "#1367 — for `kind=glucoseKetoneIndex` only: the readings the index was computed from. `id` and `at` are + the ketone reading's. Absent for every other kind." + type: object + properties: + glucoseId: + type: string + description: The paired glucose reading's id. + glucoseAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: The paired glucose reading's timestamp. + glucoseMmol: + type: number + description: The paired glucose in mmol/L, one decimal. + ketonesMmol: + type: number + description: The ketone reading (beta-hydroxybutyrate), mmol/L. + required: + - glucoseId + - glucoseAt + - glucoseMmol + - ketonesMmol + additionalProperties: false required: - id - at @@ -51146,7 +51190,10 @@ components: ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. - `stats` is always computed over the underlying readings, never over the buckets." + `stats` is always computed over the underlying readings, never over the buckets. `glucoseKetoneIndex` (#1367) is + derived at read time, never stored: one point per ketone reading above zero that has a glucose reading at the + same instant or, failing that, the nearest one within 15 minutes either side; value = glucose (mmol/L, from + mg/dL by 18.0182) / ketones (mmol/L), one decimal. Draw it as given; do not recompute it." TrendVerdict: type: object properties: @@ -53832,6 +53879,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES date: type: string format: date @@ -54112,6 +54160,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES from: type: string format: date @@ -64561,7 +64610,7 @@ components: additionalProperties: false tiles: minItems: 1 - maxItems: 63 + maxItems: 64 type: array items: type: object @@ -64617,6 +64666,7 @@ components: - audio-events - daylight - blood-glucose + - ketones - skin-temperature - wrist-temperature - mood @@ -88230,6 +88280,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones additionalProperties: type: object properties: @@ -88496,6 +88547,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones additionalProperties: type: object properties: @@ -93870,6 +93922,14 @@ components: type: array items: $ref: "#/components/schemas/DayValue" + glucoseKetoneIndex: + description: "#1367 — the day's glucose-ketone index, oldest first: glucose (mmol/L) over ketones (mmol/L) for each + ketone reading above zero with a glucose reading at the same instant or within 15 minutes either side. + Computed at read time; show it as given. Absent when the day has none, when either reading type is hidden + from the caller, and on older servers." + type: array + items: + $ref: "#/components/schemas/DayGlucoseKetoneIndex" events: type: array items: @@ -94074,6 +94134,43 @@ components: - source - band additionalProperties: false + DayGlucoseKetoneIndex: + type: object + properties: + at: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: The ketone reading's instant. + value: + type: number + description: The index, to one decimal. + ketoneId: + type: string + description: The ketone reading's id. + glucoseId: + type: string + description: The paired glucose reading's id. + glucoseAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: The paired glucose reading's instant. + ketonesMmol: + type: number + description: Beta-hydroxybutyrate, mmol/L. + glucoseMmol: + type: number + description: The paired glucose in mmol/L, one decimal. + required: + - at + - value + - ketoneId + - glucoseId + - glucoseAt + - ketonesMmol + - glucoseMmol + additionalProperties: false DayEvent: type: object properties: diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md index 65d4c11b7..c59065e39 100644 --- a/docs/integrations/data-import.md +++ b/docs/integrations/data-import.md @@ -163,6 +163,7 @@ hand-authored. | `RESPIRATORY_RATE` | count/min | | `OXYGEN_SATURATION` | percent (0–100) | | `BLOOD_GLUCOSE` | mg/dL | +| `BLOOD_KETONES` | mmol/L | | `BODY_FAT` | percent (0–100) | | `FAT_MASS` | kg | | `FAT_FREE_MASS` | kg | diff --git a/messages/de.json b/messages/de.json index 32e236fd1..c9df357c7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -813,6 +813,10 @@ "typeSleep": "Schlaf", "typeSteps": "Schritte", "typeBloodGlucose": "Blutzucker", + "typeBloodKetones": "Blutketone", + "glucoseKetoneIndex": "Glukose-Keton-Index", + "pairedGlucoseLabel": "Blutzucker aus derselben Messung ({unit})", + "pairedGlucoseHint": "Wird als eigener Blutzuckerwert mit derselben Uhrzeit gespeichert; aus dem Paar ergibt sich der Glukose-Keton-Index.", "typeTotalBodyWater": "Gesamtkörperwasser", "typeBoneMass": "Knochenmasse", "typeOxygenSaturation": "Sauerstoffsättigung", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glukose", + "navBloodKetones": "Ketone", + "bloodKetones": { + "title": "Blutketone", + "description": "Deine Beta-Hydroxybutyrat-Werte im Zeitverlauf, in {unit}.", + "chartTitle": "Blutketone", + "emptyState": { + "title": "Noch keine Ketonwerte", + "description": "Ketonwerte erscheinen hier, sobald du einen erfasst, allein oder mit dem Blutzucker aus demselben Fingerstich.", + "cta": "Ketone erfassen" + }, + "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.", + "latest": "Zuletzt {value}: Blutzucker {glucose} {unit} durch Ketone {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Gesamtkörperwasser", "description": "Dein Gesamtkörperwasser im Zeitverlauf, in Kilogramm.", @@ -13689,6 +13710,10 @@ "valuesAllFew": "Alle {count} Werte", "valuesAllOther": "Alle {count} Werte", "valuesFewer": "Weniger Werte", + "gkiPair": "Blutzucker {glucose} / Ketone {ketones}", + "gkiCountOne": "Letzter von {count}", + "gkiCountFew": "Letzter von {count}", + "gkiCountOther": "Letzter von {count}", "countEntriesOne": "1 Eintrag", "countEntriesFew": "{count} Einträge", "countEntriesOther": "{count} Einträge", diff --git a/messages/en.json b/messages/en.json index dd1f767d9..48ed32245 100644 --- a/messages/en.json +++ b/messages/en.json @@ -813,6 +813,10 @@ "typeSleep": "Sleep", "typeSteps": "Steps", "typeBloodGlucose": "Blood glucose", + "typeBloodKetones": "Blood ketones", + "glucoseKetoneIndex": "Glucose-ketone index", + "pairedGlucoseLabel": "Glucose from the same reading ({unit})", + "pairedGlucoseHint": "Saved as its own glucose reading at the same time; the glucose-ketone index is worked out from the pair.", "typeTotalBodyWater": "Total body water", "typeBoneMass": "Bone mass", "typeOxygenSaturation": "Oxygen saturation", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucose", + "navBloodKetones": "Ketones", + "bloodKetones": { + "title": "Blood Ketones", + "description": "Your beta-hydroxybutyrate readings over time, in {unit}.", + "chartTitle": "Blood ketones", + "emptyState": { + "title": "No ketone readings yet", + "description": "Ketone readings appear here once you log one, on its own or with the glucose from the same fingerstick.", + "cta": "Log ketones" + }, + "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.", + "latest": "Latest {value}: glucose {glucose} {unit} over ketones {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Total Body Water", "description": "Your total body water over time, in kilograms.", @@ -13689,6 +13710,10 @@ "valuesAllFew": "All {count} values", "valuesAllOther": "All {count} values", "valuesFewer": "Fewer values", + "gkiPair": "Glucose {glucose} / ketones {ketones}", + "gkiCountOne": "Latest of {count}", + "gkiCountFew": "Latest of {count}", + "gkiCountOther": "Latest of {count}", "countEntriesOne": "1 entry", "countEntriesFew": "{count} entries", "countEntriesOther": "{count} entries", diff --git a/messages/es.json b/messages/es.json index e65f3d6bf..6ea863120 100644 --- a/messages/es.json +++ b/messages/es.json @@ -813,6 +813,10 @@ "typeSleep": "Sueño", "typeSteps": "Pasos", "typeBloodGlucose": "Glucemia", + "typeBloodKetones": "Cetonas en sangre", + "glucoseKetoneIndex": "Índice glucosa-cetonas", + "pairedGlucoseLabel": "Glucosa de la misma medición ({unit})", + "pairedGlucoseHint": "Se guarda como una lectura de glucosa propia con la misma hora; el índice glucosa-cetonas se calcula a partir del par.", "typeTotalBodyWater": "Agua corporal total", "typeBoneMass": "Masa ósea", "typeOxygenSaturation": "Saturación de oxígeno", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucosa", + "navBloodKetones": "Cetonas", + "bloodKetones": { + "title": "Cetonas en sangre", + "description": "Tus lecturas de beta-hidroxibutirato en el tiempo, en {unit}.", + "chartTitle": "Cetonas en sangre", + "emptyState": { + "title": "Aún no hay lecturas de cetonas", + "description": "Las lecturas de cetonas aparecen aquí cuando registras una, sola o con la glucosa del mismo pinchazo.", + "cta": "Registrar cetonas" + }, + "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.", + "latest": "Último {value}: glucosa {glucose} {unit} entre cetonas {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Agua corporal total", "description": "Tu agua corporal total en el tiempo, en kilogramos.", @@ -13689,6 +13710,10 @@ "valuesAllFew": "Los {count} valores", "valuesAllOther": "Los {count} valores", "valuesFewer": "Menos valores", + "gkiPair": "Glucosa {glucose} / cetonas {ketones}", + "gkiCountOne": "Último de {count}", + "gkiCountFew": "Último de {count}", + "gkiCountOther": "Último de {count}", "countEntriesOne": "1 entrada", "countEntriesFew": "{count} entradas", "countEntriesOther": "{count} entradas", diff --git a/messages/fr.json b/messages/fr.json index e223f107e..0f19fac15 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -813,6 +813,10 @@ "typeSleep": "Sommeil", "typeSteps": "Pas", "typeBloodGlucose": "Glycémie", + "typeBloodKetones": "Cétones sanguines", + "glucoseKetoneIndex": "Indice glucose-cétones", + "pairedGlucoseLabel": "Glycémie de la même mesure ({unit})", + "pairedGlucoseHint": "Enregistrée comme un relevé de glycémie à la même heure ; l’indice glucose-cétones est calculé à partir de la paire.", "typeTotalBodyWater": "Eau corporelle totale", "typeBoneMass": "Masse osseuse", "typeOxygenSaturation": "Saturation en oxygène", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucose", + "navBloodKetones": "Cétones", + "bloodKetones": { + "title": "Cétones sanguines", + "description": "Vos relevés de bêta-hydroxybutyrate au fil du temps, en {unit}.", + "chartTitle": "Cétones sanguines", + "emptyState": { + "title": "Pas encore de relevé de cétones", + "description": "Les relevés de cétones apparaissent ici dès que vous en saisissez un, seul ou avec la glycémie de la même piqûre.", + "cta": "Enregistrer les cétones" + }, + "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.", + "latest": "Dernier {value} : glycémie {glucose} {unit} sur cétones {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Eau corporelle totale", "description": "Votre eau corporelle totale au fil du temps, en kilogrammes.", @@ -13689,6 +13710,10 @@ "valuesAllFew": "Les {count} valeurs", "valuesAllOther": "Les {count} valeurs", "valuesFewer": "Moins de valeurs", + "gkiPair": "Glycémie {glucose} / cétones {ketones}", + "gkiCountOne": "Dernier sur {count}", + "gkiCountFew": "Dernier sur {count}", + "gkiCountOther": "Dernier sur {count}", "countEntriesOne": "{count} entrée", "countEntriesFew": "{count} entrées", "countEntriesOther": "{count} entrées", diff --git a/messages/it.json b/messages/it.json index 1344a1f42..17b61f5a1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -813,6 +813,10 @@ "typeSleep": "Sonno", "typeSteps": "Passi", "typeBloodGlucose": "Glicemia", + "typeBloodKetones": "Chetoni nel sangue", + "glucoseKetoneIndex": "Indice glucosio-chetoni", + "pairedGlucoseLabel": "Glicemia della stessa misurazione ({unit})", + "pairedGlucoseHint": "Salvata come lettura di glicemia a sé con lo stesso orario; l’indice glucosio-chetoni si ricava dalla coppia.", "typeTotalBodyWater": "Acqua corporea totale", "typeBoneMass": "Massa ossea", "typeOxygenSaturation": "Saturazione di ossigeno", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucosio", + "navBloodKetones": "Chetoni", + "bloodKetones": { + "title": "Chetoni nel sangue", + "description": "Le tue letture di beta-idrossibutirrato nel tempo, in {unit}.", + "chartTitle": "Chetoni nel sangue", + "emptyState": { + "title": "Ancora nessuna lettura di chetoni", + "description": "Le letture di chetoni appaiono qui quando ne registri una, da sola o con la glicemia della stessa puntura.", + "cta": "Registra i chetoni" + }, + "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.", + "latest": "Ultimo {value}: glicemia {glucose} {unit} su chetoni {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Acqua corporea totale", "description": "La tua acqua corporea totale nel tempo, in chilogrammi.", @@ -13689,6 +13710,10 @@ "valuesAllFew": "Tutti i {count} valori", "valuesAllOther": "Tutti i {count} valori", "valuesFewer": "Meno valori", + "gkiPair": "Glicemia {glucose} / chetoni {ketones}", + "gkiCountOne": "Ultimo di {count}", + "gkiCountFew": "Ultimo di {count}", + "gkiCountOther": "Ultimo di {count}", "countEntriesOne": "1 voce", "countEntriesFew": "{count} voci", "countEntriesOther": "{count} voci", diff --git a/messages/ko.json b/messages/ko.json index 3f8be41c8..213235474 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -813,6 +813,10 @@ "typeSleep": "수면", "typeSteps": "걸음 수", "typeBloodGlucose": "혈당", + "typeBloodKetones": "혈중 케톤", + "glucoseKetoneIndex": "포도당-케톤 지수", + "pairedGlucoseLabel": "같은 측정의 혈당 ({unit})", + "pairedGlucoseHint": "같은 시각의 별도 혈당 기록으로 저장되고, 두 값으로 포도당-케톤 지수를 계산해요.", "typeTotalBodyWater": "체수분", "typeBoneMass": "골량", "typeOxygenSaturation": "산소포화도", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "혈당", + "navBloodKetones": "케톤", + "bloodKetones": { + "title": "혈중 케톤", + "description": "시간에 따른 베타-하이드록시부티르산 측정값이에요({unit}).", + "chartTitle": "혈중 케톤", + "emptyState": { + "title": "아직 케톤 측정값이 없어요", + "description": "케톤을 단독으로 또는 같은 채혈의 혈당과 함께 기록하면 여기에 측정값이 나타나요.", + "cta": "케톤 기록" + }, + "gki": { + "description": "15분 이내에 혈당 측정값이 있는 케톤 측정마다 혈당을 케톤으로 나눈 값이에요.", + "emptyTitle": "아직 지수가 없어요", + "emptyDescription": "케톤 측정과 같은 시각이나 15분 이내에 잰 혈당이 있으면 나타나요.", + "latest": "최근 {value}: 혈당 {glucose} {unit} ÷ 케톤 {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "총 체수분", "description": "시간에 따른 총 체수분이에요(kg).", @@ -13689,6 +13710,10 @@ "valuesAllFew": "수치 {count}개 모두", "valuesAllOther": "수치 {count}개 모두", "valuesFewer": "수치 줄여 보기", + "gkiPair": "혈당 {glucose} / 케톤 {ketones}", + "gkiCountOne": "{count}개 중 최근", + "gkiCountFew": "{count}개 중 최근", + "gkiCountOther": "{count}개 중 최근", "countEntriesOne": "기록 1개", "countEntriesFew": "기록 {count}개", "countEntriesOther": "기록 {count}개", diff --git a/messages/nl.json b/messages/nl.json index 6927f1ddf..57be9591e 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -813,6 +813,10 @@ "typeSleep": "Slaap", "typeSteps": "Stappen", "typeBloodGlucose": "Bloedglucose", + "typeBloodKetones": "Bloedketonen", + "glucoseKetoneIndex": "Glucose-ketonindex", + "pairedGlucoseLabel": "Glucose uit dezelfde meting ({unit})", + "pairedGlucoseHint": "Wordt als eigen glucosemeting met hetzelfde tijdstip opgeslagen; uit het paar volgt de glucose-ketonindex.", "typeTotalBodyWater": "Totaal lichaamswater", "typeBoneMass": "Botmassa", "typeOxygenSaturation": "Zuurstofsaturatie", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucose", + "navBloodKetones": "Ketonen", + "bloodKetones": { + "title": "Bloedketonen", + "description": "Je bèta-hydroxybutyraatmetingen in de loop van de tijd, in {unit}.", + "chartTitle": "Bloedketonen", + "emptyState": { + "title": "Nog geen ketonmetingen", + "description": "Ketonmetingen verschijnen hier zodra je er een vastlegt, los of met de glucose uit dezelfde vingerprik.", + "cta": "Ketonen vastleggen" + }, + "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.", + "latest": "Laatst {value}: glucose {glucose} {unit} gedeeld door ketonen {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Totaal lichaamswater", "description": "Je totale lichaamswater in de loop van de tijd, in kilogram.", @@ -13689,6 +13710,10 @@ "valuesAllFew": "Alle {count} waarden", "valuesAllOther": "Alle {count} waarden", "valuesFewer": "Minder waarden", + "gkiPair": "Glucose {glucose} / ketonen {ketones}", + "gkiCountOne": "Laatste van {count}", + "gkiCountFew": "Laatste van {count}", + "gkiCountOther": "Laatste van {count}", "countEntriesOne": "1 registratie", "countEntriesFew": "{count} registraties", "countEntriesOther": "{count} registraties", diff --git a/messages/pl.json b/messages/pl.json index eb32afe8a..5f4636245 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -813,6 +813,10 @@ "typeSleep": "Sen", "typeSteps": "Kroki", "typeBloodGlucose": "Glikemia", + "typeBloodKetones": "Ketony we krwi", + "glucoseKetoneIndex": "Indeks glukozowo-ketonowy", + "pairedGlucoseLabel": "Glukoza z tego samego pomiaru ({unit})", + "pairedGlucoseHint": "Zapisywana jako osobny odczyt glukozy z tą samą godziną; z tej pary obliczany jest indeks glukozowo-ketonowy.", "typeTotalBodyWater": "Całkowita woda w organizmie", "typeBoneMass": "Masa kostna", "typeOxygenSaturation": "Saturacja tlenem", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glukoza", + "navBloodKetones": "Ketony", + "bloodKetones": { + "title": "Ketony we krwi", + "description": "Twoje odczyty beta-hydroksymaślanu w czasie, w {unit}.", + "chartTitle": "Ketony we krwi", + "emptyState": { + "title": "Brak jeszcze odczytów ketonów", + "description": "Odczyty ketonów pojawią się tutaj po zapisaniu jednego, osobno lub z glukozą z tego samego nakłucia.", + "cta": "Zapisz ketony" + }, + "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.", + "latest": "Ostatnio {value}: glukoza {glucose} {unit} przez ketony {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Całkowita woda w organizmie", "description": "Twoja całkowita woda w organizmie w czasie, w kilogramach.", @@ -13689,6 +13710,10 @@ "valuesAllFew": "Wszystkie {count} wartości", "valuesAllOther": "Wszystkie {count} wartości", "valuesFewer": "Mniej wartości", + "gkiPair": "Glukoza {glucose} / ketony {ketones}", + "gkiCountOne": "Ostatni z {count}", + "gkiCountFew": "Ostatni z {count}", + "gkiCountOther": "Ostatni z {count}", "countEntriesOne": "1 wpis", "countEntriesFew": "{count} wpisy", "countEntriesOther": "{count} wpisów", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index c14042ca8..3d09866b4 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -813,6 +813,10 @@ "typeSleep": "Sono", "typeSteps": "Passos", "typeBloodGlucose": "Glicemia", + "typeBloodKetones": "Cetonas no sangue", + "glucoseKetoneIndex": "Índice glicose-cetona", + "pairedGlucoseLabel": "Glicose da mesma medição ({unit})", + "pairedGlucoseHint": "Salva como uma leitura de glicose própria no mesmo horário; o índice glicose-cetona é calculado a partir do par.", "typeTotalBodyWater": "Água corporal total", "typeBoneMass": "Massa óssea", "typeOxygenSaturation": "Saturação de oxigênio", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glicose", + "navBloodKetones": "Cetonas", + "bloodKetones": { + "title": "Cetonas no sangue", + "description": "Suas leituras de beta-hidroxibutirato ao longo do tempo, em {unit}.", + "chartTitle": "Cetonas no sangue", + "emptyState": { + "title": "Ainda não há leituras de cetonas", + "description": "As leituras de cetonas aparecem aqui assim que você registra uma, sozinha ou com a glicose da mesma picada.", + "cta": "Registrar cetonas" + }, + "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.", + "latest": "Último {value}: glicose {glucose} {unit} sobre cetonas {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Água Corporal Total", "description": "Sua água corporal total ao longo do tempo, em quilogramas.", @@ -13689,6 +13710,10 @@ "valuesAllFew": "Todos os {count} valores", "valuesAllOther": "Todos os {count} valores", "valuesFewer": "Menos valores", + "gkiPair": "Glicose {glucose} / cetonas {ketones}", + "gkiCountOne": "Último de {count}", + "gkiCountFew": "Último de {count}", + "gkiCountOther": "Último de {count}", "countEntriesOne": "{count} entrada", "countEntriesFew": "{count} entradas", "countEntriesOther": "{count} entradas", diff --git a/messages/ru.json b/messages/ru.json index f39bdda15..9a206fa9f 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -813,6 +813,10 @@ "typeSleep": "Сон", "typeSteps": "Шаги", "typeBloodGlucose": "Глюкоза крови", + "typeBloodKetones": "Кетоны в крови", + "glucoseKetoneIndex": "Глюкозо-кетоновый индекс", + "pairedGlucoseLabel": "Глюкоза из того же измерения ({unit})", + "pairedGlucoseHint": "Сохраняется как отдельное измерение глюкозы с тем же временем; по этой паре рассчитывается глюкозо-кетоновый индекс.", "typeTotalBodyWater": "Общая вода в организме", "typeBoneMass": "Костная масса", "typeOxygenSaturation": "Насыщение кислородом", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Глюкоза", + "navBloodKetones": "Кетоны", + "bloodKetones": { + "title": "Кетоны в крови", + "description": "Ваши измерения бета-гидроксибутирата в динамике, в {unit}.", + "chartTitle": "Кетоны в крови", + "emptyState": { + "title": "Измерений кетонов пока нет", + "description": "Измерения кетонов появятся здесь, как только вы запишете одно, отдельно или вместе с глюкозой из того же прокола.", + "cta": "Записать кетоны" + }, + "gki": { + "description": "Глюкоза, делённая на кетоны, для каждого измерения кетонов с измерением глюкозы в пределах 15 минут.", + "emptyTitle": "Индекса пока нет", + "emptyDescription": "Он появится, когда к измерению кетонов будет измерение глюкозы в то же время или в пределах 15 минут.", + "latest": "Последний {value}: глюкоза {glucose} {unit} на кетоны {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Общая вода в организме", "description": "Ваша общая вода в организме в динамике, в килограммах.", @@ -13689,6 +13710,10 @@ "valuesAllFew": "Все {count} значения", "valuesAllOther": "Все {count} значений", "valuesFewer": "Меньше значений", + "gkiPair": "Глюкоза {glucose} / кетоны {ketones}", + "gkiCountOne": "Последний из {count}", + "gkiCountFew": "Последний из {count}", + "gkiCountOther": "Последний из {count}", "countEntriesOne": "{count} запись", "countEntriesFew": "Записей: {count}", "countEntriesOther": "Записей: {count}", diff --git a/prisma/migrations/0496_blood_ketones/migration.sql b/prisma/migrations/0496_blood_ketones/migration.sql new file mode 100644 index 000000000..c0d43bbbc --- /dev/null +++ b/prisma/migrations/0496_blood_ketones/migration.sql @@ -0,0 +1,12 @@ +-- Extend `measurement_type` with blood ketones (#1367): +-- +-- BLOOD_KETONES <- beta-hydroxybutyrate from a blood ketone meter (mmol/L) +-- +-- Purely additive: no existing row carries the value, and nothing in this +-- file uses it, so the rule that a value added in a transaction cannot be used +-- in that same transaction never comes into play. IF NOT EXISTS keeps a replay +-- on a partially applied database harmless. Enum values cannot be dropped in +-- PostgreSQL, so there is no down path. The glucose-ketone index is computed +-- at read time and has no column. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BLOOD_KETONES'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbf05d6e6..80f3d5962 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1816,6 +1816,10 @@ enum MeasurementType { // v1.47.1 — resting energy (#1340). Its own type beside ACTIVE_ENERGY_BURNED, // never folded into it and never summed with it into a total. BASAL_ENERGY_BURNED // kcal — HealthKit `basalEnergyBurned`, the energy the body uses at rest; cumulative, daily SUM. + // v1.47.2 — blood ketones (#1367). Beta-hydroxybutyrate from a fingerstick + // meter, typed by hand. The glucose-ketone index is derived from this and + // BLOOD_GLUCOSE at read time and never stored. + BLOOD_KETONES // mmol/L — capillary or venous beta-hydroxybutyrate (BHB); spot reading, never summed. @@map("measurement_type") } diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 1770cb655..e6d9b833e 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -11,7 +11,7 @@ "components/insights/coach-panel/use-coach.ts": 1486, "components/insights/derived/vitals-dashboard.tsx": 1033, "components/insights/health-score-card.tsx": 1023, - "components/insights/insights-tab-strip.tsx": 1086, + "components/insights/insights-tab-strip.tsx": 1087, "components/measurements/measurement-form.tsx": 815, "components/medications/detail/medication-detail-tabs.tsx": 1157, "components/medications/medication-table.tsx": 853, @@ -39,7 +39,7 @@ "lib/daily/digest.ts": 1317, "lib/daily/load-digest.ts": 1000, "lib/daily/today-overview.ts": 838, - "lib/dashboard-layout.ts": 976, + "lib/dashboard-layout.ts": 977, "lib/dashboard/snapshot.ts": 1550, "lib/day/records.ts": 1174, "lib/export/backup-plan.ts": 838, diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index dc52ec8a1..34880beb6 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -149,6 +149,10 @@ const ALLOWED_SQL: Record = { "Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.", "lib/dashboard/summary-reads.ts::1": "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/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 52bf07926..8dfdae6b6 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(111); - expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(91); + expect(ALL_LEAF_IDS.length).toBe(112); + expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(92); expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20); }); diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index 79c26c6fd..e2d36f53b 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -294,7 +294,7 @@ const UNIT_LITERAL_ALLOWLIST: Record< reason: "the onboarding unit picker: its options are the two units", }, "components/measurements/measurement-form.tsx": { - lines: 11, + lines: 12, reason: "input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save", }, @@ -362,9 +362,9 @@ const UNIT_LITERAL_ALLOWLIST: Record< reason: "the legacy export endpoint's canonical storage-unit contract", }, "lib/measurements/series/kinds.ts": { - lines: 4, + lines: 5, reason: - "canonical wire units; glucose is overridden to the reader's unit per request", + "canonical wire units; glucose is overridden to the reader's unit per request, ketones have no unit choice", }, "lib/doctor-report/stat-display.ts": { lines: 3, diff --git a/src/app/insights/ketones/page.tsx b/src/app/insights/ketones/page.tsx new file mode 100644 index 000000000..27899b8c3 --- /dev/null +++ b/src/app/insights/ketones/page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Droplet } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { GkiCard } from "@/components/insights/ketones/gki-card"; +import { getUnitForType } from "@/lib/measurements/unit-map"; + +/** mmol/L, from the one canonical unit table; ketones have no unit choice. */ +const KETONE_UNIT = getUnitForType("BLOOD_KETONES"); + +/** + * `/insights/ketones` (#1367). + * + * Blood ketones (beta-hydroxybutyrate, mmol/L) on the generic metric + * scaffold, with the glucose-ketone index beneath the chart. Readings are + * typed off a meter, alone or together with the glucose from the same + * fingerstick, so the empty state offers manual entry. No band and no + * assessment card: what a ketone reading should be follows the plan a + * clinician sets, and the page shows the readings as recorded. + */ +export default function InsightsKetonesPage() { + return ( + } + emptyStateCtaType="BLOOD_KETONES" + captureType="BLOOD_KETONES" + afterChart={} + /> + ); +} diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx index ce8a9a162..c1251cb8d 100644 --- a/src/components/clinician/report-sections.tsx +++ b/src/components/clinician/report-sections.tsx @@ -261,6 +261,21 @@ export function MeasurementGroups({ max: show(type, stat!.max, digits), })} /> + {type === "BLOOD_KETONES" && + report.glucoseKetoneIndex && + scope.admits("BLOOD_GLUCOSE") ? ( + // #1367 — the index the server derived from the ketone + // and glucose readings, beside the ketones; no unit. + + ) : null} {unvalidated > 0 && (

[number]; + +/** + * The day's glucose-ketone index (#1367), one tile beside the value tiles. + * Every figure is the server's: the index and the two readings it was built + * from. A day with one fingerstick shows its index and the pair; a day with + * several shows the latest index and how many there were, and the ketones page + * lists each. Nothing grades the number. + */ +export function DayGkiTile({ points }: { points: readonly DayGki[] }) { + const { t, tCount } = useTranslations(); + const fmt = useFormatters(); + const latest = points[points.length - 1]; + if (!latest) return null; + return ( +

  • + + + {t("measurements.glucoseKetoneIndex")} + + + {fmt.number(latest.value, 1)} + + + {points.length > 1 + ? tCount("day.gkiCount", points.length) + : t("day.gkiPair", { + glucose: fmt.number(latest.glucoseMmol, 1), + ketones: fmt.number(latest.ketonesMmol, 1), + })} + + +
  • + ); +} diff --git a/src/components/day/day-sections.tsx b/src/components/day/day-sections.tsx index 498d06e2d..bd44b5729 100644 --- a/src/components/day/day-sections.tsx +++ b/src/components/day/day-sections.tsx @@ -29,9 +29,9 @@ import { type DayEvent, type DayEventKind, type DayNotable, + type DayResponse, type DayRunningItem, type DayRunningKind, - type DayValue, } from "@/lib/day/contract"; import { visitKindWords } from "@/components/timeline/item-words"; import { @@ -62,6 +62,7 @@ import { WORKOUT_SPORT_KEY, } from "./label-keys"; import { cycleRunningLine } from "./day-cycle-line"; +import { DayGkiTile } from "./day-gki-tile"; import { useDayValueFormat } from "./use-day-value-format"; /** @@ -508,12 +509,14 @@ function ValueTile({ } export function DayValues({ - values, + day, focusTypes, }: { - values: readonly DayValue[]; + /** The day's values and its server-derived glucose-ketone index (#1367). */ + day: Pick; focusTypes: readonly string[]; }) { + const { values } = day; const { t, tCount } = useTranslations(); const [expanded, setExpanded] = useState(false); if (values.length === 0) return null; @@ -537,6 +540,7 @@ export function DayValues({ selected={focusKeys.has(tile.key)} /> ))} + {rest.length > 0 ? ( + + + + + {t("measurements.formReset")} + + + + +
    + {onCancel && ( + + )} + +
    + + ); +} + +/** + * #1367 — the glucose a ketone meter reads from the same drop, beside the + * ketones. Optional: a meter that reads ketones only leaves it empty. It asks + * in the account's glucose unit, which the write path converts back to mg/dL. + */ +export function PairedGlucoseField({ + value, + onChange, + unit, + invalid, + describedBy, +}: { + value: string; + onChange: (next: string) => void; + unit: GlucoseUnit; + invalid: boolean; + describedBy?: string; +}) { + const { t } = useTranslations(); + return ( + + {t("measurements.pairedGlucoseLabel", { unit })} + + ({t("common.optional")}) + + + } + hint={t("measurements.pairedGlucoseHint")} + > + onChange(e.target.value)} + placeholder={glucoseFractionDigits(unit) > 0 ? "5.3" : "95"} + aria-invalid={invalid || undefined} + aria-describedby={describedBy} + /> + + ); +} diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 4a4fd42e6..76e7b2f95 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -5,13 +5,6 @@ import { useActiveRecordName } from "@/hooks/use-record-capabilities"; import { useId, useState } from "react"; import { createPortal } from "react-dom"; import { useQueryClient } from "@tanstack/react-query"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { DateTimeField } from "@/components/ui/date-time-field"; import { FieldGroup } from "@/components/ui/field-group"; @@ -23,7 +16,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; @@ -50,6 +42,11 @@ import { } from "@/lib/measurements/last-used-type"; import { DurationReadAs } from "./duration-read-as"; +import { ketoneFingerstickBatch } from "./ketone-fingerstick"; +import { + MeasurementFormFooter, + PairedGlucoseField, +} from "./measurement-form-parts"; const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH; @@ -104,6 +101,13 @@ export const MEASUREMENT_TYPES = [ unit: "mg/dL", placeholder: "95", }, + // #1367 — ketones, with the same fingerstick's glucose (one entry). + { + value: "BLOOD_KETONES", + labelKey: "measurements.typeBloodKetones", + unit: "mmol/L", + placeholder: "1.2", + }, { value: "TOTAL_BODY_WATER", labelKey: "measurements.typeTotalBodyWater", @@ -360,6 +364,8 @@ export function MeasurementForm({ const [sysBp, setSysBp] = useState(""); const [diaBp, setDiaBp] = useState(""); const [pulse, setPulse] = useState(""); + // The glucose of a fingerstick entered with its ketones; optional. + const [pairedGlucose, setPairedGlucose] = useState(""); const [notes, setNotes] = useState(""); const [measuredAt, setMeasuredAt] = useState(() => localDateTimeValue(defaultDate), @@ -383,6 +389,7 @@ export function MeasurementForm({ const typeInfo = MEASUREMENT_TYPES.find((t) => t.value === type); const isBpMode = type === "BLOOD_PRESSURE"; const isGlucoseMode = type === "BLOOD_GLUCOSE"; + const isKetoneMode = type === "BLOOD_KETONES"; // A duration field (sleep, in hours) also reads "7:30" and "7h 30m"; the // hint under it says how the entry was understood. const durationUnit = durationEntryUnit(type); @@ -395,6 +402,7 @@ export function MeasurementForm({ setSysBp(""); setDiaBp(""); setPulse(""); + setPairedGlucose(""); setNotes(""); setMeasuredAt(localDateTimeValue(defaultDate)); setError(null); @@ -440,6 +448,21 @@ export function MeasurementForm({ }); } + await apiPost("/api/measurements", batch); + } else if (isKetoneMode) { + // One fingerstick, posted as one transaction (`ketone-fingerstick.ts`). + const batch = ketoneFingerstickBatch({ + ketones: value, + glucose: pairedGlucose, + glucoseUnit, + glucoseContext, + measuredAt: timestamp, + notes, + }); + if (!batch) { + setError(t("measurements.saveError")); + return; + } await apiPost("/api/measurements", batch); } else { // Single measurement. Canonical storage stays SI: an imperial user @@ -503,6 +526,7 @@ export function MeasurementForm({ setSysBp(""); setDiaBp(""); setPulse(""); + setPairedGlucose(""); setNotes(""); await invalidateKeys(queryClient, measurementDependentKeys); await refetchInactiveDailyReads(queryClient); @@ -528,49 +552,12 @@ export function MeasurementForm({ } const footerNode = ( -
    - - - - - - - - {t("measurements.formReset")} - - - - -
    - {onCancel && ( - - )} - -
    -
    + ); return ( @@ -718,7 +705,17 @@ export function MeasurementForm({ )} - {isGlucoseMode && ( + {isKetoneMode && ( + + )} + + {(isGlucoseMode || (isKetoneMode && pairedGlucose.trim() !== "")) && ( = { SLEEP_DURATION: "measurements.typeSleep", ACTIVITY_STEPS: "measurements.typeSteps", BLOOD_GLUCOSE: "measurements.typeBloodGlucose", + BLOOD_KETONES: "measurements.typeBloodKetones", TOTAL_BODY_WATER: "measurements.typeTotalBodyWater", BONE_MASS: "measurements.typeBoneMass", OXYGEN_SATURATION: "measurements.typeOxygenSaturation", @@ -159,6 +160,7 @@ export const MEASUREMENT_TYPE_ICONS: Record = { SLEEP_DURATION: Moon, ACTIVITY_STEPS: Footprints, BLOOD_GLUCOSE: Droplet, + BLOOD_KETONES: Droplet, TOTAL_BODY_WATER: Droplet, BONE_MASS: Bone, OXYGEN_SATURATION: Wind, diff --git a/src/components/measurements/measurement-type-colors.ts b/src/components/measurements/measurement-type-colors.ts index cd317846c..1b97cce47 100644 --- a/src/components/measurements/measurement-type-colors.ts +++ b/src/components/measurements/measurement-type-colors.ts @@ -13,6 +13,7 @@ export const MEASUREMENT_TYPE_COLORS: Record = { SLEEP_DURATION: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", ACTIVITY_STEPS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", BLOOD_GLUCOSE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + BLOOD_KETONES: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", TOTAL_BODY_WATER: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", BONE_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", OXYGEN_SATURATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index f65401533..ac4322fbb 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -131,10 +131,12 @@ const EXPECTED_TYPES = [ "ACUTE_TRAINING_LOAD", // ── resting energy (#1340) ── "BASAL_ENERGY_BURNED", + // ── blood ketones (#1367) ── + "BLOOD_KETONES", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 91 canonical measurement types", () => { + it("exposes the 92 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index fb9dac05e..32dd2cfa8 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -341,6 +341,7 @@ export const CHART_OVERLAY_KEYS = [ "scoreSleep", "scoreStress", "scoreStrain", + "bloodKetones", // #1367 — the chart on the ketones page. ] as const; export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number]; diff --git a/src/lib/day/load-day.ts b/src/lib/day/load-day.ts index 0d517c770..675dd52bc 100644 --- a/src/lib/day/load-day.ts +++ b/src/lib/day/load-day.ts @@ -29,6 +29,7 @@ import { import { DAY_SCORE_MEASUREMENT_TYPES, readDayScores } from "@/lib/day/scores"; import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections"; import { readDayBands, shapeDayValues } from "@/lib/day/values"; +import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index"; import { resolveDayReadings, type DayReadings, @@ -153,6 +154,19 @@ export async function loadDay(args: LoadDayArgs): Promise { ]); const presentTypes = [...readings.rowsByType.keys()]; + // The index reveals the glucose it was built from, so it needs both + // reading types visible, not only the ketones the day holds. + const gkiReadable = + readings.rowsByType.has("BLOOD_KETONES") && + typeVisible("BLOOD_KETONES") && + typeVisible("BLOOD_GLUCOSE"); + const glucoseKetoneIndex = gkiReadable + ? await readGlucoseKetoneIndex({ + userId: recordId, + from: dayStart, + to: dayEnd, + }) + : []; const [bands, notable] = presentTypes.length === 0 ? [new Map(), []] @@ -194,6 +208,7 @@ export async function loadDay(args: LoadDayArgs): Promise { counts: { values: values.length, entries: events.length }, running, values, + ...(glucoseKetoneIndex.length > 0 ? { glucoseKetoneIndex } : {}), events, notable: notable.map(({ kind, type, params }) => ({ kind, type, params })), scores, diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 2f25fae0e..cb3a400b1 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -117,6 +117,24 @@ const dayValue = z }) .meta({ id: "DayValue" }); +/** + * #1367 — one glucose-ketone index of the day, derived from a ketone reading + * and the glucose reading paired with it; never stored. + */ +const dayGlucoseKetoneIndex = z + .object({ + at: instant.describe("The ketone reading's instant."), + value: z.number().describe("The index, to one decimal."), + ketoneId: z.string().describe("The ketone reading's id."), + glucoseId: z.string().describe("The paired glucose reading's id."), + glucoseAt: instant.describe("The paired glucose reading's instant."), + ketonesMmol: z.number().describe("Beta-hydroxybutyrate, mmol/L."), + glucoseMmol: z + .number() + .describe("The paired glucose in mmol/L, one decimal."), + }) + .meta({ id: "DayGlucoseKetoneIndex" }); + const dayEvent = z .object({ at: instant @@ -232,6 +250,12 @@ export const dayResponseSchema = z }), running: z.array(dayRunningItem), values: z.array(dayValue), + glucoseKetoneIndex: z + .array(dayGlucoseKetoneIndex) + .optional() + .describe( + "#1367 — the day's glucose-ketone index, oldest first: glucose (mmol/L) over ketones (mmol/L) for each ketone reading above zero with a glucose reading at the same instant or within 15 minutes either side. Computed at read time; show it as given. Absent when the day has none, when either reading type is hidden from the caller, and on older servers.", + ), events: z.array(dayEvent), notable: z.array(dayNotable), scores: z diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index 700fbabd6..30edb5bf2 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -186,8 +186,9 @@ function groupSubHeadRow(label: string): VitalTableRow { } /** - * Per-context glucose rows. Values are stored canonically in mg/dL and print - * in the owner's unit, in the precision their readings carry. + * The glucose group's own rows: one per meal context, stored canonically in + * mg/dL and printed in the owner's unit in the precision their readings + * carry, then the glucose-ketone index (#1367), a ratio without a unit. */ function glucoseContextTableRows( context: DoctorReportPdfRenderContext, @@ -209,6 +210,17 @@ function glucoseContextTableRows( String(s.count), ]); } + const gki = data.glucoseKetoneIndex; + if (gki) { + rows.push([ + t("measurements.glucoseKetoneIndex"), + num(gki.latest, 1), + num(gki.avg, 1), + num(gki.min, 1), + num(gki.max, 1), + String(gki.count), + ]); + } return rows; } diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index ec7c151c4..b82c4fffe 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -118,6 +118,13 @@ export interface DoctorReportData { * `stillLearning` when the period is too thin to assert clinically. */ glucoseClinical: GlucoseClinicalMetrics; + /** + * #1367 — the glucose-ketone index over the period (count, mean, range, + * latest), derived at read time from each ketone reading and the glucose + * reading paired with it. Null when there is none or when either reading + * type is withheld. Optional so older fixtures still typecheck. + */ + glucoseKetoneIndex?: DoctorReportStats | null; bmi: number | null; /** Keyed by medication id; see `DoctorReportCompliance`. */ compliance: Record; diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 79b431564..e40039f56 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -67,6 +67,7 @@ import { import { loadDenseMeasurementBuckets } from "./dense-buckets"; import { summariseDenseBuckets } from "./measurement-series"; import { emptyGlucoseClinical } from "./glucose-panel"; +import { readReportGki } from "./glucose-ketone-index"; import { buildAdministrationLedger, buildGlp1Block } from "./medications"; import { loadReportWorkouts } from "./workouts"; import { loadReportMedicationEffects } from "./medication-effects"; @@ -249,9 +250,8 @@ export async function collectDoctorReportData( ...(rawExcluded.length > 0 ? { type: { notIn: rawExcluded } } : {}), }, orderBy: { measuredAt: "asc" }, - // Narrow select: the collector reads exactly these seven fields. - // The full-width read pulled every column — including the encrypted - // notes — across up to 730 days of rows. + // Narrow select: the full-width read pulled every column, encrypted + // notes included, across up to 730 days of rows. select: { type: true, value: true, @@ -739,6 +739,7 @@ export async function collectDoctorReportData( glucoseStats, glucoseRanges, glucoseClinical, + glucoseKetoneIndex: await readReportGki(gate, stats, userId, start, end), glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null), bmi: gate.admits("BODY_MASS_INDEX") ? bmi : null, compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {}, diff --git a/src/lib/doctor-report/glucose-ketone-index.ts b/src/lib/doctor-report/glucose-ketone-index.ts new file mode 100644 index 000000000..cd20865ba --- /dev/null +++ b/src/lib/doctor-report/glucose-ketone-index.ts @@ -0,0 +1,45 @@ +/** + * The glucose-ketone index in the doctor report (#1367): count, mean, range + * and latest over the report window, read by the one pairing rule + * (`read-glucose-ketone-index.ts`) so the report carries the numbers the + * ketones page shows. + * + * Both leaves must admit it. The index is glucose over ketones, so beside the + * ketone series it gives the glucose back exactly: a report that withholds + * glucose withholds the index too. + */ +import type { DoctorReportStats } from "@/lib/doctor-report-types"; +import { summariseGlucoseKetoneIndex } from "@/lib/measurements/glucose-ketone-index"; +import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index"; + +import type { ReportGate } from "./selection-gate"; + +export async function readReportGki( + gate: ReportGate, + /** The report's own stats: no ketone row in the window, no read. */ + stats: Readonly>, + userId: string, + /** The report window; `end` is inclusive, as the measurement read has it. */ + start: Date, + end: Date, +): Promise { + if (!gate.admits("BLOOD_KETONES") || !gate.admits("BLOOD_GLUCOSE")) { + return null; + } + if (!stats.BLOOD_KETONES?.count) return null; + const points = await readGlucoseKetoneIndex({ + userId, + from: start, + to: new Date(end.getTime() + 1), + }); + const summary = summariseGlucoseKetoneIndex(points); + if (!summary) return null; + return { + avg: summary.mean, + min: summary.min, + max: summary.max, + count: summary.count, + latest: summary.latest, + latestAt: points[points.length - 1]!.at, + }; +} diff --git a/src/lib/fhir/__tests__/series.test.ts b/src/lib/fhir/__tests__/series.test.ts index ef832ef0d..5659383b1 100644 --- a/src/lib/fhir/__tests__/series.test.ts +++ b/src/lib/fhir/__tests__/series.test.ts @@ -196,6 +196,32 @@ describe("measurementSeriesObservation", () => { expect(o.code.coding?.[0]?.code).toBe(MEASUREMENT_LOINC.WEIGHT.loinc); }); + it("codes blood ketones as beta-hydroxybutyrate in blood (#1367)", () => { + const o = measurementSeriesObservation( + { + id: "k1", + type: "BLOOD_KETONES", + value: 1.4, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + null, + )!; + assertR4Observation(o); + expect(o.category?.[0]?.coding?.[0]?.code).toBe("laboratory"); + expect(o.code.coding).toEqual([ + { + system: "http://loinc.org", + code: "104816-4", + display: "Beta hydroxybutyrate [Moles/volume] in Blood", + }, + ]); + expect(o.valueQuantity).toMatchObject({ value: 1.4, unit: "mmol/L" }); + }); + it("labels RMSSD as its own text-only statistic, never under SDNN's code", () => { const o = measurementSeriesObservation( { diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index f35d6d054..7a3160e6a 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -29,6 +29,7 @@ export const ALLOWED_CHART_TOKENS = [ "metric:SLEEP_DURATION", "metric:ACTIVITY_STEPS", "metric:BLOOD_GLUCOSE", + "metric:BLOOD_KETONES", "metric:TOTAL_BODY_WATER", "metric:BONE_MASS", "metric:OXYGEN_SATURATION", diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index 2f761a6bd..5ec96d5b0 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -80,6 +80,8 @@ export type InsightMetric = // `summaries[metric].count > 0` with no new handler branch (the // metric auto-lights-up the moment the first reading lands). | "BLOOD_GLUCOSE" + // #1367 — blood ketones, keyed on its MeasurementType like glucose. + | "BLOOD_KETONES" | "TOTAL_BODY_WATER" | "BONE_MASS" // Body fat percentage, keyed on its MeasurementType like the rest of the diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index c088eabd5..5975e31e4 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -139,6 +139,8 @@ const SUB_PAGE_METRIC = { daylight: ["TIME_IN_DAYLIGHT"], // ── metabolic (v1.7.0) ── "blood-glucose": ["BLOOD_GLUCOSE"], + // #1367 — blood ketones, with the glucose-ketone index beneath the chart. + ketones: ["BLOOD_KETONES"], "skin-temperature": ["SKIN_TEMPERATURE"], // v1.10.0 — overnight wrist temperature joins the metabolic cluster. "wrist-temperature": ["WRIST_TEMPERATURE"], @@ -243,6 +245,7 @@ export const SUB_PAGE_GROUP: Partial> = { daylight: "environment", // metabolic (v1.7.0) "blood-glucose": "metabolic", + ketones: "metabolic", "skin-temperature": "metabolic", // ── v1.10.0 — additive HealthKit signals (WX-A) ── // activity / mobility cluster diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 5e858fd51..cd80e3dd1 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -105,6 +105,9 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ // derivation as PHQ-9 / GAD-7. No HK mapping by design. "WHO5_SCORE", "SCI_SCORE", + // Blood ketones (#1367): HealthKit has no beta-hydroxybutyrate quantity + // type, so a ketone reading is typed by hand. No HK mapping by design. + "BLOOD_KETONES", ]); describe("APPLE_HEALTH_TYPE_MAP", () => { diff --git a/src/lib/measurements/__tests__/glucose-ketone-index.test.ts b/src/lib/measurements/__tests__/glucose-ketone-index.test.ts new file mode 100644 index 000000000..2c91dee3c --- /dev/null +++ b/src/lib/measurements/__tests__/glucose-ketone-index.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; + +import { MGDL_PER_MMOL } from "@/lib/glucose"; +import { + GKI_PAIRING_WINDOW_MINUTES, + glucoseKetoneIndex, + pairGlucoseKetoneIndex, + summariseGlucoseKetoneIndex, + type GkiReading, +} from "@/lib/measurements/glucose-ketone-index"; + +const T0 = Date.parse("2026-11-01T08:00:00.000Z"); +const MIN = 60_000; + +function reading( + id: string, + offsetMin: number, + value: number, + source = "MANUAL", +): GkiReading { + return { id, measuredAt: new Date(T0 + offsetMin * MIN), value, source }; +} + +describe("glucoseKetoneIndex", () => { + it("divides glucose in mmol/L by ketones, converting with the one factor", () => { + // 90 mg/dL = 4.995 mmol/L; over 1.5 mmol/L ketones. + expect(glucoseKetoneIndex(90, 1.5)).toBeCloseTo(90 / MGDL_PER_MMOL / 1.5); + expect(glucoseKetoneIndex(90, 1.5)).toBeCloseTo(3.33, 2); + }); + + it("has no index without ketones or with a value that is not positive", () => { + expect(glucoseKetoneIndex(90, 0)).toBeNull(); + expect(glucoseKetoneIndex(90, -1)).toBeNull(); + expect(glucoseKetoneIndex(0, 1)).toBeNull(); + expect(glucoseKetoneIndex(Number.NaN, 1)).toBeNull(); + expect(glucoseKetoneIndex(90, Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); + +describe("pairGlucoseKetoneIndex", () => { + it("pairs a fingerstick written with one timestamp", () => { + const [point, ...rest] = pairGlucoseKetoneIndex( + [reading("k1", 0, 1.2)], + [reading("g1", 0, 81)], + ); + expect(rest).toEqual([]); + expect(point).toEqual({ + ketoneId: "k1", + glucoseId: "g1", + at: new Date(T0).toISOString(), + glucoseAt: new Date(T0).toISOString(), + // 81 / 18.0182 = 4.4954 mmol/L; / 1.2 = 3.746 -> 3.7 + value: 3.7, + ketonesMmol: 1.2, + glucoseMmol: 4.5, + }); + }); + + it("prefers the same instant over a nearer-looking neighbour", () => { + const [point] = pairGlucoseKetoneIndex( + [reading("k1", 0, 1)], + [reading("g-early", -1, 200), reading("g-same", 0, 90)], + ); + expect(point?.glucoseId).toBe("g-same"); + }); + + it("takes the nearest glucose inside the window, before or after", () => { + const [before] = pairGlucoseKetoneIndex( + [reading("k1", 0, 1)], + [reading("g-far", -14, 100), reading("g-near", -3, 90)], + ); + expect(before?.glucoseId).toBe("g-near"); + const [after] = pairGlucoseKetoneIndex( + [reading("k1", 0, 1)], + [reading("g-after", 4, 90), reading("g-before", -9, 100)], + ); + expect(after?.glucoseId).toBe("g-after"); + }); + + it("pairs at the window's edge and never beyond it", () => { + const edge = GKI_PAIRING_WINDOW_MINUTES; + expect( + pairGlucoseKetoneIndex([reading("k1", 0, 1)], [reading("g1", edge, 90)]), + ).toHaveLength(1); + expect( + pairGlucoseKetoneIndex([reading("k1", 0, 1)], [reading("g1", -edge, 90)]), + ).toHaveLength(1); + expect( + pairGlucoseKetoneIndex( + [reading("k1", 0, 1)], + [reading("g1", edge + 0.5, 90), reading("g2", -(edge + 1), 90)], + ), + ).toEqual([]); + }); + + it("leaves out a ketone reading of zero or without a partner", () => { + expect( + pairGlucoseKetoneIndex( + [reading("k-zero", 0, 0), reading("k-alone", 120, 1.4)], + [reading("g1", 0, 90)], + ), + ).toEqual([]); + }); + + it("breaks a tie by source, then by the earlier reading", () => { + const [bySource] = pairGlucoseKetoneIndex( + [reading("k1", 0, 1, "MANUAL")], + [reading("g-cgm", 0, 140, "APPLE_HEALTH"), reading("g-own", 0, 90)], + ); + expect(bySource?.glucoseId).toBe("g-own"); + const [byTime] = pairGlucoseKetoneIndex( + [reading("k1", 0, 1)], + [reading("g-later", 5, 100), reading("g-earlier", -5, 90)], + ); + expect(byTime?.glucoseId).toBe("g-earlier"); + }); + + it("answers in ketone order whatever order the readings came in", () => { + const points = pairGlucoseKetoneIndex( + [reading("k2", 60, 2), reading("k1", 0, 1)], + [reading("g2", 61, 90), reading("g1", 1, 90)], + ); + expect(points.map((p) => [p.ketoneId, p.glucoseId])).toEqual([ + ["k1", "g1"], + ["k2", "g2"], + ]); + }); + + it("lets one glucose reading serve two ketone readings and names it", () => { + const points = pairGlucoseKetoneIndex( + [reading("k1", 0, 1), reading("k2", 6, 2)], + [reading("g1", 3, 90)], + ); + expect(points.map((p) => p.glucoseId)).toEqual(["g1", "g1"]); + expect(points[1]!.value).toBeLessThan(points[0]!.value); + }); +}); + +describe("summariseGlucoseKetoneIndex", () => { + it("summarises the indices and is null for none", () => { + expect(summariseGlucoseKetoneIndex([])).toBeNull(); + const points = pairGlucoseKetoneIndex( + [reading("k1", 0, 1), reading("k2", 60, 2), reading("k3", 120, 4)], + [reading("g1", 0, 90), reading("g2", 60, 90), reading("g3", 120, 90)], + ); + // 90 mg/dL = 4.995 mmol/L: indices 5.0, 2.5, 1.2. + expect(points.map((p) => p.value)).toEqual([5, 2.5, 1.2]); + expect(summariseGlucoseKetoneIndex(points)).toEqual({ + count: 3, + mean: 2.9, + min: 1.2, + max: 5, + latest: 1.2, + }); + }); +}); diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 1855bf5f3..910966d5b 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -128,6 +128,7 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // ── Metabolic ── ["BLOOD_GLUCOSE", "metabolic"], + ["BLOOD_KETONES", "metabolic"], ["SKIN_TEMPERATURE", "metabolic"], // v1.10.0 — wrist temperature is an overnight skin-side reading; // it sits with skin temperature in the metabolic cluster. diff --git a/src/lib/measurements/glucose-ketone-index.ts b/src/lib/measurements/glucose-ketone-index.ts new file mode 100644 index 000000000..46bf54dfa --- /dev/null +++ b/src/lib/measurements/glucose-ketone-index.ts @@ -0,0 +1,189 @@ +/** + * The glucose-ketone index (GKI), derived from a ketone reading and the + * glucose reading taken with it (#1367). + * + * GKI = glucose (mmol/L) / beta-hydroxybutyrate (mmol/L) + * + * Glucose is stored in mg/dL and converted with {@link MGDL_PER_MMOL}, the one + * factor the tree uses for that conversion, so the glucose a GKI is built from + * is the same number every glucose surface shows. The index is computed when + * it is read and never stored: a stored ratio drifts the moment either reading + * it came from is edited or deleted, which is the failure a hand-typed GKI has. + * + * Pairing, per ketone reading: + * + * 1. A glucose reading at the same instant wins. A fingerstick entry writes + * both readings with one timestamp, so this is the usual case. + * 2. Otherwise the nearest glucose reading no more than + * {@link GKI_PAIRING_WINDOW_MINUTES} minutes before or after it. + * 3. Nothing further away. A ketone reading with no glucose inside the + * window, or with a value of zero, has no index. + * + * Ties at the same distance go to the reading from the same source as the + * ketone reading (a manual fingerstick pairs with the manual glucose rather + * than with a sensor reading at the same minute), then to the earlier one. One + * glucose reading may serve two ketone readings taken close together; each + * index names the glucose reading it was built from. + * + * The figure is descriptive. Nothing here grades it: what a GKI should be is a + * matter for the plan a clinician sets. + */ +import { MGDL_PER_MMOL } from "@/lib/glucose"; + +/** + * The widest gap between a ketone and a glucose reading that still pairs. + * + * Fifteen minutes covers two strips taken one after the other, a meter that + * reads ketones from a second drop, a reading typed in a few minutes late, and + * a sensor that samples every five minutes, while staying short enough that + * glucose has not moved on: after a meal it can rise by a few mg/dL a minute, + * so an hour-old reading describes a different moment and the ratio built from + * it would describe neither. + */ +export const GKI_PAIRING_WINDOW_MINUTES = 15; + +/** + * The unit both components of the index are in. Fixed, not the reader's + * glucose unit: the index is defined on mmol/L, and a ratio taken over mg/dL + * would be eighteen times larger and mean something else. + */ +export const GKI_COMPONENT_UNIT = "mmol/L"; +export const GKI_PAIRING_WINDOW_MS = GKI_PAIRING_WINDOW_MINUTES * 60_000; + +/** One stored reading, as the pairing needs it. */ +export interface GkiReading { + id: string; + measuredAt: Date; + /** Ketones in mmol/L; glucose in canonical mg/dL. */ + value: number; + source: string; +} + +/** One derived index, with the two readings it was built from. */ +export interface GlucoseKetoneIndexPoint { + /** The ketone reading's id. The index belongs to the ketone reading. */ + ketoneId: string; + /** The glucose reading it was paired with. */ + glucoseId: string; + /** The ketone reading's instant, ISO 8601. */ + at: string; + /** The glucose reading's instant, ISO 8601. */ + glucoseAt: string; + /** The index, to one decimal. */ + value: number; + /** Beta-hydroxybutyrate as recorded, mmol/L. */ + ketonesMmol: number; + /** The paired glucose in mmol/L, to one decimal. */ + glucoseMmol: number; +} + +/** + * The index of one glucose (mg/dL) and one ketone (mmol/L) value, unrounded, + * or null when it is undefined: no ketones, or a value that is not a positive + * finite number on either side. + */ +export function glucoseKetoneIndex( + glucoseMgdl: number, + ketonesMmol: number, +): number | null { + if (!Number.isFinite(glucoseMgdl) || !Number.isFinite(ketonesMmol)) { + return null; + } + if (ketonesMmol <= 0 || glucoseMgdl <= 0) return null; + return glucoseMgdl / MGDL_PER_MMOL / ketonesMmol; +} + +function oneDecimal(value: number): number { + return Math.round(value * 10) / 10; +} + +/** + * Pair every ketone reading with its glucose reading and compute the index. + * Pure. Both lists may come in any order; the answer is in ketone-reading + * order (oldest first). Ketone readings without a partner are left out. + */ +export function pairGlucoseKetoneIndex( + ketones: readonly GkiReading[], + glucose: readonly GkiReading[], +): GlucoseKetoneIndexPoint[] { + const sortedGlucose = [...glucose].sort( + (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), + ); + const sortedKetones = [...ketones].sort( + (a, b) => + a.measuredAt.getTime() - b.measuredAt.getTime() || + a.id.localeCompare(b.id), + ); + const out: GlucoseKetoneIndexPoint[] = []; + let lo = 0; + for (const ketone of sortedKetones) { + if (!(ketone.value > 0)) continue; + const at = ketone.measuredAt.getTime(); + // Ketone readings are walked in time order, so the window's lower edge + // only ever moves forward. + while ( + lo < sortedGlucose.length && + sortedGlucose[lo]!.measuredAt.getTime() < at - GKI_PAIRING_WINDOW_MS + ) { + lo++; + } + let best: GkiReading | null = null; + let bestRank: [number, number, number] | null = null; + for (let i = lo; i < sortedGlucose.length; i++) { + const candidate = sortedGlucose[i]!; + const t = candidate.measuredAt.getTime(); + if (t > at + GKI_PAIRING_WINDOW_MS) break; + const rank: [number, number, number] = [ + Math.abs(t - at), + candidate.source === ketone.source ? 0 : 1, + t, + ]; + if ( + bestRank === null || + rank[0] < bestRank[0] || + (rank[0] === bestRank[0] && + (rank[1] < bestRank[1] || + (rank[1] === bestRank[1] && rank[2] < bestRank[2]))) + ) { + best = candidate; + bestRank = rank; + } + } + if (best === null) continue; + const index = glucoseKetoneIndex(best.value, ketone.value); + if (index === null) continue; + out.push({ + ketoneId: ketone.id, + glucoseId: best.id, + at: ketone.measuredAt.toISOString(), + glucoseAt: best.measuredAt.toISOString(), + value: oneDecimal(index), + ketonesMmol: ketone.value, + glucoseMmol: oneDecimal(best.value / MGDL_PER_MMOL), + }); + } + return out; +} + +/** Count, mean, min, max and latest of a run of indices, or null when empty. */ +export interface GlucoseKetoneIndexSummary { + count: number; + mean: number; + min: number; + max: number; + latest: number; +} + +export function summariseGlucoseKetoneIndex( + points: readonly GlucoseKetoneIndexPoint[], +): GlucoseKetoneIndexSummary | null { + if (points.length === 0) return null; + const values = points.map((p) => p.value); + return { + count: values.length, + mean: oneDecimal(values.reduce((a, b) => a + b, 0) / values.length), + min: Math.min(...values), + max: Math.max(...values), + latest: values[values.length - 1]!, + }; +} diff --git a/src/lib/measurements/read-glucose-ketone-index.ts b/src/lib/measurements/read-glucose-ketone-index.ts new file mode 100644 index 000000000..cc3afe988 --- /dev/null +++ b/src/lib/measurements/read-glucose-ketone-index.ts @@ -0,0 +1,86 @@ +/** + * Read the glucose-ketone index of a window (#1367): the ketone readings in + * it and the glucose readings within pairing reach of one, paired by + * `pairGlucoseKetoneIndex`. Every surface that shows the index (the series + * route, the day, the doctor report) reads it here, so the pairing rule has + * one implementation. + * + * Glucose and ketones carry no source ladder (neither is in `RANKED_TYPES`), + * so every live row of every source is a candidate, as on the glucose chart. + * The glucose read is bounded by the ketone readings: only rows within the + * pairing window of one are fetched, so a sensor writing every five minutes + * contributes a handful of rows per fingerstick, not the window's thousands. + */ +import { prisma } from "@/lib/db"; + +import { + GKI_PAIRING_WINDOW_MINUTES, + GKI_PAIRING_WINDOW_MS, + pairGlucoseKetoneIndex, + type GkiReading, + type GlucoseKetoneIndexPoint, +} from "./glucose-ketone-index"; + +interface Row { + id: string; + value: number; + measured_at: Date; + source: string; +} + +function toReading(row: Row): GkiReading { + return { + id: row.id, + value: row.value, + measuredAt: row.measured_at, + source: row.source, + }; +} + +/** + * The indices of the ketone readings taken in `[from, to)`, oldest first. + * A glucose reading just outside the window still pairs with a ketone reading + * just inside it. + */ +export async function readGlucoseKetoneIndex(args: { + userId: string; + from: Date; + to: Date; +}): Promise { + const { userId, from, to } = args; + const ketones = await prisma.$queryRaw` + SELECT k."id", k."value", k."measured_at", k."source"::text AS "source" + 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 + ORDER BY k."measured_at" ASC, k."id" ASC + `; + if (ketones.length === 0) return []; + // An interval between two stored instants: no zone enters the arithmetic. + const reach = `${GKI_PAIRING_WINDOW_MINUTES} minutes`; + 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 + ) + `; + return pairGlucoseKetoneIndex(ketones.map(toReading), glucose.map(toReading)); +} diff --git a/src/lib/measurements/series-reference-lines.ts b/src/lib/measurements/series-reference-lines.ts index 0915c3b2d..9693e7e81 100644 --- a/src/lib/measurements/series-reference-lines.ts +++ b/src/lib/measurements/series-reference-lines.ts @@ -44,7 +44,9 @@ export type SeriesKind = | "oxygenSaturation" | "restingHeartRate" | "heartRateVariability" - | "vo2Max"; + | "vo2Max" + | "ketones" + | "glucoseKetoneIndex"; export interface SeriesReferenceLine { value: number; diff --git a/src/lib/measurements/series/kinds.ts b/src/lib/measurements/series/kinds.ts index e9a1f76cc..c4756ff70 100644 --- a/src/lib/measurements/series/kinds.ts +++ b/src/lib/measurements/series/kinds.ts @@ -72,6 +72,10 @@ export const kindEnum = z.enum([ "restingHeartRate", "heartRateVariability", "vo2Max", + // #1367 — blood ketones, and the glucose-ketone index derived from them and + // the glucose reading taken with each (`glucose-ketone-index.ts`). + "ketones", + "glucoseKetoneIndex", ]); export type SeriesKind = z.infer; @@ -90,6 +94,11 @@ export const KIND_TO_TYPE: Record = { restingHeartRate: "RESTING_HEART_RATE", heartRateVariability: "HEART_RATE_VARIABILITY", vo2Max: "VO2_MAX", + ketones: "BLOOD_KETONES", + // The index has no stored type. It belongs to its ketone reading, so the + // change direction and day values read as the ketone series' would; neither + // has a band, so nothing is judged. + glucoseKetoneIndex: "BLOOD_KETONES", }; /** @@ -119,6 +128,9 @@ export const SERIES_UNIT: Record = { restingHeartRate: "bpm", heartRateVariability: "ms", vo2Max: "mL/(kg·min)", + ketones: "mmol/L", + // A ratio of two concentrations in the same unit: dimensionless. + glucoseKetoneIndex: "ratio", }; export interface SeriesPoint { @@ -142,6 +154,17 @@ export interface SeriesPoint { */ valueMin?: number | null; valueMax?: number | null; + /** + * #1367 — the two readings a `kind=glucoseKetoneIndex` point was built + * from. Absent for every other kind. `id` and `at` are the ketone + * reading's; this names the glucose reading paired with it. + */ + gki?: { + glucoseId: string; + glucoseAt: string; + glucoseMmol: number; + ketonesMmol: number; + }; } export function stdDev(values: number[]): number { diff --git a/src/lib/measurements/series/read-glucose-ketone-index.ts b/src/lib/measurements/series/read-glucose-ketone-index.ts new file mode 100644 index 000000000..a34c50a03 --- /dev/null +++ b/src/lib/measurements/series/read-glucose-ketone-index.ts @@ -0,0 +1,37 @@ +/** + * `kind=glucoseKetoneIndex`: one point per ketone reading that pairs with a + * glucose reading (#1367). The index is derived at read time by the one + * pairing rule (`read-glucose-ketone-index.ts`); a client draws the value it + * is given and never recomputes it. Ketone readings are a few a day at most, + * so the window is never bucketed. + */ +import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index"; + +import type { SeriesRead } from "./kinds"; + +export async function readGlucoseKetoneIndexSeries( + userId: string, + since: Date, +): Promise { + const points = await readGlucoseKetoneIndex({ + userId, + from: since, + // Open-ended: a reading stamped a moment ahead of the server clock is + // still the latest one. + to: new Date(Date.now() + 86_400_000), + }); + return { + points: points.map((p) => ({ + id: p.ketoneId, + at: p.at, + value: p.value, + secondary: null, + gki: { + glucoseId: p.glucoseId, + glucoseAt: p.glucoseAt, + glucoseMmol: p.glucoseMmol, + ketonesMmol: p.ketonesMmol, + }, + })), + }; +} diff --git a/src/lib/measurements/series/read-series.ts b/src/lib/measurements/series/read-series.ts index 3061619ea..0608bc699 100644 --- a/src/lib/measurements/series/read-series.ts +++ b/src/lib/measurements/series/read-series.ts @@ -28,6 +28,7 @@ import { } from "./kinds"; import { readBloodPressureSeries } from "./read-blood-pressure"; import { readBucketedSeries, takesBucketedRead } from "./read-bucketed"; +import { readGlucoseKetoneIndexSeries } from "./read-glucose-ketone-index"; import { readRawSeries } from "./read-raw"; import { readSleepSeries } from "./read-sleep"; @@ -40,6 +41,9 @@ async function readPoints( ): Promise { if (kind === "sleep") return readSleepSeries(userId, since, days); if (kind === "bloodPressure") return readBloodPressureSeries(userId, since); + if (kind === "glucoseKetoneIndex") { + return readGlucoseKetoneIndexSeries(userId, since); + } if (await takesBucketedRead(userId, kind, days, since)) { return readBucketedSeries(userId, kind, days, since); } diff --git a/src/lib/measurements/type-label-keys.ts b/src/lib/measurements/type-label-keys.ts index b8d7315e8..02699e16e 100644 --- a/src/lib/measurements/type-label-keys.ts +++ b/src/lib/measurements/type-label-keys.ts @@ -22,6 +22,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { SLEEP_DURATION: "measurements.typeSleep", ACTIVITY_STEPS: "measurements.typeSteps", BLOOD_GLUCOSE: "measurements.typeBloodGlucose", + BLOOD_KETONES: "measurements.typeBloodKetones", TOTAL_BODY_WATER: "measurements.typeTotalBodyWater", BONE_MASS: "measurements.typeBoneMass", OXYGEN_SATURATION: "measurements.typeOxygenSaturation", diff --git a/src/lib/measurements/unit-map.ts b/src/lib/measurements/unit-map.ts index a9eef07f9..117b54d0f 100644 --- a/src/lib/measurements/unit-map.ts +++ b/src/lib/measurements/unit-map.ts @@ -21,6 +21,7 @@ const unitMap: Record = { SLEEP_DURATION: "minutes", ACTIVITY_STEPS: "steps", BLOOD_GLUCOSE: "mg/dL", + BLOOD_KETONES: "mmol/L", TOTAL_BODY_WATER: "kg", BONE_MASS: "kg", OXYGEN_SATURATION: "%", diff --git a/src/lib/measurements/value-ranges.ts b/src/lib/measurements/value-ranges.ts index f03dd6b96..41f5af2e8 100644 --- a/src/lib/measurements/value-ranges.ts +++ b/src/lib/measurements/value-ranges.ts @@ -20,6 +20,12 @@ export const VALUE_RANGES: Record = { SLEEP_DURATION: { min: 0, max: 1440 }, ACTIVITY_STEPS: { min: 0, max: 200000 }, BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia + // Beta-hydroxybutyrate, mmol/L. The blood ketone meters people type these + // off (Abbott Precision Xtra / FreeStyle Optium, Keto-Mojo, Nova Max Plus) + // measure from 0.0 or 0.1 up to 8.0 mmol/L and print "HI" above it; 10 + // leaves room for a venous value read off a lab sheet. A meter reads 0.0, + // so zero is a real reading, not an empty one. + BLOOD_KETONES: { min: 0, max: 10 }, TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg) // Pulse oximetry (SpO2). BTS Guideline 2017 + ATS clinical practice put the diff --git a/src/lib/modules/measurement-scope.ts b/src/lib/modules/measurement-scope.ts index 689b00085..122ba1757 100644 --- a/src/lib/modules/measurement-scope.ts +++ b/src/lib/modules/measurement-scope.ts @@ -105,6 +105,10 @@ const METRIC_STATUS_MODULE_OWNERS: Partial> = STRESS_LEVEL: "recovery", TRAINING_READINESS: "recovery", CARDIO_RECOVERY: "recovery", + // Blood ketones are read off the same meter as fingerstick glucose and + // belong to the glucose module; they stay off the Coach snapshot, so the + // scope token cannot reach them. + BLOOD_KETONES: "glucose", // The HRV fallback type. `resolveRichMetricForUser` swaps HRV to its RMSSD // fallback for an account that only has ring/strap nightly RMSSD rows, so // leaving this unowned would have let the fallback path serve exactly the diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index b70d84516..3463dea26 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -98,6 +98,7 @@ const STATIC_SURFACE_MODULE = { // The per-night breathing-disturbance index is a sleep-page signal. "insights-page:breathing-disturbances": "sleep", "insights-page:blood-glucose": "glucose", + "insights-page:ketones": "glucose", "insights-page:workouts": "workouts", "insights-page:nutrients": "nutrients", // Medications is surface-gated by design: its data routes stay open so an @@ -160,6 +161,8 @@ const STATIC_SURFACE_MODULE = { // ── Dashboard summary keys ── "summary:SLEEP_DURATION": "sleep", "summary:BLOOD_GLUCOSE": "glucose", + // Blood ketones sit beside glucose: same meter, same module. + "summary:BLOOD_KETONES": "glucose", "summary:CARDIO_RECOVERY": "recovery", // The recovery module owns the resting_hr domain (`measurement-scope.ts`), // so the server gates the type on it; the form and the dashboard follow. diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 543754f5c..ef61f50af 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -696,6 +696,8 @@ const seriesKindEnum = z.enum([ "restingHeartRate", "heartRateVariability", "vo2Max", + "ketones", + "glucoseKetoneIndex", ]); const seriesQuerySchema = z.object({ @@ -747,6 +749,23 @@ const seriesPointSchema = z.object({ .describe( "v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and absent for every other kind.", ), + gki: z + .object({ + glucoseId: z.string().describe("The paired glucose reading's id."), + glucoseAt: isoDateTime().describe( + "The paired glucose reading's timestamp.", + ), + glucoseMmol: z + .number() + .describe("The paired glucose in mmol/L, one decimal."), + ketonesMmol: z + .number() + .describe("The ketone reading (beta-hydroxybutyrate), mmol/L."), + }) + .optional() + .describe( + "#1367 — for `kind=glucoseKetoneIndex` only: the readings the index was computed from. `id` and `at` are the ketone reading's. Absent for every other kind.", + ), }); const seriesResponse = z @@ -755,7 +774,7 @@ const seriesResponse = z unit: z .string() .describe( - "Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours).", + "Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours); `ketones` is `mmol/L`; `glucoseKetoneIndex` is `ratio`.", ), points: z.array(seriesPointSchema), stats: z @@ -783,7 +802,7 @@ const seriesResponse = z .meta({ id: "MeasurementsSeriesResponse", description: - "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.", + "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets. `glucoseKetoneIndex` (#1367) is derived at read time, never stored: one point per ketone reading above zero that has a glucose reading at the same instant or, failing that, the nearest one within 15 minutes either side; value = glucose (mmol/L, from mg/dL by 18.0182) / ketones (mmol/L), one decimal. Draw it as given; do not recompute it.", }); // ── Batched daily series (v1.18.6 dashboard fetch coalescing) ──────── diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 0281e5f2c..96dd6ab0a 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -175,10 +175,11 @@ export function getPRDirection( if (context.weightGoal === "higher") return PersonalRecordDirection.MAX; return null; - // Explicitly no PersonalRecord — see comment block above. + // Explicitly no PersonalRecord (see above); ketones follow a clinician's plan. case "BLOOD_PRESSURE_SYS": case "BLOOD_PRESSURE_DIA": case "BLOOD_GLUCOSE": + case "BLOOD_KETONES": case "BODY_TEMPERATURE": case "SKIN_TEMPERATURE": case "PULSE": @@ -267,9 +268,8 @@ export function getPRDirection( case "TRAINING_READINESS": case "ACUTE_TRAINING_LOAD": // v1.17.1 — Oura coverage. The Sleep Score is a nightly derived composite - // (like the other sleep-quality indices), and the body-temperature - // deviation is a homeostatic signed offset centred on 0 — neither has a - // goal axis, so both stay null like the rest of the derived/homeostatic set. + // and the body-temperature deviation a homeostatic signed offset centred + // on 0: neither has a goal axis, so both stay null like the set above. case "SLEEP_SCORE": case "BODY_TEMPERATURE_DEVIATION": // v1.19.0 — Oura resilience. An ordinal-encoded categorical band diff --git a/src/lib/queries/use-glucose-ketone-index.ts b/src/lib/queries/use-glucose-ketone-index.ts new file mode 100644 index 000000000..a724cee56 --- /dev/null +++ b/src/lib/queries/use-glucose-ketone-index.ts @@ -0,0 +1,50 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; + +/** One point of `kind=glucoseKetoneIndex`, as the server resolved it. */ +export interface GlucoseKetoneIndexSeriesPoint { + /** The ketone reading's id. */ + id: string; + /** The ketone reading's instant. */ + at: string; + /** The index, one decimal. */ + value: number; + gki: { + glucoseId: string; + glucoseAt: string; + glucoseMmol: number; + ketonesMmol: number; + }; +} + +interface GlucoseKetoneIndexSeries { + kind: "glucoseKetoneIndex"; + unit: string; + points: GlucoseKetoneIndexSeriesPoint[]; +} + +/** The whole history the series route serves; the chart narrows it. */ +export const GKI_SERIES_DAYS = 3650; + +/** + * The glucose-ketone index over the last {@link GKI_SERIES_DAYS} days (#1367). + * The server pairs the readings and computes every index; the card draws them + * as given. + */ +export function useGlucoseKetoneIndexSeries() { + const { isAuthenticated } = useAuth(); + return useQuery({ + queryKey: queryKeys.glucoseKetoneIndexSeries(GKI_SERIES_DAYS), + queryFn: () => + apiGet( + `/api/measurements/series?kind=glucoseKetoneIndex&days=${GKI_SERIES_DAYS}`, + ), + staleTime: 60_000, + enabled: isAuthenticated, + }); +} diff --git a/src/lib/query-keys/measurements.ts b/src/lib/query-keys/measurements.ts index 3ff5b167d..6b9215f75 100644 --- a/src/lib/query-keys/measurements.ts +++ b/src/lib/query-keys/measurements.ts @@ -127,6 +127,14 @@ export const measurementKeys = { bpTimeOfDay: (days: number) => ["measurements", "bp-time-of-day", days] as const, + /** + * #1367 — the glucose-ketone index series + * (`GET /api/measurements/series?kind=glucoseKetoneIndex&days=`). Under the + * `["measurements"]` prefix, so a new glucose or ketone reading refreshes it. + */ + glucoseKetoneIndexSeries: (days: number) => + ["measurements", "glucose-ketone-index", days] as const, + /** * v1.8.5 — bounded recent-timestamp read powering the * measurement-diversity nudge on an insights category page. Keyed by diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index b46c8b72b..a89342313 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -63,9 +63,9 @@ function resolves(key: string): boolean { describe("report selection catalogue", () => { it("carries every measurement type and every structured leaf", () => { - expect(MEASUREMENT_LEAF_IDS).toHaveLength(91); + expect(MEASUREMENT_LEAF_IDS).toHaveLength(92); expect(STRUCTURED_LEAF_IDS).toHaveLength(20); - expect(ALL_LEAF_IDS).toHaveLength(111); + expect(ALL_LEAF_IDS).toHaveLength(112); }); it("resolves a label for every leaf", () => { diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index 69e8c001b..611b9ca24 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -197,6 +197,7 @@ export const MEASUREMENT_LEAF_GROUP: Record = { // ── glucose ── BLOOD_GLUCOSE: "glucose", + BLOOD_KETONES: "glucose", // ── fenced ── // Mindful minutes and the State of Mind valence are mental-health @@ -337,6 +338,7 @@ export const LEAF_MODULE: Partial> = { INDOOR_HUMIDITY: "environment", // Glucose: the raw series and the derived panel. BLOOD_GLUCOSE: "glucose", + BLOOD_KETONES: "glucose", GLUCOSE_PANEL: "glucose", // The screening totals ride the mental-health module as well as being // fenced in the panel. Item-level answers are never a Measurement, so only diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index 6a09e748b..eac3adf63 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -328,5 +328,11 @@ "display": "Indoor relative humidity", "unit": "%", "category": "activity" + }, + "BLOOD_KETONES": { + "loinc": "104816-4", + "display": "Beta hydroxybutyrate [Moles/volume] in Blood", + "unit": "mmol/L", + "category": "laboratory" } } diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index d807b5c25..88f6359eb 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -341,6 +341,35 @@ export const SIGNALS: Record = { mcp: true, }, }, + // Blood ketones (#1367). Beta-hydroxybutyrate from a fingerstick meter, + // typed by hand. No band: what a ketone reading should be depends on the + // plan a clinician sets, so the value is shown as recorded. Off the Coach + // snapshot and the correlations, readable over MCP. + BLOOD_KETONES: { + key: "BLOOD_KETONES", + kind: "measurement", + source: { measurementType: "BLOOD_KETONES" }, + displayName: "Blood ketones (BHB)", + unit: "mmol/L", + direction: "target-band", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: true, + }, + // LOINC 104816-4 "Beta hydroxybutyrate [Moles/volume] in Blood": the + // quantitative, method-less blood term. A typed value does not say + // whether it came from a strip or a venous draw, and the strip term + // (66441-7) is semi-quantitative, which a numeric mmol/L reading is not. + fhir: { + loinc: "104816-4", + display: "Beta hydroxybutyrate [Moles/volume] in Blood", + unit: "mmol/L", + category: "laboratory", + }, + }, WALKING_HEART_RATE_AVERAGE: { key: "WALKING_HEART_RATE_AVERAGE", kind: "measurement", diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index df2b1b383..216d52bf6 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -171,6 +171,8 @@ export const measurementTypeEnum = z.enum([ "ACUTE_TRAINING_LOAD", // ── resting energy (#1340), HealthKit `basalEnergyBurned` ── "BASAL_ENERGY_BURNED", + // ── blood ketones (#1367), beta-hydroxybutyrate in mmol/L ── + "BLOOD_KETONES", ]); /** diff --git a/tests/integration/__golden__/backup-parity/refusals.json b/tests/integration/__golden__/backup-parity/refusals.json index 356704fd6..570e213cb 100644 --- a/tests/integration/__golden__/backup-parity/refusals.json +++ b/tests/integration/__golden__/backup-parity/refusals.json @@ -68,7 +68,7 @@ "backupId": "", "ownerId": "round-trip-owner", "reason": "schema_invalid", - "message": "measurements.0.type: Invalid option: expected one of \"WEIGHT\"|\"BLOOD_PRESSURE_SYS\"|\"BLOOD_PRESSURE_DIA\"|\"PULSE\"|\"BODY_FAT\"|\"SLEEP_DURATION\"|\"ACTIVITY_STEPS\"|\"BLOOD_GLUCOSE\"|\"TOTAL_BODY_WATER\"|\"BONE_MASS\"|\"OXYGEN_SATURATION\"|\"HEART_RATE_VARIABILITY\"|\"RESTING_HEART_RATE\"|\"ACTIVE_ENERGY_BURNED\"|\"FLIGHTS_CLIMBED\"|\"WALKING_RUNNING_DISTANCE\"|\"VO2_MAX\"|\"BODY_TEMPERATURE\"|\"FAT_FREE_MASS\"|\"FAT_MASS\"|\"MUSCLE_MASS\"|\"SKIN_TEMPERATURE\"|\"PULSE_WAVE_VELOCITY\"|\"VASCULAR_AGE\"|\"VISCERAL_FAT\"|\"AUDIO_EXPOSURE_ENV\"|\"AUDIO_EXPOSURE_HEADPHONE\"|\"TIME_IN_DAYLIGHT\"|\"WALKING_STEADINESS\"|\"AUDIO_EXPOSURE_EVENT\"|\"RESPIRATORY_RATE\"|\"BODY_MASS_INDEX\"|\"LEAN_BODY_MASS\"|\"WALKING_HEART_RATE_AVERAGE\"|\"WALKING_ASYMMETRY\"|\"WALKING_DOUBLE_SUPPORT\"|\"WALKING_STEP_LENGTH\"|\"WALKING_SPEED\"|\"CARDIO_RECOVERY\"|\"WRIST_TEMPERATURE\"|\"FALL_COUNT\"|\"SIX_MINUTE_WALK_DISTANCE\"|\"STAIR_ASCENT_SPEED\"|\"STAIR_DESCENT_SPEED\"|\"BREATHING_DISTURBANCES\"|\"IRREGULAR_RHYTHM_NOTIFICATION\"|\"HIGH_HEART_RATE_EVENT\"|\"LOW_HEART_RATE_EVENT\"|\"WALKING_STEADINESS_EVENT\"|\"BREATHING_DISTURBANCE_EVENT\"|\"RECOVERY_SCORE\"|\"STRESS_SCORE\"|\"STRAIN_SCORE\"|\"HRV_RMSSD\"|\"DAY_STRAIN\"|\"WORKOUT_STRAIN\"|\"SLEEP_PERFORMANCE\"|\"SLEEP_EFFICIENCY\"|\"SLEEP_CONSISTENCY\"|\"SLEEP_NEED\"|\"ENERGY_EXPENDITURE_KJ\"|\"AVERAGE_HEART_RATE\"|\"MAX_HEART_RATE\"|\"SLEEP_DISTURBANCE_COUNT\"|\"ANS_CHARGE\"|\"CARDIO_LOAD\"|\"SLEEP_SCORE\"|\"BODY_TEMPERATURE_DEVIATION\"|\"RESILIENCE\"|\"PHQ9_SCORE\"|\"GAD7_SCORE\"|\"GRIP_STRENGTH\"|\"PAIN_NRS\"|\"WAIST_CIRCUMFERENCE\"|\"WAIST_TO_HEIGHT\"|\"WHO5_SCORE\"|\"SCI_SCORE\"|\"EXERCISE_MINUTES\"|\"STAND_MINUTES\"|\"STAND_HOURS\"|\"MINDFUL_MINUTES\"|\"STATE_OF_MIND_VALENCE\"|\"INDOOR_CO2\"|\"INDOOR_TEMPERATURE\"|\"INDOOR_HUMIDITY\"|\"AFIB_BURDEN\"|\"BODY_BATTERY\"|\"STRESS_LEVEL\"|\"TRAINING_READINESS\"|\"ACUTE_TRAINING_LOAD\"|\"BASAL_ENERGY_BURNED\"" + "message": "measurements.0.type: Invalid option: expected one of \"WEIGHT\"|\"BLOOD_PRESSURE_SYS\"|\"BLOOD_PRESSURE_DIA\"|\"PULSE\"|\"BODY_FAT\"|\"SLEEP_DURATION\"|\"ACTIVITY_STEPS\"|\"BLOOD_GLUCOSE\"|\"TOTAL_BODY_WATER\"|\"BONE_MASS\"|\"OXYGEN_SATURATION\"|\"HEART_RATE_VARIABILITY\"|\"RESTING_HEART_RATE\"|\"ACTIVE_ENERGY_BURNED\"|\"FLIGHTS_CLIMBED\"|\"WALKING_RUNNING_DISTANCE\"|\"VO2_MAX\"|\"BODY_TEMPERATURE\"|\"FAT_FREE_MASS\"|\"FAT_MASS\"|\"MUSCLE_MASS\"|\"SKIN_TEMPERATURE\"|\"PULSE_WAVE_VELOCITY\"|\"VASCULAR_AGE\"|\"VISCERAL_FAT\"|\"AUDIO_EXPOSURE_ENV\"|\"AUDIO_EXPOSURE_HEADPHONE\"|\"TIME_IN_DAYLIGHT\"|\"WALKING_STEADINESS\"|\"AUDIO_EXPOSURE_EVENT\"|\"RESPIRATORY_RATE\"|\"BODY_MASS_INDEX\"|\"LEAN_BODY_MASS\"|\"WALKING_HEART_RATE_AVERAGE\"|\"WALKING_ASYMMETRY\"|\"WALKING_DOUBLE_SUPPORT\"|\"WALKING_STEP_LENGTH\"|\"WALKING_SPEED\"|\"CARDIO_RECOVERY\"|\"WRIST_TEMPERATURE\"|\"FALL_COUNT\"|\"SIX_MINUTE_WALK_DISTANCE\"|\"STAIR_ASCENT_SPEED\"|\"STAIR_DESCENT_SPEED\"|\"BREATHING_DISTURBANCES\"|\"IRREGULAR_RHYTHM_NOTIFICATION\"|\"HIGH_HEART_RATE_EVENT\"|\"LOW_HEART_RATE_EVENT\"|\"WALKING_STEADINESS_EVENT\"|\"BREATHING_DISTURBANCE_EVENT\"|\"RECOVERY_SCORE\"|\"STRESS_SCORE\"|\"STRAIN_SCORE\"|\"HRV_RMSSD\"|\"DAY_STRAIN\"|\"WORKOUT_STRAIN\"|\"SLEEP_PERFORMANCE\"|\"SLEEP_EFFICIENCY\"|\"SLEEP_CONSISTENCY\"|\"SLEEP_NEED\"|\"ENERGY_EXPENDITURE_KJ\"|\"AVERAGE_HEART_RATE\"|\"MAX_HEART_RATE\"|\"SLEEP_DISTURBANCE_COUNT\"|\"ANS_CHARGE\"|\"CARDIO_LOAD\"|\"SLEEP_SCORE\"|\"BODY_TEMPERATURE_DEVIATION\"|\"RESILIENCE\"|\"PHQ9_SCORE\"|\"GAD7_SCORE\"|\"GRIP_STRENGTH\"|\"PAIN_NRS\"|\"WAIST_CIRCUMFERENCE\"|\"WAIST_TO_HEIGHT\"|\"WHO5_SCORE\"|\"SCI_SCORE\"|\"EXERCISE_MINUTES\"|\"STAND_MINUTES\"|\"STAND_HOURS\"|\"MINDFUL_MINUTES\"|\"STATE_OF_MIND_VALENCE\"|\"INDOOR_CO2\"|\"INDOOR_TEMPERATURE\"|\"INDOOR_HUMIDITY\"|\"AFIB_BURDEN\"|\"BODY_BATTERY\"|\"STRESS_LEVEL\"|\"TRAINING_READINESS\"|\"ACUTE_TRAINING_LOAD\"|\"BASAL_ENERGY_BURNED\"|\"BLOOD_KETONES\"" } } ], diff --git a/tests/integration/blood-ketones-gki.test.ts b/tests/integration/blood-ketones-gki.test.ts new file mode 100644 index 000000000..fccf01eda --- /dev/null +++ b/tests/integration/blood-ketones-gki.test.ts @@ -0,0 +1,339 @@ +/** + * Blood ketones and the glucose-ketone index (#1367), against real Postgres. + * + * A fingerstick posts its glucose and ketones as one array: both rows land + * with one timestamp, or neither does. The index is derived at read time by + * one pairing rule and reaches the series route, the day and the doctor + * report with the same numbers; a ketone reading without a glucose reading + * within 15 minutes, or with a value of zero, has none. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/cache/invalidate", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, invalidateUserData: vi.fn() }; +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function signIn(): Promise { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "ketones-owner", + email: "ketones-owner@example.test", + timezone: "UTC", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return owner.id; +} + +function post(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/measurements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const MIN = 60_000; +/** A recent morning, inside every default read window. */ +const T0 = new Date(Date.now() - 2 * 86_400_000); +T0.setUTCHours(8, 0, 0, 0); +const at = (offsetMin: number) => new Date(T0.getTime() + offsetMin * MIN); + +async function seed( + userId: string, + rows: Array<{ + type: "BLOOD_GLUCOSE" | "BLOOD_KETONES"; + value: number; + offsetMin: number; + }>, +) { + await getPrismaClient().measurement.createMany({ + data: rows.map((r) => ({ + userId, + type: r.type, + value: r.value, + unit: r.type === "BLOOD_GLUCOSE" ? "mg/dL" : "mmol/L", + source: "MANUAL" as const, + measuredAt: at(r.offsetMin), + glucoseContext: r.type === "BLOOD_GLUCOSE" ? ("FASTING" as const) : null, + })), + }); +} + +/** + * Four fingersticks: an exact pair, a pair ten minutes apart, a ketone + * reading whose glucose is twenty minutes away, and a ketone reading of 0. + */ +async function seedMorning(userId: string) { + await seed(userId, [ + { type: "BLOOD_GLUCOSE", value: 81, offsetMin: 0 }, + { type: "BLOOD_KETONES", value: 1.2, offsetMin: 0 }, + { type: "BLOOD_GLUCOSE", value: 90, offsetMin: 130 }, + { type: "BLOOD_KETONES", value: 2, offsetMin: 120 }, + { type: "BLOOD_GLUCOSE", value: 100, offsetMin: 260 }, + { type: "BLOOD_KETONES", value: 1.5, offsetMin: 240 }, + { type: "BLOOD_GLUCOSE", value: 95, offsetMin: 360 }, + { type: "BLOOD_KETONES", value: 0, offsetMin: 360 }, + ]); +} + +describe("fingerstick entry (#1367)", () => { + it("writes the glucose and the ketones together with one timestamp", async () => { + const userId = await signIn(); + const { POST } = await import("@/app/api/measurements/route"); + const measuredAt = at(0).toISOString(); + const res = await POST( + post([ + { + type: "BLOOD_GLUCOSE", + value: 81, + measuredAt, + glucoseContext: "FASTING", + }, + { type: "BLOOD_KETONES", value: 1.2, measuredAt }, + ]), + ); + expect(res.status).toBe(201); + const rows = await getPrismaClient().measurement.findMany({ + where: { userId }, + orderBy: { type: "asc" }, + select: { type: true, value: true, unit: true, measuredAt: true }, + }); + expect(rows).toEqual([ + { type: "BLOOD_GLUCOSE", value: 81, unit: "mg/dL", measuredAt: at(0) }, + { type: "BLOOD_KETONES", value: 1.2, unit: "mmol/L", measuredAt: at(0) }, + ]); + }); + + it("writes neither reading when the ketones are out of range", async () => { + const userId = await signIn(); + const { POST } = await import("@/app/api/measurements/route"); + const measuredAt = at(0).toISOString(); + const res = await POST( + post([ + { + type: "BLOOD_GLUCOSE", + value: 81, + measuredAt, + glucoseContext: "FASTING", + }, + { type: "BLOOD_KETONES", value: 12, measuredAt }, + ]), + ); + expect(res.status).toBe(422); + expect( + await getPrismaClient().measurement.count({ where: { userId } }), + ).toBe(0); + }); + + it("rolls the ketones back when the glucose half is a duplicate", async () => { + const userId = await signIn(); + const { POST } = await import("@/app/api/measurements/route"); + const measuredAt = at(0).toISOString(); + const glucose = { + type: "BLOOD_GLUCOSE", + value: 81, + measuredAt, + glucoseContext: "FASTING", + }; + expect((await POST(post(glucose))).status).toBe(201); + const res = await POST( + post([glucose, { type: "BLOOD_KETONES", value: 1.2, measuredAt }]), + ); + expect(res.status).toBe(409); + expect( + await getPrismaClient().measurement.count({ + where: { userId, type: "BLOOD_KETONES" }, + }), + ).toBe(0); + }); +}); + +describe("glucose-ketone index (#1367)", () => { + it("pairs by the one rule and leaves out what has no partner", async () => { + const userId = await signIn(); + await seedMorning(userId); + const points = await readGlucoseKetoneIndex({ + userId, + from: at(-60), + to: at(24 * 60), + }); + expect( + points.map((p) => ({ + at: p.at, + glucoseAt: p.glucoseAt, + value: p.value, + ketonesMmol: p.ketonesMmol, + glucoseMmol: p.glucoseMmol, + })), + ).toEqual([ + // 81 mg/dL = 4.5 mmol/L; / 1.2 = 3.7 + { + at: at(0).toISOString(), + glucoseAt: at(0).toISOString(), + value: 3.7, + ketonesMmol: 1.2, + glucoseMmol: 4.5, + }, + // 90 mg/dL = 5.0 mmol/L, ten minutes later; / 2 = 2.5 + { + at: at(120).toISOString(), + glucoseAt: at(130).toISOString(), + value: 2.5, + ketonesMmol: 2, + glucoseMmol: 5, + }, + ]); + }); + + it("pairs a glucose reading just outside the window with a ketone reading just inside it", async () => { + const userId = await signIn(); + await seed(userId, [ + { type: "BLOOD_GLUCOSE", value: 90, offsetMin: -5 }, + { type: "BLOOD_KETONES", value: 1, offsetMin: 0 }, + ]); + const points = await readGlucoseKetoneIndex({ + userId, + from: at(0), + to: at(60), + }); + expect(points.map((p) => p.glucoseAt)).toEqual([at(-5).toISOString()]); + }); + + it("serves the index and the ketones on the series route", async () => { + await signIn().then(seedMorning); + const { GET } = await import("@/app/api/measurements/series/route"); + const gki = await GET( + new NextRequest( + "http://localhost/api/measurements/series?kind=glucoseKetoneIndex&days=30", + ), + ); + expect(gki.status).toBe(200); + const gkiBody = (await gki.json()) as { + data: { + unit: string; + points: Array<{ + value: number; + gki: { glucoseAt: string; glucoseMmol: number; ketonesMmol: number }; + }>; + stats: { count: number }; + referenceLines: unknown[]; + }; + }; + expect(gkiBody.data.unit).toBe("ratio"); + expect(gkiBody.data.points.map((p) => p.value)).toEqual([3.7, 2.5]); + expect(gkiBody.data.points[1]!.gki).toMatchObject({ + glucoseAt: at(130).toISOString(), + glucoseMmol: 5, + ketonesMmol: 2, + }); + expect(gkiBody.data.stats.count).toBe(2); + expect(gkiBody.data.referenceLines).toEqual([]); + + const ketones = await GET( + new NextRequest( + "http://localhost/api/measurements/series?kind=ketones&days=30", + ), + ); + expect(ketones.status).toBe(200); + const ketoneBody = (await ketones.json()) as { + data: { unit: string; points: Array<{ value: number }> }; + }; + expect(ketoneBody.data.unit).toBe("mmol/L"); + expect(ketoneBody.data.points.map((p) => p.value)).toEqual([ + 1.2, 2, 1.5, 0, + ]); + }); + + it("carries the day's index on the day", async () => { + await signIn().then(seedMorning); + const { GET } = await import("@/app/api/day/[date]/route"); + const date = at(0).toISOString().slice(0, 10); + const res = await GET( + new Request(`http://localhost/api/day/${date}`) as never, + { params: Promise.resolve({ date }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + glucoseKetoneIndex?: Array<{ value: number; glucoseAt: string }>; + values: Array<{ type: string }>; + }; + }; + expect(body.data.glucoseKetoneIndex?.map((p) => p.value)).toEqual([ + 3.7, 2.5, + ]); + expect(body.data.values.some((v) => v.type === "BLOOD_KETONES")).toBe(true); + }); + + it("puts the index in the doctor report only beside both readings", async () => { + const userId = await signIn(); + await seedMorning(userId); + const range = { + start: at(-24 * 60), + end: at(24 * 60), + days: 2, + }; + const both = await collectDoctorReportData( + userId, + range, + selectionFromLeaves(["BLOOD_KETONES", "BLOOD_GLUCOSE"]), + ); + expect(both.stats.BLOOD_KETONES?.count).toBe(4); + expect(both.glucoseKetoneIndex).toMatchObject({ + count: 2, + latest: 2.5, + min: 2.5, + max: 3.7, + avg: 3.1, + }); + + const ketonesOnly = await collectDoctorReportData( + userId, + range, + selectionFromLeaves(["BLOOD_KETONES"]), + ); + expect(ketonesOnly.stats.BLOOD_KETONES?.count).toBe(4); + expect(ketonesOnly.glucoseKetoneIndex).toBeNull(); + }); +}); diff --git a/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap index 6f0a8bc35..2ea860c20 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap @@ -1357,6 +1357,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BLOOD_KETONES": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": null, @@ -1484,7 +1498,8 @@ "STRESS_LEVEL": null, "TRAINING_READINESS": null, "ACUTE_TRAINING_LOAD": null, - "BASAL_ENERGY_BURNED": null + "BASAL_ENERGY_BURNED": null, + "BLOOD_KETONES": null }, "sleepSourceDiscrepancy": null, "recoveryEmpty": "general" diff --git a/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap index fd8eae42c..a385f18d4 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap @@ -1357,6 +1357,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BLOOD_KETONES": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": null, @@ -1484,7 +1498,8 @@ "STRESS_LEVEL": null, "TRAINING_READINESS": null, "ACUTE_TRAINING_LOAD": null, - "BASAL_ENERGY_BURNED": null + "BASAL_ENERGY_BURNED": null, + "BLOOD_KETONES": null }, "sleepSourceDiscrepancy": null, "recoveryEmpty": "general" diff --git a/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap index 20b1ec632..b3d4ec093 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap @@ -1357,6 +1357,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BLOOD_KETONES": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": 25.9, @@ -2500,7 +2514,8 @@ "STRESS_LEVEL": null, "TRAINING_READINESS": null, "ACUTE_TRAINING_LOAD": null, - "BASAL_ENERGY_BURNED": null + "BASAL_ENERGY_BURNED": null, + "BLOOD_KETONES": null } }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap index 070bee47c..d27c3b269 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap @@ -1357,6 +1357,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BLOOD_KETONES": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": 25.9, @@ -2500,7 +2514,8 @@ "STRESS_LEVEL": null, "TRAINING_READINESS": null, "ACUTE_TRAINING_LOAD": null, - "BASAL_ENERGY_BURNED": null + "BASAL_ENERGY_BURNED": null, + "BLOOD_KETONES": null } }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/analytics.08-empty-account.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.08-empty-account.json.snap index e05ff02a7..59d1df415 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.08-empty-account.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.08-empty-account.json.snap @@ -1277,6 +1277,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BLOOD_KETONES": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": null, @@ -1647,7 +1661,8 @@ "STRESS_LEVEL": null, "TRAINING_READINESS": null, "ACUTE_TRAINING_LOAD": null, - "BASAL_ENERGY_BURNED": null + "BASAL_ENERGY_BURNED": null, + "BLOOD_KETONES": null } }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/measurement-series.09-invalid.json.snap b/tests/integration/routes/parity/__snapshots__/measurement-series.09-invalid.json.snap index 4287a32ba..a7006e51b 100644 --- a/tests/integration/routes/parity/__snapshots__/measurement-series.09-invalid.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurement-series.09-invalid.json.snap @@ -8,7 +8,7 @@ { "path": "kind", "code": "invalid_value", - "message": "Invalid option: expected one of \"weight\"|\"bloodPressure\"|\"pulse\"|\"bodyFat\"|\"glucose\"|\"sleep\"|\"steps\"|\"totalBodyWater\"|\"boneMass\"|\"oxygenSaturation\"|\"restingHeartRate\"|\"heartRateVariability\"|\"vo2Max\"" + "message": "Invalid option: expected one of \"weight\"|\"bloodPressure\"|\"pulse\"|\"bodyFat\"|\"glucose\"|\"sleep\"|\"steps\"|\"totalBodyWater\"|\"boneMass\"|\"oxygenSaturation\"|\"restingHeartRate\"|\"heartRateVariability\"|\"vo2Max\"|\"ketones\"|\"glucoseKetoneIndex\"" }, { "path": "days", @@ -39,7 +39,7 @@ { "path": "kind", "code": "invalid_value", - "message": "Invalid option: expected one of \"weight\"|\"bloodPressure\"|\"pulse\"|\"bodyFat\"|\"glucose\"|\"sleep\"|\"steps\"|\"totalBodyWater\"|\"boneMass\"|\"oxygenSaturation\"|\"restingHeartRate\"|\"heartRateVariability\"|\"vo2Max\"" + "message": "Invalid option: expected one of \"weight\"|\"bloodPressure\"|\"pulse\"|\"bodyFat\"|\"glucose\"|\"sleep\"|\"steps\"|\"totalBodyWater\"|\"boneMass\"|\"oxygenSaturation\"|\"restingHeartRate\"|\"heartRateVariability\"|\"vo2Max\"|\"ketones\"|\"glucoseKetoneIndex\"" }, { "path": "days",