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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-10 19:28:57 +01:00
1 parent 157809c24f
commit f75337e6f2
75 files changed
+2133 -94

No files matched your search

+16
View File
@@ -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
+105 -8
View File
@@ -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:
+1
View File
@@ -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 |
+25
View File
@@ -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",
+25
View File
@@ -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",
+25
View File
@@ -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",
+25
View File
@@ -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",
+25
View File
@@ -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",
+25
View File
@@ -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}개",
+25
View File
@@ -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",
+25
View File
@@ -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",
+25
View File
@@ -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",
+25
View File
@@ -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}",
@@ -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';
+4
View File
@@ -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")
}
@@ -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,
@@ -149,6 +149,10 @@ const ALLOWED_SQL: Record<string, string> = {
"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.",
};
@@ -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);
});
@@ -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,
+39
View File
@@ -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 (
<HealthKitMetricPage
measurementType="BLOOD_KETONES"
insightMetric="BLOOD_KETONES"
chartKey="bloodKetones"
i18nPrefix="insights.bloodKetones"
color="var(--chart-3)"
unit={KETONE_UNIT}
yAxisUnit={KETONE_UNIT}
statFractionDigits={1}
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType="BLOOD_KETONES"
captureType="BLOOD_KETONES"
afterChart={<GkiCard />}
/>
);
}
@@ -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.
<StatRow
label={t("measurements.glucoseKetoneIndex")}
value={statSummary(t, "", {
latest: fmtDigits(report.glucoseKetoneIndex.latest, 1),
avg: fmtDigits(report.glucoseKetoneIndex.avg, 1),
min: fmtDigits(report.glucoseKetoneIndex.min, 1),
max: fmtDigits(report.glucoseKetoneIndex.max, 1),
})}
/>
) : null}
{unvalidated > 0 && (
<p
className="text-muted-foreground text-xs"
+46
View File
@@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import type { DayResponse } from "@/lib/day/contract";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
type DayGki = NonNullable<DayResponse["glucoseKetoneIndex"]>[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 (
<li data-slot="day-gki" className="flex min-w-0">
<Link
href="/insights/ketones"
data-slot="day-value-link"
className="bg-muted/60 hover:bg-muted focus-visible:ring-ring/50 flex min-h-11 w-full min-w-0 flex-col rounded-lg px-3 py-2.5 transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="text-muted-foreground truncate text-xs">
{t("measurements.glucoseKetoneIndex")}
</span>
<span className="truncate text-base font-semibold tabular-nums">
{fmt.number(latest.value, 1)}
</span>
<span className="text-muted-foreground truncate text-xs tabular-nums">
{points.length > 1
? tCount("day.gkiCount", points.length)
: t("day.gkiPair", {
glucose: fmt.number(latest.glucoseMmol, 1),
ketones: fmt.number(latest.ketonesMmol, 1),
})}
</span>
</Link>
</li>
);
}
+7 -3
View File
@@ -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<DayResponse, "values" | "glucoseKetoneIndex">;
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)}
/>
))}
<DayGkiTile points={day.glucoseKetoneIndex ?? []} />
</ul>
{rest.length > 0 ? (
<button
+1 -1
View File
@@ -296,7 +296,7 @@ export function DayView({
<DayRunning items={data.running} date={date} />
{data.values.length > 0 ? (
<div className="space-y-2.5">
<DayValues values={data.values} focusTypes={focusTypes} />
<DayValues day={data} focusTypes={focusTypes} />
<DayNotableLines
notables={otherNotables}
labelFor={labelFor}
@@ -380,6 +380,7 @@ export const SUB_PAGE_TABS: Record<
labelKey: "insights.navBloodGlucose",
metric: "BLOOD_GLUCOSE",
},
ketones: { labelKey: "insights.navBloodKetones", metric: "BLOOD_KETONES" },
"skin-temperature": {
labelKey: "insights.navSkinTemperature",
metric: "SKIN_TEMPERATURE",
@@ -0,0 +1,109 @@
"use client";
import dynamic from "next/dynamic";
import { Divide } from "lucide-react";
import {
ChartErrorBoundary,
ChartErrorState,
} from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { GKI_COMPONENT_UNIT } from "@/lib/measurements/glucose-ketone-index";
import { useGlucoseKetoneIndexSeries } from "@/lib/queries/use-glucose-ketone-index";
import { importWithRetry } from "@/lib/retry-import";
// The line chart the custom metrics draw: a value over time with range tabs
// and the day doors. Loaded through the one recharts boundary.
const IndexChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.CustomMetricChart }),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
/**
* The glucose-ketone index (#1367), beneath the ketone chart. Every point is
* one ketone reading and the glucose reading the server paired with it; the
* value is the server's and is drawn as given. The latest pair is spelled out
* so the number can be traced to the two readings it came from. No band and
* no verdict: what the index should be is the treating clinician's call.
*/
export function GkiCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const query = useGlucoseKetoneIndexSeries();
const points = query.data?.points ?? [];
const latest = points[points.length - 1];
const title = t("measurements.glucoseKetoneIndex");
return (
<Card data-slot="gki-card" aria-busy={query.isFetching || undefined}>
<CardHeader>
<TileHeader icon={Divide} title={title} titleAs="h2" />
<CardDescription>
{t("insights.bloodKetones.gki.description")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{query.isPending ? (
<ChartSkeleton />
) : query.isError ? (
<ChartErrorState
error={query.error}
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={title}
onAction={() => void query.refetch()}
/>
) : !latest ? (
<EmptyState
variant="plain"
size="compact"
title={t("insights.bloodKetones.gki.emptyTitle")}
description={t("insights.bloodKetones.gki.emptyDescription")}
/>
) : (
<>
<p className="text-sm" data-slot="gki-latest">
{t("insights.bloodKetones.gki.latest", {
value: fmt.number(latest.value, 1),
glucose: fmt.number(latest.gki.glucoseMmol, 1),
ketones: fmt.number(latest.gki.ketonesMmol, 1),
unit: GKI_COMPONENT_UNIT,
date: fmt.dateTime(latest.at),
})}
</p>
<ChartErrorBoundary>
<IndexChartLazy
name={title}
unit=""
targetLow={null}
targetHigh={null}
decimals={1}
entries={points.map((p) => ({
id: p.id,
customMetricId: "",
value: p.value,
unit: "",
measuredAt: p.at,
note: null,
createdAt: p.at,
}))}
/>
</ChartErrorBoundary>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { ketoneFingerstickBatch } from "@/components/measurements/ketone-fingerstick";
import { createBatchMeasurementSchema } from "@/lib/validations/measurement";
const AT = "2026-11-01T08:00:00.000Z";
function batch(
overrides: Partial<Parameters<typeof ketoneFingerstickBatch>[0]>,
) {
return ketoneFingerstickBatch({
ketones: "1.2",
glucose: "",
glucoseUnit: "mg/dL",
glucoseContext: "FASTING",
measuredAt: AT,
notes: "",
...overrides,
});
}
describe("ketoneFingerstickBatch", () => {
it("posts the ketones alone when the meter gave no glucose", () => {
expect(batch({})).toEqual([
{ type: "BLOOD_KETONES", value: 1.2, measuredAt: AT, notes: undefined },
]);
});
it("posts the glucose and the ketones with one timestamp", () => {
const rows = batch({
glucose: "81",
ketones: "1,5",
notes: "after breakfast",
});
expect(rows).toEqual([
{
type: "BLOOD_GLUCOSE",
value: 81,
measuredAt: AT,
notes: "after breakfast",
glucoseContext: "FASTING",
},
{
type: "BLOOD_KETONES",
value: 1.5,
measuredAt: AT,
notes: "after breakfast",
},
]);
// The batch the route validates: the shape the form sends is accepted.
expect(
createBatchMeasurementSchema.safeParse({ measurements: rows }).success,
).toBe(true);
});
it("stores glucose typed in mmol/L as mg/dL", () => {
const rows = batch({ glucose: "4.5", glucoseUnit: "mmol/L" });
expect(rows?.[0]).toMatchObject({ type: "BLOOD_GLUCOSE", value: 81 });
});
it("refuses a number it cannot read rather than dropping half the pair", () => {
expect(batch({ ketones: "" })).toBeNull();
expect(batch({ ketones: "abc" })).toBeNull();
expect(batch({ glucose: "lots" })).toBeNull();
});
it("is refused by the route's schema above the meters' range", () => {
const rows = batch({ ketones: "12", glucose: "90" });
expect(
createBatchMeasurementSchema.safeParse({ measurements: rows }).success,
).toBe(false);
});
});
@@ -0,0 +1,56 @@
/**
* One fingerstick as one entry (#1367): the ketones, and the glucose from the
* same drop when the meter gave one, as the batch the measurements route
* writes in one transaction. Both readings carry the one timestamp, so the
* server pairs them exactly when it derives the glucose-ketone index.
*
* Pure, so the form's write path can be pinned without rendering it.
*/
import { toCanonicalMgdl, type GlucoseUnit } from "@/lib/glucose";
import { parseDecimalEntry } from "@/lib/measurements/entry-units";
export interface FingerstickRow {
type: "BLOOD_GLUCOSE" | "BLOOD_KETONES";
value: number;
measuredAt: string;
notes?: string;
glucoseContext?: string;
}
/**
* The rows to post, glucose first, or null when a typed number does not read
* as one. An empty glucose field means the meter gave ketones only.
*/
export function ketoneFingerstickBatch(input: {
ketones: string;
glucose: string;
/** The unit the glucose was typed in: the account's glucose unit. */
glucoseUnit: GlucoseUnit;
glucoseContext: string;
measuredAt: string;
notes: string;
}): FingerstickRow[] | null {
const ketones = parseDecimalEntry(input.ketones);
if (ketones === null) return null;
const notes = input.notes || undefined;
const ketoneRow: FingerstickRow = {
type: "BLOOD_KETONES",
value: ketones,
measuredAt: input.measuredAt,
notes,
};
if (input.glucose.trim() === "") return [ketoneRow];
const glucose = parseDecimalEntry(input.glucose);
if (glucose === null) return null;
return [
{
type: "BLOOD_GLUCOSE",
// Stored in mg/dL whatever the reader types in.
value: toCanonicalMgdl(glucose, input.glucoseUnit),
measuredAt: input.measuredAt,
notes,
glucoseContext: input.glucoseContext,
},
ketoneRow,
];
}
@@ -0,0 +1,127 @@
"use client";
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { glucoseFractionDigits, type GlucoseUnit } from "@/lib/glucose";
import { useTranslations } from "@/lib/i18n/context";
/**
* The measurement form's action row: the reset menu, Cancel and Save. The
* Save button names the form by id, so it still submits it when the row is
* portalled into a sheet's footer slot.
*/
export function MeasurementFormFooter({
loading,
formId,
onReset,
onCancel,
}: {
loading: boolean;
formId: string;
onReset: () => void;
onCancel?: () => void;
}) {
const { t } = useTranslations();
return (
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="size-11"
disabled={loading}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={onReset}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("measurements.formReset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
{t("common.cancel")}
</Button>
)}
<Button type="submit" form={formId} disabled={loading}>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="h-4 w-4" />
)}
{t("common.save")}
</Button>
</div>
</div>
);
}
/**
* #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 (
<FieldGroup
htmlFor="paired-glucose"
label={
<>
{t("measurements.pairedGlucoseLabel", { unit })}
<span className="text-muted-foreground ml-1 font-normal">
({t("common.optional")})
</span>
</>
}
hint={t("measurements.pairedGlucoseHint")}
>
<Input
id="paired-glucose"
// Not `type="number"`, like the value field: a decimal comma must
// survive on every engine.
inputMode="decimal"
enterKeyHint="next"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={glucoseFractionDigits(unit) > 0 ? "5.3" : "95"}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
/>
</FieldGroup>
);
}
@@ -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 = (
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="size-11"
disabled={loading}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={resetForm}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("measurements.formReset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
{t("common.cancel")}
</Button>
)}
<Button type="submit" form={formId} disabled={loading}>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="h-4 w-4" />
)}
{t("common.save")}
</Button>
</div>
</div>
<MeasurementFormFooter
loading={loading}
formId={formId}
onReset={resetForm}
onCancel={onCancel}
/>
);
return (
@@ -718,7 +705,17 @@ export function MeasurementForm({
</FieldGroup>
)}
{isGlucoseMode && (
{isKetoneMode && (
<PairedGlucoseField
value={pairedGlucose}
onChange={setPairedGlucose}
unit={glucoseUnit}
invalid={!!error}
describedBy={errorDescriptor}
/>
)}
{(isGlucoseMode || (isKetoneMode && pairedGlucose.trim() !== "")) && (
<FieldGroup
htmlFor="glucose-context"
label={t("measurements.glucoseContext")}
@@ -48,6 +48,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
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<string, LucideIcon> = {
SLEEP_DURATION: Moon,
ACTIVITY_STEPS: Footprints,
BLOOD_GLUCOSE: Droplet,
BLOOD_KETONES: Droplet,
TOTAL_BODY_WATER: Droplet,
BONE_MASS: Bone,
OXYGEN_SATURATION: Wind,
@@ -13,6 +13,7 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
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",
@@ -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(),
);
+1
View File
@@ -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];
+15
View File
@@ -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<DayResponse> {
]);
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<DayResponse> {
counts: { values: values.length, entries: events.length },
running,
values,
...(glucoseKetoneIndex.length > 0 ? { glucoseKetoneIndex } : {}),
events,
notable: notable.map(({ kind, type, params }) => ({ kind, type, params })),
scores,
+24
View File
@@ -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
@@ -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;
}
+7
View File
@@ -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<string, DoctorReportCompliance>;
+4 -3
View File
@@ -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 : {},
@@ -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<Record<string, DoctorReportStats>>,
userId: string,
/** The report window; `end` is inclusive, as the measurement read has it. */
start: Date,
end: Date,
): Promise<DoctorReportStats | null> {
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,
};
}
+26
View File
@@ -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(
{
+1
View File
@@ -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",
+2
View File
@@ -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
+3
View File
@@ -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<Record<SubPageSlug, SubPageGroup>> = {
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
@@ -105,6 +105,9 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
// 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", () => {
@@ -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,
});
});
});
+1
View File
@@ -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.
@@ -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]!,
};
}
@@ -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<GlucoseKetoneIndexPoint[]> {
const { userId, from, to } = args;
const ketones = await prisma.$queryRaw<Row[]>`
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<Row[]>`
SELECT g."id", g."value", g."measured_at", g."source"::text AS "source"
FROM measurements g
WHERE g."user_id" = ${userId}
AND g."type" = 'BLOOD_GLUCOSE'::"measurement_type"
AND g."deleted_at" IS NULL
AND g."measured_at" >= ${new Date(from.getTime() - GKI_PAIRING_WINDOW_MS)}
AND g."measured_at" <= ${new Date(to.getTime() + GKI_PAIRING_WINDOW_MS)}
AND EXISTS (
SELECT 1 FROM measurements k
WHERE k."user_id" = ${userId}
AND k."type" = 'BLOOD_KETONES'::"measurement_type"
AND k."deleted_at" IS NULL
AND k."measured_at" >= ${from}
AND k."measured_at" < ${to}
AND k."value" > 0
AND k."measured_at" BETWEEN g."measured_at" - ${reach}::interval
AND g."measured_at" + ${reach}::interval
)
`;
return pairGlucoseKetoneIndex(ketones.map(toReading), glucose.map(toReading));
}
@@ -44,7 +44,9 @@ export type SeriesKind =
| "oxygenSaturation"
| "restingHeartRate"
| "heartRateVariability"
| "vo2Max";
| "vo2Max"
| "ketones"
| "glucoseKetoneIndex";
export interface SeriesReferenceLine {
value: number;
+23
View File
@@ -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<typeof kindEnum>;
@@ -90,6 +94,11 @@ export const KIND_TO_TYPE: Record<SeriesKind, MeasurementType> = {
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<SeriesKind, string> = {
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 {
@@ -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<SeriesRead> {
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,
},
})),
};
}
@@ -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<SeriesRead> {
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);
}
+1
View File
@@ -22,6 +22,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<MeasurementType, string> = {
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",
+1
View File
@@ -21,6 +21,7 @@ const unitMap: Record<string, string> = {
SLEEP_DURATION: "minutes",
ACTIVITY_STEPS: "steps",
BLOOD_GLUCOSE: "mg/dL",
BLOOD_KETONES: "mmol/L",
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
+6
View File
@@ -20,6 +20,12 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
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
+4
View File
@@ -105,6 +105,10 @@ const METRIC_STATUS_MODULE_OWNERS: Partial<Record<MeasurementType, ModuleKey>> =
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
+3
View File
@@ -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.
+21 -2
View File
@@ -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) ────────
+4 -4
View File
@@ -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
@@ -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<GlucoseKetoneIndexSeries>(
`/api/measurements/series?kind=glucoseKetoneIndex&days=${GKI_SERIES_DAYS}`,
),
staleTime: 60_000,
enabled: isAuthenticated,
});
}
+8
View File
@@ -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
@@ -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", () => {
+2
View File
@@ -197,6 +197,7 @@ export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
// ── 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<Record<ReportLeafId, ModuleKey>> = {
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
@@ -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"
}
}
+29
View File
@@ -341,6 +341,35 @@ export const SIGNALS: Record<string, SignalDefinition> = {
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",
+2
View File
@@ -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",
]);
/**
@@ -68,7 +68,7 @@
"backupId": "<id:1>",
"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\""
}
}
],
+339
View File
@@ -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<typeof import("@/lib/cache/invalidate")>();
return { ...actual, invalidateUserData: vi.fn() };
});
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function signIn(): Promise<string> {
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();
});
});
@@ -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"
@@ -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"
@@ -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
@@ -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
@@ -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
@@ -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",