mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
57 files changed
+997
-180
No files matched your search
+7
-7
@@ -261,11 +261,11 @@
|
||||
"title": "Glukose-Übersicht (klinisch)",
|
||||
"colMetric": "Kennzahl",
|
||||
"colValue": "Wert",
|
||||
"tirInRange": "Zeit im Zielbereich (70–180 mg/dl)",
|
||||
"tirLow": "Niedrig (< 70 mg/dl)",
|
||||
"tirVeryLow": "Sehr niedrig (< 54 mg/dl)",
|
||||
"tirHigh": "Hoch (> 180 mg/dl)",
|
||||
"tirVeryHigh": "Sehr hoch (> 250 mg/dl)",
|
||||
"tirInRange": "Zeit im Zielbereich ({low}–{high} {unit})",
|
||||
"tirLow": "Niedrig (< {low} {unit})",
|
||||
"tirVeryLow": "Sehr niedrig (< {veryLow} {unit})",
|
||||
"tirHigh": "Hoch (> {high} {unit})",
|
||||
"tirVeryHigh": "Sehr hoch (> {veryHigh} {unit})",
|
||||
"mean": "Durchschnittliche Glukose",
|
||||
"gmi": "Glukose-Management-Indikator (GMI)",
|
||||
"eA1c": "Geschätzter HbA1c",
|
||||
@@ -3440,7 +3440,7 @@
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Blutzucker",
|
||||
"description": "Deine Blutzuckerwerte im Zeitverlauf, in mg/dL.",
|
||||
"description": "Deine Blutzuckerwerte im Zeitverlauf, in {unit}.",
|
||||
"chartTitle": "Blutzucker",
|
||||
"medianLabel": "Median Blutzucker",
|
||||
"emptyState": {
|
||||
@@ -3461,7 +3461,7 @@
|
||||
"daysCountOne": "1 Tag",
|
||||
"tir": {
|
||||
"title": "Zeit im Zielbereich",
|
||||
"caption": "Anteil der Messungen je Bereich (70–180 mg/dl ist das Ziel).",
|
||||
"caption": "Anteil der Messungen je Bereich ({low}–{high} {unit} ist das Ziel).",
|
||||
"veryLow": "Sehr niedrig",
|
||||
"low": "Niedrig",
|
||||
"inRange": "Im Zielbereich",
|
||||
|
||||
+8
-8
@@ -297,11 +297,11 @@
|
||||
"title": "Glucose overview (clinical)",
|
||||
"colMetric": "Metric",
|
||||
"colValue": "Value",
|
||||
"tirInRange": "Time in range (70–180 mg/dL)",
|
||||
"tirLow": "Low (< 70 mg/dL)",
|
||||
"tirVeryLow": "Very low (< 54 mg/dL)",
|
||||
"tirHigh": "High (> 180 mg/dL)",
|
||||
"tirVeryHigh": "Very high (> 250 mg/dL)",
|
||||
"tirInRange": "Time in range ({low}–{high} {unit})",
|
||||
"tirLow": "Low (< {low} {unit})",
|
||||
"tirVeryLow": "Very low (< {veryLow} {unit})",
|
||||
"tirHigh": "High (> {high} {unit})",
|
||||
"tirVeryHigh": "Very high (> {veryHigh} {unit})",
|
||||
"mean": "Average glucose",
|
||||
"gmi": "Glucose Management Indicator (GMI)",
|
||||
"eA1c": "Estimated A1C",
|
||||
@@ -3174,7 +3174,7 @@
|
||||
"headphoneAudioBody": "Headphone audio levels track how loudly you listen through earbuds or headphones, in decibels. Keeping the volume down and taking breaks protects your hearing over the long run.",
|
||||
"audioEventsBody": "Loud sound events flag moments when noise around you reached a level that can harm hearing with enough exposure. Frequent events are a cue to seek quieter surroundings or hearing protection.",
|
||||
"daylightBody": "Time in daylight is how long you spend in outdoor light each day. Regular daylight supports a healthy sleep-wake rhythm and mood (CDC 2024), and time outdoors is linked to eye health.",
|
||||
"bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals. As general guidance for adults without diabetes, a fasting reading under 100 mg/dL is normal, 100–125 is prediabetes, and 126 or above falls in the diabetes range (ADA 2024). A single high reading does not diagnose — a clinician confirms with a repeat test, and people managing diabetes use different, clinician-set targets.",
|
||||
"bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals. As general guidance for adults without diabetes, a fasting reading under {prediabetesFrom} {unit} is normal, {prediabetesFrom}–{prediabetesTo} is prediabetes, and {diabetesFrom} or above falls in the diabetes range (ADA 2024). A single high reading does not diagnose — a clinician confirms with a repeat test, and people managing diabetes use different, clinician-set targets.",
|
||||
"skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase.",
|
||||
"cardioFitnessBody": "VO₂ max is how much oxygen your body can use at peak effort — the best lab index of aerobic fitness, which wearables estimate indirectly. A higher or rising trend is a strong sign of cardiovascular health.",
|
||||
"cardioRecoveryBody": "Cardio recovery is how many beats per minute your heart rate falls in the first minute after you stop hard exercise. A bigger drop means your heart settles down faster, which is a sign of good fitness.",
|
||||
@@ -3440,7 +3440,7 @@
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Blood Glucose",
|
||||
"description": "Your blood-glucose readings over time, in mg/dL.",
|
||||
"description": "Your blood-glucose readings over time, in {unit}.",
|
||||
"chartTitle": "Blood glucose",
|
||||
"medianLabel": "Median glucose",
|
||||
"emptyState": {
|
||||
@@ -3461,7 +3461,7 @@
|
||||
"daysCountOne": "1 day",
|
||||
"tir": {
|
||||
"title": "Time in range",
|
||||
"caption": "Share of readings in each band (70–180 mg/dL is the target).",
|
||||
"caption": "Share of readings in each band ({low}–{high} {unit} is the target).",
|
||||
"veryLow": "Very low",
|
||||
"low": "Low",
|
||||
"inRange": "In range",
|
||||
|
||||
+7
-7
@@ -261,11 +261,11 @@
|
||||
"title": "Resumen de glucosa (clínico)",
|
||||
"colMetric": "Métrica",
|
||||
"colValue": "Valor",
|
||||
"tirInRange": "Tiempo en rango (70–180 mg/dl)",
|
||||
"tirLow": "Bajo (< 70 mg/dl)",
|
||||
"tirVeryLow": "Muy bajo (< 54 mg/dl)",
|
||||
"tirHigh": "Alto (> 180 mg/dl)",
|
||||
"tirVeryHigh": "Muy alto (> 250 mg/dl)",
|
||||
"tirInRange": "Tiempo en rango ({low}–{high} {unit})",
|
||||
"tirLow": "Bajo (< {low} {unit})",
|
||||
"tirVeryLow": "Muy bajo (< {veryLow} {unit})",
|
||||
"tirHigh": "Alto (> {high} {unit})",
|
||||
"tirVeryHigh": "Muy alto (> {veryHigh} {unit})",
|
||||
"mean": "Glucosa media",
|
||||
"gmi": "Indicador de gestión de la glucosa (GMI)",
|
||||
"eA1c": "A1C estimada",
|
||||
@@ -3440,7 +3440,7 @@
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glucosa en sangre",
|
||||
"description": "Tus lecturas de glucosa en el tiempo, en mg/dL.",
|
||||
"description": "Tus lecturas de glucosa en el tiempo, en {unit}.",
|
||||
"chartTitle": "Glucosa en sangre",
|
||||
"medianLabel": "Glucosa mediana",
|
||||
"emptyState": {
|
||||
@@ -3461,7 +3461,7 @@
|
||||
"daysCountOne": "1 día",
|
||||
"tir": {
|
||||
"title": "Tiempo en rango",
|
||||
"caption": "Proporción de mediciones en cada banda (70–180 mg/dl es el objetivo).",
|
||||
"caption": "Proporción de mediciones en cada banda ({low}–{high} {unit} es el objetivo).",
|
||||
"veryLow": "Muy bajo",
|
||||
"low": "Bajo",
|
||||
"inRange": "En rango",
|
||||
|
||||
+7
-7
@@ -261,11 +261,11 @@
|
||||
"title": "Aperçu glycémique (clinique)",
|
||||
"colMetric": "Indicateur",
|
||||
"colValue": "Valeur",
|
||||
"tirInRange": "Temps dans la cible (70–180 mg/dl)",
|
||||
"tirLow": "Bas (< 70 mg/dl)",
|
||||
"tirVeryLow": "Très bas (< 54 mg/dl)",
|
||||
"tirHigh": "Élevé (> 180 mg/dl)",
|
||||
"tirVeryHigh": "Très élevé (> 250 mg/dl)",
|
||||
"tirInRange": "Temps dans la cible ({low}–{high} {unit})",
|
||||
"tirLow": "Bas (< {low} {unit})",
|
||||
"tirVeryLow": "Très bas (< {veryLow} {unit})",
|
||||
"tirHigh": "Élevé (> {high} {unit})",
|
||||
"tirVeryHigh": "Très élevé (> {veryHigh} {unit})",
|
||||
"mean": "Glycémie moyenne",
|
||||
"gmi": "Indicateur de gestion du glucose (GMI)",
|
||||
"eA1c": "HbA1c estimée",
|
||||
@@ -3440,7 +3440,7 @@
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glycémie",
|
||||
"description": "Vos relevés de glycémie au fil du temps, en mg/dL.",
|
||||
"description": "Vos relevés de glycémie au fil du temps, en {unit}.",
|
||||
"chartTitle": "Glycémie",
|
||||
"medianLabel": "Glycémie médiane",
|
||||
"emptyState": {
|
||||
@@ -3461,7 +3461,7 @@
|
||||
"daysCountOne": "1 jour",
|
||||
"tir": {
|
||||
"title": "Temps dans la cible",
|
||||
"caption": "Part des mesures dans chaque plage (70–180 mg/dl est la cible).",
|
||||
"caption": "Part des mesures dans chaque plage ({low}–{high} {unit} est la cible).",
|
||||
"veryLow": "Très bas",
|
||||
"low": "Bas",
|
||||
"inRange": "Dans la cible",
|
||||
|
||||
+7
-7
@@ -261,11 +261,11 @@
|
||||
"title": "Panoramica glicemia (clinica)",
|
||||
"colMetric": "Metrica",
|
||||
"colValue": "Valore",
|
||||
"tirInRange": "Tempo nell'intervallo (70–180 mg/dl)",
|
||||
"tirLow": "Basso (< 70 mg/dl)",
|
||||
"tirVeryLow": "Molto basso (< 54 mg/dl)",
|
||||
"tirHigh": "Alto (> 180 mg/dl)",
|
||||
"tirVeryHigh": "Molto alto (> 250 mg/dl)",
|
||||
"tirInRange": "Tempo nell'intervallo ({low}–{high} {unit})",
|
||||
"tirLow": "Basso (< {low} {unit})",
|
||||
"tirVeryLow": "Molto basso (< {veryLow} {unit})",
|
||||
"tirHigh": "Alto (> {high} {unit})",
|
||||
"tirVeryHigh": "Molto alto (> {veryHigh} {unit})",
|
||||
"mean": "Glicemia media",
|
||||
"gmi": "Indicatore di gestione del glucosio (GMI)",
|
||||
"eA1c": "A1C stimata",
|
||||
@@ -3440,7 +3440,7 @@
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glicemia",
|
||||
"description": "Le tue letture di glicemia nel tempo, in mg/dL.",
|
||||
"description": "Le tue letture di glicemia nel tempo, in {unit}.",
|
||||
"chartTitle": "Glicemia",
|
||||
"medianLabel": "Glicemia mediana",
|
||||
"emptyState": {
|
||||
@@ -3461,7 +3461,7 @@
|
||||
"daysCountOne": "1 giorno",
|
||||
"tir": {
|
||||
"title": "Tempo nel range",
|
||||
"caption": "Quota di misurazioni in ciascuna fascia (70–180 mg/dl è l’obiettivo).",
|
||||
"caption": "Quota di misurazioni in ciascuna fascia ({low}–{high} {unit} è l’obiettivo).",
|
||||
"veryLow": "Molto basso",
|
||||
"low": "Basso",
|
||||
"inRange": "Nel range",
|
||||
|
||||
+8
-8
@@ -297,11 +297,11 @@
|
||||
"title": "혈당 개요 (임상)",
|
||||
"colMetric": "지표",
|
||||
"colValue": "값",
|
||||
"tirInRange": "목표 범위 내 시간 (70–180 mg/dL)",
|
||||
"tirLow": "낮음 (< 70 mg/dL)",
|
||||
"tirVeryLow": "매우 낮음 (< 54 mg/dL)",
|
||||
"tirHigh": "높음 (> 180 mg/dL)",
|
||||
"tirVeryHigh": "매우 높음 (> 250 mg/dL)",
|
||||
"tirInRange": "목표 범위 내 시간 ({low}–{high} {unit})",
|
||||
"tirLow": "낮음 (< {low} {unit})",
|
||||
"tirVeryLow": "매우 낮음 (< {veryLow} {unit})",
|
||||
"tirHigh": "높음 (> {high} {unit})",
|
||||
"tirVeryHigh": "매우 높음 (> {veryHigh} {unit})",
|
||||
"mean": "평균 혈당",
|
||||
"gmi": "혈당 관리 지표 (GMI)",
|
||||
"eA1c": "추정 당화혈색소 (A1C)",
|
||||
@@ -3174,7 +3174,7 @@
|
||||
"headphoneAudioBody": "헤드폰 음량은 이어폰이나 헤드폰으로 얼마나 크게 듣는지를 dB로 기록해요. 볼륨을 낮추고 중간중간 쉬어 주면 오래도록 청력을 지킬 수 있어요.",
|
||||
"audioEventsBody": "큰 소리 이벤트는 주변 소음이 충분히 노출될 경우 청력을 해칠 수 있는 수준에 도달한 순간을 표시해요. 자주 발생한다면 더 조용한 환경을 찾거나 청력 보호구를 쓰라는 신호예요.",
|
||||
"daylightBody": "햇빛 노출 시간은 하루에 야외 빛 속에서 보낸 시간이에요. 규칙적인 햇빛은 건강한 수면-각성 리듬과 기분에 도움이 되고(CDC 2024), 야외에서 보내는 시간은 눈 건강과도 연결돼요.",
|
||||
"bloodGlucoseBody": "혈당은 몸의 주된 에너지원인 혈액 속 당의 양이에요. 식후에 올라가고 식사 사이에 가라앉아요. 당뇨가 없는 성인의 일반적인 기준으로 공복 혈당 100mg/dL 미만이 정상, 100~125는 당뇨 전 단계, 126 이상은 당뇨 범위예요(ADA 2024). 한 번 높게 나왔다고 진단되지는 않아요 — 임상의가 재검으로 확인하고, 당뇨를 관리하는 사람은 임상의가 정한 다른 목표를 써요.",
|
||||
"bloodGlucoseBody": "혈당은 몸의 주된 에너지원인 혈액 속 당의 양이에요. 식후에 올라가고 식사 사이에 가라앉아요. 당뇨가 없는 성인의 일반적인 기준으로 공복 혈당 {prediabetesFrom} {unit} 미만이 정상, {prediabetesFrom}~{prediabetesTo}는 당뇨 전 단계, {diabetesFrom} 이상은 당뇨 범위예요(ADA 2024). 한 번 높게 나왔다고 진단되지는 않아요 — 임상의가 재검으로 확인하고, 당뇨를 관리하는 사람은 임상의가 정한 다른 목표를 써요.",
|
||||
"skinTemperatureBody": "여기서 피부 온도는 절대적인 발열 수치가 아니라 본인 기준값 대비 밤사이 손목 온도의 작은 변화예요. 이 변화는 수면·환경·질병·생리 주기 단계를 반영할 수 있어요.",
|
||||
"cardioFitnessBody": "VO₂ max는 최대 노력에서 몸이 쓸 수 있는 산소량으로, 유산소 체력의 가장 좋은 실험실 지표이고 웨어러블은 이를 간접 추정해요. 값이 높거나 올라가는 추이는 심혈관 건강의 강력한 신호예요.",
|
||||
"cardioRecoveryBody": "심박 회복은 격한 운동을 멈춘 뒤 1분 동안 심박수가 분당 몇 회 떨어지는지예요. 많이 떨어질수록 심장이 더 빨리 안정된다는 뜻이고, 체력이 좋다는 신호예요.",
|
||||
@@ -3440,7 +3440,7 @@
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "혈당",
|
||||
"description": "시간에 따른 혈당 측정값이에요(mg/dL).",
|
||||
"description": "시간에 따른 혈당 측정값이에요({unit}).",
|
||||
"chartTitle": "혈당",
|
||||
"medianLabel": "혈당 중앙값",
|
||||
"emptyState": {
|
||||
@@ -3461,7 +3461,7 @@
|
||||
"daysCountOne": "1일",
|
||||
"tir": {
|
||||
"title": "목표 범위 내 시간",
|
||||
"caption": "구간별 측정값 비율이에요(목표는 70~180mg/dL).",
|
||||
"caption": "구간별 측정값 비율이에요(목표는 {low}~{high} {unit}).",
|
||||
"veryLow": "매우 낮음",
|
||||
"low": "낮음",
|
||||
"inRange": "목표 범위",
|
||||
|
||||
+7
-7
@@ -261,11 +261,11 @@
|
||||
"title": "Przegląd glukozy (kliniczny)",
|
||||
"colMetric": "Wskaźnik",
|
||||
"colValue": "Wartość",
|
||||
"tirInRange": "Czas w zakresie (70–180 mg/dl)",
|
||||
"tirLow": "Niski (< 70 mg/dl)",
|
||||
"tirVeryLow": "Bardzo niski (< 54 mg/dl)",
|
||||
"tirHigh": "Wysoki (> 180 mg/dl)",
|
||||
"tirVeryHigh": "Bardzo wysoki (> 250 mg/dl)",
|
||||
"tirInRange": "Czas w zakresie ({low}–{high} {unit})",
|
||||
"tirLow": "Niski (< {low} {unit})",
|
||||
"tirVeryLow": "Bardzo niski (< {veryLow} {unit})",
|
||||
"tirHigh": "Wysoki (> {high} {unit})",
|
||||
"tirVeryHigh": "Bardzo wysoki (> {veryHigh} {unit})",
|
||||
"mean": "Średnia glukoza",
|
||||
"gmi": "Wskaźnik zarządzania glukozą (GMI)",
|
||||
"eA1c": "Szacowane HbA1c",
|
||||
@@ -3440,7 +3440,7 @@
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Poziom glukozy",
|
||||
"description": "Twoje odczyty glukozy w czasie, w mg/dL.",
|
||||
"description": "Twoje odczyty glukozy w czasie, w {unit}.",
|
||||
"chartTitle": "Poziom glukozy",
|
||||
"medianLabel": "Mediana glukozy",
|
||||
"emptyState": {
|
||||
@@ -3461,7 +3461,7 @@
|
||||
"daysCountOne": "1 dzień",
|
||||
"tir": {
|
||||
"title": "Czas w zakresie",
|
||||
"caption": "Udział pomiarów w każdym przedziale (70–180 mg/dl to cel).",
|
||||
"caption": "Udział pomiarów w każdym przedziale ({low}–{high} {unit} to cel).",
|
||||
"veryLow": "Bardzo niski",
|
||||
"low": "Niski",
|
||||
"inRange": "W zakresie",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* Mutation check: add `unit="kg"` back to any swept page and this goes red.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { stripComments, walkSourceFiles } from "./helpers/source-files";
|
||||
@@ -515,3 +515,74 @@ describe("unit-preference display guard — AI and Coach producers", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Locale strings that may name a glucose unit literally, each with the reason
|
||||
* it is not a reading shown in the reader's unit. Keys are dotted paths and
|
||||
* apply to every locale bundle.
|
||||
*/
|
||||
const GLUCOSE_UNIT_LITERAL_ALLOWLIST: Record<string, string> = {
|
||||
"dashboard.metric.unit.glucose":
|
||||
"the unit token the dashboard summary wire hands the native client; the web resolves the glucose unit from the account instead",
|
||||
"labs.biomarker.form.unitPlaceholder":
|
||||
"an example of a free-text lab unit in an input placeholder, not a reading",
|
||||
};
|
||||
|
||||
const GLUCOSE_UNIT_LITERAL = /mg\/dl|mmol\/l\b/i;
|
||||
|
||||
function flattenStrings(
|
||||
node: unknown,
|
||||
path: string,
|
||||
out: Array<[string, string]>,
|
||||
): void {
|
||||
if (typeof node === "string") {
|
||||
out.push([path, node]);
|
||||
} else if (node && typeof node === "object") {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
flattenStrings(value, path ? `${path}.${key}` : key, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("unit-preference display guard — locale strings", () => {
|
||||
const MESSAGES = join(process.cwd(), "messages");
|
||||
const bundles = readdirSync(MESSAGES).filter((f) => f.endsWith(".json"));
|
||||
|
||||
it("reads every locale bundle", () => {
|
||||
expect(bundles.length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
|
||||
it("no locale string fixes a glucose unit outside the allowlist", () => {
|
||||
// A sentence that prints "70–180 mg/dL" shows mg/dL to a reader who
|
||||
// chose mmol/L. Band and unit go in as parameters (`glucoseBandParams`).
|
||||
const offenders: string[] = [];
|
||||
for (const file of bundles) {
|
||||
const entries: Array<[string, string]> = [];
|
||||
flattenStrings(
|
||||
JSON.parse(readFileSync(join(MESSAGES, file), "utf8")),
|
||||
"",
|
||||
entries,
|
||||
);
|
||||
for (const [key, value] of entries) {
|
||||
if (key in GLUCOSE_UNIT_LITERAL_ALLOWLIST) continue;
|
||||
if (GLUCOSE_UNIT_LITERAL.test(value)) offenders.push(`${file}: ${key}`);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("every allowlisted key still exists and still names a unit", () => {
|
||||
// An entry whose string was reworded or removed is a stale exemption.
|
||||
const en: Array<[string, string]> = [];
|
||||
flattenStrings(
|
||||
JSON.parse(readFileSync(join(MESSAGES, "en.json"), "utf8")),
|
||||
"",
|
||||
en,
|
||||
);
|
||||
const byKey = new Map(en);
|
||||
const stale = Object.keys(GLUCOSE_UNIT_LITERAL_ALLOWLIST).filter(
|
||||
(key) => !GLUCOSE_UNIT_LITERAL.test(byKey.get(key) ?? ""),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,12 @@ import { Droplet } from "lucide-react";
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
glucoseBandParams,
|
||||
MGDL_PER_MMOL,
|
||||
resolveGlucoseUnit,
|
||||
} from "@/lib/glucose";
|
||||
|
||||
/**
|
||||
* v1.7.0 — `/insights/blood-glucose`.
|
||||
@@ -27,6 +31,7 @@ import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
export default function InsightsBlutzuckerPage() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
|
||||
const isMmol = glucoseUnit === "mmol/L";
|
||||
|
||||
@@ -38,6 +43,9 @@ export default function InsightsBlutzuckerPage() {
|
||||
chartKey="bloodGlucose"
|
||||
i18nPrefix="insights.bloodGlucose"
|
||||
explainerMetric="bloodGlucose"
|
||||
// The definition names the fasting cut-points; they follow the
|
||||
// reader's glucose unit like the chart does.
|
||||
explainerParams={glucoseBandParams(glucoseUnit, fmt.number)}
|
||||
color="var(--chart-3)"
|
||||
unit={glucoseUnit}
|
||||
yAxisUnit={glucoseUnit}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
: "";
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
glucoseContextBucket,
|
||||
groupByGlucoseContext,
|
||||
GLUCOSE_CONTEXT_UNSPECIFIED,
|
||||
GLUCOSE_FASTING_CUT_POINTS,
|
||||
GLUCOSE_TIR_CUT_POINTS,
|
||||
glucoseBandParams,
|
||||
} from "../glucose";
|
||||
import { makeFormatters } from "../format-locale";
|
||||
|
||||
describe("glucose conversion", () => {
|
||||
it("converts mg/dL to mmol/L with 1 decimal", () => {
|
||||
@@ -157,3 +161,45 @@ describe("glucose context buckets (#943)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("glucose band parameters", () => {
|
||||
const de = makeFormatters("de", "UTC", "AUTO", "AUTO").number;
|
||||
const en = makeFormatters("en", "UTC", "AUTO", "AUTO").number;
|
||||
|
||||
it("states the consensus bands in mg/dL as whole numbers", () => {
|
||||
expect(glucoseBandParams("mg/dL", en)).toMatchObject({
|
||||
unit: "mg/dL",
|
||||
veryLow: "54",
|
||||
low: "70",
|
||||
high: "180",
|
||||
veryHigh: "250",
|
||||
prediabetesFrom: "100",
|
||||
prediabetesTo: "125",
|
||||
diabetesFrom: "126",
|
||||
});
|
||||
});
|
||||
|
||||
it("states them in mmol/L with the locale's decimal separator", () => {
|
||||
expect(glucoseBandParams("mmol/L", de)).toMatchObject({
|
||||
unit: "mmol/L",
|
||||
veryLow: "3,0",
|
||||
low: "3,9",
|
||||
high: "10,0",
|
||||
veryHigh: "13,9",
|
||||
prediabetesFrom: "5,6",
|
||||
prediabetesTo: "6,9",
|
||||
diabetesFrom: "7,0",
|
||||
});
|
||||
});
|
||||
|
||||
it("carries SI figures that agree with the conversion", () => {
|
||||
// The consensus bands convert exactly; the ADA cut-points are within
|
||||
// one rounding step of it (100 mg/dL is printed as 5.6, converts to 5.5).
|
||||
for (const point of Object.values(GLUCOSE_TIR_CUT_POINTS)) {
|
||||
expect(point.mmol).toBe(mgdlToMmol(point.mgdl));
|
||||
}
|
||||
for (const point of Object.values(GLUCOSE_FASTING_CUT_POINTS)) {
|
||||
expect(Math.abs(point.mmol - point.mgdl / 18.0182)).toBeLessThan(0.1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,8 @@
|
||||
* Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085.
|
||||
*/
|
||||
|
||||
import { GLUCOSE_TIR_CUT_POINTS } from "@/lib/glucose";
|
||||
|
||||
// ── Inputs ───────────────────────────────────────────────
|
||||
|
||||
/** A single spot blood-glucose reading. Value is canonical mg/dL. */
|
||||
@@ -79,11 +81,13 @@ export interface GlucoseMetricsOptions {
|
||||
}
|
||||
|
||||
// ── Consensus thresholds (Battelino 2019) ────────────────
|
||||
// Bands in mg/dL. These are consensus FACTS, cited above.
|
||||
const TBR_LEVEL2_MAX = 54; // very low: G < 54
|
||||
const TBR_LEVEL1_MAX = 70; // low: G < 70 (level-1 sub-band is 54–69)
|
||||
const TAR_LEVEL1_MIN = 180; // high: G > 180 (level-1 sub-band is 181–250)
|
||||
const TAR_LEVEL2_MIN = 250; // very high: G > 250
|
||||
// Bands in mg/dL. These are consensus FACTS, cited above. The values live in
|
||||
// `GLUCOSE_TIR_CUT_POINTS` so the band labels a reader sees and the bands
|
||||
// this engine counts are the same numbers.
|
||||
const TBR_LEVEL2_MAX = GLUCOSE_TIR_CUT_POINTS.veryLow.mgdl; // very low: G < 54
|
||||
const TBR_LEVEL1_MAX = GLUCOSE_TIR_CUT_POINTS.low.mgdl; // low: G < 70 (level-1 sub-band is 54–69)
|
||||
const TAR_LEVEL1_MIN = GLUCOSE_TIR_CUT_POINTS.high.mgdl; // high: G > 180 (level-1 sub-band is 181–250)
|
||||
const TAR_LEVEL2_MIN = GLUCOSE_TIR_CUT_POINTS.veryHigh.mgdl; // very high: G > 250
|
||||
// Time-in-range target band is [70, 180] inclusive.
|
||||
/** The time-in-range band, mg/dL, inclusive at both ends. */
|
||||
export const TIME_IN_RANGE_MGDL = {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user