mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge branch 'feat/v1472-ketones' into feat/cycle-feed-forecast-style
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
62f4578874
75 files changed
+2133
-94
No files matched your search
@@ -34,6 +34,22 @@
|
||||
with your account and applied by the server, so the app shows the same
|
||||
order. The labs list layout accepts `categoryOrder`, and
|
||||
`GET /api/labs/overview` answers `categories` in that order. (#1349)
|
||||
- **Blood ketones, and the glucose-ketone index worked out for you.**
|
||||
Ketones (beta-hydroxybutyrate, mmol/L) are a measurement of their own
|
||||
now, so a ketogenic diet no longer has to live in a custom metric with
|
||||
the index typed in by hand beside it. Choose "Blood ketones" when you
|
||||
add a measurement and type the glucose from the same fingerstick into
|
||||
the field below: both readings are saved together with one time, or
|
||||
neither is. The glucose-ketone index is glucose in mmol/L divided by
|
||||
ketones, taken from the glucose reading at the same time or, failing
|
||||
that, the nearest one within 15 minutes. It is never stored, so it
|
||||
follows any reading you edit or delete. It shows under the chart on the
|
||||
new Ketones page, in the day view, and beside the ketones in the doctor
|
||||
report and the clinician share; the FHIR export carries the ketone
|
||||
readings under LOINC 104816-4. Values are shown as recorded, with no
|
||||
band and no verdict. `GET /api/measurements/series` takes
|
||||
`kind=ketones` and `kind=glucoseKetoneIndex`, and `GET /api/day/{date}`
|
||||
carries `glucoseKetoneIndex`. Migration `0496_blood_ketones`. (#1367)
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+105
-8
@@ -4672,6 +4672,8 @@ paths:
|
||||
- restingHeartRate
|
||||
- heartRateVariability
|
||||
- vo2Max
|
||||
- ketones
|
||||
- glucoseKetoneIndex
|
||||
required: true
|
||||
- in: query
|
||||
name: days
|
||||
@@ -4811,6 +4813,7 @@ paths:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
@@ -5736,6 +5739,7 @@ paths:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
description: The metric.
|
||||
required: true
|
||||
description: The metric.
|
||||
@@ -5866,6 +5870,7 @@ paths:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
description: The metric.
|
||||
required: true
|
||||
description: The metric.
|
||||
@@ -28773,7 +28778,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 111
|
||||
maxItems: 112
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -28835,7 +28840,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 111
|
||||
maxItems: 112
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -29715,6 +29720,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
value:
|
||||
type: number
|
||||
measuredAt:
|
||||
@@ -31449,6 +31455,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
biomarkerId:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -31948,7 +31955,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 111
|
||||
maxItems: 112
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -32593,7 +32600,7 @@ components:
|
||||
- order
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 63
|
||||
maxItems: 64
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -32649,6 +32656,7 @@ components:
|
||||
- audio-events
|
||||
- daylight
|
||||
- blood-glucose
|
||||
- ketones
|
||||
- skin-temperature
|
||||
- wrist-temperature
|
||||
- mood
|
||||
@@ -33151,6 +33159,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy,
|
||||
flights, walking/running distance, VO2 max, body temperature).
|
||||
InsightsSettingsPutRequest:
|
||||
@@ -37697,6 +37706,7 @@ components:
|
||||
- scoreSleep
|
||||
- scoreStress
|
||||
- scoreStrain
|
||||
- bloodKetones
|
||||
description: Which chart's overlays are being written. Closed enum.
|
||||
prefs:
|
||||
type: object
|
||||
@@ -37886,6 +37896,7 @@ components:
|
||||
- scoreSleep
|
||||
- scoreStress
|
||||
- scoreStrain
|
||||
- bloodKetones
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
@@ -38858,7 +38869,7 @@ components:
|
||||
additionalProperties: false
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 63
|
||||
maxItems: 64
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -38914,6 +38925,7 @@ components:
|
||||
- audio-events
|
||||
- daylight
|
||||
- blood-glucose
|
||||
- ketones
|
||||
- skin-temperature
|
||||
- wrist-temperature
|
||||
- mood
|
||||
@@ -42723,6 +42735,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
value:
|
||||
type: number
|
||||
valueMin:
|
||||
@@ -43491,6 +43504,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
- type: "null"
|
||||
biomarkerName:
|
||||
anyOf:
|
||||
@@ -46089,6 +46103,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
dateKey:
|
||||
type: string
|
||||
pattern: ^\d{4}-\d{2}-\d{2}$
|
||||
@@ -47289,6 +47304,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
- type: "null"
|
||||
intervalDays:
|
||||
anyOf:
|
||||
@@ -47515,6 +47531,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
metricSlot:
|
||||
type:
|
||||
- string
|
||||
@@ -51039,10 +51056,12 @@ components:
|
||||
- restingHeartRate
|
||||
- heartRateVariability
|
||||
- vo2Max
|
||||
- ketones
|
||||
- glucoseKetoneIndex
|
||||
unit:
|
||||
type: string
|
||||
description: Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h`
|
||||
(per-night time-asleep in hours).
|
||||
(per-night time-asleep in hours); `ketones` is `mmol/L`; `glucoseKetoneIndex` is `ratio`.
|
||||
points:
|
||||
type: array
|
||||
items:
|
||||
@@ -51089,6 +51108,31 @@ components:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
gki:
|
||||
description: "#1367 — for `kind=glucoseKetoneIndex` only: the readings the index was computed from. `id` and `at` are
|
||||
the ketone reading's. Absent for every other kind."
|
||||
type: object
|
||||
properties:
|
||||
glucoseId:
|
||||
type: string
|
||||
description: The paired glucose reading's id.
|
||||
glucoseAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: The paired glucose reading's timestamp.
|
||||
glucoseMmol:
|
||||
type: number
|
||||
description: The paired glucose in mmol/L, one decimal.
|
||||
ketonesMmol:
|
||||
type: number
|
||||
description: The ketone reading (beta-hydroxybutyrate), mmol/L.
|
||||
required:
|
||||
- glucoseId
|
||||
- glucoseAt
|
||||
- glucoseMmol
|
||||
- ketonesMmol
|
||||
additionalProperties: false
|
||||
required:
|
||||
- id
|
||||
- at
|
||||
@@ -51146,7 +51190,10 @@ components:
|
||||
ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries
|
||||
the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its
|
||||
range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading.
|
||||
`stats` is always computed over the underlying readings, never over the buckets."
|
||||
`stats` is always computed over the underlying readings, never over the buckets. `glucoseKetoneIndex` (#1367) is
|
||||
derived at read time, never stored: one point per ketone reading above zero that has a glucose reading at the
|
||||
same instant or, failing that, the nearest one within 15 minutes either side; value = glucose (mmol/L, from
|
||||
mg/dL by 18.0182) / ketones (mmol/L), one decimal. Draw it as given; do not recompute it."
|
||||
TrendVerdict:
|
||||
type: object
|
||||
properties:
|
||||
@@ -53832,6 +53879,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
@@ -54112,6 +54160,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
from:
|
||||
type: string
|
||||
format: date
|
||||
@@ -64561,7 +64610,7 @@ components:
|
||||
additionalProperties: false
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 63
|
||||
maxItems: 64
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -64617,6 +64666,7 @@ components:
|
||||
- audio-events
|
||||
- daylight
|
||||
- blood-glucose
|
||||
- ketones
|
||||
- skin-temperature
|
||||
- wrist-temperature
|
||||
- mood
|
||||
@@ -88230,6 +88280,7 @@ components:
|
||||
- scoreSleep
|
||||
- scoreStress
|
||||
- scoreStrain
|
||||
- bloodKetones
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
@@ -88496,6 +88547,7 @@ components:
|
||||
- scoreSleep
|
||||
- scoreStress
|
||||
- scoreStrain
|
||||
- bloodKetones
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
@@ -93870,6 +93922,14 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DayValue"
|
||||
glucoseKetoneIndex:
|
||||
description: "#1367 — the day's glucose-ketone index, oldest first: glucose (mmol/L) over ketones (mmol/L) for each
|
||||
ketone reading above zero with a glucose reading at the same instant or within 15 minutes either side.
|
||||
Computed at read time; show it as given. Absent when the day has none, when either reading type is hidden
|
||||
from the caller, and on older servers."
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DayGlucoseKetoneIndex"
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
@@ -94074,6 +94134,43 @@ components:
|
||||
- source
|
||||
- band
|
||||
additionalProperties: false
|
||||
DayGlucoseKetoneIndex:
|
||||
type: object
|
||||
properties:
|
||||
at:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: The ketone reading's instant.
|
||||
value:
|
||||
type: number
|
||||
description: The index, to one decimal.
|
||||
ketoneId:
|
||||
type: string
|
||||
description: The ketone reading's id.
|
||||
glucoseId:
|
||||
type: string
|
||||
description: The paired glucose reading's id.
|
||||
glucoseAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: The paired glucose reading's instant.
|
||||
ketonesMmol:
|
||||
type: number
|
||||
description: Beta-hydroxybutyrate, mmol/L.
|
||||
glucoseMmol:
|
||||
type: number
|
||||
description: The paired glucose in mmol/L, one decimal.
|
||||
required:
|
||||
- at
|
||||
- value
|
||||
- ketoneId
|
||||
- glucoseId
|
||||
- glucoseAt
|
||||
- ketonesMmol
|
||||
- glucoseMmol
|
||||
additionalProperties: false
|
||||
DayEvent:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Schlaf",
|
||||
"typeSteps": "Schritte",
|
||||
"typeBloodGlucose": "Blutzucker",
|
||||
"typeBloodKetones": "Blutketone",
|
||||
"glucoseKetoneIndex": "Glukose-Keton-Index",
|
||||
"pairedGlucoseLabel": "Blutzucker aus derselben Messung ({unit})",
|
||||
"pairedGlucoseHint": "Wird als eigener Blutzuckerwert mit derselben Uhrzeit gespeichert; aus dem Paar ergibt sich der Glukose-Keton-Index.",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"typeOxygenSaturation": "Sauerstoffsättigung",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glukose",
|
||||
"navBloodKetones": "Ketone",
|
||||
"bloodKetones": {
|
||||
"title": "Blutketone",
|
||||
"description": "Deine Beta-Hydroxybutyrat-Werte im Zeitverlauf, in {unit}.",
|
||||
"chartTitle": "Blutketone",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Ketonwerte",
|
||||
"description": "Ketonwerte erscheinen hier, sobald du einen erfasst, allein oder mit dem Blutzucker aus demselben Fingerstich.",
|
||||
"cta": "Ketone erfassen"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Blutzucker geteilt durch Ketone, für jeden Ketonwert mit einem Blutzuckerwert innerhalb von 15 Minuten.",
|
||||
"emptyTitle": "Noch kein Index",
|
||||
"emptyDescription": "Er erscheint, sobald zu einem Ketonwert ein Blutzuckerwert zur selben Zeit oder innerhalb von 15 Minuten vorliegt.",
|
||||
"latest": "Zuletzt {value}: Blutzucker {glucose} {unit} durch Ketone {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Gesamtkörperwasser",
|
||||
"description": "Dein Gesamtkörperwasser im Zeitverlauf, in Kilogramm.",
|
||||
@@ -13689,6 +13710,10 @@
|
||||
"valuesAllFew": "Alle {count} Werte",
|
||||
"valuesAllOther": "Alle {count} Werte",
|
||||
"valuesFewer": "Weniger Werte",
|
||||
"gkiPair": "Blutzucker {glucose} / Ketone {ketones}",
|
||||
"gkiCountOne": "Letzter von {count}",
|
||||
"gkiCountFew": "Letzter von {count}",
|
||||
"gkiCountOther": "Letzter von {count}",
|
||||
"countEntriesOne": "1 Eintrag",
|
||||
"countEntriesFew": "{count} Einträge",
|
||||
"countEntriesOther": "{count} Einträge",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Sleep",
|
||||
"typeSteps": "Steps",
|
||||
"typeBloodGlucose": "Blood glucose",
|
||||
"typeBloodKetones": "Blood ketones",
|
||||
"glucoseKetoneIndex": "Glucose-ketone index",
|
||||
"pairedGlucoseLabel": "Glucose from the same reading ({unit})",
|
||||
"pairedGlucoseHint": "Saved as its own glucose reading at the same time; the glucose-ketone index is worked out from the pair.",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"typeOxygenSaturation": "Oxygen saturation",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucose",
|
||||
"navBloodKetones": "Ketones",
|
||||
"bloodKetones": {
|
||||
"title": "Blood Ketones",
|
||||
"description": "Your beta-hydroxybutyrate readings over time, in {unit}.",
|
||||
"chartTitle": "Blood ketones",
|
||||
"emptyState": {
|
||||
"title": "No ketone readings yet",
|
||||
"description": "Ketone readings appear here once you log one, on its own or with the glucose from the same fingerstick.",
|
||||
"cta": "Log ketones"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glucose divided by ketones, for each ketone reading with a glucose reading within 15 minutes.",
|
||||
"emptyTitle": "No index yet",
|
||||
"emptyDescription": "It appears once a ketone reading has a glucose reading taken at the same time or within 15 minutes.",
|
||||
"latest": "Latest {value}: glucose {glucose} {unit} over ketones {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Total Body Water",
|
||||
"description": "Your total body water over time, in kilograms.",
|
||||
@@ -13689,6 +13710,10 @@
|
||||
"valuesAllFew": "All {count} values",
|
||||
"valuesAllOther": "All {count} values",
|
||||
"valuesFewer": "Fewer values",
|
||||
"gkiPair": "Glucose {glucose} / ketones {ketones}",
|
||||
"gkiCountOne": "Latest of {count}",
|
||||
"gkiCountFew": "Latest of {count}",
|
||||
"gkiCountOther": "Latest of {count}",
|
||||
"countEntriesOne": "1 entry",
|
||||
"countEntriesFew": "{count} entries",
|
||||
"countEntriesOther": "{count} entries",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Sueño",
|
||||
"typeSteps": "Pasos",
|
||||
"typeBloodGlucose": "Glucemia",
|
||||
"typeBloodKetones": "Cetonas en sangre",
|
||||
"glucoseKetoneIndex": "Índice glucosa-cetonas",
|
||||
"pairedGlucoseLabel": "Glucosa de la misma medición ({unit})",
|
||||
"pairedGlucoseHint": "Se guarda como una lectura de glucosa propia con la misma hora; el índice glucosa-cetonas se calcula a partir del par.",
|
||||
"typeTotalBodyWater": "Agua corporal total",
|
||||
"typeBoneMass": "Masa ósea",
|
||||
"typeOxygenSaturation": "Saturación de oxígeno",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucosa",
|
||||
"navBloodKetones": "Cetonas",
|
||||
"bloodKetones": {
|
||||
"title": "Cetonas en sangre",
|
||||
"description": "Tus lecturas de beta-hidroxibutirato en el tiempo, en {unit}.",
|
||||
"chartTitle": "Cetonas en sangre",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay lecturas de cetonas",
|
||||
"description": "Las lecturas de cetonas aparecen aquí cuando registras una, sola o con la glucosa del mismo pinchazo.",
|
||||
"cta": "Registrar cetonas"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glucosa dividida por cetonas, para cada lectura de cetonas con una de glucosa en un margen de 15 minutos.",
|
||||
"emptyTitle": "Aún no hay índice",
|
||||
"emptyDescription": "Aparece cuando una lectura de cetonas tiene una de glucosa tomada a la misma hora o en un margen de 15 minutos.",
|
||||
"latest": "Último {value}: glucosa {glucose} {unit} entre cetonas {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Agua corporal total",
|
||||
"description": "Tu agua corporal total en el tiempo, en kilogramos.",
|
||||
@@ -13689,6 +13710,10 @@
|
||||
"valuesAllFew": "Los {count} valores",
|
||||
"valuesAllOther": "Los {count} valores",
|
||||
"valuesFewer": "Menos valores",
|
||||
"gkiPair": "Glucosa {glucose} / cetonas {ketones}",
|
||||
"gkiCountOne": "Último de {count}",
|
||||
"gkiCountFew": "Último de {count}",
|
||||
"gkiCountOther": "Último de {count}",
|
||||
"countEntriesOne": "1 entrada",
|
||||
"countEntriesFew": "{count} entradas",
|
||||
"countEntriesOther": "{count} entradas",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Sommeil",
|
||||
"typeSteps": "Pas",
|
||||
"typeBloodGlucose": "Glycémie",
|
||||
"typeBloodKetones": "Cétones sanguines",
|
||||
"glucoseKetoneIndex": "Indice glucose-cétones",
|
||||
"pairedGlucoseLabel": "Glycémie de la même mesure ({unit})",
|
||||
"pairedGlucoseHint": "Enregistrée comme un relevé de glycémie à la même heure ; l’indice glucose-cétones est calculé à partir de la paire.",
|
||||
"typeTotalBodyWater": "Eau corporelle totale",
|
||||
"typeBoneMass": "Masse osseuse",
|
||||
"typeOxygenSaturation": "Saturation en oxygène",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucose",
|
||||
"navBloodKetones": "Cétones",
|
||||
"bloodKetones": {
|
||||
"title": "Cétones sanguines",
|
||||
"description": "Vos relevés de bêta-hydroxybutyrate au fil du temps, en {unit}.",
|
||||
"chartTitle": "Cétones sanguines",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de relevé de cétones",
|
||||
"description": "Les relevés de cétones apparaissent ici dès que vous en saisissez un, seul ou avec la glycémie de la même piqûre.",
|
||||
"cta": "Enregistrer les cétones"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glycémie divisée par les cétones, pour chaque relevé de cétones accompagné d’une glycémie à 15 minutes près.",
|
||||
"emptyTitle": "Pas encore d’indice",
|
||||
"emptyDescription": "Il apparaît dès qu’un relevé de cétones a une glycémie prise au même moment ou à 15 minutes près.",
|
||||
"latest": "Dernier {value} : glycémie {glucose} {unit} sur cétones {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Eau corporelle totale",
|
||||
"description": "Votre eau corporelle totale au fil du temps, en kilogrammes.",
|
||||
@@ -13689,6 +13710,10 @@
|
||||
"valuesAllFew": "Les {count} valeurs",
|
||||
"valuesAllOther": "Les {count} valeurs",
|
||||
"valuesFewer": "Moins de valeurs",
|
||||
"gkiPair": "Glycémie {glucose} / cétones {ketones}",
|
||||
"gkiCountOne": "Dernier sur {count}",
|
||||
"gkiCountFew": "Dernier sur {count}",
|
||||
"gkiCountOther": "Dernier sur {count}",
|
||||
"countEntriesOne": "{count} entrée",
|
||||
"countEntriesFew": "{count} entrées",
|
||||
"countEntriesOther": "{count} entrées",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Sonno",
|
||||
"typeSteps": "Passi",
|
||||
"typeBloodGlucose": "Glicemia",
|
||||
"typeBloodKetones": "Chetoni nel sangue",
|
||||
"glucoseKetoneIndex": "Indice glucosio-chetoni",
|
||||
"pairedGlucoseLabel": "Glicemia della stessa misurazione ({unit})",
|
||||
"pairedGlucoseHint": "Salvata come lettura di glicemia a sé con lo stesso orario; l’indice glucosio-chetoni si ricava dalla coppia.",
|
||||
"typeTotalBodyWater": "Acqua corporea totale",
|
||||
"typeBoneMass": "Massa ossea",
|
||||
"typeOxygenSaturation": "Saturazione di ossigeno",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucosio",
|
||||
"navBloodKetones": "Chetoni",
|
||||
"bloodKetones": {
|
||||
"title": "Chetoni nel sangue",
|
||||
"description": "Le tue letture di beta-idrossibutirrato nel tempo, in {unit}.",
|
||||
"chartTitle": "Chetoni nel sangue",
|
||||
"emptyState": {
|
||||
"title": "Ancora nessuna lettura di chetoni",
|
||||
"description": "Le letture di chetoni appaiono qui quando ne registri una, da sola o con la glicemia della stessa puntura.",
|
||||
"cta": "Registra i chetoni"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glicemia divisa per i chetoni, per ogni lettura di chetoni con una glicemia entro 15 minuti.",
|
||||
"emptyTitle": "Ancora nessun indice",
|
||||
"emptyDescription": "Compare quando una lettura di chetoni ha una glicemia presa allo stesso momento o entro 15 minuti.",
|
||||
"latest": "Ultimo {value}: glicemia {glucose} {unit} su chetoni {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Acqua corporea totale",
|
||||
"description": "La tua acqua corporea totale nel tempo, in chilogrammi.",
|
||||
@@ -13689,6 +13710,10 @@
|
||||
"valuesAllFew": "Tutti i {count} valori",
|
||||
"valuesAllOther": "Tutti i {count} valori",
|
||||
"valuesFewer": "Meno valori",
|
||||
"gkiPair": "Glicemia {glucose} / chetoni {ketones}",
|
||||
"gkiCountOne": "Ultimo di {count}",
|
||||
"gkiCountFew": "Ultimo di {count}",
|
||||
"gkiCountOther": "Ultimo di {count}",
|
||||
"countEntriesOne": "1 voce",
|
||||
"countEntriesFew": "{count} voci",
|
||||
"countEntriesOther": "{count} voci",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "수면",
|
||||
"typeSteps": "걸음 수",
|
||||
"typeBloodGlucose": "혈당",
|
||||
"typeBloodKetones": "혈중 케톤",
|
||||
"glucoseKetoneIndex": "포도당-케톤 지수",
|
||||
"pairedGlucoseLabel": "같은 측정의 혈당 ({unit})",
|
||||
"pairedGlucoseHint": "같은 시각의 별도 혈당 기록으로 저장되고, 두 값으로 포도당-케톤 지수를 계산해요.",
|
||||
"typeTotalBodyWater": "체수분",
|
||||
"typeBoneMass": "골량",
|
||||
"typeOxygenSaturation": "산소포화도",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "혈당",
|
||||
"navBloodKetones": "케톤",
|
||||
"bloodKetones": {
|
||||
"title": "혈중 케톤",
|
||||
"description": "시간에 따른 베타-하이드록시부티르산 측정값이에요({unit}).",
|
||||
"chartTitle": "혈중 케톤",
|
||||
"emptyState": {
|
||||
"title": "아직 케톤 측정값이 없어요",
|
||||
"description": "케톤을 단독으로 또는 같은 채혈의 혈당과 함께 기록하면 여기에 측정값이 나타나요.",
|
||||
"cta": "케톤 기록"
|
||||
},
|
||||
"gki": {
|
||||
"description": "15분 이내에 혈당 측정값이 있는 케톤 측정마다 혈당을 케톤으로 나눈 값이에요.",
|
||||
"emptyTitle": "아직 지수가 없어요",
|
||||
"emptyDescription": "케톤 측정과 같은 시각이나 15분 이내에 잰 혈당이 있으면 나타나요.",
|
||||
"latest": "최근 {value}: 혈당 {glucose} {unit} ÷ 케톤 {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "총 체수분",
|
||||
"description": "시간에 따른 총 체수분이에요(kg).",
|
||||
@@ -13689,6 +13710,10 @@
|
||||
"valuesAllFew": "수치 {count}개 모두",
|
||||
"valuesAllOther": "수치 {count}개 모두",
|
||||
"valuesFewer": "수치 줄여 보기",
|
||||
"gkiPair": "혈당 {glucose} / 케톤 {ketones}",
|
||||
"gkiCountOne": "{count}개 중 최근",
|
||||
"gkiCountFew": "{count}개 중 최근",
|
||||
"gkiCountOther": "{count}개 중 최근",
|
||||
"countEntriesOne": "기록 1개",
|
||||
"countEntriesFew": "기록 {count}개",
|
||||
"countEntriesOther": "기록 {count}개",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Slaap",
|
||||
"typeSteps": "Stappen",
|
||||
"typeBloodGlucose": "Bloedglucose",
|
||||
"typeBloodKetones": "Bloedketonen",
|
||||
"glucoseKetoneIndex": "Glucose-ketonindex",
|
||||
"pairedGlucoseLabel": "Glucose uit dezelfde meting ({unit})",
|
||||
"pairedGlucoseHint": "Wordt als eigen glucosemeting met hetzelfde tijdstip opgeslagen; uit het paar volgt de glucose-ketonindex.",
|
||||
"typeTotalBodyWater": "Totaal lichaamswater",
|
||||
"typeBoneMass": "Botmassa",
|
||||
"typeOxygenSaturation": "Zuurstofsaturatie",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucose",
|
||||
"navBloodKetones": "Ketonen",
|
||||
"bloodKetones": {
|
||||
"title": "Bloedketonen",
|
||||
"description": "Je bèta-hydroxybutyraatmetingen in de loop van de tijd, in {unit}.",
|
||||
"chartTitle": "Bloedketonen",
|
||||
"emptyState": {
|
||||
"title": "Nog geen ketonmetingen",
|
||||
"description": "Ketonmetingen verschijnen hier zodra je er een vastlegt, los of met de glucose uit dezelfde vingerprik.",
|
||||
"cta": "Ketonen vastleggen"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glucose gedeeld door ketonen, voor elke ketonmeting met een glucosemeting binnen 15 minuten.",
|
||||
"emptyTitle": "Nog geen index",
|
||||
"emptyDescription": "Hij verschijnt zodra een ketonmeting een glucosemeting op hetzelfde moment of binnen 15 minuten heeft.",
|
||||
"latest": "Laatst {value}: glucose {glucose} {unit} gedeeld door ketonen {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Totaal lichaamswater",
|
||||
"description": "Je totale lichaamswater in de loop van de tijd, in kilogram.",
|
||||
@@ -13689,6 +13710,10 @@
|
||||
"valuesAllFew": "Alle {count} waarden",
|
||||
"valuesAllOther": "Alle {count} waarden",
|
||||
"valuesFewer": "Minder waarden",
|
||||
"gkiPair": "Glucose {glucose} / ketonen {ketones}",
|
||||
"gkiCountOne": "Laatste van {count}",
|
||||
"gkiCountFew": "Laatste van {count}",
|
||||
"gkiCountOther": "Laatste van {count}",
|
||||
"countEntriesOne": "1 registratie",
|
||||
"countEntriesFew": "{count} registraties",
|
||||
"countEntriesOther": "{count} registraties",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Sen",
|
||||
"typeSteps": "Kroki",
|
||||
"typeBloodGlucose": "Glikemia",
|
||||
"typeBloodKetones": "Ketony we krwi",
|
||||
"glucoseKetoneIndex": "Indeks glukozowo-ketonowy",
|
||||
"pairedGlucoseLabel": "Glukoza z tego samego pomiaru ({unit})",
|
||||
"pairedGlucoseHint": "Zapisywana jako osobny odczyt glukozy z tą samą godziną; z tej pary obliczany jest indeks glukozowo-ketonowy.",
|
||||
"typeTotalBodyWater": "Całkowita woda w organizmie",
|
||||
"typeBoneMass": "Masa kostna",
|
||||
"typeOxygenSaturation": "Saturacja tlenem",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glukoza",
|
||||
"navBloodKetones": "Ketony",
|
||||
"bloodKetones": {
|
||||
"title": "Ketony we krwi",
|
||||
"description": "Twoje odczyty beta-hydroksymaślanu w czasie, w {unit}.",
|
||||
"chartTitle": "Ketony we krwi",
|
||||
"emptyState": {
|
||||
"title": "Brak jeszcze odczytów ketonów",
|
||||
"description": "Odczyty ketonów pojawią się tutaj po zapisaniu jednego, osobno lub z glukozą z tego samego nakłucia.",
|
||||
"cta": "Zapisz ketony"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glukoza podzielona przez ketony, dla każdego odczytu ketonów z odczytem glukozy w ciągu 15 minut.",
|
||||
"emptyTitle": "Brak jeszcze indeksu",
|
||||
"emptyDescription": "Pojawi się, gdy do odczytu ketonów będzie odczyt glukozy z tej samej chwili lub w ciągu 15 minut.",
|
||||
"latest": "Ostatnio {value}: glukoza {glucose} {unit} przez ketony {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Całkowita woda w organizmie",
|
||||
"description": "Twoja całkowita woda w organizmie w czasie, w kilogramach.",
|
||||
@@ -13689,6 +13710,10 @@
|
||||
"valuesAllFew": "Wszystkie {count} wartości",
|
||||
"valuesAllOther": "Wszystkie {count} wartości",
|
||||
"valuesFewer": "Mniej wartości",
|
||||
"gkiPair": "Glukoza {glucose} / ketony {ketones}",
|
||||
"gkiCountOne": "Ostatni z {count}",
|
||||
"gkiCountFew": "Ostatni z {count}",
|
||||
"gkiCountOther": "Ostatni z {count}",
|
||||
"countEntriesOne": "1 wpis",
|
||||
"countEntriesFew": "{count} wpisy",
|
||||
"countEntriesOther": "{count} wpisów",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Sono",
|
||||
"typeSteps": "Passos",
|
||||
"typeBloodGlucose": "Glicemia",
|
||||
"typeBloodKetones": "Cetonas no sangue",
|
||||
"glucoseKetoneIndex": "Índice glicose-cetona",
|
||||
"pairedGlucoseLabel": "Glicose da mesma medição ({unit})",
|
||||
"pairedGlucoseHint": "Salva como uma leitura de glicose própria no mesmo horário; o índice glicose-cetona é calculado a partir do par.",
|
||||
"typeTotalBodyWater": "Água corporal total",
|
||||
"typeBoneMass": "Massa óssea",
|
||||
"typeOxygenSaturation": "Saturação de oxigênio",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glicose",
|
||||
"navBloodKetones": "Cetonas",
|
||||
"bloodKetones": {
|
||||
"title": "Cetonas no sangue",
|
||||
"description": "Suas leituras de beta-hidroxibutirato ao longo do tempo, em {unit}.",
|
||||
"chartTitle": "Cetonas no sangue",
|
||||
"emptyState": {
|
||||
"title": "Ainda não há leituras de cetonas",
|
||||
"description": "As leituras de cetonas aparecem aqui assim que você registra uma, sozinha ou com a glicose da mesma picada.",
|
||||
"cta": "Registrar cetonas"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glicose dividida pelas cetonas, para cada leitura de cetonas com uma de glicose em até 15 minutos.",
|
||||
"emptyTitle": "Ainda não há índice",
|
||||
"emptyDescription": "Ele aparece quando uma leitura de cetonas tem uma de glicose feita no mesmo horário ou em até 15 minutos.",
|
||||
"latest": "Último {value}: glicose {glucose} {unit} sobre cetonas {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Água Corporal Total",
|
||||
"description": "Sua água corporal total ao longo do tempo, em quilogramas.",
|
||||
@@ -13689,6 +13710,10 @@
|
||||
"valuesAllFew": "Todos os {count} valores",
|
||||
"valuesAllOther": "Todos os {count} valores",
|
||||
"valuesFewer": "Menos valores",
|
||||
"gkiPair": "Glicose {glucose} / cetonas {ketones}",
|
||||
"gkiCountOne": "Último de {count}",
|
||||
"gkiCountFew": "Último de {count}",
|
||||
"gkiCountOther": "Último de {count}",
|
||||
"countEntriesOne": "{count} entrada",
|
||||
"countEntriesFew": "{count} entradas",
|
||||
"countEntriesOther": "{count} entradas",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Сон",
|
||||
"typeSteps": "Шаги",
|
||||
"typeBloodGlucose": "Глюкоза крови",
|
||||
"typeBloodKetones": "Кетоны в крови",
|
||||
"glucoseKetoneIndex": "Глюкозо-кетоновый индекс",
|
||||
"pairedGlucoseLabel": "Глюкоза из того же измерения ({unit})",
|
||||
"pairedGlucoseHint": "Сохраняется как отдельное измерение глюкозы с тем же временем; по этой паре рассчитывается глюкозо-кетоновый индекс.",
|
||||
"typeTotalBodyWater": "Общая вода в организме",
|
||||
"typeBoneMass": "Костная масса",
|
||||
"typeOxygenSaturation": "Насыщение кислородом",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Глюкоза",
|
||||
"navBloodKetones": "Кетоны",
|
||||
"bloodKetones": {
|
||||
"title": "Кетоны в крови",
|
||||
"description": "Ваши измерения бета-гидроксибутирата в динамике, в {unit}.",
|
||||
"chartTitle": "Кетоны в крови",
|
||||
"emptyState": {
|
||||
"title": "Измерений кетонов пока нет",
|
||||
"description": "Измерения кетонов появятся здесь, как только вы запишете одно, отдельно или вместе с глюкозой из того же прокола.",
|
||||
"cta": "Записать кетоны"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Глюкоза, делённая на кетоны, для каждого измерения кетонов с измерением глюкозы в пределах 15 минут.",
|
||||
"emptyTitle": "Индекса пока нет",
|
||||
"emptyDescription": "Он появится, когда к измерению кетонов будет измерение глюкозы в то же время или в пределах 15 минут.",
|
||||
"latest": "Последний {value}: глюкоза {glucose} {unit} на кетоны {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Общая вода в организме",
|
||||
"description": "Ваша общая вода в организме в динамике, в килограммах.",
|
||||
@@ -13689,6 +13710,10 @@
|
||||
"valuesAllFew": "Все {count} значения",
|
||||
"valuesAllOther": "Все {count} значений",
|
||||
"valuesFewer": "Меньше значений",
|
||||
"gkiPair": "Глюкоза {glucose} / кетоны {ketones}",
|
||||
"gkiCountOne": "Последний из {count}",
|
||||
"gkiCountFew": "Последний из {count}",
|
||||
"gkiCountOther": "Последний из {count}",
|
||||
"countEntriesOne": "{count} запись",
|
||||
"countEntriesFew": "Записей: {count}",
|
||||
"countEntriesOther": "Записей: {count}",
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "%",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) ────────
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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\""
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+16
-1
@@ -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"
|
||||
|
||||
+16
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user