From 75361d0666e5df22629cdf2192b7a16dc09fc86a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 22:07:07 +0200 Subject: [PATCH] fix(glucose): state band edges in the reader's glucose unit The time-in-range labels in the doctor report, the target caption on the glucose panel, the glucose page description and the fasting explainer printed fixed mg/dL figures, so a reader on mmol/L saw "Low (< 70 mg/dL)" beside values in mmol/L. The cut-points now live in one place (`GLUCOSE_TIR_CUT_POINTS`, `GLUCOSE_FASTING_CUT_POINTS`) with the SI figure each guideline prints, the analytics engine counts its bands from the same constants, and every sentence takes the band edges and unit as parameters through `glucoseBandParams`, formatted with the reader's number format. The unit-preference guard now also reads the locale bundles: a string that names mg/dL or mmol/L fails unless it is allowlisted with a reason. Refs #1067 --- messages/de.json | 14 ++-- messages/en.json | 16 ++-- messages/es.json | 14 ++-- messages/fr.json | 14 ++-- messages/it.json | 14 ++-- messages/ko.json | 16 ++-- messages/pl.json | 14 ++-- .../unit-preference-display-guard.test.ts | 73 ++++++++++++++++++- src/app/insights/blood-glucose/page.tsx | 12 ++- .../__tests__/sub-page-shell.test.tsx | 23 ++++++ .../__tests__/glucose-clinical-panel.test.tsx | 24 ++++++ .../glucose/glucose-clinical-panel.tsx | 14 +++- .../insights/healthkit-metric-page.tsx | 12 ++- src/components/insights/sub-page-shell.tsx | 11 ++- .../__tests__/doctor-report-pdf-core.test.ts | 24 ++++++ src/lib/__tests__/glucose.test.ts | 46 ++++++++++++ src/lib/analytics/glucose-metrics.ts | 14 ++-- .../measurements-charts-section.ts | 19 +++-- src/lib/glucose.ts | 53 ++++++++++++++ 19 files changed, 357 insertions(+), 70 deletions(-) diff --git a/messages/de.json b/messages/de.json index 371a8386b..66cc4a50a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -261,11 +261,11 @@ "title": "Glukose-Übersicht (klinisch)", "colMetric": "Kennzahl", "colValue": "Wert", - "tirInRange": "Zeit im Zielbereich (70–180 mg/dl)", - "tirLow": "Niedrig (< 70 mg/dl)", - "tirVeryLow": "Sehr niedrig (< 54 mg/dl)", - "tirHigh": "Hoch (> 180 mg/dl)", - "tirVeryHigh": "Sehr hoch (> 250 mg/dl)", + "tirInRange": "Zeit im Zielbereich ({low}–{high} {unit})", + "tirLow": "Niedrig (< {low} {unit})", + "tirVeryLow": "Sehr niedrig (< {veryLow} {unit})", + "tirHigh": "Hoch (> {high} {unit})", + "tirVeryHigh": "Sehr hoch (> {veryHigh} {unit})", "mean": "Durchschnittliche Glukose", "gmi": "Glukose-Management-Indikator (GMI)", "eA1c": "Geschätzter HbA1c", @@ -3440,7 +3440,7 @@ }, "bloodGlucose": { "title": "Blutzucker", - "description": "Deine Blutzuckerwerte im Zeitverlauf, in mg/dL.", + "description": "Deine Blutzuckerwerte im Zeitverlauf, in {unit}.", "chartTitle": "Blutzucker", "medianLabel": "Median Blutzucker", "emptyState": { @@ -3461,7 +3461,7 @@ "daysCountOne": "1 Tag", "tir": { "title": "Zeit im Zielbereich", - "caption": "Anteil der Messungen je Bereich (70–180 mg/dl ist das Ziel).", + "caption": "Anteil der Messungen je Bereich ({low}–{high} {unit} ist das Ziel).", "veryLow": "Sehr niedrig", "low": "Niedrig", "inRange": "Im Zielbereich", diff --git a/messages/en.json b/messages/en.json index 718b70a66..8bfe26d62 100644 --- a/messages/en.json +++ b/messages/en.json @@ -297,11 +297,11 @@ "title": "Glucose overview (clinical)", "colMetric": "Metric", "colValue": "Value", - "tirInRange": "Time in range (70–180 mg/dL)", - "tirLow": "Low (< 70 mg/dL)", - "tirVeryLow": "Very low (< 54 mg/dL)", - "tirHigh": "High (> 180 mg/dL)", - "tirVeryHigh": "Very high (> 250 mg/dL)", + "tirInRange": "Time in range ({low}–{high} {unit})", + "tirLow": "Low (< {low} {unit})", + "tirVeryLow": "Very low (< {veryLow} {unit})", + "tirHigh": "High (> {high} {unit})", + "tirVeryHigh": "Very high (> {veryHigh} {unit})", "mean": "Average glucose", "gmi": "Glucose Management Indicator (GMI)", "eA1c": "Estimated A1C", @@ -3174,7 +3174,7 @@ "headphoneAudioBody": "Headphone audio levels track how loudly you listen through earbuds or headphones, in decibels. Keeping the volume down and taking breaks protects your hearing over the long run.", "audioEventsBody": "Loud sound events flag moments when noise around you reached a level that can harm hearing with enough exposure. Frequent events are a cue to seek quieter surroundings or hearing protection.", "daylightBody": "Time in daylight is how long you spend in outdoor light each day. Regular daylight supports a healthy sleep-wake rhythm and mood (CDC 2024), and time outdoors is linked to eye health.", - "bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals. As general guidance for adults without diabetes, a fasting reading under 100 mg/dL is normal, 100–125 is prediabetes, and 126 or above falls in the diabetes range (ADA 2024). A single high reading does not diagnose — a clinician confirms with a repeat test, and people managing diabetes use different, clinician-set targets.", + "bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals. As general guidance for adults without diabetes, a fasting reading under {prediabetesFrom} {unit} is normal, {prediabetesFrom}–{prediabetesTo} is prediabetes, and {diabetesFrom} or above falls in the diabetes range (ADA 2024). A single high reading does not diagnose — a clinician confirms with a repeat test, and people managing diabetes use different, clinician-set targets.", "skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase.", "cardioFitnessBody": "VO₂ max is how much oxygen your body can use at peak effort — the best lab index of aerobic fitness, which wearables estimate indirectly. A higher or rising trend is a strong sign of cardiovascular health.", "cardioRecoveryBody": "Cardio recovery is how many beats per minute your heart rate falls in the first minute after you stop hard exercise. A bigger drop means your heart settles down faster, which is a sign of good fitness.", @@ -3440,7 +3440,7 @@ }, "bloodGlucose": { "title": "Blood Glucose", - "description": "Your blood-glucose readings over time, in mg/dL.", + "description": "Your blood-glucose readings over time, in {unit}.", "chartTitle": "Blood glucose", "medianLabel": "Median glucose", "emptyState": { @@ -3461,7 +3461,7 @@ "daysCountOne": "1 day", "tir": { "title": "Time in range", - "caption": "Share of readings in each band (70–180 mg/dL is the target).", + "caption": "Share of readings in each band ({low}–{high} {unit} is the target).", "veryLow": "Very low", "low": "Low", "inRange": "In range", diff --git a/messages/es.json b/messages/es.json index 5fdcd3a00..a3e109389 100644 --- a/messages/es.json +++ b/messages/es.json @@ -261,11 +261,11 @@ "title": "Resumen de glucosa (clínico)", "colMetric": "Métrica", "colValue": "Valor", - "tirInRange": "Tiempo en rango (70–180 mg/dl)", - "tirLow": "Bajo (< 70 mg/dl)", - "tirVeryLow": "Muy bajo (< 54 mg/dl)", - "tirHigh": "Alto (> 180 mg/dl)", - "tirVeryHigh": "Muy alto (> 250 mg/dl)", + "tirInRange": "Tiempo en rango ({low}–{high} {unit})", + "tirLow": "Bajo (< {low} {unit})", + "tirVeryLow": "Muy bajo (< {veryLow} {unit})", + "tirHigh": "Alto (> {high} {unit})", + "tirVeryHigh": "Muy alto (> {veryHigh} {unit})", "mean": "Glucosa media", "gmi": "Indicador de gestión de la glucosa (GMI)", "eA1c": "A1C estimada", @@ -3440,7 +3440,7 @@ }, "bloodGlucose": { "title": "Glucosa en sangre", - "description": "Tus lecturas de glucosa en el tiempo, en mg/dL.", + "description": "Tus lecturas de glucosa en el tiempo, en {unit}.", "chartTitle": "Glucosa en sangre", "medianLabel": "Glucosa mediana", "emptyState": { @@ -3461,7 +3461,7 @@ "daysCountOne": "1 día", "tir": { "title": "Tiempo en rango", - "caption": "Proporción de mediciones en cada banda (70–180 mg/dl es el objetivo).", + "caption": "Proporción de mediciones en cada banda ({low}–{high} {unit} es el objetivo).", "veryLow": "Muy bajo", "low": "Bajo", "inRange": "En rango", diff --git a/messages/fr.json b/messages/fr.json index 771801950..d9e6a5017 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -261,11 +261,11 @@ "title": "Aperçu glycémique (clinique)", "colMetric": "Indicateur", "colValue": "Valeur", - "tirInRange": "Temps dans la cible (70–180 mg/dl)", - "tirLow": "Bas (< 70 mg/dl)", - "tirVeryLow": "Très bas (< 54 mg/dl)", - "tirHigh": "Élevé (> 180 mg/dl)", - "tirVeryHigh": "Très élevé (> 250 mg/dl)", + "tirInRange": "Temps dans la cible ({low}–{high} {unit})", + "tirLow": "Bas (< {low} {unit})", + "tirVeryLow": "Très bas (< {veryLow} {unit})", + "tirHigh": "Élevé (> {high} {unit})", + "tirVeryHigh": "Très élevé (> {veryHigh} {unit})", "mean": "Glycémie moyenne", "gmi": "Indicateur de gestion du glucose (GMI)", "eA1c": "HbA1c estimée", @@ -3440,7 +3440,7 @@ }, "bloodGlucose": { "title": "Glycémie", - "description": "Vos relevés de glycémie au fil du temps, en mg/dL.", + "description": "Vos relevés de glycémie au fil du temps, en {unit}.", "chartTitle": "Glycémie", "medianLabel": "Glycémie médiane", "emptyState": { @@ -3461,7 +3461,7 @@ "daysCountOne": "1 jour", "tir": { "title": "Temps dans la cible", - "caption": "Part des mesures dans chaque plage (70–180 mg/dl est la cible).", + "caption": "Part des mesures dans chaque plage ({low}–{high} {unit} est la cible).", "veryLow": "Très bas", "low": "Bas", "inRange": "Dans la cible", diff --git a/messages/it.json b/messages/it.json index 904422f6e..639c0a7a6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -261,11 +261,11 @@ "title": "Panoramica glicemia (clinica)", "colMetric": "Metrica", "colValue": "Valore", - "tirInRange": "Tempo nell'intervallo (70–180 mg/dl)", - "tirLow": "Basso (< 70 mg/dl)", - "tirVeryLow": "Molto basso (< 54 mg/dl)", - "tirHigh": "Alto (> 180 mg/dl)", - "tirVeryHigh": "Molto alto (> 250 mg/dl)", + "tirInRange": "Tempo nell'intervallo ({low}–{high} {unit})", + "tirLow": "Basso (< {low} {unit})", + "tirVeryLow": "Molto basso (< {veryLow} {unit})", + "tirHigh": "Alto (> {high} {unit})", + "tirVeryHigh": "Molto alto (> {veryHigh} {unit})", "mean": "Glicemia media", "gmi": "Indicatore di gestione del glucosio (GMI)", "eA1c": "A1C stimata", @@ -3440,7 +3440,7 @@ }, "bloodGlucose": { "title": "Glicemia", - "description": "Le tue letture di glicemia nel tempo, in mg/dL.", + "description": "Le tue letture di glicemia nel tempo, in {unit}.", "chartTitle": "Glicemia", "medianLabel": "Glicemia mediana", "emptyState": { @@ -3461,7 +3461,7 @@ "daysCountOne": "1 giorno", "tir": { "title": "Tempo nel range", - "caption": "Quota di misurazioni in ciascuna fascia (70–180 mg/dl è l’obiettivo).", + "caption": "Quota di misurazioni in ciascuna fascia ({low}–{high} {unit} è l’obiettivo).", "veryLow": "Molto basso", "low": "Basso", "inRange": "Nel range", diff --git a/messages/ko.json b/messages/ko.json index 9b67626fa..bab8a4d71 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -297,11 +297,11 @@ "title": "혈당 개요 (임상)", "colMetric": "지표", "colValue": "값", - "tirInRange": "목표 범위 내 시간 (70–180 mg/dL)", - "tirLow": "낮음 (< 70 mg/dL)", - "tirVeryLow": "매우 낮음 (< 54 mg/dL)", - "tirHigh": "높음 (> 180 mg/dL)", - "tirVeryHigh": "매우 높음 (> 250 mg/dL)", + "tirInRange": "목표 범위 내 시간 ({low}–{high} {unit})", + "tirLow": "낮음 (< {low} {unit})", + "tirVeryLow": "매우 낮음 (< {veryLow} {unit})", + "tirHigh": "높음 (> {high} {unit})", + "tirVeryHigh": "매우 높음 (> {veryHigh} {unit})", "mean": "평균 혈당", "gmi": "혈당 관리 지표 (GMI)", "eA1c": "추정 당화혈색소 (A1C)", @@ -3174,7 +3174,7 @@ "headphoneAudioBody": "헤드폰 음량은 이어폰이나 헤드폰으로 얼마나 크게 듣는지를 dB로 기록해요. 볼륨을 낮추고 중간중간 쉬어 주면 오래도록 청력을 지킬 수 있어요.", "audioEventsBody": "큰 소리 이벤트는 주변 소음이 충분히 노출될 경우 청력을 해칠 수 있는 수준에 도달한 순간을 표시해요. 자주 발생한다면 더 조용한 환경을 찾거나 청력 보호구를 쓰라는 신호예요.", "daylightBody": "햇빛 노출 시간은 하루에 야외 빛 속에서 보낸 시간이에요. 규칙적인 햇빛은 건강한 수면-각성 리듬과 기분에 도움이 되고(CDC 2024), 야외에서 보내는 시간은 눈 건강과도 연결돼요.", - "bloodGlucoseBody": "혈당은 몸의 주된 에너지원인 혈액 속 당의 양이에요. 식후에 올라가고 식사 사이에 가라앉아요. 당뇨가 없는 성인의 일반적인 기준으로 공복 혈당 100mg/dL 미만이 정상, 100~125는 당뇨 전 단계, 126 이상은 당뇨 범위예요(ADA 2024). 한 번 높게 나왔다고 진단되지는 않아요 — 임상의가 재검으로 확인하고, 당뇨를 관리하는 사람은 임상의가 정한 다른 목표를 써요.", + "bloodGlucoseBody": "혈당은 몸의 주된 에너지원인 혈액 속 당의 양이에요. 식후에 올라가고 식사 사이에 가라앉아요. 당뇨가 없는 성인의 일반적인 기준으로 공복 혈당 {prediabetesFrom} {unit} 미만이 정상, {prediabetesFrom}~{prediabetesTo}는 당뇨 전 단계, {diabetesFrom} 이상은 당뇨 범위예요(ADA 2024). 한 번 높게 나왔다고 진단되지는 않아요 — 임상의가 재검으로 확인하고, 당뇨를 관리하는 사람은 임상의가 정한 다른 목표를 써요.", "skinTemperatureBody": "여기서 피부 온도는 절대적인 발열 수치가 아니라 본인 기준값 대비 밤사이 손목 온도의 작은 변화예요. 이 변화는 수면·환경·질병·생리 주기 단계를 반영할 수 있어요.", "cardioFitnessBody": "VO₂ max는 최대 노력에서 몸이 쓸 수 있는 산소량으로, 유산소 체력의 가장 좋은 실험실 지표이고 웨어러블은 이를 간접 추정해요. 값이 높거나 올라가는 추이는 심혈관 건강의 강력한 신호예요.", "cardioRecoveryBody": "심박 회복은 격한 운동을 멈춘 뒤 1분 동안 심박수가 분당 몇 회 떨어지는지예요. 많이 떨어질수록 심장이 더 빨리 안정된다는 뜻이고, 체력이 좋다는 신호예요.", @@ -3440,7 +3440,7 @@ }, "bloodGlucose": { "title": "혈당", - "description": "시간에 따른 혈당 측정값이에요(mg/dL).", + "description": "시간에 따른 혈당 측정값이에요({unit}).", "chartTitle": "혈당", "medianLabel": "혈당 중앙값", "emptyState": { @@ -3461,7 +3461,7 @@ "daysCountOne": "1일", "tir": { "title": "목표 범위 내 시간", - "caption": "구간별 측정값 비율이에요(목표는 70~180mg/dL).", + "caption": "구간별 측정값 비율이에요(목표는 {low}~{high} {unit}).", "veryLow": "매우 낮음", "low": "낮음", "inRange": "목표 범위", diff --git a/messages/pl.json b/messages/pl.json index f5eb1ceb2..1ecc73cc5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -261,11 +261,11 @@ "title": "Przegląd glukozy (kliniczny)", "colMetric": "Wskaźnik", "colValue": "Wartość", - "tirInRange": "Czas w zakresie (70–180 mg/dl)", - "tirLow": "Niski (< 70 mg/dl)", - "tirVeryLow": "Bardzo niski (< 54 mg/dl)", - "tirHigh": "Wysoki (> 180 mg/dl)", - "tirVeryHigh": "Bardzo wysoki (> 250 mg/dl)", + "tirInRange": "Czas w zakresie ({low}–{high} {unit})", + "tirLow": "Niski (< {low} {unit})", + "tirVeryLow": "Bardzo niski (< {veryLow} {unit})", + "tirHigh": "Wysoki (> {high} {unit})", + "tirVeryHigh": "Bardzo wysoki (> {veryHigh} {unit})", "mean": "Średnia glukoza", "gmi": "Wskaźnik zarządzania glukozą (GMI)", "eA1c": "Szacowane HbA1c", @@ -3440,7 +3440,7 @@ }, "bloodGlucose": { "title": "Poziom glukozy", - "description": "Twoje odczyty glukozy w czasie, w mg/dL.", + "description": "Twoje odczyty glukozy w czasie, w {unit}.", "chartTitle": "Poziom glukozy", "medianLabel": "Mediana glukozy", "emptyState": { @@ -3461,7 +3461,7 @@ "daysCountOne": "1 dzień", "tir": { "title": "Czas w zakresie", - "caption": "Udział pomiarów w każdym przedziale (70–180 mg/dl to cel).", + "caption": "Udział pomiarów w każdym przedziale ({low}–{high} {unit} to cel).", "veryLow": "Bardzo niski", "low": "Niski", "inRange": "W zakresie", diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index b7fe91d41..296434789 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -10,7 +10,7 @@ * * Mutation check: add `unit="kg"` back to any swept page and this goes red. */ -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { stripComments, walkSourceFiles } from "./helpers/source-files"; @@ -515,3 +515,74 @@ describe("unit-preference display guard — AI and Coach producers", () => { expect(offenders).toEqual([]); }); }); + +/** + * Locale strings that may name a glucose unit literally, each with the reason + * it is not a reading shown in the reader's unit. Keys are dotted paths and + * apply to every locale bundle. + */ +const GLUCOSE_UNIT_LITERAL_ALLOWLIST: Record = { + "dashboard.metric.unit.glucose": + "the unit token the dashboard summary wire hands the native client; the web resolves the glucose unit from the account instead", + "labs.biomarker.form.unitPlaceholder": + "an example of a free-text lab unit in an input placeholder, not a reading", +}; + +const GLUCOSE_UNIT_LITERAL = /mg\/dl|mmol\/l\b/i; + +function flattenStrings( + node: unknown, + path: string, + out: Array<[string, string]>, +): void { + if (typeof node === "string") { + out.push([path, node]); + } else if (node && typeof node === "object") { + for (const [key, value] of Object.entries(node)) { + flattenStrings(value, path ? `${path}.${key}` : key, out); + } + } +} + +describe("unit-preference display guard — locale strings", () => { + const MESSAGES = join(process.cwd(), "messages"); + const bundles = readdirSync(MESSAGES).filter((f) => f.endsWith(".json")); + + it("reads every locale bundle", () => { + expect(bundles.length).toBeGreaterThanOrEqual(7); + }); + + it("no locale string fixes a glucose unit outside the allowlist", () => { + // A sentence that prints "70–180 mg/dL" shows mg/dL to a reader who + // chose mmol/L. Band and unit go in as parameters (`glucoseBandParams`). + const offenders: string[] = []; + for (const file of bundles) { + const entries: Array<[string, string]> = []; + flattenStrings( + JSON.parse(readFileSync(join(MESSAGES, file), "utf8")), + "", + entries, + ); + for (const [key, value] of entries) { + if (key in GLUCOSE_UNIT_LITERAL_ALLOWLIST) continue; + if (GLUCOSE_UNIT_LITERAL.test(value)) offenders.push(`${file}: ${key}`); + } + } + expect(offenders).toEqual([]); + }); + + it("every allowlisted key still exists and still names a unit", () => { + // An entry whose string was reworded or removed is a stale exemption. + const en: Array<[string, string]> = []; + flattenStrings( + JSON.parse(readFileSync(join(MESSAGES, "en.json"), "utf8")), + "", + en, + ); + const byKey = new Map(en); + const stale = Object.keys(GLUCOSE_UNIT_LITERAL_ALLOWLIST).filter( + (key) => !GLUCOSE_UNIT_LITERAL.test(byKey.get(key) ?? ""), + ); + expect(stale).toEqual([]); + }); +}); diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index 0007fef34..e2acca4be 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -5,8 +5,12 @@ 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 { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { + glucoseBandParams, + MGDL_PER_MMOL, + resolveGlucoseUnit, +} from "@/lib/glucose"; /** * v1.7.0 — `/insights/blood-glucose`. @@ -27,6 +31,7 @@ import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose"; export default function InsightsBlutzuckerPage() { const { user } = useAuth(); const { t } = useTranslations(); + const fmt = useFormatters(); const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); const isMmol = glucoseUnit === "mmol/L"; @@ -38,6 +43,9 @@ export default function InsightsBlutzuckerPage() { chartKey="bloodGlucose" i18nPrefix="insights.bloodGlucose" explainerMetric="bloodGlucose" + // The definition names the fasting cut-points; they follow the + // reader's glucose unit like the chart does. + explainerParams={glucoseBandParams(glucoseUnit, fmt.number)} color="var(--chart-3)" unit={glucoseUnit} yAxisUnit={glucoseUnit} diff --git a/src/components/insights/__tests__/sub-page-shell.test.tsx b/src/components/insights/__tests__/sub-page-shell.test.tsx index c0c134ca1..bf827f969 100644 --- a/src/components/insights/__tests__/sub-page-shell.test.tsx +++ b/src/components/insights/__tests__/sub-page-shell.test.tsx @@ -2,6 +2,8 @@ import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; +import { glucoseBandParams } from "@/lib/glucose"; +import { makeFormatters } from "@/lib/format-locale"; import { SubPageShell } from "../sub-page-shell"; vi.mock("next/navigation", () => ({ @@ -68,6 +70,27 @@ describe("", () => { ); }); + it("fills the explainer's figures from the explainer parameters", () => { + // The glucose definition names the fasting cut-points; a reader on + // mmol/L reads them in mmol/L, never a fixed "100 mg/dL". + const html = render( + + + , + ); + expect(html).toContain( + "a fasting reading under 5.6 mmol/L is normal, 5.6–6.9 is prediabetes, and 7.0 or above", + ); + expect(html).not.toContain("{"); + }); + it("no longer paints the round `?` explainer trigger next to the heading", () => { const html = render( diff --git a/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx b/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx index cd3a60b1e..6dbb55925 100644 --- a/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx +++ b/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx @@ -148,6 +148,30 @@ describe(" asserted state", () => { expect(html).toContain("J-index"); }); + it("states the target band in the reader's unit and number format", () => { + // The caption used to print "70–180 mg/dL" to every reader, beside + // values the panel had already converted to mmol/L. + authMock.mockReturnValue({ + user: { glucoseUnit: "mmol/L" }, + isAuthenticated: true, + }); + analyticsMock.mockReturnValue({ + isLoading: false, + data: { glucoseClinical: assertedClinical(), glucoseByContext: {} }, + }); + const de = render(, "de"); + expect(de).toContain("(3,9–10,0 mmol/L ist das Ziel)"); + expect(de).not.toMatch(/mg\/dl/i); + + authMock.mockReturnValue({ + user: { glucoseUnit: "mg/dL" }, + isAuthenticated: true, + }); + expect(render()).toContain( + "(70–180 mg/dL is the target)", + ); + }); + it("flags the CV badge unstable at CV% >= 36", () => { // wide spread → CV well above 36 const values = Array.from({ length: 20 }, (_, i) => diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index ff9665c7a..48e440497 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -12,8 +12,12 @@ import { LearnMoreLink } from "@/components/ui/learn-more-link"; 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 { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { + convertGlucose, + glucoseBandParams, + resolveGlucoseUnit, +} from "@/lib/glucose"; import type { DataSummary } from "@/lib/analytics/trends"; import { GlucoseTirBar } from "./glucose-tir-bar"; import { GlucoseAdvancedDisclosure } from "./glucose-advanced-disclosure"; @@ -41,6 +45,7 @@ import { GlucoseAdvancedDisclosure } from "./glucose-advanced-disclosure"; export function GlucoseClinicalPanel() { const { user, isAuthenticated } = useAuth(); const { t } = useTranslations(); + const fmt = useFormatters(); const query = useAnalyticsQuery({}); const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); @@ -158,7 +163,10 @@ export function GlucoseClinicalPanel() {

