mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Add glucose clinical panel with advanced-index disclosure
This commit is contained in:
1 parent
3be7c3788b
commit
4b9f31f053
13 files changed
+1269
No files matched your search
@@ -2795,6 +2795,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",
|
||||
|
||||
@@ -2795,6 +2795,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",
|
||||
|
||||
@@ -2795,6 +2795,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",
|
||||
|
||||
@@ -2795,6 +2795,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",
|
||||
|
||||
@@ -2795,6 +2795,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",
|
||||
|
||||
@@ -2795,6 +2795,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",
|
||||
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user