Merge pull request #1081 from MBombeck/fix/v1398-polish

Glucose bands, decimals, calendar dates and chart axes as the reader expects
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-01 22:51:34 +02:00
commit 1399cca4d0
57 files changed
+997 -180

No files matched your search

+7 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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([]);
});
});
+10 -2
View File
@@ -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}
+6 -10
View File
@@ -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<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [tooltip, setTooltip] = useState<{
+3 -2
View File
@@ -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
}
/>
+6 -10
View File
@@ -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<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [tooltip, setTooltip] = useState<{
@@ -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<typeof import("@/lib/i18n/context")>();
return {
...actual,
useFormatters: () => makeFormatters("en", zone.current, "AUTO", "AUTO"),
};
});
const { DocumentCard } = await import("../document-card");
function doc(overrides: Partial<InboundDocumentDto>): 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(
<I18nProvider initialLocale="en">
<DocumentCard
document={document}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>
</I18nProvider>,
);
}
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");
});
});
+4 -4
View File
@@ -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;
@@ -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");
@@ -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<VerifyDraft>(() => draftFor(fact));
@@ -260,7 +261,7 @@ function FactRow({
</p>
<p className="text-muted-foreground text-xs">
{typeLabel}
{date ? ` · ${format.date(`${date}T12:00:00.000Z`)}` : ""}
{date ? ` · ${calendarDate(date)}` : ""}
{isObservation &&
(fact.data as ObservationFactData).referenceText ? (
<>
@@ -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}
</span>
<span className="text-muted-foreground block text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
{documentDate(doc)}
</span>
</span>
<Check
@@ -14,6 +14,7 @@
* module-gated surfaces never leak. An episode without documents shows a
* quiet one-line affordance, not a teaching empty state.
*/
import { useDocumentDate } from "@/components/documents/use-document-date";
import { useQuery } from "@tanstack/react-query";
import { FolderOpen, Link2, Upload } from "lucide-react";
import Link from "next/link";
@@ -28,13 +29,12 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
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 type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentLinkPicker } from "./document-link-picker";
import { ImportFromSourceButton } from "./sources/import-from-source-button";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { documentDateKey } from "./vault-utils";
/** Compact preview size — "Alle anzeigen" carries the long tail. */
const PREVIEW_LIMIT = 5;
@@ -46,7 +46,7 @@ interface ListPage {
export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
const format = useFormatters();
const documentDate = useDocumentDate();
const { user } = useAuth();
// Linking a document to an episode writes through
// `POST /api/documents/inbound/bulk`, the same endpoint the vault gates on
@@ -137,7 +137,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
{title}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
{documentDate(doc)}
</span>
</Link>
</ListRow>
@@ -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<DocumentPickerSystem | null>(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,
@@ -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<InboundDocumentDto, "documentDate" | "createdAt">,
) => string {
const fmt = useFormatters();
const calendarDate = useCalendarDate();
return useCallback(
(doc) =>
doc.documentDate
? calendarDate(doc.documentDate)
: fmt.date(doc.createdAt),
[fmt, calendarDate],
);
}
@@ -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,35 @@ 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);
// Mean, GMI and eA1C read with the German decimal comma.
expect(de).not.toMatch(/>\d+\.\d+</);
expect(de).toMatch(
/>\d+,\d<\/span><span class="text-muted-foreground text-xs">mmol\/L/,
);
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) =>
@@ -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,
@@ -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() {
</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}
@@ -181,7 +189,7 @@ export function GlucoseClinicalPanel() {
icon={<Activity className="h-3.5 w-3.5" aria-hidden="true" />}
label={t("insights.bloodGlucose.clinical.gmi.label")}
help={t("insights.bloodGlucose.clinical.gmi.help")}
value={clinical.gmi.toFixed(1)}
value={fmt.number(clinical.gmi, 1)}
unit="%"
/>
) : null}
@@ -190,7 +198,7 @@ export function GlucoseClinicalPanel() {
icon={<TrendingUp className="h-3.5 w-3.5" aria-hidden="true" />}
label={t("insights.bloodGlucose.clinical.eA1c.label")}
help={t("insights.bloodGlucose.clinical.eA1c.help")}
value={clinical.estimatedA1c.toFixed(1)}
value={fmt.number(clinical.estimatedA1c, 1)}
unit="%"
/>
) : 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}
@@ -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}
/>
<Tooltip
+10 -1
View File
@@ -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 ? (
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { makeFormatters } from "@/lib/format-locale";
import { I18nProvider } from "@/lib/i18n/context";
import type { LabResultDto } from "../types";
/**
* A reading imported from a report that states only its date is stored at
* 12:00 UTC. Formatted in the reader's zone it read as the next day from
* UTC+12 to UTC+14. A reading typed with its time of day is an instant and
* keeps reading in the reader's zone.
*
* Server rendering cannot see the reader's profile zone, so the test hands
* it over the two ways the app reads it: the formatter hook and the
* mirrored zone the legacy helpers read.
*/
const zone = vi.hoisted(() => ({ current: "UTC" }));
vi.mock("@/lib/timezone-mirror", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/timezone-mirror")>()),
readStoredTimezone: () => zone.current,
}));
vi.mock("@/lib/i18n/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/i18n/context")>();
return {
...actual,
useFormatters: () => makeFormatters("en", zone.current, "AUTO", "AUTO"),
};
});
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiDelete: vi.fn(),
apiPost: vi.fn(),
apiPut: vi.fn(),
}));
const { LabHistoryList } = await import("../lab-history-list");
function reading(id: string, takenAt: string): LabResultDto {
return {
id,
biomarkerId: "bm-1",
panel: null,
analyte: "TSH",
value: 1.8,
valueText: null,
unit: "mIU/L",
referenceLow: 0.4,
referenceHigh: 4,
catalogReferenceLow: 0.4,
catalogReferenceHigh: 4,
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
referenceOrigin: "catalog",
referenceDivergesFromCatalog: false,
takenAt,
source: "MANUAL",
hasNote: false,
rangeStatus: "in-range",
createdAt: takenAt,
updatedAt: takenAt,
} as LabResultDto;
}
function render(readings: LabResultDto[]) {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<LabHistoryList readings={readings} />
</I18nProvider>
</QueryClientProvider>,
);
}
const previousTz = process.env.TZ;
describe("lab reading 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 a date-only reading on its stated date", () => {
const html = render([reading("r1", "2025-10-04T12:00:00.000Z")]);
expect(html).toContain("10/04/2025");
expect(html).not.toContain("10/05/2025");
expect(html).not.toContain("10/03/2025");
});
},
);
it("dates a reading typed with its time of day in the reader's zone", () => {
// 20:30 UTC on 4 October is 10:30 on 5 October in Kiritimati.
process.env.TZ = "Pacific/Kiritimati";
zone.current = "Pacific/Kiritimati";
const html = render([reading("r1", "2025-10-04T20:30:00.000Z")]);
expect(html).toContain("10/05/2025");
});
});
@@ -7,9 +7,10 @@ import { LabReferenceRangeBar } from "../lab-reference-range-bar";
function render(
props: Partial<React.ComponentProps<typeof LabReferenceRangeBar>> = {},
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<I18nProvider initialLocale={locale}>
<LabReferenceRangeBar
value={72}
referenceLow={60}
@@ -45,4 +46,15 @@ describe("<LabReferenceRangeBar>", () => {
expect(render({ referenceLow: null })).toContain('aria-label="≤100 bpm"');
expect(render({ referenceHigh: null })).toContain('aria-label="≥60 bpm"');
});
it("prints the reading and its range in the reader's number format", () => {
const html = render(
{ value: 1.8333, referenceLow: 0.4, referenceHigh: 4.05, unit: "mIU/L" },
"de",
);
expect(html).toContain('aria-label="0,4–4,05 mIU/L"');
// Lab precision: at most two decimals, then the locale's separator.
expect(html).toContain("1,83 mIU/L");
expect(html).not.toMatch(/\d\.\d+ mIU\/L/);
});
});
+3 -2
View File
@@ -13,12 +13,12 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { BiomarkerForm } from "./biomarker-form";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import { useLabNumber } from "./use-lab-format";
/**
* v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows.
@@ -42,6 +42,7 @@ import type { BiomarkerDto, BiomarkerListResponse } from "./types";
*/
export function BiomarkerManager() {
const { t } = useTranslations();
const labNumber = useLabNumber();
const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<BiomarkerDto | null>(null);
@@ -101,7 +102,7 @@ export function BiomarkerManager() {
const range = formatReferenceRange(
marker.lowerBound,
marker.upperBound,
formatLabValue,
labNumber,
);
return (
<li
+5 -4
View File
@@ -16,12 +16,12 @@ import {
import { Button } from "@/components/ui/button";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { formatLabValue } from "@/lib/labs/format-value";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
import type { LabResultDto } from "./types";
import { useLabNumber } from "./use-lab-format";
/**
* v1.18.1 — per-biomarker trend chart, in the dashboard's visual language.
@@ -91,6 +91,7 @@ export function LabBiomarkerChart({
upperBound: number | null;
}) {
const { t } = useTranslations();
const labNumber = useLabNumber();
const fmt = useFormatters();
const [range, setRange] = useState<RangeKey>("365");
// Capture "now" once at mount so the recency filter stays pure across
@@ -224,7 +225,7 @@ export function LabBiomarkerChart({
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
tickFormatter={(v: number) => formatLabValue(v)}
tickFormatter={(v: number) => labNumber(v)}
/>
{/* Reference window — muted band + dashed bounds. No alarm
colour: the in/out verdict is shown by the neutral badge,
@@ -302,7 +303,7 @@ export function LabBiomarkerChart({
const rows: RichTooltipRow[] = [
{
name: t("labs.chart.valueLabel"),
value: `${formatLabValue(point.value)} ${unit}`,
value: `${labNumber(point.value)} ${unit}`,
color: primary,
},
];
@@ -317,7 +318,7 @@ export function LabBiomarkerChart({
`${formatReferenceRange(
point.referenceLow,
point.referenceHigh,
formatLabValue,
labNumber,
)} ${unit}`.trim(),
color: secondary,
});
+4 -7
View File
@@ -27,12 +27,12 @@ import {
parseReferenceRange,
} from "@/lib/labs/parse-reference-range";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { BiomarkerForm } from "./biomarker-form";
import { useLabNumber } from "./use-lab-format";
import type {
BiomarkerDto,
BiomarkerListResponse,
@@ -135,6 +135,7 @@ export function LabForm({
footerSlot,
}: LabFormProps) {
const { t } = useTranslations();
const labNumber = useLabNumber();
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const formId = useId();
@@ -325,11 +326,7 @@ export function LabForm({
}
const referenceText = selected
? formatReferenceRange(
selected.lowerBound,
selected.upperBound,
formatLabValue,
)
? formatReferenceRange(selected.lowerBound, selected.upperBound, labNumber)
: "";
// What the typed string resolved to, shown back so the user can see whether
@@ -342,7 +339,7 @@ export function LabForm({
: `${formatReferenceRange(
parsedSourceRange.low,
parsedSourceRange.high,
formatLabValue,
labNumber,
)} ${selected?.unit ?? ""}`.trim()
: "";
+7 -7
View File
@@ -28,7 +28,6 @@ import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDateShort } from "@/lib/format";
import { formatLabReading } from "@/lib/labs/format-value";
import { resolveNoteForUpdate } from "@/lib/labs/note-update";
import { useTranslations } from "@/lib/i18n/context";
@@ -38,6 +37,7 @@ import { ReferenceRangeBadge } from "./reference-range-badge";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { SourceRangeNote } from "./source-range-note";
import type { LabResultDetailDto, LabResultDto } from "./types";
import { useLabDate, useLabNumber } from "./use-lab-format";
const NOTE_MAX_LENGTH = 2000;
@@ -77,6 +77,8 @@ function parseDecimal(raw: string): number | null {
*/
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
const labNumber = useLabNumber();
const labDate = useLabDate();
const { canManageDomain } = useRecordCapabilities();
const canManageLabs = canManageDomain("labs");
const queryClient = useQueryClient();
@@ -353,7 +355,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
<li key={r.id} className="flex items-start gap-3 py-3">
<div className="min-w-0 flex-1 space-y-2">
<div className="font-semibold tabular-nums">
{formatLabReading(r)}
{formatLabReading(r, labNumber)}
</div>
<LabReferenceRangeBar
value={r.value}
@@ -362,9 +364,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
unit={r.unit}
/>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span className="whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
</span>
<span className="whitespace-nowrap">{labDate(r.takenAt)}</span>
<ReferenceRangeBadge status={r.rangeStatus} />
{r.hasNote ? (
<span className="text-xs">{t("labs.hasNote")}</span>
@@ -421,7 +421,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
<TableRow key={r.id}>
<TableCell className="font-semibold tabular-nums">
<div className="space-y-2">
<span>{formatLabReading(r)}</span>
<span>{formatLabReading(r, labNumber)}</span>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
@@ -431,7 +431,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
{labDate(r.takenAt)}
</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-1">
+9 -7
View File
@@ -13,15 +13,15 @@ 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, formatLabValue } from "@/lib/labs/format-value";
import { formatLabReading } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/query-keys";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { LabTrendSparkline } from "./lab-trend-sparkline";
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";
@@ -122,6 +122,8 @@ 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");
@@ -255,9 +257,9 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest)}
{formatLabReading(group.latest, labNumber)}
</span>
<span>{formatDate(group.latest.takenAt)}</span>
<span>{labDate(group.latest.takenAt)}</span>
</div>
</div>
);
@@ -373,7 +375,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<div className="flex items-end justify-between gap-3">
<div className="text-muted-foreground flex min-w-0 flex-1 flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest)}
{formatLabReading(group.latest, labNumber)}
</span>
{group.latest.value !== null &&
(group.latest.referenceLow !== null ||
@@ -383,7 +385,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
{formatReferenceRange(
group.latest.referenceLow,
group.latest.referenceHigh,
formatLabValue,
labNumber,
)}
</span>
) : null}
@@ -392,7 +394,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
className="text-xs"
/>
<span className="text-xs">
{formatDate(group.latest.takenAt)}
{labDate(group.latest.takenAt)}
</span>
{group.readings.length > 1 ? (
<span className="text-xs">
@@ -2,6 +2,8 @@
import { RangeBar } from "@/components/targets/range-bar";
import { useLabNumber } from "./use-lab-format";
/**
* v1.37.34 — compact reference-range visualization for saved lab readings.
*
@@ -20,6 +22,7 @@ export function LabReferenceRangeBar({
referenceHigh: number | null;
unit: string;
}) {
const labNumber = useLabNumber();
const hasLow = referenceLow !== null && Number.isFinite(referenceLow);
const hasHigh = referenceHigh !== null && Number.isFinite(referenceHigh);
if (
@@ -37,10 +40,10 @@ export function LabReferenceRangeBar({
const min = referenceLow ?? referenceHigh! - scale;
const max = referenceHigh ?? referenceLow! + scale;
const rangeLabel = !hasLow
? `≤${referenceHigh} ${unit}`
? `≤${labNumber(referenceHigh!)} ${unit}`
: !hasHigh
? `≥${referenceLow} ${unit}`
: `${referenceLow}–${referenceHigh} ${unit}`;
? `≥${labNumber(referenceLow!)} ${unit}`
: `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)} ${unit}`;
return (
<div
@@ -55,6 +58,7 @@ export function LabReferenceRangeBar({
unit={unit}
compact
tone="lab"
formatValue={labNumber}
minLabel={referenceLow === null ? null : undefined}
maxLabel={referenceHigh === null ? null : undefined}
/>
+4 -3
View File
@@ -1,10 +1,10 @@
"use client";
import { formatLabValue } from "@/lib/labs/format-value";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations } from "@/lib/i18n/context";
import type { LabResultDto } from "./types";
import { useLabNumber } from "./use-lab-format";
/**
* The reference window a reading's own report printed, shown wherever that
@@ -40,6 +40,7 @@ export function SourceRangeNote({
className?: string;
}) {
const { t } = useTranslations();
const labNumber = useLabNumber();
if (reading.referenceOrigin !== "source") return null;
const printed =
@@ -47,13 +48,13 @@ export function SourceRangeNote({
`${formatReferenceRange(
reading.referenceLow,
reading.referenceHigh,
formatLabValue,
labNumber,
)} ${reading.unit}`.trim();
const catalog = formatReferenceRange(
reading.catalogReferenceLow,
reading.catalogReferenceHigh,
formatLabValue,
labNumber,
);
return (
+40
View File
@@ -0,0 +1,40 @@
"use client";
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
* surface trims it (whole numbers bare, at most two decimals).
*/
export function useLabNumber(): LabNumberFormat {
const fmt = useFormatters();
return useCallback(
(value: number) => formatLabValue(value, (n) => fmt.number(n)),
[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],
);
}
@@ -26,6 +26,7 @@ import {
} from "recharts";
import { makeFormatters } from "@/lib/format-locale";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
export interface EfficacyChartTarget {
label: string;
@@ -169,7 +170,7 @@ export function EfficacyChart({
tickLine={false}
axisLine={false}
width={40}
unit={unitSuffix ? ` ${unitSuffix}` : undefined}
unit={axisUnitSuffix(unitSuffix)}
/>
<YAxis
yAxisId="adherence"
@@ -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}
</span>
<span className="text-muted-foreground block text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
{documentDate(doc)}
</span>
</span>
<Check
@@ -11,9 +11,12 @@ import { EDGE_PADDING_PERCENT, RangeBar } from "../range-bar";
* extraction is provably side-effect-free.
*/
function render(props: Parameters<typeof RangeBar>[0]) {
function render(
props: Parameters<typeof RangeBar>[0],
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<I18nProvider initialLocale={locale}>
<RangeBar {...props} />
</I18nProvider>,
);
@@ -93,4 +96,17 @@ describe("<RangeBar>", () => {
expect(html).toContain('role="img"');
expect(html).toContain('aria-label="Current: 72 bpm');
});
it("prints value, bounds and delta in the reader's number format", () => {
// A German reader saw "61.3 kg" under a German interface.
const html = render(
{ value: 72.4, min: 50.5, max: 70.25, unit: "kg" },
"de",
);
expect(html).toContain("50,5 kg");
expect(html).toContain("70,25 kg");
expect(html).toContain("72,4 kg");
expect(html).toContain("2,2 kg");
expect(html).not.toMatch(/\d\.\d+ kg/);
});
});
+21 -10
View File
@@ -6,7 +6,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
/**
* A horizontal range bar with green/yellow/red zones showing where
@@ -28,6 +28,12 @@ export interface RangeBarProps {
tone?: "target" | "lab";
minLabel?: string | null;
maxLabel?: string | null;
/**
* How a value or bound is printed. Defaults to the reader's locale number
* format; a surface with its own precision rule (lab values trim to two
* decimals) passes it here so the bar and the row beside it agree.
*/
formatValue?: (value: number) => string;
}
/**
@@ -49,8 +55,13 @@ export function RangeBar({
tone = "target",
minLabel,
maxLabel,
formatValue,
}: RangeBarProps) {
const { t } = useTranslations();
const fmt = useFormatters();
// Every printed number goes through the locale formatter: a German reader
// sees "61,3 kg", not "61.3 kg".
const show = formatValue ?? ((v: number) => fmt.number(v));
const isLab = tone === "lab";
const span = max - min;
@@ -132,8 +143,8 @@ export function RangeBar({
const deltaText =
delta > 0
? value < min
? t("targets.belowTarget", { delta: delta.toFixed(1), unit })
: t("targets.aboveTarget", { delta: delta.toFixed(1), unit })
? t("targets.belowTarget", { delta: fmt.number(delta, 1), unit })
: t("targets.aboveTarget", { delta: fmt.number(delta, 1), unit })
: t("targets.inTarget");
// 2026-07-17 a11y audit (M1) — the marker's value + range live only in a
@@ -142,8 +153,8 @@ export function RangeBar({
// 1.1.1 (no separate copy to drift) — `tabIndex` below makes the trigger
// focusable, which is all Radix needs to also open the tooltip on focus.
const markerAriaLabel = [
t("targets.currentValue", { value: String(value), unit }),
t("targets.targetRangeValue", { min: String(min), max: String(max), unit }),
t("targets.currentValue", { value: show(value), unit }),
t("targets.targetRangeValue", { min: show(min), max: show(max), unit }),
deltaText,
].join(". ");
@@ -218,12 +229,12 @@ export function RangeBar({
</TooltipTrigger>
<TooltipContent>
<p className="text-xs font-medium">
{t("targets.currentValue", { value: String(value), unit })}
{t("targets.currentValue", { value: show(value), unit })}
</p>
<p className="text-xs">
{t("targets.targetRangeValue", {
min: String(min),
max: String(max),
min: show(min),
max: show(max),
unit,
})}
</p>
@@ -241,13 +252,13 @@ export function RangeBar({
className="absolute -translate-x-1/2 whitespace-nowrap"
style={{ left: `${minLabelPosition}%` }}
>
{minLabel === null ? null : (minLabel ?? `${min} ${unit}`)}
{minLabel === null ? null : (minLabel ?? `${show(min)} ${unit}`)}
</span>
<span
className="absolute -translate-x-1/2 whitespace-nowrap"
style={{ left: `${maxLabelPosition}%` }}
>
{maxLabel === null ? null : (maxLabel ?? `${max} ${unit}`)}
{maxLabel === null ? null : (maxLabel ?? `${show(max)} ${unit}`)}
</span>
</div>
</div>
@@ -93,6 +93,19 @@ describe("<DateField>", () => {
expect(html).toContain("sm:h-10");
});
it("gives the calendar button a 44 px touch target at its 32 px size", () => {
const html = render(<DateField value="2026-02-19" />);
const button = /<button[^>]*aria-label="Open date picker"[^>]*>/.exec(
html,
)?.[0];
expect(button).toBeDefined();
// Painted size unchanged; the ::before halo adds 6 px per edge.
expect(button).toContain("h-8 w-8");
expect(button).toContain("relative");
expect(button).toContain("before:-inset-1.5");
expect(button).toContain("before:absolute");
});
it("exposes a labelled calendar picker affordance", () => {
const html = render(<DateField value="2026-02-19" />);
expect(html).toContain('aria-label="Open date picker"');
@@ -105,6 +105,18 @@ describe("formatHhmm", () => {
});
describe("<TimeField> SSR", () => {
it("gives the clock button a 44 px touch target at its 32 px size", () => {
const html = render(<TimeField value="14:05" />, "de");
const button = /<button[^>]*data-slot="time-field-trigger"[^>]*>/.exec(
html,
)?.[0];
expect(button).toBeDefined();
expect(button).toContain("h-8 w-8");
expect(button).toContain("relative");
expect(button).toContain("before:-inset-1.5");
expect(button).toContain("before:absolute");
});
it("paints the value in 24h under the de AUTO locale and exposes the picker affordance", () => {
const html = render(<TimeField value="14:05" />, "de");
expect(html).toContain('value="14:05"');
+3 -1
View File
@@ -225,7 +225,9 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
type="button"
disabled={disabled}
aria-label={t("common.openDatePicker")}
className="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:cursor-not-allowed disabled:opacity-50"
// The button paints at 32 px; the `::before` halo (6 px per edge)
// makes the touch target 44 px without changing the field.
className="text-muted-foreground hover:text-foreground relative flex h-8 w-8 shrink-0 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] disabled:cursor-not-allowed disabled:opacity-50"
>
<CalendarIcon className="h-4 w-4" aria-hidden="true" />
</button>
+3 -1
View File
@@ -189,7 +189,9 @@ export const TimeField = React.forwardRef<HTMLInputElement, TimeFieldProps>(
tabIndex={-1}
disabled={disabled}
aria-label={t("common.openTimePicker")}
className="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:cursor-not-allowed disabled:opacity-50"
// The button paints at 32 px; the `::before` halo (6 px per edge)
// makes the touch target 44 px without changing the field.
className="text-muted-foreground hover:text-foreground relative flex h-8 w-8 shrink-0 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] disabled:cursor-not-allowed disabled:opacity-50"
data-slot="time-field-trigger"
>
<Clock className="h-4 w-4" aria-hidden="true" />
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useCallback } from "react";
import { formatDate } from "@/lib/date-format";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
/**
* Format a date-only value: a `YYYY-MM-DD` key, or a stored instant that
* stands for a calendar date (noon UTC, or a `@db.Date` column's UTC
* midnight). The calendar date is read in UTC, so it is the same day in
* every zone.
*
* Formatting such a value in the reader's zone is how a noon-UTC date came
* to show the next day from UTC+12 to UTC+14. Same field order and locale as
* `useFormatters().date`, so it sits beside instant-formatted dates without
* looking different.
*/
export function useCalendarDate(): (value: string | Date) => 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],
);
}
@@ -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, {
+46
View File
@@ -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);
}
});
});
+9 -5
View File
@@ -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 = {
@@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
stripComments,
walkSourceFiles,
} from "@/__tests__/helpers/source-files";
import { axisUnitSuffix } from "../axis-unit";
const COMPONENTS = join(process.cwd(), "src", "components");
/**
* Every `<XAxis … />` / `<YAxis … />` tag's `unit=` value in the tree. The tag
* runs to its `/>`: stopping at the first `>` would end it inside an arrow
* function prop and miss a `unit` written after one.
*/
function axisUnitProps(): Array<{ file: string; value: string }> {
const out: Array<{ file: string; value: string }> = [];
for (const rel of walkSourceFiles(COMPONENTS, {
floor: 300,
extensions: [".tsx"],
})) {
if (rel.includes("__tests__")) continue;
const src = stripComments(readFileSync(join(COMPONENTS, rel), "utf8"));
for (const tag of src.matchAll(/<[XY]Axis\b[\s\S]*?\/>/g)) {
const unit = /\bunit=(\{[\s\S]*?\}|"[^"]*")/.exec(tag[0]);
if (unit) out.push({ file: rel, value: unit[1] });
}
}
return out;
}
describe("axis unit suffix", () => {
it("joins the unit with a no-break space", () => {
expect(axisUnitSuffix("bpm")).toBe(" bpm");
expect(axisUnitSuffix("")).toBeUndefined();
expect(axisUnitSuffix(null)).toBeUndefined();
});
it("finds the axis units it is meant to police", () => {
// A matcher that finds nothing would pass the check below by default.
expect(axisUnitProps().length).toBeGreaterThanOrEqual(6);
});
it("no chart axis joins a unit with a breaking space", () => {
// Recharts wraps a tick label at a breaking space when it measures the
// label wider than the axis, and it measures in the body font: "62 bpm"
// broke onto two lines and its second line sat on the first x tick.
const offenders = axisUnitProps()
.filter(({ value }) => /["`] /.test(value))
.map(({ file, value }) => `${file}: unit=${value}`);
expect(offenders).toEqual([]);
});
});
+15
View File
@@ -0,0 +1,15 @@
/**
* The unit suffix a chart axis appends to each tick value ("62 bpm").
*
* Joined with a no-break space, never a plain one. Recharts word-wraps a
* tick label that is wider than the axis, and it measures the label in the
* page's body font rather than the tick's own 11 px, so "62 bpm" and
* "106 mg/dL" measured wider than their axis and broke onto two lines on
* every screen. The second line dropped onto the first x tick. A label with
* no breaking space is one word and stays on one line.
*/
export function axisUnitSuffix(
unit: string | null | undefined,
): string | undefined {
return unit ? ` ${unit}` : undefined;
}
@@ -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) {
+53
View File
@@ -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 {
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { makeFormatters } from "@/lib/format-locale";
import { formatLabReading, formatLabValue } from "../format-value";
const de = (n: number) => makeFormatters("de", "UTC", "AUTO", "AUTO").number(n);
const en = (n: number) => makeFormatters("en", "UTC", "AUTO", "AUTO").number(n);
describe("lab value formatting", () => {
it("trims to two decimals and prints the locale's separator", () => {
expect(formatLabValue(1.8, de)).toBe("1,8");
expect(formatLabValue(1.8, en)).toBe("1.8");
expect(formatLabValue(0.123456, de)).toBe("0,12");
expect(formatLabValue(5, de)).toBe("5");
});
it("formats a numeric reading with its unit and leaves a qualitative one verbatim", () => {
expect(
formatLabReading({ value: 61.3, valueText: null, unit: "kg" }, de),
).toBe("61,3 kg");
expect(
formatLabReading({ value: null, valueText: "negativ", unit: "" }, de),
).toBe("negativ");
});
});
+22 -10
View File
@@ -5,11 +5,20 @@
* lab reading the same way: whole numbers render bare, fractional values keep
* up to two decimals (no trailing-zero noise). Centralised so the three
* surfaces stay byte-identical.
*
* The digits themselves come from the caller's locale number formatter
* (`useFormatters().number`), so a German reader sees "1,8 mIU/L" where an
* English one sees "1.8 mIU/L". This module only decides the precision.
*/
export function formatLabValue(value: number): string {
return Number.isInteger(value)
? String(value)
: String(Number(value.toFixed(2)));
export type LabNumberFormat = (value: number) => string;
export function formatLabValue(
value: number,
formatNumber: LabNumberFormat,
): string {
return formatNumber(
Number.isInteger(value) ? value : Number(value.toFixed(2)),
);
}
/**
@@ -18,13 +27,16 @@ export function formatLabValue(value: number): string {
* formatted number plus its unit. One helper so the list card, the history
* rows, and the detail header stay consistent.
*/
export function formatLabReading(reading: {
value: number | null;
valueText: string | null;
unit: string;
}): string {
export function formatLabReading(
reading: {
value: number | null;
valueText: string | null;
unit: string;
},
formatNumber: LabNumberFormat,
): string {
if (reading.value === null) {
return reading.valueText ?? "";
}
return `${formatLabValue(reading.value)} ${reading.unit}`.trim();
return `${formatLabValue(reading.value, formatNumber)} ${reading.unit}`.trim();
}
+10
View File
@@ -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);
});
});
+18
View File
@@ -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: