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 1/4] 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() {
diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx
index 3e0f5f761..f57e1c5d3 100644
--- a/src/components/labs/lab-list.tsx
+++ b/src/components/labs/lab-list.tsx
@@ -13,7 +13,6 @@ import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
-import { formatDate } from "@/lib/format";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabReading } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
@@ -22,7 +21,7 @@ import { queryKeys } from "@/lib/query-keys";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { LabTrendSparkline } from "./lab-trend-sparkline";
-import { useLabNumber } from "./use-lab-format";
+import { useLabDate, useLabNumber } from "./use-lab-format";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { SourceRangeNote } from "./source-range-note";
@@ -124,6 +123,7 @@ const MOBILE_CELL = {
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const labNumber = useLabNumber();
+ const labDate = useLabDate();
const { canWriteDomain } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const { prefs } = useModuleListPrefs("labs");
@@ -259,7 +259,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
{formatLabReading(group.latest, labNumber)}
- {formatDate(group.latest.takenAt)}
+ {labDate(group.latest.takenAt)}
);
@@ -394,7 +394,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
className="text-xs"
/>
- {formatDate(group.latest.takenAt)}
+ {labDate(group.latest.takenAt)}
{group.readings.length > 1 ? (
diff --git a/src/components/labs/use-lab-format.ts b/src/components/labs/use-lab-format.ts
index a153ecc11..ef7820fc2 100644
--- a/src/components/labs/use-lab-format.ts
+++ b/src/components/labs/use-lab-format.ts
@@ -2,8 +2,10 @@
import { useCallback } from "react";
+import { useCalendarDate } from "@/hooks/use-calendar-date";
import { useFormatters } from "@/lib/i18n/context";
import { formatLabValue, type LabNumberFormat } from "@/lib/labs/format-value";
+import { isNoonUtcAnchor } from "@/lib/tz/date-only";
/**
* A lab value in the reader's number format, trimmed the way every lab
@@ -16,3 +18,23 @@ export function useLabNumber(): LabNumberFormat {
[fmt],
);
}
+
+/**
+ * The date a lab reading was taken. A reading entered by hand carries its
+ * time of day and is dated in the reader's zone; one imported from a report
+ * that states only the date is stored at noon UTC and is dated by that
+ * calendar date, so it does not move to the next day east of UTC+12.
+ */
+export function useLabDate(): (takenAt: string) => string {
+ const fmt = useFormatters();
+ const calendarDate = useCalendarDate();
+ return useCallback(
+ (takenAt: string) => {
+ const instant = new Date(takenAt);
+ return isNoonUtcAnchor(instant)
+ ? calendarDate(instant)
+ : fmt.date(instant);
+ },
+ [fmt, calendarDate],
+ );
+}
diff --git a/src/components/settings/share-document-picker.tsx b/src/components/settings/share-document-picker.tsx
index 4320f448f..70509f8d5 100644
--- a/src/components/settings/share-document-picker.tsx
+++ b/src/components/settings/share-document-picker.tsx
@@ -13,6 +13,7 @@
* the central factory; reads unwrap `(await res.json()).data` via `apiGet`.
* No write happens here — attaching is the create mutation's job.
*/
+import { useDocumentDate } from "@/components/documents/use-document-date";
import { useQuery } from "@tanstack/react-query";
import { Check, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
@@ -23,9 +24,8 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
-import { documentDateKey } from "@/components/documents/vault-utils";
import { apiGet } from "@/lib/api/api-fetch";
-import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
@@ -55,7 +55,7 @@ export function ShareDocumentPicker({
max: number;
}) {
const { t } = useTranslations();
- const format = useFormatters();
+ const documentDate = useDocumentDate();
const [searchDraft, setSearchDraft] = useState("");
const [query, setQuery] = useState("");
@@ -195,7 +195,7 @@ export function ShareDocumentPicker({
{title}
- {format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
+ {documentDate(doc)}
string {
+ const { locale } = useTranslations();
+ const dateFormat = useDateFormatPreference();
+ return useCallback(
+ (value: string | Date) =>
+ formatDate(
+ // `formatDate` reads a bare key as a calendar date already.
+ typeof value === "string" ? value.slice(0, 10) : value,
+ dateFormat,
+ locale,
+ ),
+ [dateFormat, locale],
+ );
+}
diff --git a/src/lib/tz/__tests__/date-only.test.ts b/src/lib/tz/__tests__/date-only.test.ts
index 6c09a06b2..541fddbce 100644
--- a/src/lib/tz/__tests__/date-only.test.ts
+++ b/src/lib/tz/__tests__/date-only.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ isNoonUtcAnchor,
dateOnlyAtNoonUtc,
dateOnlyKey,
dayKeyAsUtcMidnight,
@@ -50,3 +51,12 @@ describe("calendar key arithmetic", () => {
expect(weekdayOfDateKey("2026-10-04")).toBe(0); // Sunday
});
});
+
+describe("isNoonUtcAnchor", () => {
+ it("recognises the stored anchor of a date-only value and nothing else", () => {
+ expect(isNoonUtcAnchor(dateOnlyAtNoonUtc("2025-10-04"))).toBe(true);
+ expect(isNoonUtcAnchor(new Date("2025-10-04T12:00:01.000Z"))).toBe(false);
+ expect(isNoonUtcAnchor(new Date("2025-10-04T00:00:00.000Z"))).toBe(false);
+ expect(isNoonUtcAnchor(new Date("2025-10-04T20:30:00.000Z"))).toBe(false);
+ });
+});
diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts
index 589fee3c5..5018f530a 100644
--- a/src/lib/tz/date-only.ts
+++ b/src/lib/tz/date-only.ts
@@ -58,6 +58,24 @@ export function dateOnlyAtNoonUtc(key: string): Date {
return new Date(`${key}T12:00:00.000Z`);
}
+/**
+ * True when a stored instant is the noon-UTC anchor of a date-only value.
+ *
+ * For a column that holds both kinds (a lab reading typed with its time of
+ * day, or imported from a report that states only the date), this is how a
+ * reader tells them apart. A reading typed at exactly 12:00:00.000 UTC reads
+ * as its UTC calendar date, which is its local date everywhere from UTC-11
+ * to UTC+11.
+ */
+export function isNoonUtcAnchor(value: Date): boolean {
+ return (
+ value.getUTCHours() === 12 &&
+ value.getUTCMinutes() === 0 &&
+ value.getUTCSeconds() === 0 &&
+ value.getUTCMilliseconds() === 0
+ );
+}
+
/**
* The calendar date of a stored date-only value (or of a `@db.Date` column,
* which Prisma hands back as UTC midnight). Only for values that ARE dates:
From debd44a75b00fc4ffcd03a24710a4deb56105005 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 1 Oct 2026 22:18:28 +0200
Subject: [PATCH 4/4] fix(charts): keep axis unit labels on one line; 44 px
date and time triggers
Recharts word-wraps a tick label that it measures wider than its axis,
and it measures in the page's body font rather than the tick's 11 px.
"62 bpm" on the pulse chart under a workout and "106 mg/dL" on the
glucose page therefore broke onto two lines at every width, and the
pulse chart's second line sat on the first x tick. Axis units are now
joined with a no-break space through `axisUnitSuffix`, so the label is
one word and stays on one line. A guard fails when a chart axis joins a
unit with a plain space.
The calendar and clock buttons in the date and time fields paint at
32 px; a `::before` halo now gives them a 44 px touch target without
changing the field.
---
src/components/charts/health-chart.tsx | 5 +-
.../insights/intraday-pulse-chart.tsx | 3 +-
.../detail/efficacy/efficacy-chart.tsx | 3 +-
.../ui/__tests__/date-field.test.tsx | 13 +++++
.../ui/__tests__/time-field.test.tsx | 12 ++++
src/components/ui/date-field.tsx | 4 +-
src/components/ui/time-field.tsx | 4 +-
src/lib/charts/__tests__/axis-unit.test.ts | 56 +++++++++++++++++++
src/lib/charts/axis-unit.ts | 15 +++++
9 files changed, 109 insertions(+), 6 deletions(-)
create mode 100644 src/lib/charts/__tests__/axis-unit.test.ts
create mode 100644 src/lib/charts/axis-unit.ts
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index 229f38a21..60cfe4cb8 100644
--- a/src/components/charts/health-chart.tsx
+++ b/src/components/charts/health-chart.tsx
@@ -71,6 +71,7 @@ import {
type MetricWindowStats,
} from "@/lib/charts/window-stats";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
+import { axisUnitSuffix } from "@/lib/charts/axis-unit";
// The range tabs select a CALENDAR-DAY window ending now — `days: 7` is
// "the last 7 days", not "the last 7 readings". The labels said "points"
@@ -1982,8 +1983,8 @@ export function HealthChart({
: String(value)
}
unit={
- showYAxisUnit && (yAxisUnit ?? unit)
- ? ` ${yAxisUnit ?? unit}`
+ showYAxisUnit
+ ? axisUnitSuffix(yAxisUnit ?? unit)
: undefined
}
/>
diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx
index 09e1ae79a..5fac93a99 100644
--- a/src/components/insights/intraday-pulse-chart.tsx
+++ b/src/components/insights/intraday-pulse-chart.tsx
@@ -24,6 +24,7 @@ import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
+import { axisUnitSuffix } from "@/lib/charts/axis-unit";
import {
BUCKET_MINUTES,
type IntradayHrBucket,
@@ -300,7 +301,7 @@ export function IntradayPulseChart({
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
- unit=" bpm"
+ unit={axisUnitSuffix("bpm")}
width={52}
/>
", () => {
expect(html).toContain("sm:h-10");
});
+ it("gives the calendar button a 44 px touch target at its 32 px size", () => {
+ const html = render();
+ const button = /