mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(measurements): record blood ketones and show the glucose-ketone index (#1367)
Ketones had to live in a custom metric, with the glucose-ketone index typed in by hand beside it, where it drifted from the readings it came from. - Type: BLOOD_KETONES (beta-hydroxybutyrate, mmol/L, migration 0496), plausible from 0 to 10: the blood ketone meters read 0.0 to 8.0 and print HI above it, and a venous value off a lab sheet fits under 10. Zero is a real reading. No band, no direction, no personal record, off the Coach snapshot and the correlations; readable over MCP and owned by the glucose module like glucose. - Entry: choosing blood ketones in the measurement form also takes the glucose from the same fingerstick, in the account's glucose unit. Both rows go through the array arm of POST /api/measurements with one timestamp, so they land in one transaction or not at all. - Index: glucose (mg/dL / 18.0182, the tree's one factor) over ketones, computed at read time and never stored. Each ketone reading above zero pairs with the glucose reading at the same instant, else the nearest one within 15 minutes either side; a tie goes to the same source, then to the earlier reading. Nothing pairs across a wider gap. - Surfaces: GET /api/measurements/series takes kind=ketones and kind=glucoseKetoneIndex (each point names the paired readings), and the day carries glucoseKetoneIndex when both reading types are visible. The new Ketones page charts the readings with the index beneath; the day view shows the index beside the value tiles; the doctor report, its PDF and the clinician share print it in the glucose group when both leaves are chosen, since beside the ketones it gives the glucose back. - FHIR: ketone readings export under LOINC 104816-4 (Beta hydroxybutyrate [Moles/volume] in Blood). The index is left out: it has no LOINC term, and both readings it is built from are in the bundle. - The measurement form's action row moved into its own component to keep the form inside its size budget. OpenAPI regenerated; all ten locales; the import unit table lists the type.
This commit is contained in:
1 parent
157809c24f
commit
f75337e6f2
75 files changed
+2133
-94
No files matched your search
@@ -23,6 +23,22 @@
|
||||
prevention line 109, which answers around the clock. Before, Korean
|
||||
fell through to the international set with 112, which reaches the
|
||||
police there, not an ambulance.
|
||||
- **Blood ketones, and the glucose-ketone index worked out for you.**
|
||||
Ketones (beta-hydroxybutyrate, mmol/L) are a measurement of their own
|
||||
now, so a ketogenic diet no longer has to live in a custom metric with
|
||||
the index typed in by hand beside it. Choose "Blood ketones" when you
|
||||
add a measurement and type the glucose from the same fingerstick into
|
||||
the field below: both readings are saved together with one time, or
|
||||
neither is. The glucose-ketone index is glucose in mmol/L divided by
|
||||
ketones, taken from the glucose reading at the same time or, failing
|
||||
that, the nearest one within 15 minutes. It is never stored, so it
|
||||
follows any reading you edit or delete. It shows under the chart on the
|
||||
new Ketones page, in the day view, and beside the ketones in the doctor
|
||||
report and the clinician share; the FHIR export carries the ketone
|
||||
readings under LOINC 104816-4. Values are shown as recorded, with no
|
||||
band and no verdict. `GET /api/measurements/series` takes
|
||||
`kind=ketones` and `kind=glucoseKetoneIndex`, and `GET /api/day/{date}`
|
||||
carries `glucoseKetoneIndex`. Migration `0496_blood_ketones`. (#1367)
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+105
-8
@@ -4672,6 +4672,8 @@ paths:
|
||||
- restingHeartRate
|
||||
- heartRateVariability
|
||||
- vo2Max
|
||||
- ketones
|
||||
- glucoseKetoneIndex
|
||||
required: true
|
||||
- in: query
|
||||
name: days
|
||||
@@ -4811,6 +4813,7 @@ paths:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
@@ -5736,6 +5739,7 @@ paths:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
description: The metric.
|
||||
required: true
|
||||
description: The metric.
|
||||
@@ -5866,6 +5870,7 @@ paths:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
description: The metric.
|
||||
required: true
|
||||
description: The metric.
|
||||
@@ -28773,7 +28778,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 111
|
||||
maxItems: 112
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -28835,7 +28840,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 111
|
||||
maxItems: 112
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -29715,6 +29720,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
value:
|
||||
type: number
|
||||
measuredAt:
|
||||
@@ -31449,6 +31455,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
biomarkerId:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -31948,7 +31955,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 111
|
||||
maxItems: 112
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -32593,7 +32600,7 @@ components:
|
||||
- order
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 63
|
||||
maxItems: 64
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -32649,6 +32656,7 @@ components:
|
||||
- audio-events
|
||||
- daylight
|
||||
- blood-glucose
|
||||
- ketones
|
||||
- skin-temperature
|
||||
- wrist-temperature
|
||||
- mood
|
||||
@@ -33151,6 +33159,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy,
|
||||
flights, walking/running distance, VO2 max, body temperature).
|
||||
InsightsSettingsPutRequest:
|
||||
@@ -37690,6 +37699,7 @@ components:
|
||||
- scoreSleep
|
||||
- scoreStress
|
||||
- scoreStrain
|
||||
- bloodKetones
|
||||
description: Which chart's overlays are being written. Closed enum.
|
||||
prefs:
|
||||
type: object
|
||||
@@ -37879,6 +37889,7 @@ components:
|
||||
- scoreSleep
|
||||
- scoreStress
|
||||
- scoreStrain
|
||||
- bloodKetones
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
@@ -38851,7 +38862,7 @@ components:
|
||||
additionalProperties: false
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 63
|
||||
maxItems: 64
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -38907,6 +38918,7 @@ components:
|
||||
- audio-events
|
||||
- daylight
|
||||
- blood-glucose
|
||||
- ketones
|
||||
- skin-temperature
|
||||
- wrist-temperature
|
||||
- mood
|
||||
@@ -42716,6 +42728,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
value:
|
||||
type: number
|
||||
valueMin:
|
||||
@@ -43484,6 +43497,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
- type: "null"
|
||||
biomarkerName:
|
||||
anyOf:
|
||||
@@ -46082,6 +46096,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
dateKey:
|
||||
type: string
|
||||
pattern: ^\d{4}-\d{2}-\d{2}$
|
||||
@@ -47282,6 +47297,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
- type: "null"
|
||||
intervalDays:
|
||||
anyOf:
|
||||
@@ -47508,6 +47524,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
metricSlot:
|
||||
type:
|
||||
- string
|
||||
@@ -51030,10 +51047,12 @@ components:
|
||||
- restingHeartRate
|
||||
- heartRateVariability
|
||||
- vo2Max
|
||||
- ketones
|
||||
- glucoseKetoneIndex
|
||||
unit:
|
||||
type: string
|
||||
description: Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h`
|
||||
(per-night time-asleep in hours).
|
||||
(per-night time-asleep in hours); `ketones` is `mmol/L`; `glucoseKetoneIndex` is `ratio`.
|
||||
points:
|
||||
type: array
|
||||
items:
|
||||
@@ -51080,6 +51099,31 @@ components:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
gki:
|
||||
description: "#1367 — for `kind=glucoseKetoneIndex` only: the readings the index was computed from. `id` and `at` are
|
||||
the ketone reading's. Absent for every other kind."
|
||||
type: object
|
||||
properties:
|
||||
glucoseId:
|
||||
type: string
|
||||
description: The paired glucose reading's id.
|
||||
glucoseAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: The paired glucose reading's timestamp.
|
||||
glucoseMmol:
|
||||
type: number
|
||||
description: The paired glucose in mmol/L, one decimal.
|
||||
ketonesMmol:
|
||||
type: number
|
||||
description: The ketone reading (beta-hydroxybutyrate), mmol/L.
|
||||
required:
|
||||
- glucoseId
|
||||
- glucoseAt
|
||||
- glucoseMmol
|
||||
- ketonesMmol
|
||||
additionalProperties: false
|
||||
required:
|
||||
- id
|
||||
- at
|
||||
@@ -51137,7 +51181,10 @@ components:
|
||||
ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries
|
||||
the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its
|
||||
range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading.
|
||||
`stats` is always computed over the underlying readings, never over the buckets."
|
||||
`stats` is always computed over the underlying readings, never over the buckets. `glucoseKetoneIndex` (#1367) is
|
||||
derived at read time, never stored: one point per ketone reading above zero that has a glucose reading at the
|
||||
same instant or, failing that, the nearest one within 15 minutes either side; value = glucose (mmol/L, from
|
||||
mg/dL by 18.0182) / ketones (mmol/L), one decimal. Draw it as given; do not recompute it."
|
||||
TrendVerdict:
|
||||
type: object
|
||||
properties:
|
||||
@@ -53823,6 +53870,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
@@ -54103,6 +54151,7 @@ components:
|
||||
- TRAINING_READINESS
|
||||
- ACUTE_TRAINING_LOAD
|
||||
- BASAL_ENERGY_BURNED
|
||||
- BLOOD_KETONES
|
||||
from:
|
||||
type: string
|
||||
format: date
|
||||
@@ -64552,7 +64601,7 @@ components:
|
||||
additionalProperties: false
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 63
|
||||
maxItems: 64
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -64608,6 +64657,7 @@ components:
|
||||
- audio-events
|
||||
- daylight
|
||||
- blood-glucose
|
||||
- ketones
|
||||
- skin-temperature
|
||||
- wrist-temperature
|
||||
- mood
|
||||
@@ -88209,6 +88259,7 @@ components:
|
||||
- scoreSleep
|
||||
- scoreStress
|
||||
- scoreStrain
|
||||
- bloodKetones
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
@@ -88475,6 +88526,7 @@ components:
|
||||
- scoreSleep
|
||||
- scoreStress
|
||||
- scoreStrain
|
||||
- bloodKetones
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
@@ -93849,6 +93901,14 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DayValue"
|
||||
glucoseKetoneIndex:
|
||||
description: "#1367 — the day's glucose-ketone index, oldest first: glucose (mmol/L) over ketones (mmol/L) for each
|
||||
ketone reading above zero with a glucose reading at the same instant or within 15 minutes either side.
|
||||
Computed at read time; show it as given. Absent when the day has none, when either reading type is hidden
|
||||
from the caller, and on older servers."
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DayGlucoseKetoneIndex"
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
@@ -94053,6 +94113,43 @@ components:
|
||||
- source
|
||||
- band
|
||||
additionalProperties: false
|
||||
DayGlucoseKetoneIndex:
|
||||
type: object
|
||||
properties:
|
||||
at:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: The ketone reading's instant.
|
||||
value:
|
||||
type: number
|
||||
description: The index, to one decimal.
|
||||
ketoneId:
|
||||
type: string
|
||||
description: The ketone reading's id.
|
||||
glucoseId:
|
||||
type: string
|
||||
description: The paired glucose reading's id.
|
||||
glucoseAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: The paired glucose reading's instant.
|
||||
ketonesMmol:
|
||||
type: number
|
||||
description: Beta-hydroxybutyrate, mmol/L.
|
||||
glucoseMmol:
|
||||
type: number
|
||||
description: The paired glucose in mmol/L, one decimal.
|
||||
required:
|
||||
- at
|
||||
- value
|
||||
- ketoneId
|
||||
- glucoseId
|
||||
- glucoseAt
|
||||
- ketonesMmol
|
||||
- glucoseMmol
|
||||
additionalProperties: false
|
||||
DayEvent:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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.",
|
||||
@@ -13687,6 +13708,10 @@
|
||||
"valuesAllFew": "Alle {count} Werte",
|
||||
"valuesAllOther": "Alle {count} Werte",
|
||||
"valuesFewer": "Weniger Werte",
|
||||
"gkiPair": "Blutzucker {glucose} / Ketone {ketones}",
|
||||
"gkiCountOne": "Letzter von {count}",
|
||||
"gkiCountFew": "Letzter von {count}",
|
||||
"gkiCountOther": "Letzter von {count}",
|
||||
"countEntriesOne": "1 Eintrag",
|
||||
"countEntriesFew": "{count} Einträge",
|
||||
"countEntriesOther": "{count} Einträge",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Sleep",
|
||||
"typeSteps": "Steps",
|
||||
"typeBloodGlucose": "Blood glucose",
|
||||
"typeBloodKetones": "Blood ketones",
|
||||
"glucoseKetoneIndex": "Glucose-ketone index",
|
||||
"pairedGlucoseLabel": "Glucose from the same reading ({unit})",
|
||||
"pairedGlucoseHint": "Saved as its own glucose reading at the same time; the glucose-ketone index is worked out from the pair.",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"typeOxygenSaturation": "Oxygen saturation",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucose",
|
||||
"navBloodKetones": "Ketones",
|
||||
"bloodKetones": {
|
||||
"title": "Blood Ketones",
|
||||
"description": "Your beta-hydroxybutyrate readings over time, in {unit}.",
|
||||
"chartTitle": "Blood ketones",
|
||||
"emptyState": {
|
||||
"title": "No ketone readings yet",
|
||||
"description": "Ketone readings appear here once you log one, on its own or with the glucose from the same fingerstick.",
|
||||
"cta": "Log ketones"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glucose divided by ketones, for each ketone reading with a glucose reading within 15 minutes.",
|
||||
"emptyTitle": "No index yet",
|
||||
"emptyDescription": "It appears once a ketone reading has a glucose reading taken at the same time or within 15 minutes.",
|
||||
"latest": "Latest {value}: glucose {glucose} {unit} over ketones {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Total Body Water",
|
||||
"description": "Your total body water over time, in kilograms.",
|
||||
@@ -13687,6 +13708,10 @@
|
||||
"valuesAllFew": "All {count} values",
|
||||
"valuesAllOther": "All {count} values",
|
||||
"valuesFewer": "Fewer values",
|
||||
"gkiPair": "Glucose {glucose} / ketones {ketones}",
|
||||
"gkiCountOne": "Latest of {count}",
|
||||
"gkiCountFew": "Latest of {count}",
|
||||
"gkiCountOther": "Latest of {count}",
|
||||
"countEntriesOne": "1 entry",
|
||||
"countEntriesFew": "{count} entries",
|
||||
"countEntriesOther": "{count} entries",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Sueño",
|
||||
"typeSteps": "Pasos",
|
||||
"typeBloodGlucose": "Glucemia",
|
||||
"typeBloodKetones": "Cetonas en sangre",
|
||||
"glucoseKetoneIndex": "Índice glucosa-cetonas",
|
||||
"pairedGlucoseLabel": "Glucosa de la misma medición ({unit})",
|
||||
"pairedGlucoseHint": "Se guarda como una lectura de glucosa propia con la misma hora; el índice glucosa-cetonas se calcula a partir del par.",
|
||||
"typeTotalBodyWater": "Agua corporal total",
|
||||
"typeBoneMass": "Masa ósea",
|
||||
"typeOxygenSaturation": "Saturación de oxígeno",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucosa",
|
||||
"navBloodKetones": "Cetonas",
|
||||
"bloodKetones": {
|
||||
"title": "Cetonas en sangre",
|
||||
"description": "Tus lecturas de beta-hidroxibutirato en el tiempo, en {unit}.",
|
||||
"chartTitle": "Cetonas en sangre",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay lecturas de cetonas",
|
||||
"description": "Las lecturas de cetonas aparecen aquí cuando registras una, sola o con la glucosa del mismo pinchazo.",
|
||||
"cta": "Registrar cetonas"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glucosa dividida por cetonas, para cada lectura de cetonas con una de glucosa en un margen de 15 minutos.",
|
||||
"emptyTitle": "Aún no hay índice",
|
||||
"emptyDescription": "Aparece cuando una lectura de cetonas tiene una de glucosa tomada a la misma hora o en un margen de 15 minutos.",
|
||||
"latest": "Último {value}: glucosa {glucose} {unit} entre cetonas {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Agua corporal total",
|
||||
"description": "Tu agua corporal total en el tiempo, en kilogramos.",
|
||||
@@ -13687,6 +13708,10 @@
|
||||
"valuesAllFew": "Los {count} valores",
|
||||
"valuesAllOther": "Los {count} valores",
|
||||
"valuesFewer": "Menos valores",
|
||||
"gkiPair": "Glucosa {glucose} / cetonas {ketones}",
|
||||
"gkiCountOne": "Último de {count}",
|
||||
"gkiCountFew": "Último de {count}",
|
||||
"gkiCountOther": "Último de {count}",
|
||||
"countEntriesOne": "1 entrada",
|
||||
"countEntriesFew": "{count} entradas",
|
||||
"countEntriesOther": "{count} entradas",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Sommeil",
|
||||
"typeSteps": "Pas",
|
||||
"typeBloodGlucose": "Glycémie",
|
||||
"typeBloodKetones": "Cétones sanguines",
|
||||
"glucoseKetoneIndex": "Indice glucose-cétones",
|
||||
"pairedGlucoseLabel": "Glycémie de la même mesure ({unit})",
|
||||
"pairedGlucoseHint": "Enregistrée comme un relevé de glycémie à la même heure ; l’indice glucose-cétones est calculé à partir de la paire.",
|
||||
"typeTotalBodyWater": "Eau corporelle totale",
|
||||
"typeBoneMass": "Masse osseuse",
|
||||
"typeOxygenSaturation": "Saturation en oxygène",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucose",
|
||||
"navBloodKetones": "Cétones",
|
||||
"bloodKetones": {
|
||||
"title": "Cétones sanguines",
|
||||
"description": "Vos relevés de bêta-hydroxybutyrate au fil du temps, en {unit}.",
|
||||
"chartTitle": "Cétones sanguines",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de relevé de cétones",
|
||||
"description": "Les relevés de cétones apparaissent ici dès que vous en saisissez un, seul ou avec la glycémie de la même piqûre.",
|
||||
"cta": "Enregistrer les cétones"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glycémie divisée par les cétones, pour chaque relevé de cétones accompagné d’une glycémie à 15 minutes près.",
|
||||
"emptyTitle": "Pas encore d’indice",
|
||||
"emptyDescription": "Il apparaît dès qu’un relevé de cétones a une glycémie prise au même moment ou à 15 minutes près.",
|
||||
"latest": "Dernier {value} : glycémie {glucose} {unit} sur cétones {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Eau corporelle totale",
|
||||
"description": "Votre eau corporelle totale au fil du temps, en kilogrammes.",
|
||||
@@ -13687,6 +13708,10 @@
|
||||
"valuesAllFew": "Les {count} valeurs",
|
||||
"valuesAllOther": "Les {count} valeurs",
|
||||
"valuesFewer": "Moins de valeurs",
|
||||
"gkiPair": "Glycémie {glucose} / cétones {ketones}",
|
||||
"gkiCountOne": "Dernier sur {count}",
|
||||
"gkiCountFew": "Dernier sur {count}",
|
||||
"gkiCountOther": "Dernier sur {count}",
|
||||
"countEntriesOne": "{count} entrée",
|
||||
"countEntriesFew": "{count} entrées",
|
||||
"countEntriesOther": "{count} entrées",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Sonno",
|
||||
"typeSteps": "Passi",
|
||||
"typeBloodGlucose": "Glicemia",
|
||||
"typeBloodKetones": "Chetoni nel sangue",
|
||||
"glucoseKetoneIndex": "Indice glucosio-chetoni",
|
||||
"pairedGlucoseLabel": "Glicemia della stessa misurazione ({unit})",
|
||||
"pairedGlucoseHint": "Salvata come lettura di glicemia a sé con lo stesso orario; l’indice glucosio-chetoni si ricava dalla coppia.",
|
||||
"typeTotalBodyWater": "Acqua corporea totale",
|
||||
"typeBoneMass": "Massa ossea",
|
||||
"typeOxygenSaturation": "Saturazione di ossigeno",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucosio",
|
||||
"navBloodKetones": "Chetoni",
|
||||
"bloodKetones": {
|
||||
"title": "Chetoni nel sangue",
|
||||
"description": "Le tue letture di beta-idrossibutirrato nel tempo, in {unit}.",
|
||||
"chartTitle": "Chetoni nel sangue",
|
||||
"emptyState": {
|
||||
"title": "Ancora nessuna lettura di chetoni",
|
||||
"description": "Le letture di chetoni appaiono qui quando ne registri una, da sola o con la glicemia della stessa puntura.",
|
||||
"cta": "Registra i chetoni"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glicemia divisa per i chetoni, per ogni lettura di chetoni con una glicemia entro 15 minuti.",
|
||||
"emptyTitle": "Ancora nessun indice",
|
||||
"emptyDescription": "Compare quando una lettura di chetoni ha una glicemia presa allo stesso momento o entro 15 minuti.",
|
||||
"latest": "Ultimo {value}: glicemia {glucose} {unit} su chetoni {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Acqua corporea totale",
|
||||
"description": "La tua acqua corporea totale nel tempo, in chilogrammi.",
|
||||
@@ -13687,6 +13708,10 @@
|
||||
"valuesAllFew": "Tutti i {count} valori",
|
||||
"valuesAllOther": "Tutti i {count} valori",
|
||||
"valuesFewer": "Meno valori",
|
||||
"gkiPair": "Glicemia {glucose} / chetoni {ketones}",
|
||||
"gkiCountOne": "Ultimo di {count}",
|
||||
"gkiCountFew": "Ultimo di {count}",
|
||||
"gkiCountOther": "Ultimo di {count}",
|
||||
"countEntriesOne": "1 voce",
|
||||
"countEntriesFew": "{count} voci",
|
||||
"countEntriesOther": "{count} voci",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "수면",
|
||||
"typeSteps": "걸음 수",
|
||||
"typeBloodGlucose": "혈당",
|
||||
"typeBloodKetones": "혈중 케톤",
|
||||
"glucoseKetoneIndex": "포도당-케톤 지수",
|
||||
"pairedGlucoseLabel": "같은 측정의 혈당 ({unit})",
|
||||
"pairedGlucoseHint": "같은 시각의 별도 혈당 기록으로 저장되고, 두 값으로 포도당-케톤 지수를 계산해요.",
|
||||
"typeTotalBodyWater": "체수분",
|
||||
"typeBoneMass": "골량",
|
||||
"typeOxygenSaturation": "산소포화도",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "혈당",
|
||||
"navBloodKetones": "케톤",
|
||||
"bloodKetones": {
|
||||
"title": "혈중 케톤",
|
||||
"description": "시간에 따른 베타-하이드록시부티르산 측정값이에요({unit}).",
|
||||
"chartTitle": "혈중 케톤",
|
||||
"emptyState": {
|
||||
"title": "아직 케톤 측정값이 없어요",
|
||||
"description": "케톤을 단독으로 또는 같은 채혈의 혈당과 함께 기록하면 여기에 측정값이 나타나요.",
|
||||
"cta": "케톤 기록"
|
||||
},
|
||||
"gki": {
|
||||
"description": "15분 이내에 혈당 측정값이 있는 케톤 측정마다 혈당을 케톤으로 나눈 값이에요.",
|
||||
"emptyTitle": "아직 지수가 없어요",
|
||||
"emptyDescription": "케톤 측정과 같은 시각이나 15분 이내에 잰 혈당이 있으면 나타나요.",
|
||||
"latest": "최근 {value}: 혈당 {glucose} {unit} ÷ 케톤 {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "총 체수분",
|
||||
"description": "시간에 따른 총 체수분이에요(kg).",
|
||||
@@ -13687,6 +13708,10 @@
|
||||
"valuesAllFew": "수치 {count}개 모두",
|
||||
"valuesAllOther": "수치 {count}개 모두",
|
||||
"valuesFewer": "수치 줄여 보기",
|
||||
"gkiPair": "혈당 {glucose} / 케톤 {ketones}",
|
||||
"gkiCountOne": "{count}개 중 최근",
|
||||
"gkiCountFew": "{count}개 중 최근",
|
||||
"gkiCountOther": "{count}개 중 최근",
|
||||
"countEntriesOne": "기록 1개",
|
||||
"countEntriesFew": "기록 {count}개",
|
||||
"countEntriesOther": "기록 {count}개",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Slaap",
|
||||
"typeSteps": "Stappen",
|
||||
"typeBloodGlucose": "Bloedglucose",
|
||||
"typeBloodKetones": "Bloedketonen",
|
||||
"glucoseKetoneIndex": "Glucose-ketonindex",
|
||||
"pairedGlucoseLabel": "Glucose uit dezelfde meting ({unit})",
|
||||
"pairedGlucoseHint": "Wordt als eigen glucosemeting met hetzelfde tijdstip opgeslagen; uit het paar volgt de glucose-ketonindex.",
|
||||
"typeTotalBodyWater": "Totaal lichaamswater",
|
||||
"typeBoneMass": "Botmassa",
|
||||
"typeOxygenSaturation": "Zuurstofsaturatie",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucose",
|
||||
"navBloodKetones": "Ketonen",
|
||||
"bloodKetones": {
|
||||
"title": "Bloedketonen",
|
||||
"description": "Je bèta-hydroxybutyraatmetingen in de loop van de tijd, in {unit}.",
|
||||
"chartTitle": "Bloedketonen",
|
||||
"emptyState": {
|
||||
"title": "Nog geen ketonmetingen",
|
||||
"description": "Ketonmetingen verschijnen hier zodra je er een vastlegt, los of met de glucose uit dezelfde vingerprik.",
|
||||
"cta": "Ketonen vastleggen"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glucose gedeeld door ketonen, voor elke ketonmeting met een glucosemeting binnen 15 minuten.",
|
||||
"emptyTitle": "Nog geen index",
|
||||
"emptyDescription": "Hij verschijnt zodra een ketonmeting een glucosemeting op hetzelfde moment of binnen 15 minuten heeft.",
|
||||
"latest": "Laatst {value}: glucose {glucose} {unit} gedeeld door ketonen {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Totaal lichaamswater",
|
||||
"description": "Je totale lichaamswater in de loop van de tijd, in kilogram.",
|
||||
@@ -13687,6 +13708,10 @@
|
||||
"valuesAllFew": "Alle {count} waarden",
|
||||
"valuesAllOther": "Alle {count} waarden",
|
||||
"valuesFewer": "Minder waarden",
|
||||
"gkiPair": "Glucose {glucose} / ketonen {ketones}",
|
||||
"gkiCountOne": "Laatste van {count}",
|
||||
"gkiCountFew": "Laatste van {count}",
|
||||
"gkiCountOther": "Laatste van {count}",
|
||||
"countEntriesOne": "1 registratie",
|
||||
"countEntriesFew": "{count} registraties",
|
||||
"countEntriesOther": "{count} registraties",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Sen",
|
||||
"typeSteps": "Kroki",
|
||||
"typeBloodGlucose": "Glikemia",
|
||||
"typeBloodKetones": "Ketony we krwi",
|
||||
"glucoseKetoneIndex": "Indeks glukozowo-ketonowy",
|
||||
"pairedGlucoseLabel": "Glukoza z tego samego pomiaru ({unit})",
|
||||
"pairedGlucoseHint": "Zapisywana jako osobny odczyt glukozy z tą samą godziną; z tej pary obliczany jest indeks glukozowo-ketonowy.",
|
||||
"typeTotalBodyWater": "Całkowita woda w organizmie",
|
||||
"typeBoneMass": "Masa kostna",
|
||||
"typeOxygenSaturation": "Saturacja tlenem",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glukoza",
|
||||
"navBloodKetones": "Ketony",
|
||||
"bloodKetones": {
|
||||
"title": "Ketony we krwi",
|
||||
"description": "Twoje odczyty beta-hydroksymaślanu w czasie, w {unit}.",
|
||||
"chartTitle": "Ketony we krwi",
|
||||
"emptyState": {
|
||||
"title": "Brak jeszcze odczytów ketonów",
|
||||
"description": "Odczyty ketonów pojawią się tutaj po zapisaniu jednego, osobno lub z glukozą z tego samego nakłucia.",
|
||||
"cta": "Zapisz ketony"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glukoza podzielona przez ketony, dla każdego odczytu ketonów z odczytem glukozy w ciągu 15 minut.",
|
||||
"emptyTitle": "Brak jeszcze indeksu",
|
||||
"emptyDescription": "Pojawi się, gdy do odczytu ketonów będzie odczyt glukozy z tej samej chwili lub w ciągu 15 minut.",
|
||||
"latest": "Ostatnio {value}: glukoza {glucose} {unit} przez ketony {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Całkowita woda w organizmie",
|
||||
"description": "Twoja całkowita woda w organizmie w czasie, w kilogramach.",
|
||||
@@ -13687,6 +13708,10 @@
|
||||
"valuesAllFew": "Wszystkie {count} wartości",
|
||||
"valuesAllOther": "Wszystkie {count} wartości",
|
||||
"valuesFewer": "Mniej wartości",
|
||||
"gkiPair": "Glukoza {glucose} / ketony {ketones}",
|
||||
"gkiCountOne": "Ostatni z {count}",
|
||||
"gkiCountFew": "Ostatni z {count}",
|
||||
"gkiCountOther": "Ostatni z {count}",
|
||||
"countEntriesOne": "1 wpis",
|
||||
"countEntriesFew": "{count} wpisy",
|
||||
"countEntriesOther": "{count} wpisów",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Sono",
|
||||
"typeSteps": "Passos",
|
||||
"typeBloodGlucose": "Glicemia",
|
||||
"typeBloodKetones": "Cetonas no sangue",
|
||||
"glucoseKetoneIndex": "Índice glicose-cetona",
|
||||
"pairedGlucoseLabel": "Glicose da mesma medição ({unit})",
|
||||
"pairedGlucoseHint": "Salva como uma leitura de glicose própria no mesmo horário; o índice glicose-cetona é calculado a partir do par.",
|
||||
"typeTotalBodyWater": "Água corporal total",
|
||||
"typeBoneMass": "Massa óssea",
|
||||
"typeOxygenSaturation": "Saturação de oxigênio",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glicose",
|
||||
"navBloodKetones": "Cetonas",
|
||||
"bloodKetones": {
|
||||
"title": "Cetonas no sangue",
|
||||
"description": "Suas leituras de beta-hidroxibutirato ao longo do tempo, em {unit}.",
|
||||
"chartTitle": "Cetonas no sangue",
|
||||
"emptyState": {
|
||||
"title": "Ainda não há leituras de cetonas",
|
||||
"description": "As leituras de cetonas aparecem aqui assim que você registra uma, sozinha ou com a glicose da mesma picada.",
|
||||
"cta": "Registrar cetonas"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Glicose dividida pelas cetonas, para cada leitura de cetonas com uma de glicose em até 15 minutos.",
|
||||
"emptyTitle": "Ainda não há índice",
|
||||
"emptyDescription": "Ele aparece quando uma leitura de cetonas tem uma de glicose feita no mesmo horário ou em até 15 minutos.",
|
||||
"latest": "Último {value}: glicose {glucose} {unit} sobre cetonas {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Água Corporal Total",
|
||||
"description": "Sua água corporal total ao longo do tempo, em quilogramas.",
|
||||
@@ -13687,6 +13708,10 @@
|
||||
"valuesAllFew": "Todos os {count} valores",
|
||||
"valuesAllOther": "Todos os {count} valores",
|
||||
"valuesFewer": "Menos valores",
|
||||
"gkiPair": "Glicose {glucose} / cetonas {ketones}",
|
||||
"gkiCountOne": "Último de {count}",
|
||||
"gkiCountFew": "Último de {count}",
|
||||
"gkiCountOther": "Último de {count}",
|
||||
"countEntriesOne": "{count} entrada",
|
||||
"countEntriesFew": "{count} entradas",
|
||||
"countEntriesOther": "{count} entradas",
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Сон",
|
||||
"typeSteps": "Шаги",
|
||||
"typeBloodGlucose": "Глюкоза крови",
|
||||
"typeBloodKetones": "Кетоны в крови",
|
||||
"glucoseKetoneIndex": "Глюкозо-кетоновый индекс",
|
||||
"pairedGlucoseLabel": "Глюкоза из того же измерения ({unit})",
|
||||
"pairedGlucoseHint": "Сохраняется как отдельное измерение глюкозы с тем же временем; по этой паре рассчитывается глюкозо-кетоновый индекс.",
|
||||
"typeTotalBodyWater": "Общая вода в организме",
|
||||
"typeBoneMass": "Костная масса",
|
||||
"typeOxygenSaturation": "Насыщение кислородом",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Глюкоза",
|
||||
"navBloodKetones": "Кетоны",
|
||||
"bloodKetones": {
|
||||
"title": "Кетоны в крови",
|
||||
"description": "Ваши измерения бета-гидроксибутирата в динамике, в {unit}.",
|
||||
"chartTitle": "Кетоны в крови",
|
||||
"emptyState": {
|
||||
"title": "Измерений кетонов пока нет",
|
||||
"description": "Измерения кетонов появятся здесь, как только вы запишете одно, отдельно или вместе с глюкозой из того же прокола.",
|
||||
"cta": "Записать кетоны"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Глюкоза, делённая на кетоны, для каждого измерения кетонов с измерением глюкозы в пределах 15 минут.",
|
||||
"emptyTitle": "Индекса пока нет",
|
||||
"emptyDescription": "Он появится, когда к измерению кетонов будет измерение глюкозы в то же время или в пределах 15 минут.",
|
||||
"latest": "Последний {value}: глюкоза {glucose} {unit} на кетоны {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Общая вода в организме",
|
||||
"description": "Ваша общая вода в организме в динамике, в килограммах.",
|
||||
@@ -13687,6 +13708,10 @@
|
||||
"valuesAllFew": "Все {count} значения",
|
||||
"valuesAllOther": "Все {count} значений",
|
||||
"valuesFewer": "Меньше значений",
|
||||
"gkiPair": "Глюкоза {glucose} / кетоны {ketones}",
|
||||
"gkiCountOne": "Последний из {count}",
|
||||
"gkiCountFew": "Последний из {count}",
|
||||
"gkiCountOther": "Последний из {count}",
|
||||
"countEntriesOne": "{count} запись",
|
||||
"countEntriesFew": "Записей: {count}",
|
||||
"countEntriesOther": "Записей: {count}",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Extend `measurement_type` with blood ketones (#1367):
|
||||
--
|
||||
-- BLOOD_KETONES <- beta-hydroxybutyrate from a blood ketone meter (mmol/L)
|
||||
--
|
||||
-- Purely additive: no existing row carries the value, and nothing in this
|
||||
-- file uses it, so the rule that a value added in a transaction cannot be used
|
||||
-- in that same transaction never comes into play. IF NOT EXISTS keeps a replay
|
||||
-- on a partially applied database harmless. Enum values cannot be dropped in
|
||||
-- PostgreSQL, so there is no down path. The glucose-ketone index is computed
|
||||
-- at read time and has no column.
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BLOOD_KETONES';
|
||||
@@ -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