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/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index cc4a0583b..b4fe29356 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -1,11 +1,8 @@ "use client"; +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useState, useMemo, useEffect, useRef } from "react"; -import { - useDisplayTimezone, - useFormatters, - useTranslations, -} from "@/lib/i18n/context"; +import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; import { heatmapDays } from "@/lib/charts/heatmap-days"; interface DailyData { @@ -83,12 +80,11 @@ export function ComplianceHeatmap({ stretch = false, }: ComplianceHeatmapProps) { const { t } = useTranslations(); - const fmt = useFormatters(); const timeZone = useDisplayTimezone(); - // Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the - // locale-aware renderer never lands a day off in either direction. - const formatDay = (dateKey: string) => - fmt.date(new Date(`${dateKey}T12:00:00Z`)); + // Day keys are calendar dates ("YYYY-MM-DD"), read as such in every zone. + // A noon-UTC instant formatted in the reader's zone was a day late from + // UTC+12 to UTC+14. + const formatDay = useCalendarDate(); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [tooltip, setTooltip] = useState<{ 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/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 959d25d7e..6e0276ea8 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -1,11 +1,8 @@ "use client"; +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useState, useMemo, useEffect, useRef } from "react"; -import { - useDisplayTimezone, - useFormatters, - useTranslations, -} from "@/lib/i18n/context"; +import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; import { heatmapDays } from "@/lib/charts/heatmap-days"; import { moodLabelKeyForScore } from "@/lib/mood/labels"; @@ -66,12 +63,11 @@ export function MoodHeatmap({ stretch = false, }: MoodHeatmapProps) { const { t } = useTranslations(); - const fmt = useFormatters(); const timeZone = useDisplayTimezone(); - // Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the - // locale-aware renderer never lands a day off in either direction. - const formatDay = (dateKey: string) => - fmt.date(new Date(`${dateKey}T12:00:00Z`)); + // Day keys are calendar dates ("YYYY-MM-DD"), read as such in every zone. + // A noon-UTC instant formatted in the reader's zone was a day late from + // UTC+12 to UTC+14. + const formatDay = useCalendarDate(); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [tooltip, setTooltip] = useState<{ diff --git a/src/components/documents/__tests__/document-card-dates.test.tsx b/src/components/documents/__tests__/document-card-dates.test.tsx new file mode 100644 index 000000000..dcd2e86ba --- /dev/null +++ b/src/components/documents/__tests__/document-card-dates.test.tsx @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { makeFormatters } from "@/lib/format-locale"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; + +/** + * A document's filing date is a calendar date. The card used to anchor it at + * 12:00 UTC and format that instant in the reader's zone, which shows the + * next day from UTC+12 to UTC+14. A document without a filing date shows its + * upload day, an instant, in the reader's zone. + * + * Server rendering cannot see the reader's profile zone, so the formatter + * hook is handed one. + */ +const zone = vi.hoisted(() => ({ current: "UTC" })); + +vi.mock("@/lib/i18n/context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormatters: () => makeFormatters("en", zone.current, "AUTO", "AUTO"), + }; +}); + +const { DocumentCard } = await import("../document-card"); + +function doc(overrides: Partial): InboundDocumentDto { + return { + id: "doc-1", + kind: "IMAGING", + title: "Scan", + filename: "scan.pdf", + mimeType: "application/pdf", + byteSize: 1000, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: null, + errorReason: null, + factCount: 0, + pendingCount: 0, + conditionLinks: [], + encounterLinks: [], + servingClass: "inline", + hasContentIndex: false, + contentIndexSource: null, + lastIndexAttemptAt: null, + lastIndexOutcome: null, + hasThumbnail: false, + sourceSystem: null, + sourceId: null, + createdAt: "2025-10-04T20:30:00.000Z", + updatedAt: "2025-10-04T20:30:00.000Z", + ...overrides, + }; +} + +function render(document: InboundDocumentDto) { + const noop = () => {}; + return renderToStaticMarkup( + + + , + ); +} + +const previousTz = process.env.TZ; + +describe("document card dates across zones", () => { + afterEach(() => { + process.env.TZ = previousTz; + zone.current = "UTC"; + }); + + describe.each(["Pacific/Kiritimati", "America/Los_Angeles"])( + "in %s", + (tz) => { + beforeEach(() => { + process.env.TZ = tz; + zone.current = tz; + }); + + it("shows the filing date as stated", () => { + const html = render(doc({ documentDate: "2025-10-04" })); + expect(html).toContain("10/04/2025"); + expect(html).not.toContain("10/05/2025"); + expect(html).not.toContain("10/03/2025"); + }); + }, + ); + + it("shows the upload day in the reader's zone when no filing date is set", () => { + // 20:30 UTC on 4 October is 10:30 on 5 October in Kiritimati. + process.env.TZ = "Pacific/Kiritimati"; + zone.current = "Pacific/Kiritimati"; + expect(render(doc({ documentDate: null }))).toContain("10/05/2025"); + }); +}); diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index 6fa88499e..92f12addf 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -16,6 +16,7 @@ * whole card is clickable through an invisible overlay button; the checkbox * floats above it. */ +import { useDocumentDate } from "@/components/documents/use-document-date"; import { Check, Download, Loader2, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -24,14 +25,13 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { useFencedObjectUrl } from "@/hooks/use-fenced-object-url"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import type { UploadQueueItem } from "./use-document-upload"; import { documentCardKeyAction, - documentDateKey, formatBytes, isDocumentProcessing, } from "./vault-utils"; @@ -71,7 +71,7 @@ export function DocumentCard({ onPrefetch?: (id: string) => void; }) { const { t, locale } = useTranslations(); - const format = useFormatters(); + const documentDate = useDocumentDate(); // A preview thumbnail that fails to load (still rendering, decrypt error, // 404) falls back to the kind icon — never a broken image. @@ -101,7 +101,7 @@ export function DocumentCard({ const title = document.title ?? document.filename ?? t("documents.card.untitled"); const Icon = DOCUMENT_KIND_ICONS[document.kind]; - const date = format.date(`${documentDateKey(document)}T12:00:00.000Z`); + const date = documentDate(document); const size = formatBytes(document.byteSize, locale); const showFilename = document.filename !== null && document.filename !== title; diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 070fcec12..4ced44eca 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -16,6 +16,7 @@ * copy. A Share action in the footer opens the clinician share-link create * flow (shared `ShareLinkCreateForm`) with this document pre-attached. */ +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -344,6 +345,7 @@ export function DocumentDetailSheet({ }) { const { t, tCount, locale } = useTranslations(); const format = useFormatters(); + const calendarDate = useCalendarDate(); const queryClient = useQueryClient(); // v1.28.52 (Documents R3) — the vault "Ask the Coach" action opens the REAL // fenced coach conversation in the shared side drawer (scoped to this @@ -695,7 +697,7 @@ export function DocumentDetailSheet({ ? t(`documents.kind.${suggestion.kind}`) : null; const suggestionDateLabel = suggestion?.documentDate - ? format.date(`${suggestion.documentDate}T12:00:00.000Z`) + ? calendarDate(suggestion.documentDate) : null; const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled"); diff --git a/src/components/documents/document-facts-review.tsx b/src/components/documents/document-facts-review.tsx index 4885a93fa..ae20eedff 100644 --- a/src/components/documents/document-facts-review.tsx +++ b/src/components/documents/document-facts-review.tsx @@ -24,6 +24,7 @@ * rejected (stated in the copy); an unverified low-confidence fact gets NO * decision and stays pending rather than being silently discarded. */ +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useState } from "react"; import { FlaskConical, Loader2, ScanSearch } from "lucide-react"; import { toast } from "sonner"; @@ -34,7 +35,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { ConditionFactData, @@ -209,7 +210,7 @@ function FactRow({ failure: string | null; }) { const { t } = useTranslations(); - const format = useFormatters(); + const calendarDate = useCalendarDate(); const editFact = useEditFact(); const [verifying, setVerifying] = useState(false); const [draft, setDraft] = useState(() => draftFor(fact)); @@ -260,7 +261,7 @@ function FactRow({

{typeLabel} - {date ? ` · ${format.date(`${date}T12:00:00.000Z`)}` : ""} + {date ? ` · ${calendarDate(date)}` : ""} {isObservation && (fact.data as ObservationFactData).referenceText ? ( <> diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx index aa5c50158..3cc1edcc4 100644 --- a/src/components/documents/document-link-picker.tsx +++ b/src/components/documents/document-link-picker.tsx @@ -12,6 +12,7 @@ * Shows one page (50) of matches — the search input is the way to reach an * older document, mirroring the vault's own recall model. */ +import { useDocumentDate } from "@/components/documents/use-document-date"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Search } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -22,7 +23,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import type { @@ -30,7 +31,6 @@ import type { InboundDocumentDto, } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; -import { documentDateKey } from "./vault-utils"; interface ListPage { documents: InboundDocumentDto[]; @@ -47,7 +47,7 @@ export function DocumentLinkPicker({ onOpenChange: (open: boolean) => void; }) { const { t } = useTranslations(); - const format = useFormatters(); + const documentDate = useDocumentDate(); const queryClient = useQueryClient(); const [searchDraft, setSearchDraft] = useState(""); @@ -174,7 +174,7 @@ export function DocumentLinkPicker({ {title} - {format.date(`${documentDateKey(doc)}T12:00:00.000Z`)} + {documentDate(doc)} - {format.date(`${documentDateKey(doc)}T12:00:00.000Z`)} + {documentDate(doc)} diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx index 09dc62404..cbd599ca5 100644 --- a/src/components/documents/sources/document-source-picker.tsx +++ b/src/components/documents/sources/document-source-picker.tsx @@ -27,6 +27,7 @@ * With either, a document already in HealthLog can be picked too: it is not * stored twice, it is linked or handed to the form. */ +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useInfiniteQuery, useQuery, @@ -66,7 +67,7 @@ import { type DocumentSourceSearchDto, type DocumentSourceTagDto, } from "@/lib/documents/sources/types"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; @@ -119,7 +120,7 @@ export function DocumentSourcePicker({ onImported, }: DocumentSourcePickerProps) { const { t, locale } = useTranslations(); - const format = useFormatters(); + const calendarDate = useCalendarDate(); const queryClient = useQueryClient(); const [system, setSystem] = useState(null); @@ -581,7 +582,7 @@ export function DocumentSourcePicker({ const run = runs.get(row.sourceId); const allowed = selectable(row); const meta = [ - row.date ? format.date(`${row.date}T12:00:00.000Z`) : null, + row.date ? calendarDate(row.date) : null, row.sizeBytes !== null ? formatBytes(row.sizeBytes, locale) : null, diff --git a/src/components/documents/use-document-date.ts b/src/components/documents/use-document-date.ts new file mode 100644 index 000000000..f0d08ec7f --- /dev/null +++ b/src/components/documents/use-document-date.ts @@ -0,0 +1,27 @@ +"use client"; + +import { useCallback } from "react"; + +import { useCalendarDate } from "@/hooks/use-calendar-date"; +import { useFormatters } from "@/lib/i18n/context"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; + +/** + * The date a document files under, as a reader sees it. A filing date is a + * calendar date and reads the same in every zone; a document without one + * shows the day it was uploaded, which is an instant and reads in the + * reader's zone. + */ +export function useDocumentDate(): ( + doc: Pick, +) => string { + const fmt = useFormatters(); + const calendarDate = useCalendarDate(); + return useCallback( + (doc) => + doc.documentDate + ? calendarDate(doc.documentDate) + : fmt.date(doc.createdAt), + [fmt, calendarDate], + ); +} 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..bc39ce83f 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,35 @@ 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); + // Mean, GMI and eA1C read with the German decimal comma. + expect(de).not.toMatch(/>\d+\.\d+\d+,\d<\/span>mmol\/L/, + ); + + 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-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx index 7fbe90613..e28b2b68d 100644 --- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -4,7 +4,7 @@ import { useId, useState } from "react"; import { ChevronDown } from "lucide-react"; import { InfoPopover } from "@/components/ui/info-popover"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; import type { DataSummary } from "@/lib/analytics/trends"; @@ -49,11 +49,12 @@ export function GlucoseAdvancedDisclosure({ fractionDigits = 1, }: GlucoseAdvancedDisclosureProps) { const { t } = useTranslations(); + const number = useFormatters().number; const [open, setOpen] = useState(false); const regionId = useId(); const fmt = (v: number | null) => - v === null ? "—" : v.toFixed(fractionDigits); + v === null ? "—" : number(v, fractionDigits); const contexts = Object.entries(byContext ?? {}).filter( ([, s]) => s && s.count > 0, diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index ff9665c7a..56b0cd653 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); @@ -77,7 +82,7 @@ export function GlucoseClinicalPanel() { const formatGlucoseValue = (mgdl: number): string => { const v = convertGlucose(mgdl, glucoseUnit); - return isMmol ? v.toFixed(1) : String(Math.round(v)); + return fmt.number(v, isMmol ? 1 : 0); }; // Learning state — calm, never asserting TIR / GMI off thin data. @@ -158,7 +163,10 @@ export function GlucoseClinicalPanel() {

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

) : null} @@ -181,7 +189,7 @@ export function GlucoseClinicalPanel() { icon={