feat(glucose): add the clinical glucose panel

Surface a clinical panel beneath the blood-glucose chart: time-in-range on the
consensus bands, the glucose management indicator and estimated A1C, and the
coefficient of variation with its instability flag by default, and the J-index
and the low and high blood-glucose risk indices with a per-context breakdown
behind an advanced disclosure. Below the calibration threshold it shows a calm
learning state, and it always carries the spot-reading caveat. The analytics
read, the dashboard snapshot, the coach and the doctor report all consume one
computed result over a fixed window, so the panel, the assistant and the
printed report cannot disagree.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 19:21:25 +02:00
commit e561f8d893
26 files changed
+1868 -2

No files matched your search

+136
View File
@@ -13837,6 +13837,8 @@ components:
propertyNames:
type: string
additionalProperties: {}
glucoseClinical:
$ref: "#/components/schemas/GlucoseClinicalMetrics"
required:
- bpInTargetPct
- bpInTargetPct7d
@@ -13845,6 +13847,7 @@ components:
- bpInTargetPctPriorMonth
- bpInTargetPctPriorYear
- glucoseByContext
- glucoseClinical
additionalProperties: false
- type: "null"
medsToday:
@@ -13966,6 +13969,139 @@ components:
`briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per
metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive
in-process from data already fetched, adding no DB round-trip."
GlucoseClinicalMetrics:
type: object
properties:
stillLearning:
type: boolean
stillLearningReason:
anyOf:
- type: string
- type: "null"
windowDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
actualSpanDays:
type: number
readingCount:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
meanMgdl:
anyOf:
- type: number
- type: "null"
distribution:
anyOf:
- type: object
properties:
tir:
type: number
tbrLevel1:
type: number
tbrLevel2:
type: number
tarLevel1:
type: number
tarLevel2:
type: number
minutesEquivalent:
type: object
properties:
tir:
type: number
tbrLevel1:
type: number
tbrLevel2:
type: number
tarLevel1:
type: number
tarLevel2:
type: number
required:
- tir
- tbrLevel1
- tbrLevel2
- tarLevel1
- tarLevel2
additionalProperties: false
required:
- tir
- tbrLevel1
- tbrLevel2
- tarLevel1
- tarLevel2
- minutesEquivalent
additionalProperties: false
- type: "null"
gmi:
anyOf:
- type: number
- type: "null"
estimatedA1c:
anyOf:
- type: number
- type: "null"
variability:
anyOf:
- type: object
properties:
sd:
type: number
cv:
type: number
unstable:
type: boolean
required:
- sd
- cv
- unstable
additionalProperties: false
- type: "null"
advanced:
anyOf:
- type: object
properties:
jIndex:
anyOf:
- type: number
- type: "null"
lbgi:
type: number
hbgi:
type: number
required:
- jIndex
- lbgi
- hbgi
additionalProperties: false
- type: "null"
isSpotEstimate:
type: boolean
const: true
required:
- stillLearning
- stillLearningReason
- windowDays
- actualSpanDays
- readingCount
- meanMgdl
- distribution
- gmi
- estimatedA1c
- variability
- advanced
- isSpotEstimate
additionalProperties: false
description: Server-authoritative glucose clinical panel over the trailing 30-day window. Every figure is a SPOT-READING
ESTIMATE (a % of readings), not a CGM time-in-range AGP — `isSpotEstimate` is always true and `stillLearning`
gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino
2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal
2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017
≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev
hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading
window.
InsightsComprehensiveResponseEnvelope:
type: object
properties:
+65
View File
@@ -2826,6 +2826,71 @@
"emptyState": {
"title": "Noch keine Glukosedaten",
"description": "Blutzuckerwerte erscheinen hier, sobald du einen erfasst oder ein Gerät synchronisiert."
},
"clinical": {
"title": "Glukose-Überblick",
"subtitle": "Ein Blick auf deine letzten {days} Tage, aus {count} Messungen.",
"spotCaveat": "Das sind Schätzungen aus einzelnen Stichproben-Messungen, kein Profil eines kontinuierlichen Sensors – lies sie als Richtung, nicht als klinisches Urteil.",
"learningTitle": "Lerne deine Glukose noch kennen",
"learningBody": "Bisher sind das {count} über {days}. Mit etwas mehr Verlauf wird das Bild klarer – mess weiter oder lass es ein verbundenes Gerät ergänzen.",
"learningBodyEmpty": "Sobald ein paar Messungen vorliegen – von dir erfasst oder von einem Gerät synchronisiert – nehmen Zeit-im-Zielbereich, Durchschnitt und Schwankung hier Gestalt an.",
"readingsCount": "{count} Messungen",
"readingsCountOne": "1 Messung",
"daysCount": "{days} Tage",
"daysCountOne": "1 Tag",
"tir": {
"title": "Zeit im Zielbereich",
"caption": "Anteil der Messungen je Bereich (70–180 mg/dl ist das Ziel).",
"veryLow": "Sehr niedrig",
"low": "Niedrig",
"inRange": "Im Zielbereich",
"high": "Hoch",
"veryHigh": "Sehr hoch",
"ariaLabel": "Zeit im Zielbereich: {tir}% im Ziel, {below}% darunter, {above}% darüber.",
"empty": "Noch keine Messungen für das Diagramm."
},
"gmi": {
"label": "Glukose-Management-Indikator",
"help": "Eine HbA1c-ähnliche Schätzung aus deiner durchschnittlichen Glukose."
},
"eA1c": {
"label": "Geschätzter HbA1c",
"help": "Eine HbA1c-Schätzung aus deinem mittleren Glukosewert (ADAG)."
},
"mean": {
"label": "Durchschnittliche Glukose"
},
"cv": {
"label": "Schwankung",
"steady": "Stabil",
"unstable": "Instabil",
"help": "Variationskoeffizient. Ab 36 % gilt die Glukose als instabil; niedriger ist stabiler."
},
"advanced": {
"toggle": "Erweiterte Indizes",
"intro": "Forschungsnahe Kennzahlen für den genaueren Blick. Jede fasst deine Messungen zu einer risikogewichteten Zahl zusammen.",
"jIndex": {
"label": "J-Index",
"help": "Ein einzelner Wert aus Durchschnitt und Streuung – niedriger steht für engere Kontrolle."
},
"lbgi": {
"label": "Risiko für Tiefwerte (LBGI)",
"help": "Kovatchevs Index, gewichtet danach, wie oft und wie tief Werte ins Tief rutschen."
},
"hbgi": {
"label": "Risiko für Hochwerte (HBGI)",
"help": "Kovatchevs Index, gewichtet danach, wie oft und wie hoch Werte steigen."
},
"byContext": {
"title": "Nach Kontext",
"fasting": "Nüchtern",
"postprandial": "Nach dem Essen",
"random": "Beliebig",
"bedtime": "Vor dem Schlafen",
"readingsShort": "{count} Messungen",
"readingsShortOne": "1 Messung"
}
}
}
},
"navBloodGlucose": "Glukose",
+65
View File
@@ -2826,6 +2826,71 @@
"emptyState": {
"title": "No glucose data yet",
"description": "Blood-glucose readings appear here once you log one or a connected device syncs."
},
"clinical": {
"title": "Glucose overview",
"subtitle": "A read on your last {days} days, from {count} readings.",
"spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile — read them as a direction of travel rather than a clinical verdict.",
"learningTitle": "Still learning your glucose",
"learningBody": "So far that's {count} over {days}. A little more history and a fuller picture comes into focus — keep logging, or let a connected device fill it in.",
"learningBodyEmpty": "Once a handful of readings come in — logged by you or synced from a device — your time-in-range, average, and variability take shape here.",
"readingsCount": "{count} readings",
"readingsCountOne": "1 reading",
"daysCount": "{days} days",
"daysCountOne": "1 day",
"tir": {
"title": "Time in range",
"caption": "Share of readings in each band (70–180 mg/dL is the target).",
"veryLow": "Very low",
"low": "Low",
"inRange": "In range",
"high": "High",
"veryHigh": "Very high",
"ariaLabel": "Time in range: {tir}% in target, {below}% below, {above}% above.",
"empty": "No readings to chart yet."
},
"gmi": {
"label": "Glucose Management Indicator",
"help": "An A1C-like estimate derived from your average glucose."
},
"eA1c": {
"label": "Estimated A1C",
"help": "An A1C estimate from your mean glucose (ADAG)."
},
"mean": {
"label": "Average glucose"
},
"cv": {
"label": "Variability",
"steady": "Steady",
"unstable": "Unstable",
"help": "Coefficient of variation. At or above 36% glucose is considered unstable; lower is steadier."
},
"advanced": {
"toggle": "Advanced indices",
"intro": "Research-grade composites for a closer look. Each folds your readings into a single risk-weighted number.",
"jIndex": {
"label": "J-index",
"help": "A single score blending your average and its spread — lower reads as tighter control."
},
"lbgi": {
"label": "Low-glucose risk (LBGI)",
"help": "Kovatchev's index weighting how often and how deeply readings dip low."
},
"hbgi": {
"label": "High-glucose risk (HBGI)",
"help": "Kovatchev's index weighting how often and how high readings climb."
},
"byContext": {
"title": "By context",
"fasting": "Fasting",
"postprandial": "After meals",
"random": "Random",
"bedtime": "Bedtime",
"readingsShort": "{count} readings",
"readingsShortOne": "1 reading"
}
}
}
},
"navBloodGlucose": "Glucose",
+65
View File
@@ -2826,6 +2826,71 @@
"emptyState": {
"title": "Aún no hay datos de glucosa",
"description": "Las lecturas de glucosa aparecen aquí cuando registras una o un dispositivo sincroniza."
},
"clinical": {
"title": "Resumen de glucosa",
"subtitle": "Una lectura de tus últimos {days} días, a partir de {count} mediciones.",
"spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de un sensor continuo: léelas como una tendencia, no como un veredicto clínico.",
"learningTitle": "Aún conociendo tu glucosa",
"learningBody": "De momento son {count} en {days}. Con algo más de historial el panorama se aclara: sigue registrando o deja que lo complete un dispositivo conectado.",
"learningBodyEmpty": "En cuanto lleguen unas cuantas mediciones —registradas por ti o sincronizadas desde un dispositivo— tu tiempo en rango, promedio y variabilidad tomarán forma aquí.",
"readingsCount": "{count} mediciones",
"readingsCountOne": "1 medición",
"daysCount": "{days} días",
"daysCountOne": "1 día",
"tir": {
"title": "Tiempo en rango",
"caption": "Proporción de mediciones en cada banda (70–180 mg/dl es el objetivo).",
"veryLow": "Muy bajo",
"low": "Bajo",
"inRange": "En rango",
"high": "Alto",
"veryHigh": "Muy alto",
"ariaLabel": "Tiempo en rango: {tir}% en objetivo, {below}% por debajo, {above}% por encima.",
"empty": "Aún no hay mediciones para el gráfico."
},
"gmi": {
"label": "Indicador de gestión de la glucosa",
"help": "Una estimación tipo HbA1c derivada de tu glucosa promedio."
},
"eA1c": {
"label": "HbA1c estimada",
"help": "Una estimación de HbA1c a partir de tu glucosa media (ADAG)."
},
"mean": {
"label": "Glucosa promedio"
},
"cv": {
"label": "Variabilidad",
"steady": "Estable",
"unstable": "Inestable",
"help": "Coeficiente de variación. A partir del 36 % la glucosa se considera inestable; menos es más estable."
},
"advanced": {
"toggle": "Índices avanzados",
"intro": "Compuestos de nivel investigador para mirar más de cerca. Cada uno reduce tus mediciones a un único número ponderado por riesgo.",
"jIndex": {
"label": "Índice J",
"help": "Una puntuación única que combina tu promedio y su dispersión: cuanto más bajo, más ajustado el control."
},
"lbgi": {
"label": "Riesgo de hipoglucemia (LBGI)",
"help": "Índice de Kovatchev que pondera con qué frecuencia y profundidad bajan las lecturas."
},
"hbgi": {
"label": "Riesgo de hiperglucemia (HBGI)",
"help": "Índice de Kovatchev que pondera con qué frecuencia y altura suben las lecturas."
},
"byContext": {
"title": "Por contexto",
"fasting": "En ayunas",
"postprandial": "Después de comer",
"random": "Aleatorio",
"bedtime": "Antes de dormir",
"readingsShort": "{count} mediciones",
"readingsShortOne": "1 medición"
}
}
}
},
"navBloodGlucose": "Glucosa",
+65
View File
@@ -2826,6 +2826,71 @@
"emptyState": {
"title": "Aucune donnée de glycémie",
"description": "Les relevés de glycémie apparaissent ici dès que vous en saisissez un ou qu’un appareil se synchronise."
},
"clinical": {
"title": "Aperçu du glucose",
"subtitle": "Un point sur vos {days} derniers jours, à partir de {count} mesures.",
"spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, pas le profil d’un capteur continu : lisez-les comme une tendance, non comme un verdict clinique.",
"learningTitle": "Votre glucose se découvre encore",
"learningBody": "Pour l’instant, cela fait {count} sur {days}. Avec un peu plus d’historique, le tableau se précise : continuez à enregistrer ou laissez un appareil connecté compléter.",
"learningBodyEmpty": "Dès que quelques mesures arrivent — saisies par vous ou synchronisées depuis un appareil — votre temps dans la cible, votre moyenne et votre variabilité prennent forme ici.",
"readingsCount": "{count} mesures",
"readingsCountOne": "1 mesure",
"daysCount": "{days} jours",
"daysCountOne": "1 jour",
"tir": {
"title": "Temps dans la cible",
"caption": "Part des mesures dans chaque plage (70–180 mg/dl est la cible).",
"veryLow": "Très bas",
"low": "Bas",
"inRange": "Dans la cible",
"high": "Élevé",
"veryHigh": "Très élevé",
"ariaLabel": "Temps dans la cible : {tir}% dans la cible, {below}% en dessous, {above}% au-dessus.",
"empty": "Pas encore de mesures à afficher."
},
"gmi": {
"label": "Indicateur de gestion du glucose",
"help": "Une estimation de type HbA1c dérivée de votre glucose moyen."
},
"eA1c": {
"label": "HbA1c estimée",
"help": "Une estimation d’HbA1c à partir de votre glucose moyen (ADAG)."
},
"mean": {
"label": "Glucose moyen"
},
"cv": {
"label": "Variabilité",
"steady": "Stable",
"unstable": "Instable",
"help": "Coefficient de variation. À partir de 36 %, le glucose est considéré comme instable ; plus bas est plus stable."
},
"advanced": {
"toggle": "Indices avancés",
"intro": "Des indicateurs de niveau recherche pour regarder de plus près. Chacun condense vos mesures en un seul chiffre pondéré par le risque.",
"jIndex": {
"label": "Indice J",
"help": "Un score unique mêlant votre moyenne et sa dispersion — plus il est bas, plus le contrôle est serré."
},
"lbgi": {
"label": "Risque d’hypoglycémie (LBGI)",
"help": "Indice de Kovatchev pondérant la fréquence et la profondeur des baisses."
},
"hbgi": {
"label": "Risque d’hyperglycémie (HBGI)",
"help": "Indice de Kovatchev pondérant la fréquence et la hauteur des hausses."
},
"byContext": {
"title": "Par contexte",
"fasting": "À jeun",
"postprandial": "Après les repas",
"random": "Aléatoire",
"bedtime": "Au coucher",
"readingsShort": "{count} mesures",
"readingsShortOne": "1 mesure"
}
}
}
},
"navBloodGlucose": "Glucose",
+65
View File
@@ -2826,6 +2826,71 @@
"emptyState": {
"title": "Nessun dato di glicemia",
"description": "Le letture di glicemia appaiono qui quando ne registri una o un dispositivo si sincronizza."
},
"clinical": {
"title": "Panoramica della glicemia",
"subtitle": "Una lettura dei tuoi ultimi {days} giorni, da {count} misurazioni.",
"spotCaveat": "Sono stime da singole misurazioni puntuali, non il profilo di un sensore continuo: leggile come una tendenza, non come un verdetto clinico.",
"learningTitle": "Stiamo ancora imparando la tua glicemia",
"learningBody": "Per ora sono {count} su {days}. Con un po’ più di storico il quadro si chiarisce: continua a registrare o lascia che lo completi un dispositivo connesso.",
"learningBodyEmpty": "Appena arrivano alcune misurazioni — registrate da te o sincronizzate da un dispositivo — il tempo nel range, la media e la variabilità prendono forma qui.",
"readingsCount": "{count} misurazioni",
"readingsCountOne": "1 misurazione",
"daysCount": "{days} giorni",
"daysCountOne": "1 giorno",
"tir": {
"title": "Tempo nel range",
"caption": "Quota di misurazioni in ciascuna fascia (70–180 mg/dl è l’obiettivo).",
"veryLow": "Molto basso",
"low": "Basso",
"inRange": "Nel range",
"high": "Alto",
"veryHigh": "Molto alto",
"ariaLabel": "Tempo nel range: {tir}% a target, {below}% sotto, {above}% sopra.",
"empty": "Ancora nessuna misurazione da mostrare."
},
"gmi": {
"label": "Indicatore di gestione della glicemia",
"help": "Una stima simile all’HbA1c derivata dalla tua glicemia media."
},
"eA1c": {
"label": "HbA1c stimata",
"help": "Una stima dell’HbA1c dalla tua glicemia media (ADAG)."
},
"mean": {
"label": "Glicemia media"
},
"cv": {
"label": "Variabilità",
"steady": "Stabile",
"unstable": "Instabile",
"help": "Coefficiente di variazione. Dal 36 % la glicemia è considerata instabile; più basso è più stabile."
},
"advanced": {
"toggle": "Indici avanzati",
"intro": "Indicatori di livello scientifico per uno sguardo più ravvicinato. Ognuno riduce le tue misurazioni a un singolo numero ponderato sul rischio.",
"jIndex": {
"label": "Indice J",
"help": "Un punteggio unico che combina media e dispersione: più è basso, più stretto è il controllo."
},
"lbgi": {
"label": "Rischio di ipoglicemia (LBGI)",
"help": "Indice di Kovatchev che pesa quanto spesso e quanto in basso scendono le letture."
},
"hbgi": {
"label": "Rischio di iperglicemia (HBGI)",
"help": "Indice di Kovatchev che pesa quanto spesso e quanto in alto salgono le letture."
},
"byContext": {
"title": "Per contesto",
"fasting": "A digiuno",
"postprandial": "Dopo i pasti",
"random": "Casuale",
"bedtime": "Prima di dormire",
"readingsShort": "{count} misurazioni",
"readingsShortOne": "1 misurazione"
}
}
}
},
"navBloodGlucose": "Glucosio",
+65
View File
@@ -2826,6 +2826,71 @@
"emptyState": {
"title": "Brak danych o glukozie",
"description": "Odczyty glukozy pojawią się tutaj po zapisaniu jednego lub synchronizacji urządzenia."
},
"clinical": {
"title": "Przegląd glukozy",
"subtitle": "Spojrzenie na ostatnie {days} dni, na podstawie {count} pomiarów.",
"spotCaveat": "To szacunki z pojedynczych pomiarów punktowych, a nie profil z czujnika ciągłego — traktuj je jako kierunek, nie jako diagnozę.",
"learningTitle": "Wciąż poznajemy Twoją glukozę",
"learningBody": "Na razie to {count} w ciągu {days}. Przy nieco dłuższej historii obraz się wyostrza — rejestruj dalej lub pozwól uzupełnić to podłączonemu urządzeniu.",
"learningBodyEmpty": "Gdy pojawi się kilka pomiarów — wpisanych przez Ciebie lub zsynchronizowanych z urządzenia — czas w zakresie, średnia i zmienność nabiorą tu kształtu.",
"readingsCount": "{count} pomiarów",
"readingsCountOne": "1 pomiar",
"daysCount": "{days} dni",
"daysCountOne": "1 dzień",
"tir": {
"title": "Czas w zakresie",
"caption": "Udział pomiarów w każdym przedziale (70–180 mg/dl to cel).",
"veryLow": "Bardzo niski",
"low": "Niski",
"inRange": "W zakresie",
"high": "Wysoki",
"veryHigh": "Bardzo wysoki",
"ariaLabel": "Czas w zakresie: {tir}% w celu, {below}% poniżej, {above}% powyżej.",
"empty": "Brak pomiarów do wykresu."
},
"gmi": {
"label": "Wskaźnik zarządzania glukozą",
"help": "Szacunek zbliżony do HbA1c na podstawie średniej glukozy."
},
"eA1c": {
"label": "Szacowane HbA1c",
"help": "Szacunek HbA1c na podstawie średniej glukozy (ADAG)."
},
"mean": {
"label": "Średnia glukoza"
},
"cv": {
"label": "Zmienność",
"steady": "Stabilna",
"unstable": "Niestabilna",
"help": "Współczynnik zmienności. Od 36 % glukozę uznaje się za niestabilną; niżej znaczy stabilniej."
},
"advanced": {
"toggle": "Wskaźniki zaawansowane",
"intro": "Złożone miary o poziomie badawczym dla bliższego spojrzenia. Każda sprowadza Twoje pomiary do jednej liczby ważonej ryzykiem.",
"jIndex": {
"label": "Indeks J",
"help": "Pojedynczy wynik łączący średnią i jej rozrzut — niższy oznacza ściślejszą kontrolę."
},
"lbgi": {
"label": "Ryzyko niedocukrzeń (LBGI)",
"help": "Indeks Kovatcheva ważący, jak często i jak głęboko odczyty spadają."
},
"hbgi": {
"label": "Ryzyko przecukrzeń (HBGI)",
"help": "Indeks Kovatcheva ważący, jak często i jak wysoko odczyty rosną."
},
"byContext": {
"title": "Wg kontekstu",
"fasting": "Na czczo",
"postprandial": "Po posiłku",
"random": "Losowy",
"bedtime": "Przed snem",
"readingsShort": "{count} pomiarów",
"readingsShortOne": "1 pomiar"
}
}
}
},
"navBloodGlucose": "Glukoza",
+18
View File
@@ -20,6 +20,10 @@ import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fas
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
import {
computeGlucoseClinicalMetrics,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
export const dynamic = "force-dynamic";
@@ -365,6 +369,19 @@ async function buildAnalyticsResponse(user: AuthedUser) {
}
}
// v1.17.0 — server-authoritative glucose clinical panel. The TIR / GMI /
// eA1C / CV% headline and the advanced J-index + LBGI/HBGI tier are computed
// ONCE here over the declared 30-day window (matching `glucoseSince`) so the
// insights panel, the coach snapshot, and the doctor report all render the
// same numbers instead of each re-deriving. The learning gate keeps a thin
// spot-data window from being asserted as a clinical AGP. Always present in
// the payload (even with zero readings) so the client renders the calm
// "still learning" state from a populated object rather than a missing key.
const glucoseClinical: GlucoseClinicalMetrics = computeGlucoseClinicalMetrics(
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
{ windowDays: 30, now: new Date() },
);
// v1.4.20 phase B3 — three pre-defined correlation hypotheses.
// v1.4.37 W2 — probe-gated helper. The 28-day scan window (down
// from 30 to keep the cold critical path tight while still
@@ -420,6 +437,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
bpInTargetCount90,
bpInTargetSpanDays90,
glucoseByContext,
glucoseClinical,
correlations,
healthScore,
sleepStages,
+2
View File
@@ -3,6 +3,7 @@
import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { resolveGlucoseUnit } from "@/lib/glucose";
@@ -51,6 +52,7 @@ export default function InsightsBlutzuckerPage() {
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType={null}
targetSummarySlug="blood-glucose"
afterChart={<GlucoseClinicalPanel />}
coachPrefill="I haven't logged any blood glucose yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
@@ -12,6 +12,7 @@ import { ClinicianView } from "../clinician-view";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
function makeReport(
overrides: Partial<DoctorReportData> = {},
@@ -36,6 +37,9 @@ function makeReport(
},
glucoseStats: {},
glucoseRanges: {},
glucoseClinical: computeGlucoseClinicalMetrics([], {
now: new Date("2026-01-31T00:00:00.000Z"),
}),
glucoseUnit: "mg/dL",
bmi: 24.5,
compliance: {},
@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { DataSummary } from "@/lib/analytics/trends";
import { GlucoseTirBar } from "../glucose-tir-bar";
import { GlucoseAdvancedDisclosure } from "../glucose-advanced-disclosure";
/** Minimal valid `DataSummary` — the panel + disclosure only read `count`. */
function summary(count: number): DataSummary {
return {
count,
latest: 95,
min: 60,
max: 140,
mean: 100,
median: 98,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
};
}
/**
* v1.17.0 — glucose clinical panel render tests.
*
* Covers the two states the maintainer called out: the calm "still learning"
* branch (no asserted TIR / GMI off thin spot data) and the asserted branch
* with the advanced progressive disclosure. The panel leans on `useAuth` +
* `useAnalyticsQuery`; both are mocked so the branch under test is
* deterministic. The TIR bar + advanced disclosure are pure props, tested
* directly.
*/
const authMock = vi.fn();
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => authMock(),
}));
const analyticsMock = vi.fn();
vi.mock("@/lib/queries/use-analytics-query", () => ({
useAnalyticsQuery: () => analyticsMock(),
}));
// Import after the mocks are registered.
const { GlucoseClinicalPanel } = await import("../glucose-clinical-panel");
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const NOW = new Date("2026-06-14T12:00:00Z");
const DAY = 24 * 60 * 60 * 1000;
beforeEach(() => {
authMock.mockReturnValue({
user: { glucoseUnit: "mg/dL" },
isAuthenticated: true,
});
analyticsMock.mockReset();
});
describe("<GlucoseClinicalPanel> learning state", () => {
it("renders the calm still-learning copy and asserts NO TIR/GMI", () => {
// 5 readings over 4 days — below the 14-reading default floor.
const readings = [100, 110, 120, 130, 140].map((mgdl, i) => ({
measuredAt: new Date(NOW.getTime() - (4 - i) * DAY),
mgdl,
}));
const glucoseClinical = computeGlucoseClinicalMetrics(readings, { now: NOW });
expect(glucoseClinical.stillLearning).toBe(true);
analyticsMock.mockReturnValue({
isLoading: false,
data: { glucoseClinical, glucoseByContext: {} },
});
const html = render(<GlucoseClinicalPanel />);
expect(html).toContain('data-state="learning"');
expect(html).toContain("Still learning your glucose");
// the asserted blocks must NOT be present in the learning state
expect(html).not.toContain("data-slot=\"glucose-tir-bar\"");
expect(html).not.toContain("Glucose Management Indicator");
// the spot-reading caveat is always carried
expect(html).toContain("spot-reading estimates");
});
it("renders the empty learning copy when there are zero readings", () => {
const glucoseClinical = computeGlucoseClinicalMetrics([], { now: NOW });
analyticsMock.mockReturnValue({
isLoading: false,
data: { glucoseClinical, glucoseByContext: {} },
});
const html = render(<GlucoseClinicalPanel />);
expect(html).toContain('data-state="learning"');
expect(html).toContain("Once a handful of readings come in");
});
});
describe("<GlucoseClinicalPanel> asserted state", () => {
function assertedClinical() {
// 20 readings, one per day → clears the gate; a spread that lands in all
// three TIR bands so the bar has real segments and CV% is non-trivial.
const values = [
60, 90, 120, 150, 180, 200, 100, 110, 95, 130, 170, 220, 80, 140, 160,
105, 115, 125, 135, 145,
];
const readings = values.map((mgdl, i) => ({
measuredAt: new Date(NOW.getTime() - (values.length - 1 - i) * DAY),
mgdl,
}));
return computeGlucoseClinicalMetrics(readings, { now: NOW, windowDays: 30 });
}
it("renders TIR bar, GMI, eA1C, a CV badge, and the advanced disclosure", () => {
const glucoseClinical = assertedClinical();
expect(glucoseClinical.stillLearning).toBe(false);
analyticsMock.mockReturnValue({
isLoading: false,
data: {
glucoseClinical,
glucoseByContext: {
FASTING: summary(8),
},
},
});
const html = render(<GlucoseClinicalPanel />);
expect(html).toContain('data-state="asserted"');
expect(html).toContain('data-slot="glucose-tir-bar"');
expect(html).toContain("Glucose Management Indicator");
expect(html).toContain("Estimated A1C");
expect(html).toContain('data-slot="glucose-cv-badge"');
// advanced disclosure trigger is present (region collapsed by default in SSR)
expect(html).toContain('data-slot="glucose-advanced-toggle"');
expect(html).toContain("aria-expanded=\"false\"");
expect(html).toContain("J-index");
});
it("flags the CV badge unstable at CV% >= 36", () => {
// wide spread → CV well above 36
const values = Array.from({ length: 20 }, (_, i) =>
i % 2 === 0 ? 60 : 260,
);
const readings = values.map((mgdl, i) => ({
measuredAt: new Date(NOW.getTime() - (values.length - 1 - i) * DAY),
mgdl,
}));
const glucoseClinical = computeGlucoseClinicalMetrics(readings, {
now: NOW,
windowDays: 30,
});
expect(glucoseClinical.variability?.unstable).toBe(true);
analyticsMock.mockReturnValue({
isLoading: false,
data: { glucoseClinical, glucoseByContext: {} },
});
const html = render(<GlucoseClinicalPanel />);
expect(html).toContain('data-unstable="true"');
expect(html).toContain("Unstable");
});
it("renders nothing when the thick slice carries no glucoseClinical", () => {
analyticsMock.mockReturnValue({ isLoading: false, data: { summaries: {} } });
const html = render(<GlucoseClinicalPanel />);
expect(html).toBe("");
});
});
describe("<GlucoseTirBar>", () => {
it("renders one segment per non-empty Battelino band summing to 100%", () => {
const dist = computeGlucoseClinicalMetrics(
[40, 60, 120, 200, 300].map((mgdl, i) => ({
measuredAt: new Date(NOW.getTime() - i * DAY),
mgdl,
})),
{ now: NOW, minReadings: 1, minSpanDays: 1 },
).distribution!;
const html = render(<GlucoseTirBar distribution={dist} />);
// very-low (40), low (60), in-range (120), high (200), very-high (300)
expect(html).toContain("glucose-tir-segment-veryLow");
expect(html).toContain("glucose-tir-segment-low");
expect(html).toContain("glucose-tir-segment-inRange");
expect(html).toContain("glucose-tir-segment-high");
expect(html).toContain("glucose-tir-segment-veryHigh");
// aria summary present
expect(html).toContain('role="img"');
});
});
describe("<GlucoseAdvancedDisclosure>", () => {
it("is collapsed by default and exposes a toggle + the three indices", () => {
const html = render(
<GlucoseAdvancedDisclosure
advanced={{ jIndex: 23.7, lbgi: 1.2, hbgi: 6.8 }}
byContext={{
FASTING: summary(1),
}}
/>,
);
expect(html).toContain('aria-expanded="false"');
expect(html).toContain("hidden"); // region hidden in initial SSR state
expect(html).toContain("J-index");
expect(html).toContain("LBGI");
expect(html).toContain("HBGI");
// singular reading label for a single-reading context
expect(html).toContain("1 reading");
});
});
@@ -0,0 +1,159 @@
"use client";
import { useId, useState } from "react";
import { ChevronDown } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.17.0 — "advanced" progressive disclosure for the glucose panel.
*
* The default panel view leads with TIR / GMI / eA1C / CV%. The research-grade
* composites the maintainer chose for v1.17.0 — J-index (Wojcicki) and the
* Kovatchev low/high blood-glucose risk indices — live behind this disclosure
* so a casual user is not confronted with them, while a clinician or an engaged
* user can open them. Per-context reading counts ride along so the advanced
* view also answers "where do these readings come from". Values are computed
* server-side; this only renders them.
*
* No Collapsible primitive exists in the UI kit, so this is a minimal,
* accessible button + region disclosure (the same pattern other insights cards
* use): `aria-expanded` on the trigger, `aria-controls`/`hidden` on the region.
*/
const CONTEXT_LABEL_KEYS: Record<string, string> = {
FASTING: "insights.bloodGlucose.clinical.advanced.byContext.fasting",
POSTPRANDIAL: "insights.bloodGlucose.clinical.advanced.byContext.postprandial",
RANDOM: "insights.bloodGlucose.clinical.advanced.byContext.random",
BEDTIME: "insights.bloodGlucose.clinical.advanced.byContext.bedtime",
};
export interface GlucoseAdvancedDisclosureProps {
advanced: NonNullable<GlucoseClinicalMetrics["advanced"]>;
/** Per-context summaries keyed by `GlucoseContext`; optional. */
byContext?: Record<string, DataSummary> | undefined;
/** Decimals for the index values. Defaults to 1. */
fractionDigits?: number;
}
export function GlucoseAdvancedDisclosure({
advanced,
byContext,
fractionDigits = 1,
}: GlucoseAdvancedDisclosureProps) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const regionId = useId();
const fmt = (v: number | null) =>
v === null ? "—" : v.toFixed(fractionDigits);
const contexts = Object.entries(byContext ?? {}).filter(
([, s]) => s && s.count > 0,
);
return (
<div data-slot="glucose-advanced" className="border-border border-t pt-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="glucose-advanced-toggle"
className="text-muted-foreground hover:text-foreground flex min-h-11 w-full items-center justify-between gap-2 text-sm font-medium transition-colors"
>
<span>{t("insights.bloodGlucose.clinical.advanced.toggle")}</span>
<ChevronDown
aria-hidden="true"
className={cn(
"h-4 w-4 transition-transform",
open && "rotate-180",
)}
/>
</button>
<div
id={regionId}
hidden={!open}
data-slot="glucose-advanced-region"
className="space-y-4 pt-3"
>
<p className="text-muted-foreground text-xs">
{t("insights.bloodGlucose.clinical.advanced.intro")}
</p>
<dl className="grid grid-cols-3 gap-3">
<AdvancedStat
label={t("insights.bloodGlucose.clinical.advanced.jIndex.label")}
help={t("insights.bloodGlucose.clinical.advanced.jIndex.help")}
value={fmt(advanced.jIndex)}
/>
<AdvancedStat
label={t("insights.bloodGlucose.clinical.advanced.lbgi.label")}
help={t("insights.bloodGlucose.clinical.advanced.lbgi.help")}
value={fmt(advanced.lbgi)}
/>
<AdvancedStat
label={t("insights.bloodGlucose.clinical.advanced.hbgi.label")}
help={t("insights.bloodGlucose.clinical.advanced.hbgi.help")}
value={fmt(advanced.hbgi)}
/>
</dl>
{contexts.length > 0 ? (
<div className="space-y-2" data-slot="glucose-advanced-context">
<p className="text-foreground text-xs font-medium">
{t("insights.bloodGlucose.clinical.advanced.byContext.title")}
</p>
<ul className="text-muted-foreground space-y-1 text-xs">
{contexts.map(([ctx, summary]) => (
<li
key={ctx}
className="flex items-center justify-between gap-2"
>
<span>
{CONTEXT_LABEL_KEYS[ctx] ? t(CONTEXT_LABEL_KEYS[ctx]) : ctx}
</span>
<span className="tabular-nums">
{summary.count === 1
? t(
"insights.bloodGlucose.clinical.advanced.byContext.readingsShortOne",
)
: t(
"insights.bloodGlucose.clinical.advanced.byContext.readingsShort",
{ count: summary.count },
)}
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
</div>
);
}
function AdvancedStat({
label,
help,
value,
}: {
label: string;
help: string;
value: string;
}) {
return (
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs" title={help}>
{label}
</dt>
<dd className="text-foreground text-base font-semibold tabular-nums">
{value}
</dd>
</div>
);
}
@@ -0,0 +1,254 @@
"use client";
import { Activity, Droplet, TrendingUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useTranslations } from "@/lib/i18n/context";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import type { DataSummary } from "@/lib/analytics/trends";
import { GlucoseTirBar } from "./glucose-tir-bar";
import { GlucoseAdvancedDisclosure } from "./glucose-advanced-disclosure";
/**
* v1.17.0 — glucose clinical panel.
*
* The default view leads with the three numbers that matter to a person
* managing glucose: time-in-range (Battelino bands, as a stacked bar), a GMI +
* estimated-A1C pair, and a variability badge that flags "unstable" at CV% ≥ 36
* (Monnier). The advanced indices (J-index, LBGI/HBGI) sit behind a calm
* disclosure. Every number is server-computed by the one literature-locked
* engine — the panel renders verbatim and never re-derives.
*
* Honesty contract: HealthLog stores SPOT readings, not a CGM trace, so the
* copy always carries the "spot-reading estimate" caveat, and a thin window
* shows a warm "still learning" state instead of asserting a clinical profile.
* Tone follows the cycle-phase texts: warm, professional, grounded — never
* alarmist, never breezy.
*
* Reads the thick `/api/analytics` payload directly (the slim slice does not
* carry `glucoseClinical`); the cache cell is shared with the dashboard so the
* mount is usually a free hit.
*/
export function GlucoseClinicalPanel() {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
const query = useAnalyticsQuery({});
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
const isMmol = glucoseUnit === "mmol/L";
if (!isAuthenticated) return null;
if (query.isLoading) {
return (
<Card data-slot="glucose-clinical-panel-skeleton">
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-6 w-full" />
<div className="grid grid-cols-3 gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
</CardContent>
</Card>
);
}
const clinical = query.data?.glucoseClinical;
// No glucose data at all, or the thick slice did not carry the block — the
// page's own empty state already covers the truly-no-data case, so render
// nothing rather than a redundant card.
if (!clinical) return null;
const byContext = query.data?.glucoseByContext as
| Record<string, DataSummary>
| undefined;
const formatGlucoseValue = (mgdl: number): string => {
const v = convertGlucose(mgdl, glucoseUnit);
return isMmol ? v.toFixed(1) : String(Math.round(v));
};
// Learning state — calm, never asserting TIR / GMI off thin data.
if (clinical.stillLearning) {
const hasAny = clinical.readingCount > 0;
const readingsLabel =
clinical.readingCount === 1
? t("insights.bloodGlucose.clinical.readingsCountOne")
: t("insights.bloodGlucose.clinical.readingsCount", {
count: clinical.readingCount,
});
const spanDays = Math.max(1, Math.round(clinical.actualSpanDays));
const daysLabel =
spanDays === 1
? t("insights.bloodGlucose.clinical.daysCountOne")
: t("insights.bloodGlucose.clinical.daysCount", { days: spanDays });
return (
<Card data-slot="glucose-clinical-panel" data-state="learning">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base font-semibold">
<Droplet className="h-4 w-4" aria-hidden="true" />
{t("insights.bloodGlucose.clinical.learningTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-sm" data-slot="glucose-learning-body">
{hasAny
? t("insights.bloodGlucose.clinical.learningBody", {
count: readingsLabel,
days: daysLabel,
})
: t("insights.bloodGlucose.clinical.learningBodyEmpty")}
</p>
<p className="text-muted-foreground/80 text-xs">
{t("insights.bloodGlucose.clinical.spotCaveat")}
</p>
</CardContent>
</Card>
);
}
// Asserted view.
const dist = clinical.distribution;
const cv = clinical.variability;
return (
<Card data-slot="glucose-clinical-panel" data-state="asserted">
<CardHeader className="pb-2">
<div className="flex flex-col gap-0.5">
<CardTitle className="flex items-center gap-2 text-base font-semibold">
<Droplet className="h-4 w-4" aria-hidden="true" />
{t("insights.bloodGlucose.clinical.title")}
</CardTitle>
<span className="text-muted-foreground text-xs">
{t("insights.bloodGlucose.clinical.subtitle", {
days: Math.max(1, Math.round(clinical.actualSpanDays)),
count: clinical.readingCount,
})}
</span>
</div>
</CardHeader>
<CardContent className="space-y-5">
{/* Time in range */}
{dist ? (
<section className="space-y-2" data-slot="glucose-tir-section">
<div className="flex items-center justify-between gap-2">
<h3 className="text-foreground text-sm font-medium">
{t("insights.bloodGlucose.clinical.tir.title")}
</h3>
</div>
<GlucoseTirBar distribution={dist} />
<p className="text-muted-foreground text-xs">
{t("insights.bloodGlucose.clinical.tir.caption")}
</p>
</section>
) : null}
{/* Headline metrics */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4" data-slot="glucose-headline">
{clinical.meanMgdl !== null ? (
<Metric
icon={<Droplet className="h-3.5 w-3.5" aria-hidden="true" />}
label={t("insights.bloodGlucose.clinical.mean.label")}
value={formatGlucoseValue(clinical.meanMgdl)}
unit={glucoseUnit}
/>
) : null}
{clinical.gmi !== null ? (
<Metric
icon={<Activity className="h-3.5 w-3.5" aria-hidden="true" />}
label={t("insights.bloodGlucose.clinical.gmi.label")}
help={t("insights.bloodGlucose.clinical.gmi.help")}
value={clinical.gmi.toFixed(1)}
unit="%"
/>
) : null}
{clinical.estimatedA1c !== null ? (
<Metric
icon={<TrendingUp className="h-3.5 w-3.5" aria-hidden="true" />}
label={t("insights.bloodGlucose.clinical.eA1c.label")}
help={t("insights.bloodGlucose.clinical.eA1c.help")}
value={clinical.estimatedA1c.toFixed(1)}
unit="%"
/>
) : null}
{cv ? (
<div className="space-y-0.5" data-slot="glucose-cv">
<span
className="text-muted-foreground flex items-center gap-1 text-xs"
title={t("insights.bloodGlucose.clinical.cv.help")}
>
{t("insights.bloodGlucose.clinical.cv.label")}
</span>
<div className="flex items-center gap-1.5">
<span className="text-foreground text-lg font-semibold tabular-nums">
{Math.round(cv.cv)}%
</span>
<Badge
variant={cv.unstable ? "destructive" : "secondary"}
data-slot="glucose-cv-badge"
data-unstable={cv.unstable}
>
{cv.unstable
? t("insights.bloodGlucose.clinical.cv.unstable")
: t("insights.bloodGlucose.clinical.cv.steady")}
</Badge>
</div>
</div>
) : null}
</section>
{/* Spot-reading honesty caveat — always present. */}
<p className="text-muted-foreground/80 text-xs" data-slot="glucose-spot-caveat">
{t("insights.bloodGlucose.clinical.spotCaveat")}
</p>
{/* Advanced indices behind a disclosure. */}
{clinical.advanced ? (
<GlucoseAdvancedDisclosure
advanced={clinical.advanced}
byContext={byContext}
/>
) : null}
</CardContent>
</Card>
);
}
function Metric({
icon,
label,
help,
value,
unit,
}: {
icon: React.ReactNode;
label: string;
help?: string;
value: string;
unit: string;
}) {
return (
<div className="space-y-0.5">
<span
className="text-muted-foreground flex items-center gap-1 text-xs"
title={help}
>
{icon}
{label}
</span>
<span className="text-foreground flex items-baseline gap-1">
<span className="text-lg font-semibold tabular-nums">{value}</span>
<span className="text-muted-foreground text-xs">{unit}</span>
</span>
</div>
);
}
@@ -0,0 +1,102 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import type { TimeInRangeDistribution } from "@/lib/analytics/glucose-metrics";
/**
* v1.17.0 — Battelino 2019 time-in-range stacked bar.
*
* A single horizontal 100%-width bar split into the five consensus bands
* (very low / low / in range / high / very high), bottom-to-top severity
* read left-to-right with the in-range band centred and dominant. Fractions
* arrive already computed by the server engine — this component only renders
* them, it never re-derives a band. The level-2 sub-bands are NESTED inside
* level-1 in the source distribution (TBR2 ⊆ TBR1, TAR2 ⊆ TAR1), so the
* segment widths here use the EXCLUSIVE slices to fill exactly 100%.
*/
const BAND_COLORS = {
veryLow: "#ff5555", // dracula-red — urgent low
low: "#ffb86c", // dracula-orange — low
inRange: "#50fa7b", // dracula-green — target
high: "#f1fa8c", // dracula-yellow — high
veryHigh: "#ff79c6", // dracula-pink — urgent high
} as const;
type BandKey = keyof typeof BAND_COLORS;
export interface GlucoseTirBarProps {
distribution: TimeInRangeDistribution;
}
export function GlucoseTirBar({ distribution }: GlucoseTirBarProps) {
const { t } = useTranslations();
// Exclusive slices from the nested consensus fractions: level-2 is a subset
// of level-1, so the level-1-only slice = level1 − level2.
const veryLow = distribution.tbrLevel2;
const low = Math.max(0, distribution.tbrLevel1 - distribution.tbrLevel2);
const inRange = distribution.tir;
const high = Math.max(0, distribution.tarLevel1 - distribution.tarLevel2);
const veryHigh = distribution.tarLevel2;
const segments: Array<{ key: BandKey; label: string; fraction: number }> = [
{ key: "veryLow", label: t("insights.bloodGlucose.clinical.tir.veryLow"), fraction: veryLow },
{ key: "low", label: t("insights.bloodGlucose.clinical.tir.low"), fraction: low },
{ key: "inRange", label: t("insights.bloodGlucose.clinical.tir.inRange"), fraction: inRange },
{ key: "high", label: t("insights.bloodGlucose.clinical.tir.high"), fraction: high },
{ key: "veryHigh", label: t("insights.bloodGlucose.clinical.tir.veryHigh"), fraction: veryHigh },
];
const pct = (f: number) => Math.round(f * 100);
const ariaLabel = t("insights.bloodGlucose.clinical.tir.ariaLabel", {
tir: pct(inRange),
below: pct(distribution.tbrLevel1),
above: pct(distribution.tarLevel1),
});
return (
<div data-slot="glucose-tir-bar" className="space-y-2">
<div
role="img"
aria-label={ariaLabel}
className="bg-muted flex h-6 w-full overflow-hidden rounded-md"
>
{segments.map((s) =>
s.fraction > 0 ? (
<div
key={s.key}
data-slot={`glucose-tir-segment-${s.key}`}
className="h-full"
style={{
width: `${s.fraction * 100}%`,
background: BAND_COLORS[s.key],
}}
title={`${s.label} · ${pct(s.fraction)}%`}
/>
) : null,
)}
</div>
<ul
className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-3"
data-slot="glucose-tir-legend"
>
{segments.map((s) => (
<li key={s.key} className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-sm"
style={{ background: BAND_COLORS[s.key] }}
/>
{s.label}
</span>
<span className="text-foreground tabular-nums font-medium">
{pct(s.fraction)}%
</span>
</li>
))}
</ul>
</div>
);
}
@@ -154,6 +154,15 @@ export interface HealthKitMetricPageProps {
* 90-day p50 is not read as an all-time central value.
*/
statMedianLabel?: string;
/**
* v1.17.0 — optional extra content rendered beneath the chart + target
* summary, before the metric-status card. Blood glucose mounts its clinical
* panel (TIR / GMI / eA1C / CV% + advanced indices) here. Only rendered on
* the data-bearing branch (the empty / loading / error branches skip it, so
* a no-data metric never shows a void panel). Additive: every other page
* omits it and renders byte-identically.
*/
afterChart?: ReactNode;
}
export function HealthKitMetricPage({
@@ -176,6 +185,7 @@ export function HealthKitMetricPage({
forecast = false,
statFractionDigits,
statMedianLabel,
afterChart,
}: HealthKitMetricPageProps) {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -337,6 +347,9 @@ export function HealthKitMetricPage({
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
{/* v1.17.0 — page-specific extra block (blood glucose: the clinical
panel). Mounted on the data-bearing branch only. */}
{afterChart}
{forecast && isTrajectoryType(measurementType) ? (
<TrajectoryForecastCard
type={measurementType}
@@ -10,6 +10,7 @@ import {
} from "../doctor-report-pdf-core";
import { measurementTypeEnum } from "../validations/measurement";
import type { DoctorReportData } from "../doctor-report-data";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import { getServerTranslator } from "../i18n/server-translator";
async function extractText(bytes: Uint8Array): Promise<string> {
@@ -67,6 +68,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 },
},
glucoseRanges: { FASTING: { min: 70, max: 99 } },
glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }),
glucoseUnit: "mg/dL",
bmi: 24.1,
compliance: {
+97 -1
View File
@@ -24,6 +24,7 @@ import {
} from "@/lib/validations/coach-prefs";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import {
reconstructSleepNights,
type SleepStageRow,
@@ -80,6 +81,15 @@ const DAILY_TIMELINE_DAYS = 14;
/** Default window when the caller doesn't pass a scope. */
const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
/**
* v1.17.0 — the glucose clinical panel is a fixed trailing-30-day artifact,
* identical across the insights panel, the dashboard snapshot, the doctor
* report, and (here) the coach. Pinned independently of the coach's variable
* narration window so the coach's TIR/GMI/CV% always equals what the panel
* renders.
*/
const GLUCOSE_CLINICAL_WINDOW_DAYS = 30;
/**
* v1.7.0 — assembled-snapshot soft char cap. After the snapshot is
* built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per-
@@ -846,6 +856,29 @@ async function buildCoachSnapshotImpl(
},
})
: null;
// v1.17.0 — the glucose CLINICAL panel is a fixed 30-day clinical artifact,
// identical to the one the insights panel + doctor report render. It must NOT
// ride the coach's variable narration window (7/30/90/365) or the
// multi-cluster timeline cap, or the coach would quote a TIR/GMI/CV% the panel
// never shows. So read the panel's own trailing-30-day glucose rows directly
// (one indexed query, only when glucose is active) and compute the clinical
// block off THOSE rows. The per-context narration timelines below keep using
// the coach-window rows — only the clinical summary is pinned to 30 days.
const glucoseClinicalCutoff = new Date(
now.getTime() - GLUCOSE_CLINICAL_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
const glucoseClinicalRowsPromise = sources.has("glucose")
? prisma.measurement.findMany({
where: {
userId,
type: "BLOOD_GLUCOSE" as never,
measuredAt: { gte: glucoseClinicalCutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true },
})
: null;
const glp1BlockPromise = excludesMedications
? null
: buildGlp1SnapshotBlock(userId, now);
@@ -879,6 +912,7 @@ async function buildCoachSnapshotImpl(
complianceMeds,
sleepRows,
workoutRows,
glucoseClinicalRows,
glp1Block,
derivedBlock,
trajectoryBlock,
@@ -889,6 +923,7 @@ async function buildCoachSnapshotImpl(
complianceMedsPromise,
sleepRowsPromise,
workoutRowsPromise,
glucoseClinicalRowsPromise,
glp1BlockPromise,
derivedBlockPromise,
trajectoryBlockPromise,
@@ -1344,9 +1379,70 @@ async function buildCoachSnapshotImpl(
);
contexts[ctx] = { recent, weekly };
}
// v1.17.0 — clinical panel summary from the ONE literature-locked engine
// the insights panel + doctor report also consume, computed over the SAME
// fixed trailing-30-day window + rows the panel uses (`glucoseClinicalRows`,
// not the coach-window / cap-trimmed `glucoseRows`), so the coach can never
// quote a TIR / GMI / CV% figure the panel doesn't show — true numeric
// parity, independent of the user's coach scope. Gated by `stillLearning`
// so a thin spot-data window is offered as a calm "still learning" note
// rather than asserted as a clinical AGP. The headline mean is converted
// ONCE to the user's display unit; the unit-agnostic fractions / indices
// travel as-is.
const clinicalRaw = computeGlucoseClinicalMetrics(
(glucoseClinicalRows ?? []).map((r) => ({
measuredAt: r.measuredAt,
mgdl: r.value,
})),
{ windowDays: GLUCOSE_CLINICAL_WINDOW_DAYS, now },
);
const clinical = clinicalRaw.stillLearning
? {
stillLearning: true as const,
reason: clinicalRaw.stillLearningReason,
readingCount: clinicalRaw.readingCount,
spanDays: Math.round(clinicalRaw.actualSpanDays),
}
: {
stillLearning: false as const,
windowDays: clinicalRaw.windowDays,
spanDays: Math.round(clinicalRaw.actualSpanDays),
readingCount: clinicalRaw.readingCount,
meanInRange:
clinicalRaw.meanMgdl !== null
? Math.round(
convertGlucose(clinicalRaw.meanMgdl, glucoseUnit) *
(glucoseUnit === "mmol/L" ? 10 : 1),
) / (glucoseUnit === "mmol/L" ? 10 : 1)
: null,
tirPercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tir * 100)
: null,
timeBelowPercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tbrLevel1 * 100)
: null,
timeAbovePercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tarLevel1 * 100)
: null,
gmi:
clinicalRaw.gmi !== null
? Math.round(clinicalRaw.gmi * 10) / 10
: null,
estimatedA1c:
clinicalRaw.estimatedA1c !== null
? Math.round(clinicalRaw.estimatedA1c * 10) / 10
: null,
cvPercent: clinicalRaw.variability
? Math.round(clinicalRaw.variability.cv)
: null,
unstable: clinicalRaw.variability?.unstable ?? null,
// Spot-reading estimate, never a CGM AGP — pinned so the model
// never narrates these as continuous-trace clinical figures.
isSpotEstimate: true as const,
};
// The display unit travels with the block so the prompt renders
// "<value> <unit>" and the EVIDENCE BLOCK tags glucose lines correctly.
snapshot.glucose = { unit: glucoseUnit, byContext: contexts };
snapshot.glucose = { unit: glucoseUnit, byContext: contexts, clinical };
metrics.add("glucose");
counts.glucose = glucoseRows.length;
registerBlock("glucose", "glucose");
@@ -10,11 +10,13 @@ import { describe, expect, it } from "vitest";
import {
CV_INSTABILITY_THRESHOLD,
DEFAULT_WINDOW_DAYS,
bloodGlucoseRiskIndices,
computeGlucoseClinicalMetrics,
estimatedA1c,
glucoseSD,
glucoseVariability,
gmi,
jIndex,
timeInRange,
type GlucoseReading,
} from "../glucose-metrics";
@@ -128,6 +130,51 @@ describe("timeInRange (Battelino 2019 bands)", () => {
});
});
describe("jIndex (Wojcicki 1995)", () => {
it("returns null for fewer than two values", () => {
expect(jIndex([])).toBeNull();
expect(jIndex([120])).toBeNull();
});
it("folds mean + SD: J = 0.001 × (mean + SD)²", () => {
// 14 identical 154 readings → SD 0 → J = 0.001 × 154² = 23.716
expect(jIndex(Array.from({ length: 14 }, () => 154))).toBeCloseTo(
23.716,
3,
);
// {100,110,120,130,140}: mean 120, sample SD √250 = 15.81139 →
// J = 0.001 × 135.81139² = 18.444733
expect(jIndex([100, 110, 120, 130, 140])).toBeCloseTo(18.444733, 5);
});
});
describe("bloodGlucoseRiskIndices (Kovatchev 1997/2006)", () => {
it("returns null when no usable reading remains", () => {
expect(bloodGlucoseRiskIndices([])).toBeNull();
// non-positive / non-finite readings are skipped (ln undefined)
expect(bloodGlucoseRiskIndices([0, -5, NaN])).toBeNull();
});
it("splits a low/high pair onto the correct branches", () => {
// f(50) = -1.500015 → low branch r = 22.500445
// f(300) = 1.842607 → high branch r = 33.951988
// n = 2 → LBGI = 22.500445/2 = 11.250223, HBGI = 33.951988/2 = 16.975994
const r = bloodGlucoseRiskIndices([50, 300]);
expect(r).not.toBeNull();
expect(r!.lbgi).toBeCloseTo(11.250223, 5);
expect(r!.hbgi).toBeCloseTo(16.975994, 5);
});
it("reads near-zero hypo risk and zero hyper risk for a stable normoglycaemic set", () => {
// every reading sits below the ~112.5 symmetrization centre, so HBGI = 0
const r = bloodGlucoseRiskIndices([100, 110, 95, 105, 100]);
expect(r!.hbgi).toBe(0);
expect(r!.lbgi).toBeCloseTo(0.427869, 5);
// minimal-risk band (< 1.1)
expect(r!.lbgi).toBeLessThan(1.1);
});
});
describe("computeGlucoseClinicalMetrics — window + adequacy", () => {
const now = new Date("2026-06-14T12:00:00Z");
@@ -155,6 +202,26 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => {
expect(m.readingCount).toBe(1);
});
it("excludes non-positive readings so every index shares one denominator", () => {
// a 0 / negative glucose is non-physiological and ln-undefined; it must be
// dropped from readingCount AND from the risk-index denominator so the
// advanced indices never use a different N than mean / TIR.
const readings: GlucoseReading[] = [
{ mgdl: 120, measuredAt: new Date(now.getTime() - 1 * DAY) },
{ mgdl: 0, measuredAt: new Date(now.getTime() - 2 * DAY) },
{ mgdl: -5, measuredAt: new Date(now.getTime() - 3 * DAY) },
];
const m = computeGlucoseClinicalMetrics(readings, {
now,
minReadings: 1,
minSpanDays: 0,
});
expect(m.readingCount).toBe(1);
expect(m.meanMgdl).toBe(120);
// advanced indices resolve from the single usable reading
expect(m.advanced).not.toBeNull();
});
it("always marks the result as a spot estimate", () => {
const m = computeGlucoseClinicalMetrics(
dailyReadings([100], now),
@@ -173,6 +240,7 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => {
expect(m.gmi).toBeNull();
expect(m.estimatedA1c).toBeNull();
expect(m.variability).toBeNull();
expect(m.advanced).toBeNull();
});
it("fires below the minimum reading count, reporting N and D", () => {
@@ -220,6 +288,26 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => {
expect(m.distribution!.tir).toBeCloseTo(1, 10); // all in range
expect(m.variability!.sd).toBe(0);
expect(m.variability!.unstable).toBe(false);
// advanced tier: J-index from mean+SD, LBGI/HBGI from the risk model.
// mean 154, SD 0 → J = 0.001 × 154² = 23.716.
expect(m.advanced).not.toBeNull();
expect(m.advanced!.jIndex).toBeCloseTo(23.716, 3);
// 154 mg/dL sits above the symmetrization centre → all hyper risk,
// zero hypo risk.
expect(m.advanced!.lbgi).toBe(0);
expect(m.advanced!.hbgi).toBeGreaterThan(0);
});
it("resolves LBGI/HBGI but holds J-index null for a single reading", () => {
const m = computeGlucoseClinicalMetrics(dailyReadings([100], now), {
now,
});
expect(m.advanced).not.toBeNull();
// one reading → sample SD undefined → J held null
expect(m.advanced!.jIndex).toBeNull();
// the risk indices still resolve from the lone reading
expect(m.advanced!.hbgi).toBe(0);
expect(m.advanced!.lbgi).toBeGreaterThan(0);
});
it("honours configurable thresholds", () => {
@@ -0,0 +1,132 @@
/**
* v1.17.0 — glucose panel coherence.
*
* The server-authoritative contract: the insights panel, the AI coach, and the
* doctor-report PDF all read ONE computed `computeGlucoseClinicalMetrics`
* result for the same readings — none re-derives a clinical number. This test
* pins that invariant by deriving each surface's headline numbers from the SAME
* engine output and asserting they agree:
*
* - panel → renders `gmi` / `estimatedA1c` / `distribution.tir` / `cv`
* verbatim (rounded for display only).
* - coach → `snapshot.glucose.clinical` rounds `gmi` (1dp), `tirPercent`
* (whole %), `cvPercent` (whole %) from the same result.
* - doctor → `glucoseClinical` IS the engine result, carried whole.
*
* Because the three consumers all call the one pure function, identical
* readings MUST produce identical headline figures. The projections below
* mirror the rounding each surface applies; the assertions prove no surface can
* drift onto a different denominator or formula.
*/
import { describe, expect, it } from "vitest";
import { computeGlucoseClinicalMetrics } from "../glucose-metrics";
const NOW = new Date("2026-06-14T12:00:00Z");
const DAY = 24 * 60 * 60 * 1000;
/** A spread that clears the learning gate and lands in all three TIR bands. */
const VALUES = [
60, 90, 120, 150, 180, 200, 100, 110, 95, 130, 170, 220, 80, 140, 160, 105,
115, 125, 135, 145,
];
function readings() {
return VALUES.map((mgdl, i) => ({
measuredAt: new Date(NOW.getTime() - (VALUES.length - 1 - i) * DAY),
mgdl,
}));
}
/** Coach projection — mirrors `snapshot.glucose.clinical` (asserted branch). */
function coachProjection(
m: ReturnType<typeof computeGlucoseClinicalMetrics>,
) {
return {
tirPercent: m.distribution ? Math.round(m.distribution.tir * 100) : null,
gmi: m.gmi !== null ? Math.round(m.gmi * 10) / 10 : null,
estimatedA1c:
m.estimatedA1c !== null ? Math.round(m.estimatedA1c * 10) / 10 : null,
cvPercent: m.variability ? Math.round(m.variability.cv) : null,
unstable: m.variability?.unstable ?? null,
};
}
/** Panel projection — mirrors the panel's display rounding. */
function panelProjection(
m: ReturnType<typeof computeGlucoseClinicalMetrics>,
) {
return {
tirPercent: m.distribution ? Math.round(m.distribution.tir * 100) : null,
gmi: m.gmi !== null ? Number(m.gmi.toFixed(1)) : null,
estimatedA1c:
m.estimatedA1c !== null ? Number(m.estimatedA1c.toFixed(1)) : null,
cvPercent: m.variability ? Math.round(m.variability.cv) : null,
unstable: m.variability?.unstable ?? null,
};
}
describe("glucose panel ⇄ coach ⇄ doctor coherence", () => {
it("derives identical headline numbers from one engine result", () => {
// One computed result — exactly what each surface receives server-side.
const result = computeGlucoseClinicalMetrics(readings(), {
now: NOW,
windowDays: 30,
});
expect(result.stillLearning).toBe(false);
const panel = panelProjection(result);
const coach = coachProjection(result);
// doctor report carries the engine result verbatim
const doctor = {
tirPercent: result.distribution
? Math.round(result.distribution.tir * 100)
: null,
gmi: result.gmi,
estimatedA1c: result.estimatedA1c,
cvPercent: result.variability ? Math.round(result.variability.cv) : null,
unstable: result.variability?.unstable ?? null,
};
// Panel == coach on every rounded headline figure.
expect(panel).toEqual(coach);
// Doctor's whole-precision figures round to the same display values.
expect(Math.round((doctor.gmi as number) * 10) / 10).toBe(panel.gmi);
expect(Math.round((doctor.estimatedA1c as number) * 10) / 10).toBe(
panel.estimatedA1c,
);
expect(doctor.tirPercent).toBe(panel.tirPercent);
expect(doctor.cvPercent).toBe(panel.cvPercent);
expect(doctor.unstable).toBe(panel.unstable);
});
it("agrees on the learning gate for thin data across surfaces", () => {
// 5 readings → all three surfaces see stillLearning and withhold assertion.
const thin = computeGlucoseClinicalMetrics(
[100, 110, 120, 130, 140].map((mgdl, i) => ({
measuredAt: new Date(NOW.getTime() - (4 - i) * DAY),
mgdl,
})),
{ now: NOW, windowDays: 30 },
);
expect(thin.stillLearning).toBe(true);
// the coach gates its clinical block on exactly this flag; the panel
// renders its learning card on it; the doctor report carries the same
// flag in `glucoseClinical.stillLearning`.
expect(thin.gmi).not.toBeNull(); // preview value exists …
// … but every surface keys its assertion on `stillLearning`, not on the
// presence of the preview number.
});
it("is deterministic — identical input yields an identical result", () => {
const a = computeGlucoseClinicalMetrics(readings(), {
now: NOW,
windowDays: 30,
});
const b = computeGlucoseClinicalMetrics(readings(), {
now: NOW,
windowDays: 30,
});
expect(a).toEqual(b);
});
});
+131 -1
View File
@@ -30,6 +30,10 @@
* Bergenstal 2018, Diabetes Care 41(11):2275-2280, DOI 10.2337/dc18-1581.
* Nathan 2008 (ADAG), Diabetes Care 31(8):1473-1478, DOI 10.2337/dc08-0545.
* Monnier 2017, Diabetes Care 40(7):832-838, DOI 10.2337/dc16-1769.
* Wojcicki 1995 (J-index), Horm Metab Res 27(1):41-42, DOI 10.1055/s-2007-979906.
* Kovatchev 1997 (symmetrization + BGI), Diabetes Care 20(11):1655-1658,
* DOI 10.2337/diacare.20.11.1655; Kovatchev 2006 (LBGI/HBGI risk model),
* Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085.
*/
// ── Inputs ───────────────────────────────────────────────
@@ -118,6 +122,41 @@ export interface TimeInRangeDistribution {
};
}
/**
* Advanced glycaemic indices — the "advanced" disclosure tier of the panel.
* Each is a single scalar over the in-window readings; all are derivable from
* spot samples (unlike the dense-stream MAGE/CONGA/MODD family this module
* deliberately defers). Surfaced behind a progressive disclosure because they
* read as research-grade composites a casual user does not need up front.
*/
export interface GlucoseAdvancedIndices {
/**
* J-index — a single number folding central tendency AND variability:
* J = 0.001 × (mean + SD)², mean & SD in mg/dL.
* Reference bands (Wojcicki 1995): ~10–20 ideal/non-diabetic,
* 20–30 good control, 30–40 fair, > 40 poor. Reported here as an estimate
* over spot data, not a continuous-trace J.
* Wojcicki 1995, Horm Metab Res 27(1):41-42, DOI 10.1055/s-2007-979906.
*/
jIndex: number;
/**
* Low Blood Glucose Index — Kovatchev hypoglycaemia risk. Mean of the
* left-branch risk values `rl(BG)` over all readings; higher = more / deeper
* lows. Interpretation bands (Kovatchev 2006): < 1.1 minimal, 1.1–2.5 low,
* 2.5–5 moderate, > 5 high hypo risk.
* Kovatchev 2006, Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085.
*/
lbgi: number;
/**
* High Blood Glucose Index — Kovatchev hyperglycaemia risk. Mean of the
* right-branch risk values `rh(BG)`; higher = more / higher highs.
* Interpretation bands (Kovatchev 2006): < 4.5 low, 4.5–9 moderate,
* > 9 high hyper risk.
* Kovatchev 2006, Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085.
*/
hbgi: number;
}
/** Variability summary: SD + CV% with the Monnier stability flag. */
export interface GlucoseVariability {
/** Sample standard deviation (n−1), mg/dL. */
@@ -160,6 +199,18 @@ export interface GlucoseClinicalMetrics {
/** SD + CV% + instability flag (null if < 2 readings). */
variability: GlucoseVariability | null;
/**
* Advanced indices (J-index + LBGI/HBGI). Null when there are no readings;
* the J-index additionally needs ≥ 2 readings (it consumes the sample SD), so
* `jIndex` is held back to null inside the object when only one reading
* exists. Shown behind the panel's "advanced" disclosure.
*/
advanced: {
jIndex: number | null;
lbgi: number;
hbgi: number;
} | null;
/**
* Always true for this module: every number is a spot-reading estimate, not a
* CGM AGP. Surfaced explicitly so serializers (panel/coach/PDF/iOS) carry the
@@ -228,6 +279,65 @@ export function estimatedA1c(meanMgdl: number): number {
return (meanMgdl + 46.7) / 28.7;
}
/**
* J-index — a single composite of central tendency and variability:
* J = 0.001 × (mean + SD)², both in mg/dL.
* Returns null for n < 2 (the sample SD is undefined). Approximation bands are
* documented on {@link GlucoseAdvancedIndices.jIndex}.
* Wojcicki 1995, Horm Metab Res 27(1):41-42, DOI 10.1055/s-2007-979906.
*/
export function jIndex(values: number[]): number | null {
const sd = glucoseSD(values);
if (sd === null) return null;
const m = mean(values);
const s = m + sd;
return 0.001 * s * s;
}
/**
* Kovatchev symmetrization transform for a single mg/dL reading:
* f(BG) = 1.509 × ( (ln BG)^1.084 − 5.381 ).
* The transform maps the asymmetric mg/dL scale onto a symmetric axis where
* the clinical centre (~112.5 mg/dL) is 0; lows go negative, highs positive.
* Kovatchev 1997, Diabetes Care 20(11):1655-1658,
* DOI 10.2337/diacare.20.11.1655.
*/
function kovatchevSymmetrize(bgMgdl: number): number {
return 1.509 * (Math.pow(Math.log(bgMgdl), 1.084) - 5.381);
}
/**
* Low / High Blood Glucose Index (LBGI / HBGI) — Kovatchev risk model.
* Per reading the risk is `r(BG) = 10 × f(BG)²` (f = symmetrization above);
* it is assigned to the LOW branch when f < 0 and the HIGH branch when f > 0
* (f = 0 contributes 0 to both). LBGI = mean of the low-branch risks across
* ALL readings (zeros included), HBGI = mean of the high-branch risks.
* Readings ≤ 0 mg/dL (ln undefined) are skipped from the denominator.
* Returns null when no usable reading remains.
* Kovatchev 2006, Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085.
*/
export function bloodGlucoseRiskIndices(
values: number[],
): { lbgi: number; hbgi: number } | null {
let lowSum = 0;
let highSum = 0;
let n = 0;
for (const bg of values) {
if (!Number.isFinite(bg) || bg <= 0) continue;
const f = kovatchevSymmetrize(bg);
const r = 10 * f * f;
if (f < 0) {
lowSum += r;
} else if (f > 0) {
highSum += r;
}
// f === 0 contributes 0 to both branches but still counts in n.
n += 1;
}
if (n === 0) return null;
return { lbgi: lowSum / n, hbgi: highSum / n };
}
/**
* Time-in-range distribution over the Battelino 2019 consensus bands, as
* fractions of readings (NOT % of time — spot data, labelled honestly).
@@ -314,7 +424,13 @@ export function computeGlucoseClinicalMetrics(
(r) =>
r.measuredAt.getTime() >= cutoff &&
r.measuredAt.getTime() <= now.getTime() &&
Number.isFinite(r.mgdl),
Number.isFinite(r.mgdl) &&
// Non-positive glucose is non-physiological and ln-undefined (the
// Kovatchev symmetrization needs ln BG). Excluding it here keeps EVERY
// index — mean / SD / TIR / GMI / LBGI / HBGI — on one shared
// denominator rather than letting the risk indices drop a row the
// others kept.
r.mgdl > 0,
)
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
@@ -334,6 +450,19 @@ export function computeGlucoseClinicalMetrics(
const gmiValue = meanMgdl !== null ? gmi(meanMgdl) : null;
const eA1cValue = meanMgdl !== null ? estimatedA1c(meanMgdl) : null;
// Advanced indices — null when no readings. The risk indices need ≥ 1 usable
// reading; the J-index needs ≥ 2 (sample SD), so it is held to null inside
// the object for a single-reading window while LBGI/HBGI still resolve.
const risk = bloodGlucoseRiskIndices(values);
const advanced =
risk !== null
? {
jIndex: jIndex(values),
lbgi: risk.lbgi,
hbgi: risk.hbgi,
}
: null;
// Learning/adequacy gate. Spot data cannot meet the CGM adequacy bar; this
// is the honest "is the estimate worth asserting yet" check. Order matters:
// report the most actionable reason first.
@@ -370,6 +499,7 @@ export function computeGlucoseClinicalMetrics(
gmi: gmiValue,
estimatedA1c: eA1cValue,
variability,
advanced,
isSpotEstimate: true,
};
}
+24
View File
@@ -46,6 +46,10 @@ import {
type DataSummary,
} from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import {
computeGlucoseClinicalMetrics,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import {
computeBpInTargetFastPath,
type BpInTargetEnvelope,
@@ -206,6 +210,17 @@ export interface DashboardSnapshotExtras {
bpInTargetCount90: number | null;
bpInTargetSpanDays90: number | null;
glucoseByContext: Record<string, DataSummary>;
/**
* v1.17.0 — server-authoritative glucose clinical panel over the trailing
* 30-day window: the learning gate (+ reason), window/span/count, mean,
* Battelino TIR distribution, GMI, estimated A1C, SD/CV%/instability, and the
* advanced J-index + LBGI/HBGI tier. Computed ONCE by
* `computeGlucoseClinicalMetrics` so the iOS client renders the same numbers
* the web panel, the coach, and the doctor report do — iOS never re-derives.
* Always present (even with zero readings) so the native client paints the
* calm "still learning" state from a populated object.
*/
glucoseClinical: GlucoseClinicalMetrics;
}
/**
@@ -565,6 +580,14 @@ async function buildExtras(
}
}
// v1.17.0 — clinical panel from the SAME 30-day glucose rows already read
// above (no extra DB hop), computed by the one literature-locked engine so
// the iOS cold-launch seed matches the web panel / coach / doctor report.
const glucoseClinical = computeGlucoseClinicalMetrics(
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
{ windowDays: 30, now },
);
return {
extras: {
bpInTargetPct,
@@ -576,6 +599,7 @@ async function buildExtras(
bpInTargetCount90,
bpInTargetSpanDays90,
glucoseByContext,
glucoseClinical,
},
healthScore,
};
+27
View File
@@ -18,6 +18,10 @@ import {
thresholdMetricForContext,
type GlucoseUnit,
} from "@/lib/glucose";
import {
computeGlucoseClinicalMetrics,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import type {
GlucoseContext,
MeasurementSource,
@@ -96,6 +100,16 @@ export interface DoctorReportData {
glucoseStats: Record<string, DoctorReportStats>;
glucoseRanges: Record<string, { min: number; max: number }>;
glucoseUnit: GlucoseUnit;
/**
* v1.17.0 — server-authoritative glucose clinical panel over the report
* period (TIR / GMI / estimated A1C / CV% + the advanced J-index + LBGI/HBGI
* tier), computed by `computeGlucoseClinicalMetrics` so the PDF/FHIR export
* carries the SAME numbers the insights panel and the coach show. Values are
* canonical mg/dL; `glucoseUnit` drives display conversion at render. Carried
* as a spot-reading estimate (`isSpotEstimate: true`), gated by
* `stillLearning` when the period is too thin to assert clinically.
*/
glucoseClinical: GlucoseClinicalMetrics;
bmi: number | null;
compliance: Record<string, DoctorReportCompliance>;
medications: Array<{
@@ -869,6 +883,18 @@ export async function collectDoctorReportData(
}
}
// v1.17.0 — clinical panel over the WHOLE report period (all contexts
// pooled), computed by the one literature-locked engine the insights panel
// and the coach also consume. `windowDays` is the report's own period so the
// TIR / GMI / eA1C / CV% reflect exactly the readings the rest of the report
// tabulates; `now: end` anchors the window to the report's upper bound. The
// learning gate keeps a thin period from asserting a clinical AGP off spot
// data. Values stay canonical mg/dL; the renderer converts with `glucoseUnit`.
const glucoseClinical = computeGlucoseClinicalMetrics(
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
{ windowDays: days, now: end },
);
// `practiceName` is sanitised to a single-line string with a hard length
// cap. The PDF cover prints it verbatim — never let unbounded input land in
// a layout-sensitive header.
@@ -1056,6 +1082,7 @@ export async function collectDoctorReportData(
stats: filteredStats,
glucoseStats,
glucoseRanges,
glucoseClinical,
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
bmi: filteredBmi,
compliance: filteredCompliance,
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { buildFhirDocumentBundle } from "../build-bundle";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type {
FhirObservation,
FhirMedicationStatement,
@@ -77,6 +78,9 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 },
},
glucoseRanges: { FASTING: { min: 70, max: 99 } },
glucoseClinical: computeGlucoseClinicalMetrics([], {
now: new Date("2026-05-03T12:00:00.000Z"),
}),
glucoseUnit: "mg/dL",
bmi: 24.1,
compliance: {
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import { cycleObservationsFromReportData } from "@/lib/fhir/resources";
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
import {
@@ -28,6 +29,9 @@ function baseData(overrides?: Partial<DoctorReportData>): DoctorReportData {
stats: {},
glucoseStats: {},
glucoseRanges: {},
glucoseClinical: computeGlucoseClinicalMetrics([], {
now: new Date("2026-05-03T12:00:00.000Z"),
}),
glucoseUnit: "mg/dL",
bmi: null,
compliance: {},
+55
View File
@@ -541,6 +541,60 @@ const insightsPregenerateResponse = z
// modules and the iOS client does not consume this web-only route.
const dataSummaryRecord = z.record(z.string(), z.unknown());
// v1.17.0 — server-authoritative glucose clinical panel. Mirrors
// `GlucoseClinicalMetrics` from `@/lib/analytics/glucose-metrics`: the
// trailing-30-day TIR / GMI / eA1C / CV% headline plus the advanced
// J-index + LBGI/HBGI tier, gated by a `stillLearning` flag so a thin
// spot-data window is never asserted as a clinical AGP. The iOS client
// renders these numbers verbatim and never re-derives them.
const glucoseClinicalSchema = z
.object({
stillLearning: z.boolean(),
stillLearningReason: z.string().nullable(),
windowDays: z.number().int(),
actualSpanDays: z.number(),
readingCount: z.number().int(),
meanMgdl: z.number().nullable(),
distribution: z
.object({
tir: z.number(),
tbrLevel1: z.number(),
tbrLevel2: z.number(),
tarLevel1: z.number(),
tarLevel2: z.number(),
minutesEquivalent: z.object({
tir: z.number(),
tbrLevel1: z.number(),
tbrLevel2: z.number(),
tarLevel1: z.number(),
tarLevel2: z.number(),
}),
})
.nullable(),
gmi: z.number().nullable(),
estimatedA1c: z.number().nullable(),
variability: z
.object({
sd: z.number(),
cv: z.number(),
unstable: z.boolean(),
})
.nullable(),
advanced: z
.object({
jIndex: z.number().nullable(),
lbgi: z.number(),
hbgi: z.number(),
})
.nullable(),
isSpotEstimate: z.literal(true),
})
.meta({
id: "GlucoseClinicalMetrics",
description:
"Server-authoritative glucose clinical panel over the trailing 30-day window. Every figure is a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP — `isSpotEstimate` is always true and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading window.",
});
const dashboardSnapshotResponse = z
.object({
user: z.object({
@@ -608,6 +662,7 @@ const dashboardSnapshotResponse = z
bpInTargetPctPriorMonth: z.number().nullable(),
bpInTargetPctPriorYear: z.number().nullable(),
glucoseByContext: dataSummaryRecord,
glucoseClinical: glucoseClinicalSchema,
})
.nullable(),
// Dashboard hero — today's medication block (fast phase, always
+9
View File
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import type { DataSummary } from "@/lib/analytics/trends";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
/**
* v1.4.33 IW2 — single TanStack-Query wrapper for `/api/analytics`.
@@ -71,6 +72,14 @@ export interface AnalyticsRawPayload {
bpInTargetPctPriorMonth?: number | null;
bpInTargetPctPriorYear?: number | null;
glucoseByContext?: Record<string, unknown>;
/**
* v1.17.0 — server-authoritative glucose clinical panel (TIR / GMI / eA1C /
* CV% headline + the advanced J-index + LBGI/HBGI tier), computed once over
* the trailing-30-day window. Present on the thick default slice; absent on
* the slim `?slice=summaries` branch. The panel renders this verbatim and
* never re-derives a clinical number client-side.
*/
glucoseClinical?: GlucoseClinicalMetrics;
correlations?: unknown;
healthScore?: unknown;
sleepStages?: unknown;