From f75337e6f28d79b1d73a2b3d025853df3c286180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 19:28:57 +0100 Subject: [PATCH] feat(measurements): record blood ketones and show the glucose-ketone index (#1367) Ketones had to live in a custom metric, with the glucose-ketone index typed in by hand beside it, where it drifted from the readings it came from. - Type: BLOOD_KETONES (beta-hydroxybutyrate, mmol/L, migration 0496), plausible from 0 to 10: the blood ketone meters read 0.0 to 8.0 and print HI above it, and a venous value off a lab sheet fits under 10. Zero is a real reading. No band, no direction, no personal record, off the Coach snapshot and the correlations; readable over MCP and owned by the glucose module like glucose. - Entry: choosing blood ketones in the measurement form also takes the glucose from the same fingerstick, in the account's glucose unit. Both rows go through the array arm of POST /api/measurements with one timestamp, so they land in one transaction or not at all. - Index: glucose (mg/dL / 18.0182, the tree's one factor) over ketones, computed at read time and never stored. Each ketone reading above zero pairs with the glucose reading at the same instant, else the nearest one within 15 minutes either side; a tie goes to the same source, then to the earlier reading. Nothing pairs across a wider gap. - Surfaces: GET /api/measurements/series takes kind=ketones and kind=glucoseKetoneIndex (each point names the paired readings), and the day carries glucoseKetoneIndex when both reading types are visible. The new Ketones page charts the readings with the index beneath; the day view shows the index beside the value tiles; the doctor report, its PDF and the clinician share print it in the glucose group when both leaves are chosen, since beside the ketones it gives the glucose back. - FHIR: ketone readings export under LOINC 104816-4 (Beta hydroxybutyrate [Moles/volume] in Blood). The index is left out: it has no LOINC term, and both readings it is built from are in the bundle. - The measurement form's action row moved into its own component to keep the form inside its size budget. OpenAPI regenerated; all ten locales; the import unit table lists the type. --- CHANGELOG.md | 16 + docs/api/openapi.yaml | 113 +++++- docs/integrations/data-import.md | 1 + messages/de.json | 25 ++ messages/en.json | 25 ++ messages/es.json | 25 ++ messages/fr.json | 25 ++ messages/it.json | 25 ++ messages/ko.json | 25 ++ messages/nl.json | 25 ++ messages/pl.json | 25 ++ messages/pt-BR.json | 25 ++ messages/ru.json | 25 ++ .../0496_blood_ketones/migration.sql | 12 + prisma/schema.prisma | 4 + .../baselines/source-file-lines.json | 4 +- .../dense-measurement-read-guard.test.ts | 4 + ...doctor-report-control-gating-guard.test.ts | 4 +- .../unit-preference-display-guard.test.ts | 6 +- src/app/insights/ketones/page.tsx | 39 ++ src/components/clinician/report-sections.tsx | 15 + src/components/day/day-gki-tile.tsx | 46 +++ src/components/day/day-sections.tsx | 10 +- src/components/day/day-view.tsx | 2 +- .../insights/insights-tab-strip.tsx | 1 + src/components/insights/ketones/gki-card.tsx | 109 ++++++ .../__tests__/ketone-fingerstick.test.ts | 73 ++++ .../measurements/ketone-fingerstick.ts | 56 +++ .../measurements/measurement-form-parts.tsx | 127 +++++++ .../measurements/measurement-form.tsx | 101 +++--- .../measurements/measurement-list-meta.ts | 2 + .../measurements/measurement-type-colors.ts | 1 + .../measurement-type-enum-coverage.test.ts | 4 +- src/lib/dashboard-layout.ts | 1 + src/lib/day/load-day.ts | 15 + src/lib/day/wire-schemas.ts | 24 ++ .../measurements-charts-section.ts | 16 +- src/lib/doctor-report-types.ts | 7 + src/lib/doctor-report/collect.ts | 7 +- src/lib/doctor-report/glucose-ketone-index.ts | 45 +++ src/lib/fhir/__tests__/series.test.ts | 26 ++ src/lib/insights/chart-tokens.ts | 1 + src/lib/insights/metric-availability.ts | 2 + src/lib/insights/sub-page-metric.ts | 3 + .../__tests__/apple-health-mapping.test.ts | 3 + .../__tests__/glucose-ketone-index.test.ts | 156 ++++++++ src/lib/measurements/categories.ts | 1 + src/lib/measurements/glucose-ketone-index.ts | 189 ++++++++++ .../measurements/read-glucose-ketone-index.ts | 86 +++++ .../measurements/series-reference-lines.ts | 4 +- src/lib/measurements/series/kinds.ts | 23 ++ .../series/read-glucose-ketone-index.ts | 37 ++ src/lib/measurements/series/read-series.ts | 4 + src/lib/measurements/type-label-keys.ts | 1 + src/lib/measurements/unit-map.ts | 1 + src/lib/measurements/value-ranges.ts | 6 + src/lib/modules/measurement-scope.ts | 4 + src/lib/modules/surface.ts | 3 + src/lib/openapi/routes/measurements.ts | 23 +- src/lib/personal-records/pr-direction.ts | 8 +- src/lib/queries/use-glucose-ketone-index.ts | 50 +++ src/lib/query-keys/measurements.ts | 8 + .../__tests__/catalogue-guard.test.ts | 4 +- src/lib/report-selection/catalogue.ts | 2 + .../fixtures/expected-measurement-loinc.json | 6 + src/lib/signals/registry.ts | 29 ++ src/lib/validations/measurement.ts | 2 + .../__golden__/backup-parity/refusals.json | 2 +- tests/integration/blood-ketones-gki.test.ts | 339 ++++++++++++++++++ .../analytics.02-summaries-cold.json.snap | 17 +- .../analytics.03-summaries-warm.json.snap | 17 +- .../analytics.04-default-cold.json.snap | 17 +- .../analytics.05-default-warm.json.snap | 17 +- .../analytics.08-empty-account.json.snap | 17 +- .../measurement-series.09-invalid.json.snap | 4 +- 75 files changed, 2133 insertions(+), 94 deletions(-) create mode 100644 prisma/migrations/0496_blood_ketones/migration.sql create mode 100644 src/app/insights/ketones/page.tsx create mode 100644 src/components/day/day-gki-tile.tsx create mode 100644 src/components/insights/ketones/gki-card.tsx create mode 100644 src/components/measurements/__tests__/ketone-fingerstick.test.ts create mode 100644 src/components/measurements/ketone-fingerstick.ts create mode 100644 src/components/measurements/measurement-form-parts.tsx create mode 100644 src/lib/doctor-report/glucose-ketone-index.ts create mode 100644 src/lib/measurements/__tests__/glucose-ketone-index.test.ts create mode 100644 src/lib/measurements/glucose-ketone-index.ts create mode 100644 src/lib/measurements/read-glucose-ketone-index.ts create mode 100644 src/lib/measurements/series/read-glucose-ketone-index.ts create mode 100644 src/lib/queries/use-glucose-ketone-index.ts create mode 100644 tests/integration/blood-ketones-gki.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf618510..b90cee9c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,22 @@ prevention line 109, which answers around the clock. Before, Korean fell through to the international set with 112, which reaches the police there, not an ambulance. +- **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 3f21603d9..09fab7930 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: @@ -37690,6 +37699,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones description: Which chart's overlays are being written. Closed enum. prefs: type: object @@ -37879,6 +37889,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones additionalProperties: type: object properties: @@ -38851,7 +38862,7 @@ components: additionalProperties: false tiles: minItems: 1 - maxItems: 63 + maxItems: 64 type: array items: type: object @@ -38907,6 +38918,7 @@ components: - audio-events - daylight - blood-glucose + - ketones - skin-temperature - wrist-temperature - mood @@ -42716,6 +42728,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES value: type: number valueMin: @@ -43484,6 +43497,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES - type: "null" biomarkerName: anyOf: @@ -46082,6 +46096,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES dateKey: type: string pattern: ^\d{4}-\d{2}-\d{2}$ @@ -47282,6 +47297,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES - type: "null" intervalDays: anyOf: @@ -47508,6 +47524,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES metricSlot: type: - string @@ -51030,10 +51047,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: @@ -51080,6 +51099,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 @@ -51137,7 +51181,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: @@ -53823,6 +53870,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES date: type: string format: date @@ -54103,6 +54151,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES from: type: string format: date @@ -64552,7 +64601,7 @@ components: additionalProperties: false tiles: minItems: 1 - maxItems: 63 + maxItems: 64 type: array items: type: object @@ -64608,6 +64657,7 @@ components: - audio-events - daylight - blood-glucose + - ketones - skin-temperature - wrist-temperature - mood @@ -88209,6 +88259,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones additionalProperties: type: object properties: @@ -88475,6 +88526,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones additionalProperties: type: object properties: @@ -93849,6 +93901,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: @@ -94053,6 +94113,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 578b94d21..13d9541d1 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.", @@ -13687,6 +13708,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 9d90b4bf4..3e9036e8e 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.", @@ -13687,6 +13708,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 9dc9cf7c6..0e9fcb7e9 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.", @@ -13687,6 +13708,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 ebd9846e5..131d39a22 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.", @@ -13687,6 +13708,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 f77f140ee..1be30eba6 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.", @@ -13687,6 +13708,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 1c2173f54..91c6d1cb0 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).", @@ -13687,6 +13708,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 9d4dafbdd..03178e76b 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.", @@ -13687,6 +13708,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 ea16d9a0d..a545dab25 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.", @@ -13687,6 +13708,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 6ff853515..e16d8bfb9 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.", @@ -13687,6 +13708,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 e449bbec9..65b4e71d8 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": "Ваша общая вода в организме в динамике, в килограммах.", @@ -13687,6 +13708,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",