diff --git a/messages/de.json b/messages/de.json index cfea1b804..1107a5032 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 0e147aab4..11b2e5520 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index ee2ea77c3..0a883ebb6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 54b3b084b..7aeb5ae30 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 04730edac..e488d02ef 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index e3915160a..58d6f7c71 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index 397999a13..e753ad589 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -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={} emptyStateCtaType={null} targetSummarySlug="blood-glucose" + afterChart={} coachPrefill="I haven't logged any blood glucose yet — what does this metric tell me about my health, and how do I improve it?" /> ); diff --git a/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx b/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx new file mode 100644 index 000000000..5cc49eed5 --- /dev/null +++ b/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx @@ -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( + {node}, + ); +} + +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(" 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(); + 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(); + expect(html).toContain('data-state="learning"'); + expect(html).toContain("Once a handful of readings come in"); + }); +}); + +describe(" 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(); + 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(); + 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(); + expect(html).toBe(""); + }); +}); + +describe("", () => { + 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(); + // 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("", () => { + it("is collapsed by default and exposes a toggle + the three indices", () => { + const html = render( + , + ); + 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"); + }); +}); diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx new file mode 100644 index 000000000..5be77f4ed --- /dev/null +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -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 = { + 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; + /** Per-context summaries keyed by `GlucoseContext`; optional. */ + byContext?: Record | 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 ( +
+ + + +
+ ); +} + +function AdvancedStat({ + label, + help, + value, +}: { + label: string; + help: string; + value: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx new file mode 100644 index 000000000..95f32c4fb --- /dev/null +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -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 ( + + + + + + +
+ + + +
+
+
+ ); + } + + 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 + | 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 ( + + + + + + +

+ {hasAny + ? t("insights.bloodGlucose.clinical.learningBody", { + count: readingsLabel, + days: daysLabel, + }) + : t("insights.bloodGlucose.clinical.learningBodyEmpty")} +

+

+ {t("insights.bloodGlucose.clinical.spotCaveat")} +

+
+
+ ); + } + + // Asserted view. + const dist = clinical.distribution; + const cv = clinical.variability; + + return ( + + +
+ + + + {t("insights.bloodGlucose.clinical.subtitle", { + days: Math.max(1, Math.round(clinical.actualSpanDays)), + count: clinical.readingCount, + })} + +
+
+ + {/* Time in range */} + {dist ? ( +
+
+

+ {t("insights.bloodGlucose.clinical.tir.title")} +

+
+ +

+ {t("insights.bloodGlucose.clinical.tir.caption")} +

+
+ ) : null} + + {/* Headline metrics */} +
+ {clinical.meanMgdl !== null ? ( +
+ + {/* Spot-reading honesty caveat — always present. */} +

+ {t("insights.bloodGlucose.clinical.spotCaveat")} +

+ + {/* Advanced indices behind a disclosure. */} + {clinical.advanced ? ( + + ) : null} +
+
+ ); +} + +function Metric({ + icon, + label, + help, + value, + unit, +}: { + icon: React.ReactNode; + label: string; + help?: string; + value: string; + unit: string; +}) { + return ( +
+ + {icon} + {label} + + + {value} + {unit} + +
+ ); +} diff --git a/src/components/insights/glucose/glucose-tir-bar.tsx b/src/components/insights/glucose/glucose-tir-bar.tsx new file mode 100644 index 000000000..25602a3f0 --- /dev/null +++ b/src/components/insights/glucose/glucose-tir-bar.tsx @@ -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 ( +
+
+ {segments.map((s) => + s.fraction > 0 ? ( +
+ ) : null, + )} +
+
    + {segments.map((s) => ( +
  • + + + + {pct(s.fraction)}% + +
  • + ))} +
+
+ ); +} diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 5a804ca0c..e58bea689 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -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 ? ( ) : null} + {/* v1.17.0 — page-specific extra block (blood glucose: the clinical + panel). Mounted on the data-bearing branch only. */} + {afterChart} {forecast && isTrajectoryType(measurementType) ? ( ({ + measuredAt: new Date(NOW.getTime() - (VALUES.length - 1 - i) * DAY), + mgdl, + })); +} + +/** Coach projection — mirrors `snapshot.glucose.clinical` (asserted branch). */ +function coachProjection( + m: ReturnType, +) { + 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, +) { + 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); + }); +});