mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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
This commit is contained in:
1 parent
fc47006c88
commit
75361d0666
19 files changed
+357
-70
No files matched your search
+7
-7
@@ -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",
|
||||
|
||||
+8
-8
@@ -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",
|
||||
|
||||
+7
-7
@@ -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",
|
||||
|
||||
+7
-7
@@ -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",
|
||||
|
||||
+7
-7
@@ -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",
|
||||
|
||||
+8
-8
@@ -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": "목표 범위",
|
||||
|
||||
+7
-7
@@ -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",
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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([]);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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("<SubPageShell>", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<SubPageShell
|
||||
title="Blood glucose"
|
||||
explainerMetric="bloodGlucose"
|
||||
explainerParams={glucoseBandParams(
|
||||
"mmol/L",
|
||||
makeFormatters("en", "UTC", "AUTO", "AUTO").number,
|
||||
)}
|
||||
>
|
||||
<span />
|
||||
</SubPageShell>,
|
||||
);
|
||||
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(
|
||||
<SubPageShell title="Blood pressure" explainerMetric="bloodPressure">
|
||||
|
||||
@@ -148,6 +148,30 @@ describe("<GlucoseClinicalPanel> 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(<GlucoseClinicalPanel />, "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(<GlucoseClinicalPanel />)).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) =>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
<GlucoseTirBar distribution={dist} />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.bloodGlucose.clinical.tir.caption")}
|
||||
{t(
|
||||
"insights.bloodGlucose.clinical.tir.caption",
|
||||
glucoseBandParams(glucoseUnit, fmt.number),
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -153,6 +153,8 @@ export interface HealthKitMetricPageProps {
|
||||
* Resolves `insights.subPage.explainer.<explainerMetric>{Title,Body}`.
|
||||
*/
|
||||
explainerMetric?: string;
|
||||
/** Interpolation values for the explainer sentence (`SubPageShell`). */
|
||||
explainerParams?: Record<string, string>;
|
||||
/**
|
||||
* v1.8.5 W5 — when set, renders `<MetricTargetSummary slug=…>` 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={<StatStripSkeleton />}
|
||||
>
|
||||
<ChartSkeleton />
|
||||
@@ -364,6 +371,7 @@ export function HealthKitMetricPage({
|
||||
title={title}
|
||||
description={description}
|
||||
explainerMetric={explainerMetric}
|
||||
explainerParams={explainerParams}
|
||||
>
|
||||
<QueryErrorRow
|
||||
slot="healthkit-metric-error"
|
||||
@@ -389,6 +397,7 @@ export function HealthKitMetricPage({
|
||||
title={title}
|
||||
description={description}
|
||||
explainerMetric={explainerMetric}
|
||||
explainerParams={explainerParams}
|
||||
>
|
||||
<MetricEmptyState
|
||||
icon={emptyStateIcon}
|
||||
@@ -406,6 +415,7 @@ export function HealthKitMetricPage({
|
||||
title={title}
|
||||
description={description}
|
||||
explainerMetric={explainerMetric}
|
||||
explainerParams={explainerParams}
|
||||
statStrip={
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
|
||||
@@ -70,6 +70,11 @@ export interface SubPageShellProps {
|
||||
* one- to two-sentence definition, no tap required.
|
||||
*/
|
||||
explainerMetric?: string;
|
||||
/**
|
||||
* Interpolation values for the explainer sentence, for a definition that
|
||||
* names figures in the reader's own unit (the glucose cut-points).
|
||||
*/
|
||||
explainerParams?: Record<string, string>;
|
||||
/**
|
||||
* 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,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{description && !explainerMetric ? (
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, GlucoseCutPoint>;
|
||||
|
||||
/** 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<string, GlucoseCutPoint>;
|
||||
|
||||
/**
|
||||
* 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<string, string> {
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user