- {t("insights.bloodGlucose.clinical.tir.caption")} + {t( + "insights.bloodGlucose.clinical.tir.caption", + glucoseBandParams(glucoseUnit, fmt.number), + )}

) : null} diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index c8ccf4cad..321fddb30 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -153,6 +153,8 @@ export interface HealthKitMetricPageProps { * Resolves `insights.subPage.explainer.{Title,Body}`. */ explainerMetric?: string; + /** Interpolation values for the explainer sentence (`SubPageShell`). */ + explainerParams?: Record; /** * v1.8.5 W5 — when set, renders `` beneath * the chart. Used by blood glucose, whose per-context ADA / DDG bands @@ -227,6 +229,7 @@ export function HealthKitMetricPage({ coachPrefill, valueScale, explainerMetric, + explainerParams, targetSummarySlug, statusMetric, statIcon, @@ -334,7 +337,10 @@ export function HealthKitMetricPage({ : rawSummary; const title = t(`${i18nPrefix}.title`); - const description = t(`${i18nPrefix}.description`); + // A description that names its unit ("in mg/dL") reads the resolved one. + const description = t(`${i18nPrefix}.description`, { + unit: resolvedUnit ?? "", + }); // v1.12.7 — in-flight skeleton. The page consumed only `{data, isEmpty}` // before, so the ~30 HealthKit sub-pages painted nothing until the @@ -347,6 +353,7 @@ export function HealthKitMetricPage({ title={title} description={description} explainerMetric={explainerMetric} + explainerParams={explainerParams} statStrip={} > @@ -364,6 +371,7 @@ export function HealthKitMetricPage({ title={title} description={description} explainerMetric={explainerMetric} + explainerParams={explainerParams} > ; /** * v1.4.27 MB7 / CF-35 — opt-in programmatic focus on mount. * @@ -155,6 +160,7 @@ export function SubPageShell({ badge, description, explainerMetric, + explainerParams, focusOnMount = false, statStrip, coachReadStrip, @@ -385,7 +391,10 @@ export function SubPageShell({ data-slot="metric-explainer-inline" className="text-foreground text-sm leading-relaxed" > - {t(`insights.subPage.explainer.${explainerMetric}Body`)} + {t( + `insights.subPage.explainer.${explainerMetric}Body`, + explainerParams, + )}

) : null} {description && !explainerMetric ? ( diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 58f8688af..e454d48c8 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -594,6 +594,30 @@ describe("doctor-report clinical glucose panel", () => { expect(text).toContain("Glucose Management Indicator"); }); + it("labels the time-in-range bands in the owner's glucose unit", async () => { + // The band labels used to read "Low (< 70 mg/dL)" in a report whose + // values were all in mmol/L. + const data = makeData({ + glucoseUnit: "mmol/L", + glucoseClinical: computeGlucoseClinicalMetrics(denseReadings(), { + now: FIXED_NOW, + windowDays: 90, + }), + }); + const bytes = renderDoctorReportPdfBytes(data, { + timeFormat: "AUTO", + dateFormat: "AUTO", + t: getServerTranslator("de").t, + locale: "de", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).toContain("Zeit im Zielbereich (3,9–10,0 mmol/L)"); + expect(text).toContain("Sehr niedrig (< 3,0 mmol/L)"); + expect(text).toContain("Sehr hoch (> 13,9 mmol/L)"); + expect(text).not.toMatch(/mg\/dl/i); + }); + it("omits the clinical panel when there are no glucose readings", async () => { const data = makeData(); // empty (zero-reading) glucose panel const bytes = renderDoctorReportPdfBytes(data, { diff --git a/src/lib/__tests__/glucose.test.ts b/src/lib/__tests__/glucose.test.ts index a684ba896..419bdf020 100644 --- a/src/lib/__tests__/glucose.test.ts +++ b/src/lib/__tests__/glucose.test.ts @@ -9,7 +9,11 @@ import { glucoseContextBucket, groupByGlucoseContext, GLUCOSE_CONTEXT_UNSPECIFIED, + GLUCOSE_FASTING_CUT_POINTS, + GLUCOSE_TIR_CUT_POINTS, + glucoseBandParams, } from "../glucose"; +import { makeFormatters } from "../format-locale"; describe("glucose conversion", () => { it("converts mg/dL to mmol/L with 1 decimal", () => { @@ -157,3 +161,45 @@ describe("glucose context buckets (#943)", () => { ); }); }); + +describe("glucose band parameters", () => { + const de = makeFormatters("de", "UTC", "AUTO", "AUTO").number; + const en = makeFormatters("en", "UTC", "AUTO", "AUTO").number; + + it("states the consensus bands in mg/dL as whole numbers", () => { + expect(glucoseBandParams("mg/dL", en)).toMatchObject({ + unit: "mg/dL", + veryLow: "54", + low: "70", + high: "180", + veryHigh: "250", + prediabetesFrom: "100", + prediabetesTo: "125", + diabetesFrom: "126", + }); + }); + + it("states them in mmol/L with the locale's decimal separator", () => { + expect(glucoseBandParams("mmol/L", de)).toMatchObject({ + unit: "mmol/L", + veryLow: "3,0", + low: "3,9", + high: "10,0", + veryHigh: "13,9", + prediabetesFrom: "5,6", + prediabetesTo: "6,9", + diabetesFrom: "7,0", + }); + }); + + it("carries SI figures that agree with the conversion", () => { + // The consensus bands convert exactly; the ADA cut-points are within + // one rounding step of it (100 mg/dL is printed as 5.6, converts to 5.5). + for (const point of Object.values(GLUCOSE_TIR_CUT_POINTS)) { + expect(point.mmol).toBe(mgdlToMmol(point.mgdl)); + } + for (const point of Object.values(GLUCOSE_FASTING_CUT_POINTS)) { + expect(Math.abs(point.mmol - point.mgdl / 18.0182)).toBeLessThan(0.1); + } + }); +}); diff --git a/src/lib/analytics/glucose-metrics.ts b/src/lib/analytics/glucose-metrics.ts index 332ef484f..a0f566735 100644 --- a/src/lib/analytics/glucose-metrics.ts +++ b/src/lib/analytics/glucose-metrics.ts @@ -39,6 +39,8 @@ * Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085. */ +import { GLUCOSE_TIR_CUT_POINTS } from "@/lib/glucose"; + // ── Inputs ─────────────────────────────────────────────── /** A single spot blood-glucose reading. Value is canonical mg/dL. */ @@ -79,11 +81,13 @@ export interface GlucoseMetricsOptions { } // ── Consensus thresholds (Battelino 2019) ──────────────── -// Bands in mg/dL. These are consensus FACTS, cited above. -const TBR_LEVEL2_MAX = 54; // very low: G < 54 -const TBR_LEVEL1_MAX = 70; // low: G < 70 (level-1 sub-band is 54–69) -const TAR_LEVEL1_MIN = 180; // high: G > 180 (level-1 sub-band is 181–250) -const TAR_LEVEL2_MIN = 250; // very high: G > 250 +// Bands in mg/dL. These are consensus FACTS, cited above. The values live in +// `GLUCOSE_TIR_CUT_POINTS` so the band labels a reader sees and the bands +// this engine counts are the same numbers. +const TBR_LEVEL2_MAX = GLUCOSE_TIR_CUT_POINTS.veryLow.mgdl; // very low: G < 54 +const TBR_LEVEL1_MAX = GLUCOSE_TIR_CUT_POINTS.low.mgdl; // low: G < 70 (level-1 sub-band is 54–69) +const TAR_LEVEL1_MIN = GLUCOSE_TIR_CUT_POINTS.high.mgdl; // high: G > 180 (level-1 sub-band is 181–250) +const TAR_LEVEL2_MIN = GLUCOSE_TIR_CUT_POINTS.veryHigh.mgdl; // very high: G > 250 // Time-in-range target band is [70, 180] inclusive. /** The time-in-range band, mg/dL, inclusive at both ends. */ export const TIME_IN_RANGE_MGDL = { diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index 32fdd4e1a..797c56150 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -1,7 +1,11 @@ import type { jsPDF } from "jspdf"; import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose"; import autoTable from "jspdf-autotable"; -import { convertGlucose, resolveGlucoseUnit } from "../glucose"; +import { + convertGlucose, + glucoseBandParams, + resolveGlucoseUnit, +} from "../glucose"; import { reportStatValue } from "../doctor-report/stat-display"; import type { MeasurementType } from "@/generated/prisma/client"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys"; @@ -303,26 +307,29 @@ export function buildMeasurementsChartsSection( const clinical = data.glucoseClinical; if (clinical && clinical.readingCount > 0 && clinical.distribution) { const pct = (fraction: number) => `${num(fraction * 100)} %`; + // The band edges follow the reader's glucose unit, like every value in + // the table beside them. + const bands = glucoseBandParams(glucoseUnit, num); const clinicalRows: string[][] = []; const dist = clinical.distribution; clinicalRows.push([ - t("doctorReport.glucoseClinical.tirInRange"), + t("doctorReport.glucoseClinical.tirInRange", bands), pct(dist.tir), ]); clinicalRows.push([ - t("doctorReport.glucoseClinical.tirLow"), + t("doctorReport.glucoseClinical.tirLow", bands), pct(dist.tbrLevel1), ]); clinicalRows.push([ - t("doctorReport.glucoseClinical.tirVeryLow"), + t("doctorReport.glucoseClinical.tirVeryLow", bands), pct(dist.tbrLevel2), ]); clinicalRows.push([ - t("doctorReport.glucoseClinical.tirHigh"), + t("doctorReport.glucoseClinical.tirHigh", bands), pct(dist.tarLevel1), ]); clinicalRows.push([ - t("doctorReport.glucoseClinical.tirVeryHigh"), + t("doctorReport.glucoseClinical.tirVeryHigh", bands), pct(dist.tarLevel2), ]); if (clinical.meanMgdl !== null) { diff --git a/src/lib/glucose.ts b/src/lib/glucose.ts index 4b4e69a61..c525463e4 100644 --- a/src/lib/glucose.ts +++ b/src/lib/glucose.ts @@ -42,6 +42,59 @@ export function toCanonicalMgdl(value: number, from: GlucoseUnit): number { return from === "mmol/L" ? mmolToMgdl(value) : Math.round(value); } +/** + * A published glucose cut-point, in both units as its source states it. + * + * The SI figure is carried rather than converted because a guideline prints + * its own rounding: ADA's fasting cut-point is 100 mg/dL and 5.6 mmol/L, and + * `mgdlToMmol(100)` gives 5.5. A reader on mmol/L must see the number their + * own guideline prints, not a re-rounded neighbour of it. + */ +export interface GlucoseCutPoint { + mgdl: number; + mmol: number; +} + +/** Consensus time-in-range band edges (Battelino 2019). */ +export const GLUCOSE_TIR_CUT_POINTS = { + veryLow: { mgdl: 54, mmol: 3.0 }, + low: { mgdl: 70, mmol: 3.9 }, + high: { mgdl: 180, mmol: 10.0 }, + veryHigh: { mgdl: 250, mmol: 13.9 }, +} as const satisfies Record; + +/** Fasting-glucose diagnostic cut-points for adults (ADA 2024). */ +export const GLUCOSE_FASTING_CUT_POINTS = { + prediabetesFrom: { mgdl: 100, mmol: 5.6 }, + prediabetesTo: { mgdl: 125, mmol: 6.9 }, + diabetesFrom: { mgdl: 126, mmol: 7.0 }, +} as const satisfies Record; + +/** + * The interpolation parameters every glucose band sentence takes: each + * cut-point in the reader's unit, formatted by the caller's locale-aware + * number formatter, plus the unit symbol. A sentence that names a band + * reads its numbers from here, so no locale string carries a fixed unit. + */ +export function glucoseBandParams( + unit: GlucoseUnit, + formatNumber: (value: number, fractionDigits: number) => string, +): Record { + const digits = unit === "mmol/L" ? 1 : 0; + const v = (point: GlucoseCutPoint) => + formatNumber(unit === "mmol/L" ? point.mmol : point.mgdl, digits); + return { + unit, + veryLow: v(GLUCOSE_TIR_CUT_POINTS.veryLow), + low: v(GLUCOSE_TIR_CUT_POINTS.low), + high: v(GLUCOSE_TIR_CUT_POINTS.high), + veryHigh: v(GLUCOSE_TIR_CUT_POINTS.veryHigh), + prediabetesFrom: v(GLUCOSE_FASTING_CUT_POINTS.prediabetesFrom), + prediabetesTo: v(GLUCOSE_FASTING_CUT_POINTS.prediabetesTo), + diabetesFrom: v(GLUCOSE_FASTING_CUT_POINTS.diabetesFrom), + }; +} + export function resolveGlucoseUnit( userPreference: string | null | undefined, ): GlucoseUnit {