From d00e106ce1f2e1d16e8d2a04ea21b555cab1e811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:46:39 +0200 Subject: [PATCH 01/13] fix(clinician): print every shared statistic with its unit The clinician view printed bare numbers for every measurement group and the per-context glucose rows in raw mg/dL, so a recipient read glucose without a unit and in the wrong one for an account on mmol/L. The PDF's measurement table had the same gap for the raw glucose series. Both artefacts now take the unit and the displayed number from one helper (doctor-report/stat-display.ts): SI for mass, length and temperature as before, hours for sleep, and the record owner's unit for glucose. Refs #1067 --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../__tests__/clinician-view.test.tsx | 43 ++++++++++ src/components/clinician/report-sections.tsx | 49 +++++++++--- .../__tests__/doctor-report-pdf-core.test.ts | 24 ++++++ src/lib/doctor-report-pdf-core.ts | 36 +++------ .../measurements-charts-section.ts | 10 +-- src/lib/doctor-report/stat-display.ts | 79 +++++++++++++++++++ 13 files changed, 207 insertions(+), 41 deletions(-) create mode 100644 src/lib/doctor-report/stat-display.ts diff --git a/messages/de.json b/messages/de.json index cecd50085..fc3b54979 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8167,6 +8167,7 @@ "medications": "Medikamente & Therapietreue", "bmi": "Body-Mass-Index (BMI)", "statSummary": "zuletzt {latest} (Ø {avg}, Bereich {min}–{max})", + "statSummaryWithUnit": "zuletzt {latest} {unit} (Ø {avg} {unit}, Bereich {min}–{max} {unit})", "adherence": "{rate} Therapietreue", "noAdherence": "—", "glucose": { diff --git a/messages/en.json b/messages/en.json index 322af6ad7..5fcf26297 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8167,6 +8167,7 @@ "medications": "Medications & adherence", "bmi": "Body mass index (BMI)", "statSummary": "latest {latest} (avg {avg}, range {min}–{max})", + "statSummaryWithUnit": "latest {latest} {unit} (avg {avg} {unit}, range {min}–{max} {unit})", "adherence": "{rate} adherence", "noAdherence": "—", "glucose": { diff --git a/messages/es.json b/messages/es.json index 06010c86e..4bdfb5534 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8167,6 +8167,7 @@ "medications": "Medicamentos y adherencia", "bmi": "Índice de masa corporal (IMC)", "statSummary": "último {latest} (prom. {avg}, rango {min}–{max})", + "statSummaryWithUnit": "último {latest} {unit} (prom. {avg} {unit}, rango {min}–{max} {unit})", "adherence": "{rate} de adherencia", "noAdherence": "—", "glucose": { diff --git a/messages/fr.json b/messages/fr.json index 5135074b2..f1c57d412 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8167,6 +8167,7 @@ "medications": "Médicaments et observance", "bmi": "Indice de masse corporelle (IMC)", "statSummary": "dernier {latest} (moy. {avg}, plage {min}–{max})", + "statSummaryWithUnit": "dernier {latest} {unit} (moy. {avg} {unit}, plage {min}–{max} {unit})", "adherence": "{rate} d'observance", "noAdherence": "—", "glucose": { diff --git a/messages/it.json b/messages/it.json index 7ffad325e..79b439e50 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8167,6 +8167,7 @@ "medications": "Farmaci e aderenza", "bmi": "Indice di massa corporea (IMC)", "statSummary": "ultimo {latest} (media {avg}, intervallo {min}–{max})", + "statSummaryWithUnit": "ultimo {latest} {unit} (media {avg} {unit}, intervallo {min}–{max} {unit})", "adherence": "{rate} di aderenza", "noAdherence": "—", "glucose": { diff --git a/messages/ko.json b/messages/ko.json index 28038e56f..26052e28f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8167,6 +8167,7 @@ "medications": "복약 및 복약 순응도", "bmi": "체질량지수(BMI)", "statSummary": "최근 {latest} (평균 {avg}, 범위 {min}–{max})", + "statSummaryWithUnit": "최근 {latest} {unit} (평균 {avg} {unit}, 범위 {min}–{max} {unit})", "adherence": "복약 순응도 {rate}", "noAdherence": "—", "glucose": { diff --git a/messages/pl.json b/messages/pl.json index 295afc34f..396f758be 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8167,6 +8167,7 @@ "medications": "Leki i przestrzeganie zaleceń", "bmi": "Wskaźnik masy ciała (BMI)", "statSummary": "ostatnio {latest} (śr. {avg}, zakres {min}–{max})", + "statSummaryWithUnit": "ostatnio {latest} {unit} (śr. {avg} {unit}, zakres {min}–{max} {unit})", "adherence": "{rate} przestrzegania", "noAdherence": "—", "glucose": { diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index cb62d4f82..22fb9d4e0 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -225,6 +225,49 @@ describe("", () => { expect(html).not.toContain(">WEIGHT<"); }); + it("prints every measurement group with its unit", () => { + const html = render( + makeReport({ + stats: { + WEIGHT: { avg: 80, min: 78, max: 82, count: 12, latest: 79 }, + // Per-night asleep minutes; the report reads them in hours. + SLEEP_DURATION: { + avg: 450, + min: 420, + max: 480, + count: 7, + latest: 450, + }, + }, + }), + ); + expect(html).toContain("latest 79 kg (avg 80 kg, range 78–82 kg)"); + expect(html).toContain("latest 7.5 h (avg 7.5 h, range 7–8 h)"); + }); + + it("shows glucose in the record owner's unit, never raw mg/dL", () => { + const html = render( + makeReport({ + glucoseUnit: "mmol/L", + stats: { + BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 }, + }, + glucoseStats: { + FASTING: { avg: 99, min: 90, max: 108, count: 5, latest: 95 }, + }, + }), + ); + // 99 mg/dL = 5.5 mmol/L, 108 = 6.0, 90 = 5.0, 126 = 7.0, 95 = 5.3. + expect(html).toContain( + "latest 5.5 mmol/L (avg 6 mmol/L, range 5–7 mmol/L)", + ); + expect(html).toContain( + "latest 5.3 mmol/L (avg 5.5 mmol/L, range 5–6 mmol/L)", + ); + expect(html).not.toContain("mg/dL"); + expect(html).not.toContain("latest 99"); + }); + it("omits the wellness card when there are no scores", () => { const html = render(makeReport({ wellnessScores: [] })); expect(html).not.toContain("Wellness scores"); diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx index bf41f8535..db44a8de2 100644 --- a/src/components/clinician/report-sections.tsx +++ b/src/components/clinician/report-sections.tsx @@ -15,6 +15,11 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { + reportStatUnit, + reportStatValue, +} from "@/lib/doctor-report/stat-display"; +import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"; import { isReportLeafId, @@ -212,6 +217,13 @@ export function MeasurementGroups({ if (groups.length === 0) return null; + // The same unit and the same number the PDF prints for the row: SI for + // mass, length and temperature, the owner's unit for glucose, hours for + // sleep. A clinician must never read a figure without knowing its unit. + const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null); + const show = (type: string, value: number) => + fmtNum(reportStatValue(type, value, glucoseUnit)); + return ( <> {groups.map((group) => ( @@ -224,11 +236,11 @@ export function MeasurementGroups({ ))} @@ -238,6 +250,20 @@ export function MeasurementGroups({ ); } +/** + * "latest 79 kg (avg 80 kg, range 78–82 kg)". A type with no unit (a score, a + * count) keeps the unit-less sentence rather than printing a dangling space. + */ +function statSummary( + t: Translate, + unit: string, + values: { latest: number; avg: number; min: number; max: number }, +): string { + return unit + ? t("clinicianView.statSummaryWithUnit", { ...values, unit }) + : t("clinicianView.statSummary", values); +} + /** * Per-context glucose stats. The heading used to read "Lab values", which was * the wrong name for it even before the record's actual lab results reached @@ -258,6 +284,9 @@ export function GlucoseSection({ const entries = Object.entries(report.glucoseStats).filter( ([, s]) => s.count > 0, ); + // Stored in mg/dL; shown in the record owner's unit, as the PDF shows it. + const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null); + const show = (value: number) => fmtNum(convertGlucose(value, glucoseUnit)); return ( ))} diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index b525b4857..58f8688af 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -227,6 +227,30 @@ describe("renderDoctorReportPdfBytes", () => { expect(text).not.toContain("450 h"); }); + it("prints the raw glucose row in the owner's unit, like the glucose panel", async () => { + // The measurement table used to take BLOOD_GLUCOSE's canonical unit and + // number, so an mmol/L account read "99.0 mg/dL" two lines above its own + // context rows in mmol/L. + const data = makeData({ + glucoseUnit: "mmol/L", + stats: { + ...makeData().stats, + BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 }, + }, + }); + const bytes = renderDoctorReportPdfBytes(data, { + timeFormat: "AUTO", + dateFormat: "AUTO", + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).toContain("5.5 mmol/L"); + expect(text).toContain("6.0 mmol/L"); + expect(text).not.toContain("mg/dL"); + }); + it("omits the sleep vitals row when there are no sleep stats", async () => { const bytes = renderDoctorReportPdfBytes(makeData(), { timeFormat: "AUTO", diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index bdb689fea..3da0ac540 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -19,7 +19,11 @@ import { REPORT_GROUPS, } from "./report-selection/catalogue"; -import { getUnitForType } from "./validations/measurement"; +import { + DOCTOR_REPORT_TYPE_UNIT_KEYS, + reportStatUnit, +} from "./doctor-report/stat-display"; +import { resolveGlucoseUnit } from "./glucose"; import { makeFormatters, type DateFormatPreference, @@ -175,18 +179,9 @@ export interface DoctorReportRenderOptions { includeCharts?: boolean; } -export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { - WEIGHT: "kg", - BLOOD_PRESSURE_SYS: "mmHg", - BLOOD_PRESSURE_DIA: "mmHg", - PULSE: "bpm", - BODY_FAT: "%", - SLEEP_DURATION: "h", - ACTIVITY_STEPS: null, // translated unit - TOTAL_BODY_WATER: "kg", - BONE_MASS: "kg", - OXYGEN_SATURATION: "%", -}; +// The unit map moved beside the clinician view's reader of it; re-exported so +// the enum-coverage test keeps one import. +export { DOCTOR_REPORT_TYPE_UNIT_KEYS }; /** * The measurement table is driven by the selection, not by a whitelist. @@ -238,18 +233,9 @@ export function buildDoctorReportPdfDocument( const footerTz = userTz && isValidTimezone(userTz) ? userTz : DEFAULT_TIMEZONE; - const unitFor = (type: string): string => { - const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type]; - if (staticUnit === null && type === "ACTIVITY_STEPS") { - return t("doctorReport.unitSteps"); - } - if (staticUnit !== undefined && staticUnit !== null) return staticUnit; - // Every other type falls back to the canonical unit the measurement - // validation layer already records. A type with no recorded unit prints - // none — an absent unit is absent, not a guessed symbol. - const canonical = getUnitForType(type); - return canonical === "unknown" ? "" : canonical; - }; + const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null); + const unitFor = (type: string): string => + reportStatUnit(type, glucoseUnit, t); const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); patchPdfTextSanitiser(doc); diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index fef72fd68..32fdd4e1a 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -2,6 +2,7 @@ import type { jsPDF } from "jspdf"; import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose"; import autoTable from "jspdf-autotable"; import { convertGlucose, 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"; import { @@ -235,12 +236,9 @@ export function buildMeasurementsChartsSection( const s = data.stats[type]; if (!s) continue; const unit = unitFor(type); - // SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data - // layer reconstructs them for exactly this row); the vitals unit is - // hours (`h`), so convert to keep value and unit consistent — the same - // hours value the FHIR sleep Observation emits. - const conv = - type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v; + // Sleep minutes print as hours and glucose in the owner's unit; the + // clinician view reads the same helper, so the two cannot drift. + const conv = (v: number) => reportStatValue(type, v, glucoseUnit); groupRows.push([ t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""), `${num(conv(s.latest))} ${unit}`.trim(), diff --git a/src/lib/doctor-report/stat-display.ts b/src/lib/doctor-report/stat-display.ts new file mode 100644 index 000000000..a2ede71f9 --- /dev/null +++ b/src/lib/doctor-report/stat-display.ts @@ -0,0 +1,79 @@ +/** + * How a measurement statistic reads in the clinician-facing artefacts: the + * PDF report and the shared clinician view. + * + * Both describe the same record to the same reader, so they take the unit and + * the displayed number from here and nowhere else. Before this module the PDF + * carried its own unit map and the clinician view printed bare numbers, so a + * doctor opening a share link read "latest 5.8 (avg 6.1, range 4.9–7.4)" with + * no unit at all, and for an account on mmol/L the glucose rows of the PDF's + * measurement table still printed raw mg/dL beside a mmol/L glucose panel. + * + * The policy, which `display-transform.ts` states for the metric/imperial + * preference: a document handed to a clinician stays SI for mass, length and + * temperature regardless of the owner's imperial preference. Glucose is the + * exception, because mmol/L versus mg/dL is not an imperial question — it is + * the clinical convention of the country the record lives in — so glucose + * follows the owner's `glucoseUnit` here as it does everywhere a person reads + * it. + */ +import { convertGlucose, type GlucoseUnit } from "@/lib/glucose"; +import { getUnitForType } from "@/lib/validations/measurement"; + +/** + * Report-specific unit symbols, where the report's own reading differs from + * the validation layer's canonical unit. `null` marks a unit that has to be + * translated (steps). Everything absent falls back to `getUnitForType`. + */ +export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { + WEIGHT: "kg", + BLOOD_PRESSURE_SYS: "mmHg", + BLOOD_PRESSURE_DIA: "mmHg", + PULSE: "bpm", + BODY_FAT: "%", + SLEEP_DURATION: "h", + ACTIVITY_STEPS: null, // translated unit + TOTAL_BODY_WATER: "kg", + BONE_MASS: "kg", + OXYGEN_SATURATION: "%", +}; + +/** + * The unit symbol a report statistic of `type` is printed with. + * + * `translate` resolves the one translated unit (steps). A type with no + * recorded unit prints none — an absent unit is absent, not a guessed symbol. + */ +export function reportStatUnit( + type: string, + glucoseUnit: GlucoseUnit, + translate: (key: string) => string, +): string { + if (type === "BLOOD_GLUCOSE") return glucoseUnit; + const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type]; + if (staticUnit === null && type === "ACTIVITY_STEPS") { + return translate("doctorReport.unitSteps"); + } + if (staticUnit !== undefined && staticUnit !== null) return staticUnit; + const canonical = getUnitForType(type); + return canonical === "unknown" ? "" : canonical; +} + +/** + * The number a report statistic of `type` is printed as, in the unit + * {@link reportStatUnit} names. + * + * SLEEP_DURATION statistics are per-night asleep totals in minutes (the data + * layer reconstructs them for exactly this row) while the unit is hours, so + * they are divided — the same hours value the FHIR sleep Observation emits. + * Glucose is stored in mg/dL and converted to the owner's unit. + */ +export function reportStatValue( + type: string, + value: number, + glucoseUnit: GlucoseUnit, +): number { + if (type === "SLEEP_DURATION") return value / 60; + if (type === "BLOOD_GLUCOSE") return convertGlucose(value, glucoseUnit); + return value; +} From 70e6e07a899dc15e394d6399401d27e3ed0d1432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:48:39 +0200 Subject: [PATCH 02/13] refactor(units): resolve both unit preferences through one registry Metric/imperial and the glucose unit were two separate lookups, so every server-side text producer had to remember both and most remembered neither. display-transform.ts now carries a glucose branch keyed by the glucose preference (getReadingTransform), the metre-valued workout quantities (distance, elevation, pace), and a resolver for the two stored columns together. loadUnitPreferences reads them in one query and useUnitDisplay hands the glucose unit out beside the metric/imperial branch. Refs #1067 --- src/hooks/use-unit-display.ts | 20 ++- .../__tests__/display-transform.test.ts | 89 ++++++++++++ src/lib/measurements/display-transform.ts | 131 ++++++++++++++++++ src/lib/measurements/unit-preferences.ts | 26 ++++ 4 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 src/lib/measurements/unit-preferences.ts diff --git a/src/hooks/use-unit-display.ts b/src/hooks/use-unit-display.ts index f74b311da..212823877 100644 --- a/src/hooks/use-unit-display.ts +++ b/src/hooks/use-unit-display.ts @@ -23,13 +23,21 @@ import { getDisplayTransform, invertDisplayTransform, hasDisplayTransform, + resolveUnitPreference, type DisplayTransform, type UnitPreference, } from "@/lib/measurements/display-transform"; +import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose"; export interface UnitDisplay { /** Resolved preference ("metric" by default / on a stale payload). */ preference: UnitPreference; + /** + * Resolved glucose unit ("mg/dL" by default). Carried beside the + * metric/imperial branch so a surface that edits or renders glucose reads + * both preferences from the one hook. + */ + glucoseUnit: GlucoseUnit; /** The full transform for a type under the current preference. */ transformFor: (type: string) => DisplayTransform; /** Convert a raw canonical ABSOLUTE value to the display value. */ @@ -48,7 +56,7 @@ export interface UnitDisplay { export function useUnitDisplay(): UnitDisplay { const { user } = useAuth(); - return useDisplayForPreference(user?.unitPreference); + return useDisplayForPreference(user?.unitPreference, user?.glucoseUnit); } /** @@ -69,20 +77,22 @@ export function useUnitDisplay(): UnitDisplay { */ export function useUnitDisplayOnceMounted(): UnitDisplay { const user = useAccountOnceMounted(); - return useDisplayForPreference(user?.unitPreference); + return useDisplayForPreference(user?.unitPreference, user?.glucoseUnit); } function useDisplayForPreference( rawPreference: string | null | undefined, + rawGlucoseUnit: string | null | undefined, ): UnitDisplay { - const preference: UnitPreference = - rawPreference === "imperial" ? "imperial" : "metric"; + const preference = resolveUnitPreference(rawPreference); + const glucoseUnit = resolveGlucoseUnit(rawGlucoseUnit); return useMemo(() => { const transformFor = (type: string) => getDisplayTransform(type, preference); return { preference, + glucoseUnit, transformFor, toDisplay: (type, rawValue) => applyDisplayTransform(rawValue, transformFor(type)), @@ -94,5 +104,5 @@ function useDisplayForPreference( decimalsFor: (type) => transformFor(type).decimals, isTransformed: (type) => hasDisplayTransform(type), }; - }, [preference]); + }, [preference, glucoseUnit]); } diff --git a/src/lib/measurements/__tests__/display-transform.test.ts b/src/lib/measurements/__tests__/display-transform.test.ts index ccb7c5d79..91f23e674 100644 --- a/src/lib/measurements/__tests__/display-transform.test.ts +++ b/src/lib/measurements/__tests__/display-transform.test.ts @@ -10,7 +10,14 @@ import { transformRescales, TRANSFORMED_TYPES, DEFAULT_UNIT_PREFERENCE, + DEFAULT_UNIT_PREFERENCES, + getQuantityTransform, + getReadingTransform, + isUnitSensitiveType, + paceSecondsPerDistanceUnit, + resolveUnitPreferences, } from "../display-transform"; +import { convertGlucose } from "@/lib/glucose"; describe("display-transform", () => { it("converts WALKING_SPEED m/s → km/h (factor 3.6)", () => { @@ -308,3 +315,85 @@ describe("display-transform — converted values carry the declared precision", expect(invertDisplayTransform(185.2, imperial)).not.toBe(84); }); }); + +describe("display-transform — both unit preferences, one resolver", () => { + it("reads glucose in the preferred clinical unit, matching convertGlucose", () => { + const mmol = getReadingTransform("BLOOD_GLUCOSE", { + system: "metric", + glucoseUnit: "mmol/L", + }); + expect(mmol.displayUnit).toBe("mmol/L"); + for (const mgdl of [54, 70, 99, 126, 140, 250]) { + expect(applyDisplayTransform(mgdl, mmol)).toBe( + convertGlucose(mgdl, "mmol/L"), + ); + } + const mgdl = getReadingTransform("BLOOD_GLUCOSE", DEFAULT_UNIT_PREFERENCES); + expect(mgdl.displayUnit).toBe("mg/dL"); + expect(applyDisplayTransform(99, mgdl)).toBe(99); + }); + + it("leaves glucose alone under the imperial preference", () => { + const t = getReadingTransform("BLOOD_GLUCOSE", { + system: "imperial", + glucoseUnit: "mg/dL", + }); + expect(t.displayUnit).toBe("mg/dL"); + }); + + it("delegates every other type to the metric/imperial registry", () => { + expect( + getReadingTransform("WEIGHT", { + system: "imperial", + glucoseUnit: "mmol/L", + }).displayUnit, + ).toBe("lb"); + expect( + getReadingTransform("PULSE", { + system: "imperial", + glucoseUnit: "mmol/L", + }).displayUnit, + ).toBe("bpm"); + }); + + it("marks glucose and the transformed set as unit-sensitive", () => { + expect(isUnitSensitiveType("BLOOD_GLUCOSE")).toBe(true); + expect(isUnitSensitiveType("WEIGHT")).toBe(true); + expect(isUnitSensitiveType("HEART_RATE_VARIABILITY")).toBe(false); + }); + + it("resolves the stored columns with their defaults", () => { + expect(resolveUnitPreferences({})).toEqual(DEFAULT_UNIT_PREFERENCES); + expect( + resolveUnitPreferences({ + unitPreference: "imperial", + glucoseUnit: "mmol/L", + }), + ).toEqual({ system: "imperial", glucoseUnit: "mmol/L" }); + expect(resolveUnitPreferences({ unitPreference: "bogus" }).system).toBe( + "metric", + ); + }); + + it("converts workout distance, elevation and pace per preference", () => { + const km = getQuantityTransform("distance", "metric"); + const mi = getQuantityTransform("distance", "imperial"); + expect(applyDisplayTransform(5000, km)).toBe(5); + expect(km.displayUnit).toBe("km"); + expect(applyDisplayTransform(5000, mi)).toBe(3.11); + expect(mi.displayUnit).toBe("mi"); + expect( + applyDisplayTransform(100, getQuantityTransform("elevation", "imperial")), + ).toBe(328); + expect(getQuantityTransform("elevation", "metric").displayUnit).toBe("m"); + // 30 min over 5 km: 360 s/km, 579.4 s/mi. + expect(paceSecondsPerDistanceUnit(1800, 5000, "metric")).toBeCloseTo( + 360, + 6, + ); + expect(paceSecondsPerDistanceUnit(1800, 5000, "imperial")).toBeCloseTo( + 579.36, + 2, + ); + }); +}); diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index ec61fe1f2..5e1cba548 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -61,12 +61,54 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; +import { + MGDL_PER_MMOL, + resolveGlucoseUnit, + type GlucoseUnit, +} from "@/lib/glucose"; import { getUnitForType } from "@/lib/validations/measurement"; export type UnitPreference = "metric" | "imperial"; export const DEFAULT_UNIT_PREFERENCE: UnitPreference = "metric"; +/** The stored preference column resolved to a branch; anything unknown is metric. */ +export function resolveUnitPreference( + raw: string | null | undefined, +): UnitPreference { + return raw === "imperial" ? "imperial" : DEFAULT_UNIT_PREFERENCE; +} + +/** + * Both unit choices a person makes, together. They are separate columns + * because they are separate questions — metric versus imperial is about mass, + * length and temperature, mg/dL versus mmol/L is the clinical convention of + * the country the record lives in — but a surface that renders a reading + * needs both, and taking them as one value is what keeps a server-side text + * producer from honouring one and forgetting the other. + */ +export interface UnitPreferences { + system: UnitPreference; + glucoseUnit: GlucoseUnit; +} + +/** Resolve both stored columns at once (each falls back to its default). */ +export function resolveUnitPreferences(row: { + unitPreference?: string | null; + glucoseUnit?: string | null; +}): UnitPreferences { + return { + system: resolveUnitPreference(row.unitPreference), + glucoseUnit: resolveGlucoseUnit(row.glucoseUnit), + }; +} + +/** The defaults: metric, mg/dL. */ +export const DEFAULT_UNIT_PREFERENCES: UnitPreferences = { + system: DEFAULT_UNIT_PREFERENCE, + glucoseUnit: "mg/dL", +}; + export interface DisplayTransform { /** Multiplier applied to the raw canonical value for display. */ factor: number; @@ -156,6 +198,95 @@ const TRANSFORMS: Partial< }, }; +/** + * Blood glucose, stored in mg/dL, read in either clinical unit. Keyed by the + * glucose preference rather than metric/imperial. The mmol/L branch rounds to + * one decimal through `applyDisplayTransform`, which is exactly what + * `mgdlToMmol` does, so a server-side text and a screen built on + * `convertGlucose` print the same figure. + */ +const GLUCOSE_TRANSFORM: Record = { + "mg/dL": { factor: 1, displayUnit: "mg/dL", decimals: 0 }, + "mmol/L": { factor: 1 / MGDL_PER_MMOL, displayUnit: "mmol/L", decimals: 1 }, +}; + +/** + * The transform for a reading of `type` under BOTH unit preferences — the + * one resolver a server-side text producer (a status note, a narrative, a + * prompt) takes its numbers and unit symbols from. + * + * The client surfaces keep `getDisplayTransform` through `useUnitDisplay` + * and convert glucose through `convertGlucose` at the few places that render + * it; folding glucose into that path would convert it a second time on the + * screens that already do. Text built on the server has no such history, so + * it starts from the complete answer. + */ +export function getReadingTransform( + type: string, + preferences: UnitPreferences, +): DisplayTransform { + if (type === "BLOOD_GLUCOSE") + return GLUCOSE_TRANSFORM[preferences.glucoseUnit]; + return getDisplayTransform(type, preferences.system); +} + +/** + * Every measurement type whose displayed number or unit depends on a unit + * preference: the metric/imperial set plus glucose. Text that quoted one of + * these was written in a unit, and goes stale when the preference changes. + */ +export function isUnitSensitiveType(type: string): boolean { + return type === "BLOOD_GLUCOSE" || TRANSFORMED_TYPES.has(type); +} + +/** + * Quantities that are not a stored measurement type but are read in a unit + * system all the same: a workout's distance and its elevation gain, both + * stored in metres. Same rules as the type registry — the metric distance + * branch reads kilometres, which is a rescale, so it rounds; the metric + * elevation branch is the identity. + */ +export type QuantityKind = "distance" | "elevation"; + +// 1 m = 3.28083989501 ft. +const M_TO_FT = 3.28083989501; + +const QUANTITY_TRANSFORMS: Record< + QuantityKind, + Record +> = { + distance: { + metric: { factor: 0.001, displayUnit: "km", decimals: 2 }, + imperial: { factor: 0.000621371192237, displayUnit: "mi", decimals: 2 }, + }, + elevation: { + metric: { factor: 1, displayUnit: "m", decimals: 0 }, + imperial: { factor: M_TO_FT, displayUnit: "ft", decimals: 0 }, + }, +}; + +/** The transform for a metre-valued quantity under a metric/imperial preference. */ +export function getQuantityTransform( + kind: QuantityKind, + preference: UnitPreference, +): DisplayTransform { + return QUANTITY_TRANSFORMS[kind][preference]; +} + +/** + * Seconds per displayed distance unit (per km, or per mile) from a duration + * and a distance in metres. A pace is a rate in the inverse direction of a + * distance, so it divides by the factor instead of multiplying. + */ +export function paceSecondsPerDistanceUnit( + durationSec: number, + meters: number, + preference: UnitPreference, +): number { + const { factor } = QUANTITY_TRANSFORMS.distance[preference]; + return durationSec / (meters * factor); +} + /** * Every type with a registered transform. The structural guard test uses * this set to assert no display surface hardcodes a unit string / scale diff --git a/src/lib/measurements/unit-preferences.ts b/src/lib/measurements/unit-preferences.ts new file mode 100644 index 000000000..5dadc9b7b --- /dev/null +++ b/src/lib/measurements/unit-preferences.ts @@ -0,0 +1,26 @@ +/** + * Server-side read of a record's two unit preferences. + * + * Every text the server writes for a person to read — a status note, a period + * narrative, a score label — quotes numbers, and a number is only right in the + * unit its reader chose. This is the one read those producers share, so none + * of them can load `unitPreference` and forget `glucoseUnit` or the reverse. + */ +import { prisma } from "@/lib/db"; + +import { + DEFAULT_UNIT_PREFERENCES, + resolveUnitPreferences, + type UnitPreferences, +} from "./display-transform"; + +/** Both preferences of one record; the defaults when the row is gone. */ +export async function loadUnitPreferences( + userId: string, +): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { unitPreference: true, glucoseUnit: true }, + }); + return row ? resolveUnitPreferences(row) : DEFAULT_UNIT_PREFERENCES; +} From 6c6d363bba0c41503b8f8cbc850fbaccec98ebd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:52:44 +0200 Subject: [PATCH 03/13] fix(insights): write metric status notes in the reader's units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-metric status note quoted glucose in mg/dL, body composition in kg, temperatures in °C and walking distance in metres, whatever the reader had chosen; the deterministic floor without a provider did the same. The generator now converts the series, the reference band and the signal into the reader's units before they reach the prompt or the floor, and prints the guideline band table in that unit while still classifying on the canonical value. A changed glucose unit or metric/imperial preference (own account or a managed record) now re-warms the stored notes and narratives through the hash-gated refill, so today's text does not keep the old unit. Refs #1067 --- .../me/glucose-unit/__tests__/route.test.ts | 35 +++++ src/app/api/auth/me/glucose-unit/route.ts | 7 + .../unit-preference/__tests__/route.test.ts | 35 +++++ src/app/api/auth/me/unit-preference/route.ts | 7 + src/app/api/record-settings/[family]/route.ts | 4 + .../__tests__/interpretation-block.test.ts | 23 +++ src/lib/ai/prompts/interpretation-block.ts | 32 ++-- .../insights/__tests__/metric-status.test.ts | 131 ++++++++++++++++ .../__tests__/unit-change-refresh.test.ts | 60 ++++++++ src/lib/insights/graded-series.ts | 29 ++++ src/lib/insights/metric-status.ts | 145 ++++++++++++++++-- src/lib/insights/no-key-fallbacks.ts | 8 +- src/lib/insights/unit-change-refresh.ts | 63 ++++++++ 13 files changed, 551 insertions(+), 28 deletions(-) create mode 100644 src/lib/insights/__tests__/unit-change-refresh.test.ts create mode 100644 src/lib/insights/unit-change-refresh.ts diff --git a/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts index a699059cb..1e19944a9 100644 --- a/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts +++ b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts @@ -23,6 +23,10 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/insights/unit-change-refresh", () => ({ + refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); @@ -56,6 +60,7 @@ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; import { checkRateLimit } from "@/lib/rate-limit"; const SESSION_OK = { @@ -210,3 +215,33 @@ describe("PATCH /api/auth/me/glucose-unit", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); }); + +describe("PATCH re-warms the texts that quote the unit", () => { + it("re-warms the stored notes when the unit actually changes", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ glucoseUnit: "mmol/L" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1"); + }); + + it("leaves them alone when the same unit is saved again", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: "mmol/L", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ glucoseUnit: "mmol/L" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/glucose-unit/route.ts b/src/app/api/auth/me/glucose-unit/route.ts index 144fa8df6..b11373224 100644 --- a/src/app/api/auth/me/glucose-unit/route.ts +++ b/src/app/api/auth/me/glucose-unit/route.ts @@ -38,6 +38,7 @@ import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose"; import { glucoseUnitPatchSchema } from "@/lib/validations/user-prefs"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; export const dynamic = "force-dynamic"; @@ -112,6 +113,12 @@ export const PATCH = apiHandler(async (req: Request) => { }, }); + // The status notes and narratives quote glucose in this unit; re-warm + // them so today's stored text does not keep the old one. + if (resolveGlucoseUnit(previous?.glucoseUnit) !== next) { + void refreshTextsAfterUnitChange(user.id); + } + annotate({ action: { name: "auth.me.glucose-unit.patch" }, meta: { glucoseUnit: next }, diff --git a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts index 17f9e74a3..430a05879 100644 --- a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts +++ b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts @@ -11,6 +11,10 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/insights/unit-change-refresh", () => ({ + refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); @@ -43,6 +47,7 @@ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -197,3 +202,33 @@ describe("PATCH /api/auth/me/unit-preference", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); }); + +describe("PATCH re-warms the texts that quote the unit", () => { + it("re-warms the stored notes when the unit actually changes", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + unitPreference: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ unitPreference: "imperial" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1"); + }); + + it("leaves them alone when the same unit is saved again", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + unitPreference: "imperial", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ unitPreference: "imperial" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/unit-preference/route.ts b/src/app/api/auth/me/unit-preference/route.ts index ae7b459d7..fe17915fa 100644 --- a/src/app/api/auth/me/unit-preference/route.ts +++ b/src/app/api/auth/me/unit-preference/route.ts @@ -38,6 +38,7 @@ import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { DEFAULT_UNIT_PREFERENCE } from "@/lib/measurements/display-transform"; import { unitPreferencePatchSchema } from "@/lib/validations/user-prefs"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; export const dynamic = "force-dynamic"; @@ -118,6 +119,12 @@ export const PATCH = apiHandler(async (req: Request) => { }, }); + // The status notes and narratives quote mass, length and temperature in + // this branch; re-warm them so today's stored text follows the switch. + if (resolve(previous?.unitPreference) !== next) { + void refreshTextsAfterUnitChange(user.id); + } + annotate({ action: { name: "auth.me.unit-preference.patch" }, meta: { unitPreference: next }, diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index 5b91fa3de..f72f62af9 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -41,6 +41,7 @@ import { parseNotificationPrefs, resolveNotificationPrefs, } from "@/lib/validations/notification-prefs"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; export const dynamic = "force-dynamic"; @@ -230,6 +231,9 @@ export const PATCH = apiHandler( }, }); invalidateUserProfile(access.recordId); + if (patch.unitPreference !== undefined) { + void refreshTextsAfterUnitChange(access.recordId); + } if (patch.timezone !== undefined) { invalidateUserMedications(access.recordId, { evict: true }); } diff --git a/src/lib/ai/prompts/__tests__/interpretation-block.test.ts b/src/lib/ai/prompts/__tests__/interpretation-block.test.ts index 31a04abca..010100fb2 100644 --- a/src/lib/ai/prompts/__tests__/interpretation-block.test.ts +++ b/src/lib/ai/prompts/__tests__/interpretation-block.test.ts @@ -9,6 +9,29 @@ import { describe, expect, it } from "vitest"; import { buildInterpretationBlock } from "../interpretation-block"; describe("buildInterpretationBlock", () => { + it("classifies on the canonical value but prints in the reader's unit", () => { + const toF = (c: number) => Math.round((c * 1.8 + 32) * 10) / 10; + const block = buildInterpretationBlock({ + metricKey: "BODY_TEMPERATURE", + value: 36.8, + sex: null, + locale: "en", + display: { unit: "°F", convert: toF }, + }); + expect(block).toBeDefined(); + expect(block).toContain("Current value 98.2 °F"); + expect(block).not.toContain("°C"); + // Same band as the canonical block names. + const canonical = buildInterpretationBlock({ + metricKey: "BODY_TEMPERATURE", + value: 36.8, + sex: null, + locale: "en", + })!; + const band = (text: string) => text.match(/is in the "([^"]+)" band/)![1]; + expect(band(block!)).toBe(band(canonical)); + }); + it("renders the band position for visceral fat 2.7 (the headline case)", () => { const en = buildInterpretationBlock({ metricKey: "VISCERAL_FAT", diff --git a/src/lib/ai/prompts/interpretation-block.ts b/src/lib/ai/prompts/interpretation-block.ts index 84bad478e..7e42185a6 100644 --- a/src/lib/ai/prompts/interpretation-block.ts +++ b/src/lib/ai/prompts/interpretation-block.ts @@ -130,9 +130,17 @@ function proximityPhrase( */ export function buildInterpretationBlock(args: { metricKey: string; + /** The value in the guideline's own (canonical) unit — what is classified. */ value: number; sex: "MALE" | "FEMALE" | null | undefined; locale: Locale; + /** + * Print the block in the reader's unit. Classification always runs on the + * canonical value against the canonical bands; only the printed numbers and + * the unit symbol are converted, so a prompt whose snapshot reads °F does + * not carry a band table in °C beside it. + */ + display?: { unit: string; convert: (canonical: number) => number }; }): string | undefined { const resolved = resolveInterpretation(args.metricKey, args.sex ?? null); if (!resolved) return undefined; @@ -140,22 +148,24 @@ export function buildInterpretationBlock(args: { const loc: "en" | "de" = instructionLocale(args.locale); const pos = classifyBandPosition(args.value, resolved.bands); - const rows = resolved.bands + const show = (n: number) => (args.display ? args.display.convert(n) : n); + const unit = args.display ? args.display.unit : resolved.unit; + const shownBands = resolved.bands.map((band) => ({ + ...band, + upTo: band.upTo === null ? null : show(band.upTo), + })); + + const rows = shownBands .map((band, i) => - bandRow( - band, - i > 0 ? resolved.bands[i - 1].upTo : null, - resolved.unit, - loc, - ), + bandRow(band, i > 0 ? shownBands[i - 1].upTo : null, unit, loc), ) .map((r) => ` - ${r}`) .join("\n"); const proximity = proximityPhrase( pos.proximity, - pos.nearestEdge, - resolved.unit, + pos.nearestEdge === null ? null : show(pos.nearestEdge), + unit, loc, ); const valence = valencePhrase(pos.band.valence, loc); @@ -164,14 +174,14 @@ export function buildInterpretationBlock(args: { if (loc === "en") { return `INTERPRETATION CONTEXT (guideline bands — computed server-side; state, do NOT recompute): -- Current value ${args.value} ${resolved.unit} is in the "${pos.band.label}" band, and ${proximity}. +- Current value ${show(args.value)} ${unit} is in the "${pos.band.label}" band, and ${proximity}. - Guideline bands (${resolved.source}; ${direction}): ${rows} - Values in the "${pos.band.label}" band are considered ${valence} per these guidelines.${caveat} - Name where the value sits and what that band means in plain words. Judge any trend BY this position: a shift deep inside a favourable band is a footnote; the same shift approaching or crossing a boundary is the headline. Frame consequence without diagnosis ("values in this range are considered …", never "you have / are at risk of …").`; } return `EINORDNUNGS-KONTEXT (Leitlinien-Bänder — serverseitig berechnet; nennen, NICHT neu rechnen): -- Der aktuelle Wert ${args.value} ${resolved.unit} liegt im Band "${pos.band.label}" und ${proximity}. +- Der aktuelle Wert ${show(args.value)} ${unit} liegt im Band "${pos.band.label}" und ${proximity}. - Leitlinien-Bänder (${resolved.source}; ${direction}): ${rows} - Werte im Band "${pos.band.label}" gelten laut diesen Leitlinien als ${valence}.${caveat} diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 0f7fe5be4..74903b025 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -507,3 +507,134 @@ describe("generateMetricStatus — generation path", () => { expect(result.text).toContain("Steady."); }); }); + +describe("generateMetricStatus — the note is written in the reader's units", () => { + function seed(values: (day: number) => number, days = 120) { + const now = new Date(); + const records: Array<{ value: number; measuredAt: Date }> = []; + for (let day = 0; day < days; day++) { + records.push({ + value: values(day), + measuredAt: new Date(now.getTime() - day * dayMs), + }); + } + vi.mocked(prisma.measurement.count).mockResolvedValue(days as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); + } + + function snapshotOf(userPrompt: string) { + return JSON.parse(userPrompt.match(/\{[\s\S]*\}/)![0]); + } + + it("states glucose, its band and its series in mmol/L for an mmol/L reader", async () => { + seed((day) => 99 + (day % 4) * 3); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: "metric", + glucoseUnit: "mmol/L", + } as never); + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Steady."}', captured); + + await generateMetricStatus({ + metric: "BLOOD_GLUCOSE", + userId: "user-1", + locale: "en", + }); + + expect(captured.systemPrompt).toContain("unit: mmol/L"); + // 70–140 mg/dL is 3.9–7.8 mmol/L. + expect(captured.systemPrompt).toContain("3.9–7.8 mmol/L"); + // The shared safety contract quotes its own fixed thresholds; the metric + // block itself must not carry the canonical band. + expect(captured.systemPrompt).not.toContain("70–140 mg/dL"); + const snapshot = snapshotOf(captured.userPrompt!); + expect(snapshot.metric.unit).toBe("mmol/L"); + expect(snapshot.metric.normalRange).toMatchObject({ low: 3.9, high: 7.8 }); + const recentMeans = snapshot.BLOOD_GLUCOSE.series.recent.map( + (b: { mean: number }) => b.mean, + ); + expect(recentMeans.length).toBeGreaterThan(0); + for (const mean of recentMeans) { + expect(mean).toBeGreaterThan(5); + expect(mean).toBeLessThan(6.5); + } + expect(captured.userPrompt).not.toContain("mg/dL"); + }); + + it("states body temperature in °F for an imperial reader, guideline band included", async () => { + seed((day) => 36.6 + (day % 3) * 0.1); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: "imperial", + glucoseUnit: null, + } as never); + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Steady."}', captured); + + await generateMetricStatus({ + metric: "BODY_TEMPERATURE", + userId: "user-1", + locale: "en", + }); + + expect(captured.systemPrompt).toContain("unit: °F"); + expect(captured.systemPrompt).not.toContain("36.1–37.2 °C"); + // The interpretation block classifies in °C but prints in °F. + expect(captured.userPrompt).toContain("INTERPRETATION CONTEXT"); + expect(captured.userPrompt).not.toContain("°C"); + expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("°F"); + }); + + it("writes the no-provider floor in the reader's glucose unit", async () => { + seed((day) => 99 + (day % 4) * 3); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: "metric", + glucoseUnit: "mmol/L", + } as never); + vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never); + + const result = await generateMetricStatus({ + metric: "BLOOD_GLUCOSE", + userId: "user-1", + locale: "en", + }); + + expect(result.text).toMatch(/\d\.\d mmol\/L/); + expect(result.text).not.toContain("mg/dL"); + }); + + it("leaves a metric reader on the default units byte-identical", async () => { + seed((day) => 99 + (day % 4) * 3); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: null, + glucoseUnit: null, + } as never); + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Steady."}', captured); + + await generateMetricStatus({ + metric: "BLOOD_GLUCOSE", + userId: "user-1", + locale: "en", + }); + + expect(captured.systemPrompt).toContain("70–140 mg/dL"); + expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("mg/dL"); + }); +}); diff --git a/src/lib/insights/__tests__/unit-change-refresh.test.ts b/src/lib/insights/__tests__/unit-change-refresh.test.ts new file mode 100644 index 000000000..15a0eac27 --- /dev/null +++ b/src/lib/insights/__tests__/unit-change-refresh.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + insightNarrative: { findMany: vi.fn() }, + }, +})); +vi.mock("@/lib/insights/status-invalidation", () => ({ + enqueueStatusRefillForUser: vi.fn(), +})); +vi.mock("@/lib/jobs/period-narrative-shared", () => ({ + enqueueNarrativeWarm: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation"; +import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; +import { refreshTextsAfterUnitChange } from "../unit-change-refresh"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("refreshTextsAfterUnitChange", () => { + it("refills the status cards in the reader's locale and re-warms each stored narrative", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "de", + } as never); + vi.mocked(enqueueStatusRefillForUser).mockResolvedValue(12); + vi.mocked(prisma.insightNarrative.findMany).mockResolvedValue([ + { period: "week", locale: "de" }, + { period: "month", locale: "en" }, + { period: "year", locale: "de" }, + { period: "week", locale: "xx" }, + ] as never); + + await refreshTextsAfterUnitChange("user-1"); + + expect(enqueueStatusRefillForUser).toHaveBeenCalledWith("user-1", "de"); + expect(enqueueNarrativeWarm).toHaveBeenCalledTimes(2); + expect(enqueueNarrativeWarm).toHaveBeenCalledWith({ + userId: "user-1", + period: "week", + locale: "de", + }); + expect(enqueueNarrativeWarm).toHaveBeenCalledWith({ + userId: "user-1", + period: "month", + locale: "en", + }); + }); + + it("never throws into the preference write", async () => { + vi.mocked(prisma.user.findUnique).mockRejectedValue(new Error("db down")); + await expect(refreshTextsAfterUnitChange("user-1")).resolves.toBe( + undefined, + ); + }); +}); diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts index b67298080..73e1938f8 100644 --- a/src/lib/insights/graded-series.ts +++ b/src/lib/insights/graded-series.ts @@ -598,6 +598,35 @@ export function scaleGradedSeries( }; } +/** + * Re-express a canonical graded series in a display unit: absolute bucket + * values (`min / max / mean`) take the full affine conversion, the yearly + * slope — a rate, not a level — takes the factor alone, exactly as + * `applyDisplayTransformDelta` treats a delta. Bucket keys and counts are + * untouched. Used where a series is narrated in the reader's unit; the + * series it is built from stays canonical. + */ +export function convertGradedSeries( + series: GradedSeries, + conversion: { factor: number; offset?: number }, + digits = 2, +): GradedSeries { + const offset = conversion.offset ?? 0; + const abs = (v: number) => round(v * conversion.factor + offset, digits); + const bucket = ( + b: T, + ): T => ({ ...b, min: abs(b.min), max: abs(b.max), mean: abs(b.mean) }); + return { + recent: series.recent.map(bucket), + weekly: series.weekly.map(bucket), + monthly: series.monthly.map(bucket), + yearly: series.yearly.map((b) => ({ + ...bucket(b), + slope: b.slope === null ? null : round(b.slope * conversion.factor, 4), + })), + }; +} + /** * Assembled-snapshot soft char cap, mirroring the Coach's * `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index a7d922cf3..b937b689c 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -58,7 +58,9 @@ import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints, buildGradedSeriesWithRollups, + convertGradedSeries, degradeStatusSnapshotToBudget, + type GradedSeries, } from "@/lib/insights/graded-series"; import { type SupportedLocale, @@ -84,6 +86,16 @@ import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { binaryReferenceSex } from "@/lib/profile/sex"; +import { + applyDisplayTransform, + getReadingTransform, + invertDisplayTransform, + isUnitSensitiveType, + resolveUnitPreferences, + transformRescales, + type DisplayTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; export interface MetricStatusResult { hasProvider: boolean; @@ -222,9 +234,40 @@ export async function generateMetricStatus(args: { const user = await prisma.user.findUnique({ where: { id: args.userId }, - select: { dateOfBirth: true, gender: true }, + select: { + dateOfBirth: true, + gender: true, + unitPreference: true, + glucoseUnit: true, + }, }); + // The note is written in the reader's units. The series, the reference band + // and the signal are converted once, here, before any of them reaches the + // prompt or the deterministic floor; the registry entry, the rollups and + // the guideline classification stay canonical. An untransformed metric (and + // every metric on the default preferences that does not rescale) passes + // through unchanged, so its snapshot hash does not move. + const reading = readingDisplay( + meta, + resolveUnitPreferences({ + unitPreference: user?.unitPreference, + glucoseUnit: user?.glucoseUnit, + }), + ); + const shownMeta: MetricStatusMeta = { + ...meta, + unit: reading.unit, + ...(meta.normalRange + ? { + normalRange: { + low: reading.toDisplay(meta.normalRange.low), + high: reading.toDisplay(meta.normalRange.high), + }, + } + : {}), + }; + const now = new Date(); // Bounded raw read for the derived recent stats; the monthly/yearly @@ -295,7 +338,10 @@ export async function generateMetricStatus(args: { }) .then((r) => r.reverse()); for (const m of rows) { - points.push({ measuredAt: m.measuredAt, value: m.value }); + points.push({ + measuredAt: m.measuredAt, + value: reading.toDisplay(m.value), + }); } measurements = rows.map((m) => ({ measuredAt: m.measuredAt })); } @@ -309,11 +355,13 @@ export async function generateMetricStatus(args: { // recent/weekly/monthly/yearly bucket is a night total, not a stage sum. const graded = isSleep ? buildGradedSeriesFromPoints(points, now, userTz) - : await buildGradedSeriesWithRollups( - args.userId, - meta.measurementType, - now, - userTz, + : reading.convertGraded( + await buildGradedSeriesWithRollups( + args.userId, + meta.measurementType, + now, + userTz, + ), ); const summary = summarizeSeries( series.daily.map((bucket) => ({ value: bucket.value })), @@ -344,7 +392,14 @@ export async function generateMetricStatus(args: { // metric + profile; otherwise keep the existing flat anchor (strictly // additive, never a regression). Age + sex only — no ancestry/region. const sharpenedRange = lookupNormalRange(meta.id, ageYears, sex); - const normalRange = sharpenedRange ?? meta.normalRange; + const canonicalRange = sharpenedRange ?? meta.normalRange; + const normalRange = canonicalRange + ? { + ...canonicalRange, + low: reading.toDisplay(canonicalRange.low), + high: reading.toDisplay(canonicalRange.high), + } + : canonicalRange; // v1.13.x — the SIGNAL block: the recent-vs-baseline comparison + the // normal-swing verdict, computed server-side so the model states it rather @@ -353,7 +408,7 @@ export async function generateMetricStatus(args: { // when the recent window is empty, in which case it is omitted. const signal = buildMetricSignal({ metric: meta.displayName, - unit: meta.unit, + unit: shownMeta.unit, direction: meta.direction, graded, normalRange: normalRange ?? null, @@ -369,7 +424,7 @@ export async function generateMetricStatus(args: { metric: { id: meta.id, displayName: meta.displayName, - unit: meta.unit, + unit: shownMeta.unit, direction: meta.direction, ...(normalRange ? { normalRange } : {}), ...(sharpenedRange ? { normalRangeSource: "age-sex-adjusted" } : {}), @@ -477,14 +532,24 @@ export async function generateMetricStatus(args: { // window exists); absent for uncovered metrics (fail-soft to personal- // relative). Sex-split bands (waist) resolve only when the profile carries a // sex. + // The guideline bands are canonical, so the band position is classified on + // the canonical value; the block then prints in the reader's unit. const interpretationValue = signal?.current ?? latest?.value ?? null; const interpretationBlock = interpretationValue !== null ? buildInterpretationBlock({ metricKey: meta.id, - value: interpretationValue, + value: reading.toCanonical(interpretationValue), sex, locale, + ...(reading.converts + ? { + display: { + unit: reading.unit, + convert: reading.toDisplay, + }, + } + : {}), }) : undefined; @@ -502,9 +567,9 @@ export async function generateMetricStatus(args: { userId: args.userId, cacheAction, capability: "statusText", - systemPrompt: getMetricArchetypeSystemPrompt(meta, locale), + systemPrompt: getMetricArchetypeSystemPrompt(shownMeta, locale), userPrompt: getMetricArchetypeUserPrompt( - meta, + shownMeta, snapshotJson, todayKey, locale, @@ -526,7 +591,7 @@ export async function generateMetricStatus(args: { if (outcome.kind === "none") { return { hasProvider: false, - text: getNoKeyMetricStatusText(locale, signal), + text: getNoKeyMetricStatusText(locale, signal, reading.decimals), cached: true, updatedAt: null, }; @@ -537,7 +602,7 @@ export async function generateMetricStatus(args: { reason: outcome.kind, userId: args.userId, todayKey, - stubText: getNoKeyMetricStatusText(locale, signal), + stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals), }); } @@ -553,7 +618,7 @@ export async function generateMetricStatus(args: { reason: "screened", userId: args.userId, todayKey, - stubText: getNoKeyMetricStatusText(locale, signal), + stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals), }); } const text = screened.text; @@ -577,3 +642,51 @@ export function resolveMetricStatusLocale( ): SupportedLocale { return normalizeLocale(locale); } + +/** + * How one metric's numbers are written for this reader: the unit symbol, the + * conversions in both directions, and the decimals a value is read at. + * + * Only a unit-sensitive type (`isUnitSensitiveType`: glucose and the + * metric/imperial set) converts; every other metric keeps its registry unit + * and its stored numbers. A transform that does not rescale (`converts` + * false) is the identity, so the default preferences leave a snapshot + * byte-identical for the metrics whose registry unit already is the display + * unit. + */ +function readingDisplay( + meta: MetricStatusMeta, + preferences: UnitPreferences, +): { + unit: string; + decimals: number | undefined; + converts: boolean; + toDisplay: (canonical: number) => number; + toCanonical: (display: number) => number; + convertGraded: (series: GradedSeries) => GradedSeries; +} { + if (!isUnitSensitiveType(meta.measurementType)) { + return { + unit: meta.unit, + decimals: undefined, + converts: false, + toDisplay: (v) => v, + toCanonical: (v) => v, + convertGraded: (series) => series, + }; + } + const transform: DisplayTransform = getReadingTransform( + meta.measurementType, + preferences, + ); + const converts = transform.factor !== 1 || (transform.offset ?? 0) !== 0; + return { + unit: transform.displayUnit, + decimals: transform.decimals, + converts, + toDisplay: (v) => applyDisplayTransform(v, transform), + toCanonical: (v) => invertDisplayTransform(v, transform), + convertGraded: (series) => + converts ? convertGradedSeries(series, transform) : series, + }; +} diff --git a/src/lib/insights/no-key-fallbacks.ts b/src/lib/insights/no-key-fallbacks.ts index 7be0863dd..fd54f0cf3 100644 --- a/src/lib/insights/no-key-fallbacks.ts +++ b/src/lib/insights/no-key-fallbacks.ts @@ -439,6 +439,12 @@ const GENERIC_METRIC_POINTER: Localized = { export function getNoKeyMetricStatusText( locale: InsightLocale, signal?: MetricSignal | null, + /** + * Decimals the signal's unit is read at (0 for mg/dL, 1 for mmol/L or + * pounds). Defaults to one, which was the only precision before the + * signal could arrive in the reader's unit. + */ + digits = 1, ): string { const grounded = signal && Number.isFinite(signal.current) @@ -449,7 +455,7 @@ export function getNoKeyMetricStatusText( // localised upstream, so it stands for every locale. label: { en: signal.metric }, ...(signal.unit ? { unit: ` ${signal.unit}` } : {}), - digits: 1, + digits, pointer: GENERIC_METRIC_POINTER, }, locale, diff --git a/src/lib/insights/unit-change-refresh.ts b/src/lib/insights/unit-change-refresh.ts new file mode 100644 index 000000000..de820ceed --- /dev/null +++ b/src/lib/insights/unit-change-refresh.ts @@ -0,0 +1,63 @@ +/** + * Re-warm the stored texts that quote numbers after a unit preference changes. + * + * A status note and a period narrative are written once and served all day + * (the narrative for up to twenty hours). Both now state their figures in the + * reader's units, so a note written before a switch from mg/dL to mmol/L — or + * from metric to imperial — keeps saying the old unit until something + * regenerates it. Nothing did: neither preference write touched the stored + * text. + * + * The refill goes through the same hash-gated path as the manual regenerate: + * every card the record has is enqueued, the worker forces a fresh snapshot, + * and a card whose snapshot did not change (a metric that does not depend on + * the preference) gets a timestamp refresh and no model call. Narratives are + * re-warmed only where one already exists. Best-effort and fire-and-forget: + * the preference write never waits on it and never fails because of it. + */ +import { prisma } from "@/lib/db"; +import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; +import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation"; +import { normalizeLocale } from "@/lib/insights/status-shared"; +import { locales, type Locale } from "@/lib/i18n/config"; +import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative"; +import { annotate } from "@/lib/logging/context"; + +export async function refreshTextsAfterUnitChange( + userId: string, +): Promise { + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const statusScopes = await enqueueStatusRefillForUser( + userId, + normalizeLocale(user?.locale), + ); + + const narratives = await prisma.insightNarrative.findMany({ + where: { userId }, + select: { period: true, locale: true }, + }); + let narrativeCount = 0; + for (const row of narratives) { + if (row.period !== "week" && row.period !== "month") continue; + if (!(locales as readonly string[]).includes(row.locale)) continue; + void enqueueNarrativeWarm({ + userId, + period: row.period as NarrativePeriod, + locale: row.locale as Locale, + }); + narrativeCount++; + } + + annotate({ + action: { name: "insights.unit-change.refresh" }, + meta: { status_scopes: statusScopes, narratives: narrativeCount }, + }); + } catch { + // Best-effort: the nightly warm and the next ingest still converge the + // texts; a failed enqueue must not surface on the preference write. + } +} From 10588597301ecca47d0473c8586bb5f44be15cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:55:19 +0200 Subject: [PATCH 04/13] fix(narrative): narrate period changes in the reader's units The period narrative pinned kilograms and mg/dL on its change list, and its personal-range lines carried no unit at all. Every delta and band edge is now stated in the reader's units (computed canonically, converted at the end, percent left unit-free), and each band transition carries its unit into the prompt. The sleep channel, a per-night total in minutes, was labelled hours and is now converted to them. Refs #1067 --- .../__tests__/period-narrative-db.test.ts | 8 +- .../period-narrative-deterministic.test.ts | 1 + .../__tests__/period-narrative.test.ts | 117 +++++++++++++++ .../narrative/period-narrative-generate.ts | 3 +- .../insights/narrative/period-narrative.ts | 134 ++++++++++++++---- 5 files changed, 234 insertions(+), 29 deletions(-) diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index b742c12ac..29462480f 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -225,9 +225,11 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => { expect(sleepDelta).toBeDefined(); // The pre-fix local `toDailyMeans` twin would have averaged the three // segment durations: (240 + 90 + 80) / 3 ≈ 137 — a number with no - // clinical meaning. The correct grain is the night's TOTAL (410). - expect(sleepDelta!.current).toBe(410); - expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3, 0); + // clinical meaning. The correct grain is the night's TOTAL (410 min), + // narrated in the hours its unit names: this used to read "410 h". + expect(sleepDelta!.unit).toBe("h"); + expect(sleepDelta!.current).toBe(6.83); + expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3 / 60, 1); }); it("orders the measurement read DESC + cap so the CURRENT period survives a capped window (QA F2)", async () => { diff --git a/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts index 3eae6875d..af39d5e72 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts @@ -113,6 +113,7 @@ describe("buildDeterministicNarrative", () => { bandTransitions: [ { type: "BLOOD_PRESSURE_SYS", + unit: "mmHg", center: 140, bandLow: 110, bandHigh: 130, diff --git a/src/lib/insights/narrative/__tests__/period-narrative.test.ts b/src/lib/insights/narrative/__tests__/period-narrative.test.ts index 292aa268c..99e3c7e68 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative.test.ts @@ -9,6 +9,9 @@ import type { DailySeriesPoint, NamedSeries, } from "@/lib/insights/correlation-discovery"; +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; +import { buildDeterministicNarrative } from "../period-narrative-deterministic"; +import { buildNarrativeUserPrompt } from "../period-narrative-generate"; /** * Build a contiguous daily series ending at `endDay` (YYYY-MM-DD), one point @@ -42,6 +45,7 @@ function monthInput( discoverySeries, locale: "en", computedAt: "2026-04-30T12:00:00.000Z", + units: DEFAULT_UNIT_PREFERENCES, }; } @@ -122,6 +126,119 @@ describe("assemblePeriodNarrativeContext — metric deltas", () => { }); }); +describe("assemblePeriodNarrativeContext — the reader's units", () => { + function glucoseAndWeight() { + const m = new Map(); + // Prior 30 days at 90 mg/dL / 80 kg, current 30 at 108 mg/dL / 82 kg. + m.set( + "BLOOD_GLUCOSE", + seriesEndingAt( + [...Array(30).fill(90), ...Array(30).fill(108)], + "2026-04-30", + ), + ); + m.set( + "WEIGHT", + seriesEndingAt( + [...Array(30).fill(80), ...Array(30).fill(82)], + "2026-04-30", + ), + ); + return m; + } + + it("narrates glucose in mmol/L and weight in pounds when the reader chose them", () => { + const ctx = assertReady( + assemblePeriodNarrativeContext({ + ...monthInput(glucoseAndWeight()), + units: { system: "imperial", glucoseUnit: "mmol/L" }, + }), + ); + const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!; + expect(g.unit).toBe("mmol/L"); + expect(g.current).toBe(5.99); + expect(g.prior).toBe(4.99); + expect(g.delta).toBe(1); + // Percent is unit-free. + expect(g.deltaPercent).toBe(20); + const w = ctx.metricDeltas.find((d) => d.type === "WEIGHT")!; + expect(w.unit).toBe("lb"); + expect(w.current).toBe(180.78); + expect(w.delta).toBe(4.41); + + const prose = buildDeterministicNarrative(ctx, "en"); + expect(prose).toContain("mmol/L"); + expect(prose).toContain("lb"); + expect(prose).not.toContain("mg/dL"); + expect(prose).not.toContain("kg"); + const prompt = buildNarrativeUserPrompt(ctx, "en"); + expect(prompt).toContain("BLOOD_GLUCOSE: 5.99 mmol/L"); + expect(prompt).not.toContain("mg/dL"); + }); + + it("keeps mg/dL and kilograms on the default preferences", () => { + const ctx = assertReady( + assemblePeriodNarrativeContext(monthInput(glucoseAndWeight())), + ); + const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!; + expect(g.unit).toBe("mg/dL"); + expect(g.current).toBe(108); + expect(ctx.metricDeltas.find((d) => d.type === "WEIGHT")!.unit).toBe("kg"); + }); + + it("narrates sleep minutes as hours, the unit it always claimed", () => { + const m = new Map(); + m.set( + "SLEEP_DURATION", + seriesEndingAt( + [...Array(30).fill(420), ...Array(30).fill(450)], + "2026-04-30", + ), + ); + m.set( + "WEIGHT", + seriesEndingAt( + [...Array(30).fill(80), ...Array(30).fill(82)], + "2026-04-30", + ), + ); + const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); + const s = ctx.metricDeltas.find((d) => d.type === "SLEEP_DURATION")!; + expect(s.unit).toBe("h"); + expect(s.current).toBe(7.5); + expect(s.delta).toBe(0.5); + }); + + it("states a band transition with its unit, converted", () => { + const m = new Map(); + const prior = Array.from({ length: 30 }, (_, i) => 90 + (i % 3)); + m.set( + "BLOOD_GLUCOSE", + seriesEndingAt([...prior, ...Array(30).fill(140)], "2026-04-30"), + ); + m.set( + "WEIGHT", + seriesEndingAt( + [...Array(30).fill(80), ...Array(30).fill(82)], + "2026-04-30", + ), + ); + const ctx = assertReady( + assemblePeriodNarrativeContext({ + ...monthInput(m), + units: { system: "metric", glucoseUnit: "mmol/L" }, + }), + ); + const t = ctx.bandTransitions.find((b) => b.type === "BLOOD_GLUCOSE")!; + expect(t.unit).toBe("mmol/L"); + expect(t.center).toBe(7.77); + expect(t.movedOut).toBe(true); + expect(buildNarrativeUserPrompt(ctx, "en")).toContain( + "BLOOD_GLUCOSE: 7.77 mmol/L", + ); + }); +}); + describe("assemblePeriodNarrativeContext — band transitions", () => { it("flags a vital whose current center moved above its prior band", () => { const m = new Map(); diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index 0d50e9dc3..22a33b2fc 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -259,8 +259,9 @@ export function buildNarrativeUserPrompt( : locale === "de" ? "im Bereich" : "in range"; + const unit = b.unit ? ` ${b.unit}` : ""; lines.push( - `- ${b.type}: ${b.center} (${b.bandLow}–${b.bandHigh}) → ${where}`, + `- ${b.type}: ${b.center}${unit} (${b.bandLow}–${b.bandHigh}${unit}) → ${where}`, ); } } diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index c5cab2167..c03deab01 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -56,6 +56,13 @@ import { VITALS_BASELINE_TYPES } from "@/lib/insights/derived/registry"; import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; import { resolveModuleMap } from "@/lib/modules/gate"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { + applyDisplayTransformUnrounded, + getReadingTransform, + isUnitSensitiveType, + resolveUnitPreferences, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -74,20 +81,78 @@ export const MIN_COVERED_DAYS_PER_METRIC = 3; /** A band must rest on at least this many prior-period days to be trusted. */ export const MIN_BASELINE_DAYS = 7; -/** The metrics the period-delta beat scans, with their display unit. */ -const DELTA_METRICS: Array<{ type: MeasurementType; unit: string }> = [ - { type: "WEIGHT", unit: "kg" }, - { type: "BLOOD_PRESSURE_SYS", unit: "mmHg" }, - { type: "BLOOD_PRESSURE_DIA", unit: "mmHg" }, - { type: "PULSE", unit: "bpm" }, - { type: "RESTING_HEART_RATE", unit: "bpm" }, - { type: "HEART_RATE_VARIABILITY", unit: "ms" }, - { type: "SLEEP_DURATION", unit: "h" }, - { type: "ACTIVITY_STEPS", unit: "" }, - { type: "BODY_FAT", unit: "%" }, - { type: "BLOOD_GLUCOSE", unit: "mg/dL" }, +/** + * The metrics the period-delta beat scans. Their unit is not listed here: a + * reading is narrated in the reader's unit (see `narratedUnit`), and the list + * used to pin kilograms and mg/dL for everyone. + */ +const DELTA_METRICS: MeasurementType[] = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + "BODY_FAT", + "BLOOD_GLUCOSE", ]; +/** + * Unit symbols of the narrated types whose unit no preference changes. The + * sleep channel is a per-night total in minutes and is narrated in hours. + */ +const FIXED_NARRATED_UNITS: Partial> = { + BLOOD_PRESSURE_SYS: "mmHg", + BLOOD_PRESSURE_DIA: "mmHg", + PULSE: "bpm", + RESTING_HEART_RATE: "bpm", + HEART_RATE_VARIABILITY: "ms", + RESPIRATORY_RATE: "breaths/min", + OXYGEN_SATURATION: "%", + SLEEP_DURATION: "h", + ACTIVITY_STEPS: "", + BODY_FAT: "%", +}; + +/** + * How one narrated type is written for this reader: its unit symbol, the + * conversion of an absolute value (a mean, a band edge) and of a difference + * (a delta — factor only, never an affine offset). The computation itself + * stays canonical, so the personal-band floors and the percent change do not + * depend on the unit the reader chose. + */ +function narratedUnit( + type: MeasurementType, + units: UnitPreferences, +): { + unit: string; + abs: (canonical: number) => number; + delta: (canonical: number) => number; +} { + if (isUnitSensitiveType(type)) { + const transform = getReadingTransform(type, units); + return { + unit: transform.displayUnit, + abs: (v) => round2(applyDisplayTransformUnrounded(v, transform)), + delta: (v) => round2(v * transform.factor), + }; + } + if (type === "SLEEP_DURATION") { + return { + unit: "h", + abs: (v) => round2(v / 60), + delta: (v) => round2(v / 60), + }; + } + return { + unit: FIXED_NARRATED_UNITS[type] ?? "", + abs: round2, + delta: round2, + }; +} + // ── context shape ─────────────────────────────────────────────────────── /** One metric's current-period mean vs the prior period of equal length. */ @@ -114,6 +179,8 @@ export interface MetricDelta { */ export interface BandTransition { type: MeasurementType; + /** Unit of `center` / `bandLow` / `bandHigh`, in the reader's preference. */ + unit: string; /** Current-period robust center (median of per-day means). */ center: number; /** Prior-period band edges. */ @@ -243,6 +310,12 @@ export interface AssembleInput { locale: Locale; /** Compute time (ISO). */ computedAt: string; + /** + * The reader's unit preferences. Required for the same reason the locale + * is: the context carries numbers a page and a prompt print verbatim, and a + * caller that stays quiet must not get kilograms and mg/dL by default. + */ + units: UnitPreferences; } /** @@ -262,12 +335,14 @@ export function assemblePeriodNarrativeContext( discoverySeries, locale, computedAt, + units, } = input; // ── metric deltas (current period vs prior period of equal length) ────── const metricDeltas: MetricDelta[] = []; const metricsWithCoverage: string[] = []; - for (const { type, unit } of DELTA_METRICS) { + for (const type of DELTA_METRICS) { + const shown = narratedUnit(type, units); const points = seriesByMetric.get(type) ?? []; const { current, prior } = splitByPeriod(points, currentFrom, priorFrom); const currentAvg = meanOrNull(current); @@ -280,16 +355,21 @@ export function assemblePeriodNarrativeContext( currentAvg !== null && priorAvg !== null ? round2(currentAvg - priorAvg) : null; + // Percent on the canonical values: it is unit-free for a pure rescale, + // and the one affine unit (°F) would otherwise move it. const deltaPercent = delta !== null && priorAvg !== null && priorAvg !== 0 ? Math.round((delta / Math.abs(priorAvg)) * 1000) / 10 : null; metricDeltas.push({ type, - unit, - current: currentAvg === null ? null : round2(currentAvg), - prior: priorAvg === null ? null : round2(priorAvg), - delta, + unit: shown.unit, + current: currentAvg === null ? null : shown.abs(currentAvg), + prior: priorAvg === null ? null : shown.abs(priorAvg), + delta: + currentAvg !== null && priorAvg !== null + ? shown.delta(currentAvg - priorAvg) + : null, deltaPercent, currentDays: current.length, priorDays: prior.length, @@ -324,11 +404,13 @@ export function assemblePeriodNarrativeContext( const center = median(current); const above = center > band.high; const below = center < band.low; + const shown = narratedUnit(type, units); bandTransitions.push({ type, - center: round2(center), - bandLow: round2(band.low), - bandHigh: round2(band.high), + unit: shown.unit, + center: shown.abs(center), + bandLow: shown.abs(band.low), + bandHigh: shown.abs(band.high), direction: above ? "above" : below ? "below" : "in", movedOut: above || below, baselineDays: prior.length, @@ -479,9 +561,13 @@ export async function buildPeriodNarrativeContext( const profile = await prisma.user.findUnique({ where: { id: userId }, - select: { timezone: true }, + select: { timezone: true, unitPreference: true, glucoseUnit: true }, }); const tz = profile?.timezone ?? DEFAULT_TIMEZONE; + const units = resolveUnitPreferences({ + unitPreference: profile?.unitPreference, + glucoseUnit: profile?.glucoseUnit, + }); const currentFrom = tzDayKey( new Date(now.getTime() - periodDays * MS_PER_DAY), @@ -523,10 +609,7 @@ export async function buildPeriodNarrativeContext( // comment for why it is one and not four. includeMoodFactors: true, extraMeasurementTypes: Array.from( - new Set([ - ...DELTA_METRICS.map((m) => m.type), - ...VITALS_BASELINE_TYPES, - ]), + new Set([...DELTA_METRICS, ...VITALS_BASELINE_TYPES]), ), }); @@ -553,5 +636,6 @@ export async function buildPeriodNarrativeContext( discoverySeries, locale: opts.locale, computedAt: now.toISOString(), + units, }); } From d0a9086538884540fe06360423b45c76113a70d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:58:24 +0200 Subject: [PATCH 05/13] fix(targets): edit glucose thresholds in the reader's glucose unit The settings threshold editor took the identity path for the glucose thresholds, so an account on mmol/L was shown a mg/dL default and asked for mg/dL figures against 40-400 bounds, while the insights sheet for the same threshold already spoke mmol/L. The shared target adapter now owns the glucose unit as it owns pounds: fields, inward-rounded bounds, the default hint and the canonical write all go through it, and the insights panel and its sheet use the same branch instead of their own conversion. Refs #1067 --- .../insights/metric-target-summary.tsx | 49 ++++----- .../thresholds-editor-glucose-unit.test.tsx | 76 +++++++++++++ .../settings/thresholds-editor-section.tsx | 8 +- src/components/targets/target-edit-sheet.tsx | 65 +++++------ .../__tests__/target-unit-display.test.ts | 101 ++++++++++++++++-- src/lib/targets/target-unit-display.ts | 47 +++++++- 6 files changed, 270 insertions(+), 76 deletions(-) create mode 100644 src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index cfdaa0be4..845b1b8d6 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -8,7 +8,6 @@ import { useAuth } from "@/hooks/use-auth"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; -import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { RangeBar } from "@/components/targets/range-bar"; @@ -18,6 +17,7 @@ import { TileHeader } from "@/components/insights/tile-header"; import { useTargetAdjust } from "@/lib/insights/target-adjust-context"; import { getTargetSourceLink } from "@/lib/targets/source-link"; import { apiGet } from "@/lib/api/api-fetch"; +import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose"; /** * v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each @@ -152,28 +152,17 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { // mg/dL values to the user's display unit. The route's per-context // labels are i18n keys, so resolve them. if (isGlucose) { - const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null); - const convert = (v: number | null) => - v == null ? null : convertGlucose(v, displayUnit); + // The payload stays canonical mg/dL here; each panel converts through + // the shared target adapter, which owns the glucose unit as it owns + // pounds, so the panel and its edit sheet cannot convert twice. + const glucoseUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null); const panels = GLUCOSE_TARGET_TYPES.map((type) => data.targets.find((entry) => entry.type === type), ) .filter( (entry): entry is TargetItem => entry != null && entry.range != null, ) - .map((entry) => ({ - ...entry, - label: t(entry.label), - unit: displayUnit, - current: convert(entry.current), - average30: convert(entry.average30), - range: entry.range - ? { - min: convertGlucose(entry.range.min, displayUnit), - max: convertGlucose(entry.range.max, displayUnit), - } - : null, - })); + .map((entry) => ({ ...entry, label: t(entry.label) })); if (panels.length === 0) return null; @@ -188,6 +177,7 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { key={panel.type} target={panel} heading={panel.label} + glucoseUnit={glucoseUnit} /> ))} @@ -212,6 +202,12 @@ interface TargetReferencePanelProps { bpDiastolic?: TargetsResponse["bpDiastolic"]; /** Optional sub-heading shown above the panel (used for glucose contexts). */ heading?: string; + /** + * The record's glucose unit as the targets payload resolved it. The + * glucose panels pass it so the band reads in the unit the server + * resolved for this record; every other panel leaves it to the hook. + */ + glucoseUnit?: GlucoseUnit; } /** @@ -232,21 +228,26 @@ function TargetReferencePanel({ target, bpDiastolic, heading, + glucoseUnit: payloadGlucoseUnit, }: TargetReferencePanelProps) { const { t } = useTranslations(); const adjust = useTargetAdjust(); - const { preference } = useUnitDisplay(); + const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay(); + const glucoseUnit = payloadGlucoseUnit ?? accountGlucoseUnit; // v1.32.27 — the panel renders in the user's preferred unit. The // `/api/insights/targets` payload is canonical (kg for weight); the // adapter converts it once, here, and everything downstream — the // range bar, the status pill, the 30-day average, and the edit - // sheet's seed — consumes the converted pair. Glucose contexts are - // pre-converted by the caller above and carry no display transform, - // so they take the adapter's identity path and are never scaled - // twice. For a metric account the adapter is the exact identity, so - // this panel is byte-identical to the previous release. - const units = resolveTargetUnitAdapter(target.type, target.unit, preference); + // sheet's seed — consumes the converted pair. The glucose contexts + // arrive canonical too and convert here to the reader's glucose unit. + // On the default preferences the adapter is the exact identity. + const units = resolveTargetUnitAdapter( + target.type, + target.unit, + preference, + glucoseUnit, + ); const unit = units.unit; const range = target.range ? { diff --git a/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx b/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx new file mode 100644 index 000000000..362d029b6 --- /dev/null +++ b/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx @@ -0,0 +1,76 @@ +/** + * The settings threshold editor reads glucose in the reader's unit. + * + * It used to pass every glucose threshold through the identity path, so an + * mmol/L account was shown "Default: 70–99 mg/dL" and mg/dL input fields + * bounded at 40–400, while the insights sheet for the same threshold already + * spoke mmol/L. The stored value stays canonical mg/dL either way. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const authUser = vi.hoisted(() => ({ + current: { glucoseUnit: "mmol/L", unitPreference: "metric" } as Record< + string, + unknown + >, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: authUser.current, isAuthenticated: true }), + useAccountOnceMounted: () => authUser.current, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + isLoading: false, + data: { + effective: { + BLOOD_GLUCOSE_FASTING: { + range: { greenMin: 72, greenMax: 108 }, + isOverride: true, + default: { greenMin: 70, greenMax: 99 }, + bounds: { min: 40, max: 400, unit: "mg/dL" }, + }, + }, + overrides: { BLOOD_GLUCOSE_FASTING: { min: 72, max: 108 } }, + }, + }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ThresholdsEditorSection } from "../thresholds-editor-section"; + +function render(): string { + return renderToStaticMarkup( + + + , + ); +} + +describe(" glucose unit", () => { + it("states the default, the fields and the bounds in mmol/L", () => { + authUser.current = { glucoseUnit: "mmol/L", unitPreference: "metric" }; + const html = render(); + // 70–99 mg/dL is 3.9–5.5 mmol/L. + expect(html).toMatch(/3\.9–5\.5 mmol\/L/); + expect(html).not.toMatch(/70(\.0)?–99(\.0)? mg\/dL/); + // The stored 72–108 mg/dL override seeds 4–6 mmol/L. + expect(html).toContain('value="4"'); + expect(html).toContain('value="6"'); + // Inward-rounded guardrail of 40–400 mg/dL. + expect(html).toContain('min="2.3"'); + expect(html).toContain('max="22.1"'); + }); + + it("keeps mg/dL for an mg/dL reader", () => { + authUser.current = { glucoseUnit: "mg/dL", unitPreference: "metric" }; + const html = render(); + expect(html).toMatch(/70(\.0)?–99(\.0)? mg\/dL/); + expect(html).toContain('value="72"'); + expect(html).toContain('min="40"'); + }); +}); diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx index 0d638024b..8a50c0fb8 100644 --- a/src/components/settings/thresholds-editor-section.tsx +++ b/src/components/settings/thresholds-editor-section.tsx @@ -237,7 +237,7 @@ function MetricRow({ }: MetricRowProps) { const { t } = useTranslations(); const fmt = useFormatters(); - const { preference } = useUnitDisplay(); + const { preference, glucoseUnit } = useUnitDisplay(); const canonicalBounds = METRIC_BOUNDS[metric]; // v1.32.27 — the row reads and writes in the user's preferred unit @@ -246,12 +246,14 @@ function MetricRow({ // canonical, so it converts on the way into the fields and inverts on // the way back out in `onSave`. Guardrails round inward so a value // typed at the displayed limit still passes the server's canonical - // check. Metrics without a transform — and every metric for a metric - // user — take the adapter's identity path untouched. + // check. The glucose thresholds read in the reader's glucose unit the + // same way. Metrics without a transform — and every metric on the default + // preferences — take the adapter's identity path untouched. const units = resolveTargetUnitAdapter( metric, canonicalBounds.unit, preference, + glucoseUnit, ); const bounds = { ...units.bounds(canonicalBounds), unit: units.unit }; diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index d3a788d42..bddd162e0 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -17,8 +17,11 @@ import { type ThresholdMetric, type EffectiveRange, } from "@/lib/analytics/effective-range"; -import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose"; -import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display"; +import { + isGlucoseTargetMetric, + resolveTargetUnitAdapter, +} from "@/lib/targets/target-unit-display"; +import { resolveGlucoseUnit } from "@/lib/glucose"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; @@ -129,49 +132,33 @@ function TargetEditSheetBody({ const metric = TARGET_TYPE_TO_METRIC[targetType] ?? null; const isDerivedMetric = !isBp && metric == null; - // Glucose is the one metric whose display unit can differ from the - // canonical storage unit: HealthLog stores mg/dL, but a user on the - // mmol/L preference sees — and types — mmol/L. The parent already - // hands us `unit="mmol/L"` and a `initialRange` pre-converted to that - // unit. We must therefore (a) seed the mg/dL persisted override into - // the display unit, (b) validate the typed value against bounds - // expressed in the display unit, and (c) convert the typed value back - // to canonical mg/dL before the PUT — otherwise a `5.5 mmol/L` target - // is rejected by the 40–400 mg/dL bounds or stored verbatim as 5.5. - const isGlucoseMmol = - metric != null && metric.startsWith("BLOOD_GLUCOSE") && unit === "mmol/L"; - - // v1.32.27 — the same coupled treatment now covers the metric/imperial - // preference. Weight-class thresholds are stored in kg; an imperial - // user seeds, validates, and types in lb, and the adapter inverts the - // typed number back to kg before the PUT. Glucose keeps its own - // mg/dL ↔ mmol/L dialect (it carries no display transform, so the two - // never compose); every other metric takes the adapter's identity - // path and is numerically untouched. - const { preference } = useUnitDisplay(); + // One adapter owns every direction of the unit conversion: an imperial + // user seeds, validates and types weight-class thresholds in pounds, an + // mmol/L user types glucose thresholds in mmol/L, and the typed number is + // inverted back to the canonical kg / mg/dL before the PUT. The parent + // hands `unit` and `initialRange` already in the display unit; the + // persisted override and `METRIC_BOUNDS` are canonical and go through the + // adapter. Every other metric takes the identity path. + // A glucose sheet speaks the unit its panel registered, so the sheet and + // the band it edits can never disagree. + const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay(); + const adapterKey = isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? ""); const units = resolveTargetUnitAdapter( - isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? ""), + adapterKey, unit, preference, + isGlucoseTargetMetric(adapterKey) + ? resolveGlucoseUnit(unit) + : accountGlucoseUnit, ); - const toDisplay = (canonical: number) => - isGlucoseMmol - ? convertGlucose(canonical, "mmol/L") - : units.toDisplay(canonical); - const toCanonical = (displayValue: number) => - isGlucoseMmol - ? toCanonicalMgdl(displayValue, "mmol/L") - : units.toCanonical(displayValue); - // Guardrails the typed value is checked against, expressed in the - // display unit. Glucose projects its mg/dL window through the same - // conversion the fields use; every other metric rounds the window - // INWARD so a value typed at the display bound never inverts to a - // canonical value the server rejects. + const toDisplay = (canonical: number) => units.toDisplay(canonical); + const toCanonical = (displayValue: number) => units.toCanonical(displayValue); + // Guardrails the typed value is checked against, expressed in the display + // unit and rounded INWARD so a value typed at the display bound never + // inverts to a canonical value the server rejects. const toDisplayBounds = (canonical: { min: number; max: number }) => - isGlucoseMmol - ? { min: toDisplay(canonical.min), max: toDisplay(canonical.max) } - : units.bounds(canonical); + units.bounds(canonical); // Lazy-load the thresholds payload so the dialog also catches any // already-persisted override even when the seeded `initialRange` diff --git a/src/lib/targets/__tests__/target-unit-display.test.ts b/src/lib/targets/__tests__/target-unit-display.test.ts index da8702179..3d2fa1522 100644 --- a/src/lib/targets/__tests__/target-unit-display.test.ts +++ b/src/lib/targets/__tests__/target-unit-display.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { resolveTargetUnitAdapter } from "../target-unit-display"; +import { toCanonicalMgdl } from "@/lib/glucose"; /** * v1.32.27 — the target/threshold unit adapter. @@ -18,7 +19,7 @@ const KG_PER_LB = 0.45359237; describe("resolveTargetUnitAdapter — metric account (identity)", () => { it("leaves a weight threshold bit-for-bit untouched", () => { - const units = resolveTargetUnitAdapter("WEIGHT", "kg", "metric"); + const units = resolveTargetUnitAdapter("WEIGHT", "kg", "metric", "mg/dL"); expect(units.rescales).toBe(false); expect(units.unit).toBe("kg"); // Not "close to" — exactly the same double, in both directions. @@ -37,6 +38,7 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => { "BLOOD_PRESSURE_SYS", "mmHg", preference, + "mg/dL", ); expect(units.rescales).toBe(false); expect(units.unit).toBe("mmHg"); @@ -51,11 +53,14 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => { it("is inert for the keys the edit sheet passes on its non-metric paths", () => { // A derived card (BMI, mood, medication compliance) has no editable - // threshold and the sheet resolves the adapter with an empty key; - // glucose keeps its own mg/dL ↔ mmol/L dialect and must never pick - // up a second conversion on top. - for (const key of ["", "BMI", "BLOOD_GLUCOSE_FASTING"]) { - const units = resolveTargetUnitAdapter(key, "mmol/L", "imperial"); + // threshold and the sheet resolves the adapter with an empty key. + for (const key of ["", "BMI"]) { + const units = resolveTargetUnitAdapter( + key, + "mmol/L", + "imperial", + "mg/dL", + ); expect(units.rescales).toBe(false); expect(units.unit).toBe("mmol/L"); expect(units.toDisplay(5.5)).toBe(5.5); @@ -66,14 +71,15 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => { it("keeps the whole-hundred step for a step-count target", () => { for (const preference of ["metric", "imperial"] as const) { expect( - resolveTargetUnitAdapter("ACTIVITY_STEPS", "steps", preference).step, + resolveTargetUnitAdapter("ACTIVITY_STEPS", "steps", preference, "mg/dL") + .step, ).toBe(100); } }); }); describe("resolveTargetUnitAdapter — imperial weight class", () => { - const units = resolveTargetUnitAdapter("WEIGHT", "kg", "imperial"); + const units = resolveTargetUnitAdapter("WEIGHT", "kg", "imperial", "mg/dL"); it("announces pounds and a one-decimal step", () => { expect(units.rescales).toBe(true); @@ -145,7 +151,12 @@ describe("resolveTargetUnitAdapter — imperial weight class", () => { it("carries the same treatment across the whole weight class", () => { for (const metric of ["TOTAL_BODY_WATER", "BONE_MASS"] as const) { - const adapter = resolveTargetUnitAdapter(metric, "kg", "imperial"); + const adapter = resolveTargetUnitAdapter( + metric, + "kg", + "imperial", + "mg/dL", + ); expect(adapter.unit).toBe("lb"); const bounds = adapter.bounds(METRIC_BOUNDS[metric]); expect(adapter.toCanonical(bounds.min)).toBeGreaterThanOrEqual( @@ -169,6 +180,7 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => { "BODY_TEMPERATURE", "celsius", "imperial", + "mg/dL", ); it("shifts absolute readings but never a difference", () => { @@ -208,6 +220,7 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => { "BODY_TEMPERATURE", "celsius", "metric", + "mg/dL", ); expect(metricUnits.rescales).toBe(false); expect(metricUnits.toDisplay(37)).toBe(37); @@ -224,3 +237,73 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => { expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual(45); }); }); + +describe("resolveTargetUnitAdapter — glucose thresholds follow the glucose unit", () => { + const contexts = [ + "BLOOD_GLUCOSE_FASTING", + "BLOOD_GLUCOSE_POSTPRANDIAL", + "BLOOD_GLUCOSE_RANDOM", + "BLOOD_GLUCOSE_BEDTIME", + ] as const; + + it("reads mmol/L for an mmol/L reader, whatever the metric/imperial branch", () => { + for (const metric of contexts) { + for (const preference of ["metric", "imperial"] as const) { + const units = resolveTargetUnitAdapter( + metric, + "mg/dL", + preference, + "mmol/L", + ); + expect(units.unit).toBe("mmol/L"); + expect(units.rescales).toBe(true); + expect(units.step).toBe(0.1); + // The 70–99 mg/dL fasting default reads 3.9–5.5. + expect(units.toDisplay(70)).toBe(3.9); + expect(units.toDisplay(99)).toBe(5.5); + } + } + }); + + it("round-trips a typed mmol/L value to whole mg/dL and back", () => { + const units = resolveTargetUnitAdapter( + "BLOOD_GLUCOSE_FASTING", + "mg/dL", + "metric", + "mmol/L", + ); + expect(units.toCanonical(5.5)).toBe(99); + expect(units.toDisplay(units.toCanonical(5.5))).toBe(5.5); + expect(units.toCanonical(3.9)).toBe(toCanonicalMgdl(3.9, "mmol/L")); + }); + + it("rounds the canonical 40–400 mg/dL guardrail inward", () => { + const units = resolveTargetUnitAdapter( + "BLOOD_GLUCOSE_FASTING", + "mg/dL", + "metric", + "mmol/L", + ); + const bounds = units.bounds(METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING); + expect(bounds).toEqual({ min: 2.3, max: 22.1 }); + expect(units.toCanonical(bounds.min)).toBeGreaterThanOrEqual( + METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING.min, + ); + expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual( + METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING.max, + ); + }); + + it("stays the exact identity for an mg/dL reader", () => { + const units = resolveTargetUnitAdapter( + "BLOOD_GLUCOSE_FASTING", + "mg/dL", + "imperial", + "mg/dL", + ); + expect(units.unit).toBe("mg/dL"); + expect(units.rescales).toBe(false); + expect(units.toDisplay(99)).toBe(99); + expect(units.toCanonical(99)).toBe(99); + }); +}); diff --git a/src/lib/targets/target-unit-display.ts b/src/lib/targets/target-unit-display.ts index a92218e4e..0afb78a7d 100644 --- a/src/lib/targets/target-unit-display.ts +++ b/src/lib/targets/target-unit-display.ts @@ -48,6 +48,12 @@ import { transformRescales, type UnitPreference, } from "@/lib/measurements/display-transform"; +import { + MGDL_PER_MMOL, + convertGlucose, + toCanonicalMgdl, + type GlucoseUnit, +} from "@/lib/glucose"; /** Canonical decimals a converted threshold value persists with. */ const CANONICAL_DECIMALS = 2; @@ -119,6 +125,41 @@ function identityAdapter(metric: string, unit: string): TargetUnitAdapter { }; } +/** + * The glucose thresholds (`BLOOD_GLUCOSE_FASTING` and its siblings) are + * stored in mg/dL and read in the reader's glucose unit. The settings editor + * used to take the identity path for them, so an mmol/L account was asked + * for mg/dL figures under a label that said nothing, against 40–400 bounds + * and a "default: 70–99" hint — while the insights sheet for the same + * threshold already spoke mmol/L. + * + * mmol/L reads at one decimal; the canonical write rounds to whole mg/dL + * (`toCanonicalMgdl`), the precision every glucose path stores. Guardrails + * round inward exactly like the imperial branch: 40 mg/dL is 2.22 mmol/L, + * shown as 2.3 so that the typed bound inverts to 41 mg/dL and passes. + */ +function glucoseAdapter(glucoseUnit: GlucoseUnit): TargetUnitAdapter { + if (glucoseUnit === "mg/dL") return identityAdapter("BLOOD_GLUCOSE", "mg/dL"); + const toMmol = (mgdl: number) => mgdl / MGDL_PER_MMOL; + return { + unit: "mmol/L", + step: 0.1, + rescales: true, + toDisplay: (canonical) => convertGlucose(canonical, "mmol/L"), + toCanonical: (display) => toCanonicalMgdl(display, "mmol/L"), + toDisplayDelta: (canonicalDelta) => roundTo(toMmol(canonicalDelta), 1), + bounds: (canonical) => ({ + min: ceilTo(toMmol(canonical.min), 1), + max: floorTo(toMmol(canonical.max), 1), + }), + }; +} + +/** True for a glucose threshold metric or target-card type. */ +export function isGlucoseTargetMetric(metric: string): boolean { + return metric === "BLOOD_GLUCOSE" || metric.startsWith("BLOOD_GLUCOSE_"); +} + /** * Resolve the unit adapter for one threshold metric / target-card type. * @@ -128,13 +169,17 @@ function identityAdapter(metric: string, unit: string): TargetUnitAdapter { * on, so a metric participates exactly when it has a registered * transform. `canonicalUnit` is the unit the surface announced before * this release (`METRIC_BOUNDS[metric].unit` or the targets payload's - * `unit`) and is what an untransformed metric keeps. + * `unit`) and is what an untransformed metric keeps. `glucoseUnit` is + * the reader's glucose preference; it decides the glucose thresholds and + * nothing else. */ export function resolveTargetUnitAdapter( metric: string, canonicalUnit: string, preference: UnitPreference, + glucoseUnit: GlucoseUnit, ): TargetUnitAdapter { + if (isGlucoseTargetMetric(metric)) return glucoseAdapter(glucoseUnit); if (!hasDisplayTransform(metric)) return identityAdapter(metric, canonicalUnit); From 5486014360038369308bc17a94b729dc139179c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:00:53 +0200 Subject: [PATCH 06/13] fix(score): write the glycaemia labels in the reader's glucose unit The fasting-glucose pillar served its observed and reference labels as "Fasting median 99 mg/dL" and "70-99 mg/dL fasting" whatever the account's unit, and clients print those labels as they come. They are now written in the record's glucose unit. The scored value, its unit and the band edges stay canonical, so the score does not move and the web, which converts from the value, cannot convert twice. The schema now says which fields are canonical and which are display text. Refs #1067 --- docs/api/openapi.yaml | 8 ++++- src/app/api/analytics/route.ts | 3 +- .../__tests__/composition-source.test.ts | 1 + ...onfig-change-is-not-a-health-event.test.ts | 1 + .../analytics/score/__tests__/pillars.test.ts | 36 +++++++++++++++++++ .../analytics/score/__tests__/reader.test.ts | 1 + .../reference-citation-guard.test.ts | 1 + src/lib/analytics/score/derived.ts | 3 ++ src/lib/analytics/score/glycaemia.ts | 8 +++-- src/lib/analytics/score/reader.ts | 8 +++++ src/lib/analytics/score/types.ts | 7 ++++ src/lib/dashboard/snapshot.ts | 3 +- src/lib/openapi/routes/analytics.ts | 20 +++++++---- 13 files changed, 89 insertions(+), 11 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20072acf6..8ca9ebec5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -55784,11 +55784,14 @@ components: properties: value: type: number + description: The scored value in its canonical unit (fasting glucose in mg/dL). unit: type: string + description: The canonical unit of `value`. label: type: string - description: The complete display value, paired or panel values included. + description: The complete display value, paired or panel values included, written in the reader's units (fasting glucose + in the account's glucose unit). asOf: type: string format: date-time @@ -55817,12 +55820,15 @@ components: anyOf: - type: number - type: "null" + description: Lower band edge, canonical unit. high: anyOf: - type: number - type: "null" + description: Upper band edge, canonical unit. label: type: string + description: The band as display text, in the reader's units. source: type: string required: diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 02056525c..de8e909bd 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -34,7 +34,7 @@ import { GLUCOSE_PANEL_WINDOW_DAYS, type GlucoseClinicalMetrics, } from "@/lib/analytics/glucose-metrics"; -import { groupByGlucoseContext } from "@/lib/glucose"; +import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose"; import { decisionForEvidence, PATTERN_FAMILIES, @@ -619,6 +619,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { timezone: userTz, sourcePriorityJson: scoreSourcePriority, thresholdsJson: user.thresholdsJson ?? null, + glucoseUnit: resolveGlucoseUnit(user.glucoseUnit), }, modules: { glucose: glucoseEnabled, diff --git a/src/lib/analytics/score/__tests__/composition-source.test.ts b/src/lib/analytics/score/__tests__/composition-source.test.ts index e28140842..89af33f19 100644 --- a/src/lib/analytics/score/__tests__/composition-source.test.ts +++ b/src/lib/analytics/score/__tests__/composition-source.test.ts @@ -240,6 +240,7 @@ function score(args: { timezone: "UTC", sourcePriorityJson: null, thresholdsJson: null, + glucoseUnit: "mg/dL" as const, }, modules: args.modules, healthScoreConfigJson: args.healthScoreConfigJson, diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index 171e54fec..d031de698 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -318,6 +318,7 @@ function scoreInput(healthScoreConfigJson: unknown) { timezone: "UTC", sourcePriorityJson: null, thresholdsJson: null, + glucoseUnit: "mg/dL" as const, }, modules: { glucose: true, diff --git a/src/lib/analytics/score/__tests__/pillars.test.ts b/src/lib/analytics/score/__tests__/pillars.test.ts index 67f173c85..939558c0d 100644 --- a/src/lib/analytics/score/__tests__/pillars.test.ts +++ b/src/lib/analytics/score/__tests__/pillars.test.ts @@ -14,6 +14,7 @@ const live = { source: "live" as const, readFailed: false, timezone: "Europe/Berlin", + glucoseUnit: "mg/dL" as const, }; function days(count: number, value: number) { @@ -160,6 +161,41 @@ describe("reference-score pillars", () => { expect(eligible.status).toBe("ok"); }); + it("writes the fasting labels in the reader's glucose unit, value and band canonical", () => { + const fasting = days(8, 99).map((point) => ({ + value: point.value, + at: new Date(`${point.day}T08:00:00Z`), + source: "APPLE_HEALTH", + })); + const mmol = computeGlycaemiaPillar({ + ...live, + glucoseUnit: "mmol/L", + asOf: NOW, + hba1c: [], + fastingGlucose: fasting, + }); + const mgdl = computeGlycaemiaPillar({ + ...live, + asOf: NOW, + hba1c: [], + fastingGlucose: fasting, + }); + expect(mmol.status).toBe("ok"); + expect(mgdl.status).toBe("ok"); + if (mmol.status !== "ok" || mgdl.status !== "ok") return; + // These labels reach the app as finished text. + expect(mmol.value.observed.label).toBe("Fasting median 5.5 mmol/L"); + expect(mmol.value.reference.label).toBe("3.9–5.5 mmol/L fasting"); + expect(mgdl.value.observed.label).toBe("Fasting median 99 mg/dL"); + expect(mgdl.value.reference.label).toBe("70–99 mg/dL fasting"); + // The score and the numbers the web converts from do not move. + expect(mmol.value.observed.value).toBe(99); + expect(mmol.value.observed.unit).toBe("mg/dL"); + expect(mmol.value.reference.low).toBe(70); + expect(mmol.value.reference.high).toBe(99); + expect(mmol.value.score).toBe(mgdl.value.score); + }); + it("does not use fasting fallback when the preferred lab read failed", () => { const result = computeGlycaemiaPillar({ ...live, diff --git a/src/lib/analytics/score/__tests__/reader.test.ts b/src/lib/analytics/score/__tests__/reader.test.ts index 972fd9190..af86b62af 100644 --- a/src/lib/analytics/score/__tests__/reader.test.ts +++ b/src/lib/analytics/score/__tests__/reader.test.ts @@ -137,6 +137,7 @@ describe("score reader bounds and source parity", () => { timezone: "Europe/Berlin", sourcePriorityJson: null, thresholdsJson: null, + glucoseUnit: "mg/dL" as const, }, modules: { glucose: true, diff --git a/src/lib/analytics/score/__tests__/reference-citation-guard.test.ts b/src/lib/analytics/score/__tests__/reference-citation-guard.test.ts index 75a459e86..a88abdb95 100644 --- a/src/lib/analytics/score/__tests__/reference-citation-guard.test.ts +++ b/src/lib/analytics/score/__tests__/reference-citation-guard.test.ts @@ -233,6 +233,7 @@ const live = { source: "live" as const, readFailed: false, timezone: "Europe/Berlin", + glucoseUnit: "mg/dL" as const, }; function days(count: number, value: number) { diff --git a/src/lib/analytics/score/derived.ts b/src/lib/analytics/score/derived.ts index 4065c65a1..5a70969f7 100644 --- a/src/lib/analytics/score/derived.ts +++ b/src/lib/analytics/score/derived.ts @@ -5,6 +5,7 @@ import { getBpTargets } from "@/lib/analytics/bp-targets"; import { resolveModuleMap } from "@/lib/modules/gate"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { resolveGlucoseUnit } from "@/lib/glucose"; import { computeAndRecordUserHealthScore } from "./record"; import type { CompositeValue } from "./types"; @@ -23,6 +24,7 @@ export async function computeHealthScoreDerived( timezone: true, thresholdsJson: true, healthScoreConfigJson: true, + glucoseUnit: true, }, }); const now = new Date(); @@ -59,6 +61,7 @@ export async function computeHealthScoreDerived( timezone, sourcePriorityJson, thresholdsJson: user.thresholdsJson, + glucoseUnit: resolveGlucoseUnit(user.glucoseUnit), }, modules: { glucose: modules.glucose !== false, diff --git a/src/lib/analytics/score/glycaemia.ts b/src/lib/analytics/score/glycaemia.ts index 08cb64555..b62462a02 100644 --- a/src/lib/analytics/score/glycaemia.ts +++ b/src/lib/analytics/score/glycaemia.ts @@ -1,4 +1,5 @@ import type { Derived } from "@/lib/insights/derived/types"; +import { convertGlucose } from "@/lib/glucose"; import type { GlycaemiaPillarInput, PillarValue } from "./types"; import { @@ -153,10 +154,13 @@ export function computeGlycaemiaPillar( FASTING_GLUCOSE_REFERENCE.low, FASTING_GLUCOSE_REFERENCE.high, ), + // `value`, `unit` and the band stay canonical: the score is computed on + // them and the web converts from them. The labels are finished text a + // client prints as-is, so they are written in the reader's unit. observed: { value: observed, unit: "mg/dL", - label: `Fasting median ${Math.round(observed)} mg/dL`, + label: `Fasting median ${convertGlucose(observed, input.glucoseUnit)} ${input.glucoseUnit}`, asOf: fasting.at(-1)!.at.toISOString(), sources: uniqueSources(fasting.map((row) => row.source)), }, @@ -164,7 +168,7 @@ export function computeGlycaemiaPillar( kind: "clinical-threshold", low: FASTING_GLUCOSE_REFERENCE.low, high: FASTING_GLUCOSE_REFERENCE.high, - label: `${FASTING_GLUCOSE_REFERENCE.low}–${FASTING_GLUCOSE_REFERENCE.high} mg/dL fasting`, + label: `${convertGlucose(FASTING_GLUCOSE_REFERENCE.low, input.glucoseUnit)}–${convertGlucose(FASTING_GLUCOSE_REFERENCE.high, input.glucoseUnit)} ${input.glucoseUnit} fasting`, source: "ADA 2025; Selvin 2010", }, noiseFloor: Math.max(1, Math.abs(ceilingScore - floorScore)), diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index 7f9e80559..eae8b85a8 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -42,6 +42,7 @@ import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types"; import { computeWeightGoal } from "./weight-goal"; import { DAY_MS, uniqueSources } from "./shared"; import type { DerivedProvenanceSource } from "@/lib/insights/derived/types"; +import type { GlucoseUnit } from "@/lib/glucose"; const HISTORY_OFFSET_DAYS = 14; const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS; @@ -52,6 +53,12 @@ export interface ScoreReaderProfile { timezone: string; sourcePriorityJson: unknown; thresholdsJson: unknown; + /** + * The record's glucose unit, for the glycaemia pillar's display labels. + * Required: those labels are served as finished text (the app prints them + * verbatim), and a caller that forgets the unit must not get mg/dL. + */ + glucoseUnit: GlucoseUnit; } export interface UserHealthScoreInput { @@ -513,6 +520,7 @@ function scoreInputsFor(args: { }, GLYCAEMIA: { source: "live", + glucoseUnit: input.profile.glucoseUnit, readFailed: (!input.modules.glucose || !args.glucose.ok) && (!input.modules.labs || !args.labs.ok), diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index 44dc7ed1b..67c25081a 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -8,6 +8,7 @@ import type { // runtime cycle. The tier's definition belongs beside the rule that // produces it; its NAME belongs here, where the wire shape is declared. import type { ScoreBreadthTier } from "./breadth"; +import type { GlucoseUnit } from "@/lib/glucose"; /** * The scoring method's identity. @@ -346,6 +347,12 @@ export interface GlucoseReading { export interface GlycaemiaPillarInput extends DomainReadState { asOf: Date; + /** + * The unit the fasting-glucose labels are written in. The scored value, + * its `unit` and the reference band stay canonical mg/dL; only the two + * human-readable labels follow the reader. + */ + glucoseUnit: GlucoseUnit; hba1cReadFailed?: boolean; fastingReadFailed?: boolean; hba1c: HbA1cReading[]; diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 6d0f074a7..83dd06f32 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -49,7 +49,7 @@ import { GLUCOSE_PANEL_WINDOW_DAYS, type GlucoseClinicalMetrics, } from "@/lib/analytics/glucose-metrics"; -import { groupByGlucoseContext } from "@/lib/glucose"; +import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose"; import { computeBpInTargetFastPath, type BpInTargetEnvelope, @@ -788,6 +788,7 @@ async function buildExtras( timezone: userTz, sourcePriorityJson: scoreSourcePriority, thresholdsJson: user.thresholdsJson, + glucoseUnit: resolveGlucoseUnit(user.glucoseUnit), }, modules: { glucose: modules.glucose !== false, diff --git a/src/lib/openapi/routes/analytics.ts b/src/lib/openapi/routes/analytics.ts index f51360fee..41dda0037 100644 --- a/src/lib/openapi/routes/analytics.ts +++ b/src/lib/openapi/routes/analytics.ts @@ -239,11 +239,17 @@ const scorePillarIdEnum = z.enum(SCORE_PILLAR_IDS); const pillarValue = z.object({ score: z.number(), observed: z.object({ - value: z.number(), - unit: z.string(), + value: z + .number() + .describe( + "The scored value in its canonical unit (fasting glucose in mg/dL).", + ), + unit: z.string().describe("The canonical unit of `value`."), label: z .string() - .describe("The complete display value, paired or panel values included."), + .describe( + "The complete display value, paired or panel values included, written in the reader's units (fasting glucose in the account's glucose unit).", + ), asOf: z.iso.datetime({ offset: true }), sources: z.array(z.string()), }), @@ -253,9 +259,11 @@ const pillarValue = z.object({ "population-percentile", "guideline-band", ]), - low: z.number().nullable(), - high: z.number().nullable(), - label: z.string(), + low: z.number().nullable().describe("Lower band edge, canonical unit."), + high: z.number().nullable().describe("Upper band edge, canonical unit."), + label: z + .string() + .describe("The band as display text, in the reader's units."), source: z.string(), }), personalReference: z From d6f79c8beffb38ed932726edeb2925938477ab7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:03:51 +0200 Subject: [PATCH 07/13] fix(workouts): show distance, pace and elevation in the reader's units The workout list, the detail header, the stat tiles, the sport-average line and the elevation chart printed kilometres, minutes per kilometre and metres for every reader. They now read distance, pace and elevation through the unit registry (km or mi, /km or /mi, m or ft). The splits table keeps its kilometre rows, which are cut server-side, and says so. A pace that rounded to sixty seconds now carries into the minute. Refs #1067 --- .../charts/workout-elevation-chart.tsx | 23 +++++- .../__tests__/workout-detail.test.tsx | 53 ++++++++++++- .../insights/__tests__/workout-list.test.tsx | 27 ++++++- .../insights/workout-detail/format.ts | 78 +++++++++++++++---- .../insights/workout-detail/header.tsx | 6 +- .../insights/workout-detail/splits.tsx | 2 +- .../insights/workout-detail/stats.tsx | 19 +++-- src/components/insights/workout-list.tsx | 13 +--- .../__tests__/display-transform.test.ts | 2 +- src/lib/measurements/display-transform.ts | 4 +- 10 files changed, 184 insertions(+), 43 deletions(-) diff --git a/src/components/charts/workout-elevation-chart.tsx b/src/components/charts/workout-elevation-chart.tsx index 8c565bd11..3fd0cfa93 100644 --- a/src/components/charts/workout-elevation-chart.tsx +++ b/src/components/charts/workout-elevation-chart.tsx @@ -11,6 +11,11 @@ import { } from "recharts"; import { useTranslations } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { + applyDisplayTransformUnrounded, + getQuantityTransform, +} from "@/lib/measurements/display-transform"; /** * Workout elevation profile — a small cumulative-distance / altitude @@ -30,7 +35,15 @@ export function WorkoutElevationChart({ points: WorkoutElevationPoint[]; }) { const { t } = useTranslations(); - const data = points.map((p) => ({ km: p.distanceM / 1000, alt: p.altitude })); + const { preference } = useUnitDisplay(); + // Distance along the route in km or mi, altitude in m or ft — the same + // registry the stat tiles read, so the axis and the tiles agree. + const distance = getQuantityTransform("distance", preference); + const elevation = getQuantityTransform("elevation", preference); + const data = points.map((p) => ({ + dist: applyDisplayTransformUnrounded(p.distanceM, distance), + alt: applyDisplayTransformUnrounded(p.altitude, elevation), + })); return (
@@ -57,7 +70,7 @@ export function WorkoutElevationChart({ opacity={0.4} /> `${Number(km).toFixed(2)} km`} + labelFormatter={(d) => + `${Number(d).toFixed(2)} ${distance.displayUnit}` + } formatter={(value) => [ - `${Math.round(Number(value))} m`, + `${Math.round(Number(value))} ${elevation.displayUnit}`, t("insights.workouts.detail.elevationTitle"), ]} /> diff --git a/src/components/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx index a7916eaaa..242331f7a 100644 --- a/src/components/insights/__tests__/workout-detail.test.tsx +++ b/src/components/insights/__tests__/workout-detail.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -16,6 +16,19 @@ import { WorkoutInsightCard } from "../workout-detail/insight-slot"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; import type { RouteCoordinate } from "@/lib/workouts/route-svg"; +// The unit preference comes from the account payload; a mutable stub lets a +// test switch the reader to imperial. +const account = vi.hoisted(() => ({ + user: { unitPreference: "metric", glucoseUnit: null } as Record< + string, + unknown + >, +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: account.user, isAuthenticated: true }), + useAccountOnceMounted: () => account.user, +})); + /** * #67 — `` unit tests over the split `workout-detail/` * directory. Each primitive runs through SSR with a canonical fixture; @@ -149,6 +162,44 @@ describe("", () => { expect(html).toContain("148 bpm"); }); + it("states distance, pace, elevation and the average line in imperial units", () => { + account.user = { unitPreference: "imperial", glucoseUnit: null }; + try { + const html = render( + , + ); + // 5200 m = 3.23 mi; 1800 s over it = 9:17 per mile; 12.5 m = 41.0 ft. + expect(html).toContain("3.23 mi"); + expect(html).toContain("9:17 /mi"); + expect(html).toContain("41.0 ft"); + // The sport-average line: 5800 m = 3.60 mi. + expect(html).toContain("3.60 mi"); + expect(html).not.toContain(" km"); + expect(html).not.toContain("/km"); + const header = render(); + expect(header).toContain("3.23 mi"); + } finally { + account.user = { unitPreference: "metric", glucoseUnit: null }; + } + }); + + it("keeps kilometres, minutes per km and metres for a metric reader", () => { + const html = render(); + expect(html).toContain("5.20 km"); + expect(html).toContain("5:46 /km"); + expect(html).toContain("12.5 m"); + }); + it("hides the average line for a lone session", () => { const lone: WorkoutDetailPayload = { ...FIXTURE, diff --git a/src/components/insights/__tests__/workout-list.test.tsx b/src/components/insights/__tests__/workout-list.test.tsx index d6cb72ec5..a3dad821a 100644 --- a/src/components/insights/__tests__/workout-list.test.tsx +++ b/src/components/insights/__tests__/workout-list.test.tsx @@ -1,10 +1,23 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { WorkoutList } from "../workout-list"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; +// The unit preference comes from the account payload; a mutable stub lets a +// test switch the reader to imperial. +const account = vi.hoisted(() => ({ + user: { unitPreference: "metric", glucoseUnit: null } as Record< + string, + unknown + >, +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: account.user, isAuthenticated: true }), + useAccountOnceMounted: () => account.user, +})); + /** * v1.4.32 — `` unit tests. * @@ -47,6 +60,18 @@ describe("", () => { expect(html).toContain("320 kcal"); }); + it("states the distance in miles for an imperial reader", () => { + account.user = { unitPreference: "imperial", glucoseUnit: null }; + try { + const html = render(); + // 5200 m = 3.23 mi. + expect(html).toContain("3.23 mi"); + expect(html).not.toContain(" km"); + } finally { + account.user = { unitPreference: "metric", glucoseUnit: null }; + } + }); + it("renders aggregate average HR without claiming an HR series", () => { const html = render( , diff --git a/src/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts index 50fc6f775..03e559ea1 100644 --- a/src/components/insights/workout-detail/format.ts +++ b/src/components/insights/workout-detail/format.ts @@ -1,4 +1,11 @@ import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; +import { + applyDisplayTransform, + applyDisplayTransformUnrounded, + getQuantityTransform, + paceSecondsPerDistanceUnit, + type UnitPreference, +} from "@/lib/measurements/display-transform"; import { hourCycleOptions, type TimeFormatPreference, @@ -27,12 +34,38 @@ export function formatDurationMinutes(seconds: number, locale: string): string { return formatNumber(minutes, locale); } -export function formatDistanceKm(meters: number, locale: string): string { - const km = meters / 1000; - return getNumberFormat(locale, { +/** + * A workout distance in the reader's unit, with its symbol: "5.80 km" or + * "3.60 mi". The workout list, the detail header, the stat tile and the + * sport-average line all print through this, so none of them can keep + * kilometres for an imperial reader. + */ +export function formatDistance( + meters: number, + locale: string, + preference: UnitPreference, +): string { + const transform = getQuantityTransform("distance", preference); + const value = applyDisplayTransformUnrounded(meters, transform); + const formatted = getNumberFormat(locale, { maximumFractionDigits: 2, - minimumFractionDigits: km < 10 ? 2 : 1, - }).format(km); + minimumFractionDigits: value < 10 ? 2 : 1, + }).format(value); + return `${formatted} ${transform.displayUnit}`; +} + +/** Elevation gain in the reader's unit with its symbol: "123.4 m" / "404.9 ft". */ +export function formatElevation( + meters: number, + locale: string, + preference: UnitPreference, +): string { + const transform = getQuantityTransform("elevation", preference); + return `${formatNumber( + applyDisplayTransform(meters, transform), + locale, + transform.decimals, + )} ${transform.displayUnit}`; } export function formatNumber( @@ -46,21 +79,36 @@ export function formatNumber( }).format(value); } -export function formatPaceMinPerKm( +/** + * Average pace per kilometre or per mile, by the reader's preference. Only + * meaningful for run / walk / hike / ride; the caller gates on distance + + * sport. + */ +export function formatPace( durationSec: number, meters: number, + preference: UnitPreference, ): string { - // Seconds-per-kilometre. Only meaningful for run / walk / hike / ride; - // the caller gates on distance + sport. - const secPerKm = (durationSec / meters) * 1000; - return formatPaceSeconds(secPerKm); + return formatPaceSeconds( + paceSecondsPerDistanceUnit(durationSec, meters, preference), + getQuantityTransform("distance", preference).displayUnit, + ); } -/** "m:ss /km" from a seconds-per-km value (used by pace + splits). */ -export function formatPaceSeconds(secPerKm: number): string { - const m = Math.floor(secPerKm / 60); - const s = Math.round(secPerKm % 60); - return `${m}:${s.toString().padStart(2, "0")} /km`; +/** + * "m:ss /km" from a seconds-per-unit value. The splits table passes "km" + * explicitly: its rows are kilometre segments cut server-side, so their + * pace is per kilometre whatever the preference. + */ +export function formatPaceSeconds(secPerUnit: number, unit: string): string { + let m = Math.floor(secPerUnit / 60); + let s = Math.round(secPerUnit % 60); + // 359.6 s is 6:00, not 5:60. + if (s === 60) { + m += 1; + s = 0; + } + return `${m}:${s.toString().padStart(2, "0")} /${unit}`; } export function formatDateRange( diff --git a/src/components/insights/workout-detail/header.tsx b/src/components/insights/workout-detail/header.tsx index 172316af1..4a54a9bea 100644 --- a/src/components/insights/workout-detail/header.tsx +++ b/src/components/insights/workout-detail/header.tsx @@ -3,8 +3,9 @@ import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; -import { formatDuration, formatDistanceKm, formatDateRange } from "./format"; +import { formatDuration, formatDistance, formatDateRange } from "./format"; /** * Hero header — the at-a-glance verdict row. Sport icon badge, source @@ -30,6 +31,7 @@ function renderSportIconBadge(sportType: string) { export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { const { t, locale } = useTranslations(); const timeFormat = useTimeFormatPreference(); + const { preference } = useUnitDisplay(); const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; const sportLabel = t(sportLabelKey); const sportName = @@ -67,7 +69,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { {workout.distanceM != null ? ( - {formatDistanceKm(workout.distanceM, locale)} km + {formatDistance(workout.distanceM, locale, preference)} ) : null}
diff --git a/src/components/insights/workout-detail/splits.tsx b/src/components/insights/workout-detail/splits.tsx index 42fafe68e..16d228a8e 100644 --- a/src/components/insights/workout-detail/splits.tsx +++ b/src/components/insights/workout-detail/splits.tsx @@ -70,7 +70,7 @@ export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) { : "py-1.5 text-right tabular-nums" } > - {formatPaceSeconds(s.paceSecPerKm)} + {formatPaceSeconds(s.paceSecPerKm, "km")} ))} diff --git a/src/components/insights/workout-detail/stats.tsx b/src/components/insights/workout-detail/stats.tsx index 72767d043..8d2ab065a 100644 --- a/src/components/insights/workout-detail/stats.tsx +++ b/src/components/insights/workout-detail/stats.tsx @@ -17,12 +17,15 @@ import { TileHeader } from "@/components/insights/tile-header"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; +import type { UnitPreference } from "@/lib/measurements/display-transform"; import { formatDuration, - formatDistanceKm, + formatDistance, + formatElevation, formatNumber, - formatPaceMinPerKm, + formatPace, formatDurationMinutes, } from "./format"; @@ -69,12 +72,13 @@ const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]); function sportAverageLine( workout: WorkoutDetailPayload, locale: string, + preference: UnitPreference, ): string | null { const ctx = workout.sportContext; if (!ctx || ctx.count < 2) return null; const parts: string[] = []; if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) { - parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`); + parts.push(formatDistance(ctx.avgDistanceM, locale, preference)); } parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`); if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`); @@ -83,6 +87,7 @@ function sportAverageLine( export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { const { t, locale } = useTranslations(); + const { preference } = useUnitDisplay(); const tiles: StatTileProps[] = []; tiles.push({ @@ -95,7 +100,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { tiles.push({ icon: , label: t("insights.workouts.detail.statsDistance"), - value: `${formatDistanceKm(workout.distanceM, locale)} km`, + value: formatDistance(workout.distanceM, locale, preference), }); } @@ -139,7 +144,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { tiles.push({ icon: , label: t("insights.workouts.detail.statsElevation"), - value: `${formatNumber(workout.elevationM, locale, 1)} m`, + value: formatElevation(workout.elevationM, locale, preference), }); } @@ -151,11 +156,11 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { tiles.push({ icon: , label: t("insights.workouts.detail.statsPace"), - value: formatPaceMinPerKm(workout.durationSec, workout.distanceM), + value: formatPace(workout.durationSec, workout.distanceM, preference), }); } - const averageLine = sportAverageLine(workout, locale); + const averageLine = sportAverageLine(workout, locale, preference); return ( diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 150dcb106..ab9130316 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -13,6 +13,8 @@ import { import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; import { Button } from "@/components/ui/button"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { formatDistance } from "@/components/insights/workout-detail/format"; /** * v1.4.32 — workout list row primitive. @@ -33,14 +35,6 @@ function formatDuration(seconds: number): string { return `${m}m`; } -function formatDistanceKm(meters: number, locale: string): string { - const km = meters / 1000; - return getNumberFormat(locale, { - maximumFractionDigits: 2, - minimumFractionDigits: km < 10 ? 2 : 1, - }).format(km); -} - function formatEnergy(kcal: number, locale: string): string { return getNumberFormat(locale, { maximumFractionDigits: 0, @@ -86,6 +80,7 @@ export function WorkoutList({ }: WorkoutListProps) { const { t, locale } = useTranslations(); const timeFormat = useTimeFormatPreference(); + const { preference } = useUnitDisplay(); return (
    {workout.distanceM != null ? ( - {formatDistanceKm(workout.distanceM, locale)} km + {formatDistance(workout.distanceM, locale, preference)} ) : null} {workout.activeEnergyKcal != null ? ( diff --git a/src/lib/measurements/__tests__/display-transform.test.ts b/src/lib/measurements/__tests__/display-transform.test.ts index 91f23e674..62fa98d33 100644 --- a/src/lib/measurements/__tests__/display-transform.test.ts +++ b/src/lib/measurements/__tests__/display-transform.test.ts @@ -384,7 +384,7 @@ describe("display-transform — both unit preferences, one resolver", () => { expect(mi.displayUnit).toBe("mi"); expect( applyDisplayTransform(100, getQuantityTransform("elevation", "imperial")), - ).toBe(328); + ).toBe(328.1); expect(getQuantityTransform("elevation", "metric").displayUnit).toBe("m"); // 30 min over 5 km: 360 s/km, 579.4 s/mi. expect(paceSecondsPerDistanceUnit(1800, 5000, "metric")).toBeCloseTo( diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 5e1cba548..72b7426d1 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -260,8 +260,8 @@ const QUANTITY_TRANSFORMS: Record< imperial: { factor: 0.000621371192237, displayUnit: "mi", decimals: 2 }, }, elevation: { - metric: { factor: 1, displayUnit: "m", decimals: 0 }, - imperial: { factor: M_TO_FT, displayUnit: "ft", decimals: 0 }, + metric: { factor: 1, displayUnit: "m", decimals: 1 }, + imperial: { factor: M_TO_FT, displayUnit: "ft", decimals: 1 }, }, }; From bc2d60cce8286d4e7e593949abc723d372c9987a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:08:11 +0200 Subject: [PATCH 08/13] fix(insights): plot the trends row and log fevers in the reader's units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more surfaces carried a fixed unit. The insights trends row labelled weight in kg and body temperature in °C for every reader, and plotted walking distance as raw metres under a km label; its trend caption said the same. A single transformed series there now takes unit, scale and offset from the preference, and the caption describes the converted series against a converted noise floor. The illness day sheet and timeline showed and took a fever in °C. The fever is now seeded, typed and shown in the reader's temperature unit and stored in °C as before. Refs #1067 --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- .../illness-day-timeline-units.test.tsx | 70 +++++++++++++++++++ .../illness/illness-day-timeline.tsx | 7 +- src/components/illness/log-day-sheet.tsx | 20 ++++-- .../insights/__tests__/trends-row.test.tsx | 53 +++++++++++++- src/components/insights/trend-annotation.tsx | 22 ++++-- src/components/insights/trends-row.tsx | 17 ++++- .../__tests__/trend-descriptor.test.ts | 28 ++++++-- src/lib/insights/trend-chart-select.ts | 12 ++-- src/lib/insights/trend-descriptor.ts | 24 +++++-- 16 files changed, 229 insertions(+), 38 deletions(-) create mode 100644 src/components/illness/__tests__/illness-day-timeline-units.test.tsx diff --git a/messages/de.json b/messages/de.json index fc3b54979..606ecda35 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9405,7 +9405,7 @@ "severity": "Stärke {severity}", "noSymptoms": "Keine Symptome erfasst.", "impact": "Befinden: {impact}", - "fever": "Temperatur: {value} °C", + "fever": "Temperatur: {value} {unit}", "empty": "Noch keine Tage erfasst." }, "loadError": "Diese Episode konnte nicht geladen werden.", diff --git a/messages/en.json b/messages/en.json index 5fcf26297..72344cb64 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9405,7 +9405,7 @@ "severity": "Severity {severity}", "noSymptoms": "No symptoms logged.", "impact": "How you functioned: {impact}", - "fever": "Temperature: {value} °C", + "fever": "Temperature: {value} {unit}", "empty": "No days logged yet." }, "loadError": "Could not load this episode.", diff --git a/messages/es.json b/messages/es.json index 4bdfb5534..d2e383e31 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9405,7 +9405,7 @@ "severity": "Intensidad {severity}", "noSymptoms": "Sin síntomas registrados.", "impact": "Cómo funcionaste: {impact}", - "fever": "Temperatura: {value} °C", + "fever": "Temperatura: {value} {unit}", "empty": "Todavía no hay días registrados." }, "loadError": "No se pudo cargar este episodio.", diff --git a/messages/fr.json b/messages/fr.json index f1c57d412..bada54749 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9405,7 +9405,7 @@ "severity": "Intensité {severity}", "noSymptoms": "Aucun symptôme enregistré.", "impact": "Votre forme : {impact}", - "fever": "Température : {value} °C", + "fever": "Température : {value} {unit}", "empty": "Aucun jour enregistré pour l’instant." }, "loadError": "Impossible de charger cet épisode.", diff --git a/messages/it.json b/messages/it.json index 79b439e50..2ba28ca6d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9405,7 +9405,7 @@ "severity": "Intensità {severity}", "noSymptoms": "Nessun sintomo registrato.", "impact": "Come ti sei sentito: {impact}", - "fever": "Temperatura: {value} °C", + "fever": "Temperatura: {value} {unit}", "empty": "Nessun giorno registrato finora." }, "loadError": "Impossibile caricare questo episodio.", diff --git a/messages/ko.json b/messages/ko.json index 26052e28f..b1fb0504d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9405,7 +9405,7 @@ "severity": "심각도 {severity}", "noSymptoms": "기록한 증상이 없어요.", "impact": "활동 정도: {impact}", - "fever": "체온: {value}°C", + "fever": "체온: {value}{unit}", "empty": "아직 기록한 날이 없어요." }, "loadError": "이 에피소드를 불러오지 못했어요.", diff --git a/messages/pl.json b/messages/pl.json index 396f758be..f3c2c7b3b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9405,7 +9405,7 @@ "severity": "Nasilenie {severity}", "noSymptoms": "Nie zapisano objawów.", "impact": "Jak funkcjonowałeś: {impact}", - "fever": "Temperatura: {value} °C", + "fever": "Temperatura: {value} {unit}", "empty": "Nie zapisano jeszcze żadnych dni." }, "loadError": "Nie udało się wczytać tego epizodu.", diff --git a/src/components/illness/__tests__/illness-day-timeline-units.test.tsx b/src/components/illness/__tests__/illness-day-timeline-units.test.tsx new file mode 100644 index 000000000..a01dda594 --- /dev/null +++ b/src/components/illness/__tests__/illness-day-timeline-units.test.tsx @@ -0,0 +1,70 @@ +/** + * A logged fever reads in the reader's temperature unit. The timeline and the + * day sheet printed a hardcoded °C, so an imperial account read its own + * fever in Celsius. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const account = vi.hoisted(() => ({ + user: { unitPreference: "imperial", glucoseUnit: null } as Record< + string, + unknown + >, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: account.user, isAuthenticated: true }), + useAccountOnceMounted: () => account.user, +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManageDomain: () => true }), +})); + +vi.mock("../use-illness", () => ({ + useIllnessDayLogList: () => ({ + isLoading: false, + isError: false, + data: { + dayLogs: [ + { + id: "d1", + episodeId: "e1", + date: "2026-09-30", + functionalImpact: null, + feverC: 38.5, + symptoms: [], + note: null, + updatedAt: "2026-09-30T08:00:00.000Z", + }, + ], + }, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { IllnessDayTimeline } from "../illness-day-timeline"; + +function render(): string { + return renderToStaticMarkup( + + {}} /> + , + ); +} + +describe(" fever unit", () => { + it("shows the fever in °F for an imperial reader", () => { + account.user = { unitPreference: "imperial", glucoseUnit: null }; + const html = render(); + // 38.5 °C = 101.3 °F. + expect(html).toContain("Temperature: 101.3 °F"); + expect(html).not.toContain("°C"); + }); + + it("keeps °C for a metric reader", () => { + account.user = { unitPreference: "metric", glucoseUnit: null }; + expect(render()).toContain("Temperature: 38.5 °C"); + }); +}); diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index fa99bea56..3dd029e55 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -19,6 +19,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; import { useIllnessDayLogList } from "./use-illness"; @@ -32,6 +33,7 @@ function symptomLabelKey(key: string): string | null { function DayLogRow({ log }: { log: IllnessDayLogDTO }) { const { t } = useTranslations(); const fmt = useFormatters(); + const { toDisplay, unitFor } = useUnitDisplay(); return (
    @@ -72,7 +74,10 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) { {log.feverC !== null ? (

    - {t("illness.timeline.fever", { value: fmt.number(log.feverC, 1) })} + {t("illness.timeline.fever", { + value: fmt.number(toDisplay("BODY_TEMPERATURE", log.feverC), 1), + unit: unitFor("BODY_TEMPERATURE"), + })}

    ) : null} diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx index 0afacddc7..b5d7e69da 100644 --- a/src/components/illness/log-day-sheet.tsx +++ b/src/components/illness/log-day-sheet.tsx @@ -25,6 +25,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; import { useIllnessDayLog, useUpsertDayLog } from "./use-illness"; @@ -140,6 +141,10 @@ export function LogDaySheet({ onsetDate, }: LogDaySheetProps) { const { t } = useTranslations(); + // The fever is stored in °C and typed in the reader's temperature unit: + // seeded through the transform, inverted on save. + const { toDisplay, fromDisplay, unitFor } = useUnitDisplay(); + const feverUnit = unitFor("BODY_TEMPERATURE"); // The day being logged — seeded from `date` (today), but the picker lets the // user backdate within [onset, today] for the retrospective journal. @@ -178,7 +183,11 @@ export function LogDaySheet({ new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity ?? null])), ); setFunctionalImpact(dto?.functionalImpact ?? null); - setFever(dto?.feverC != null ? String(dto.feverC) : ""); + setFever( + dto?.feverC != null + ? String(toDisplay("BODY_TEMPERATURE", dto.feverC)) + : "", + ); setNote(dto?.note ?? ""); } else if (!open && lastFormKey !== null) { setLastFormKey(null); @@ -213,7 +222,10 @@ export function LogDaySheet({ const input: IllnessDayLogInput = { date: selectedDate, functionalImpact, - feverC: feverValue, + feverC: + feverValue == null + ? null + : Math.round(fromDisplay("BODY_TEMPERATURE", feverValue) * 100) / 100, symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({ key, severity: severity ?? undefined, @@ -333,7 +345,7 @@ export function LogDaySheet({ summary={ feverValue != null ? ( - {feverValue.toFixed(1)} °C + {feverValue.toFixed(1)} {feverUnit} ) : null } @@ -345,7 +357,7 @@ export function LogDaySheet({ setFever(e.target.value)} className="max-w-32" diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx index c8b18b288..8b85cfd45 100644 --- a/src/components/insights/__tests__/trends-row.test.tsx +++ b/src/components/insights/__tests__/trends-row.test.tsx @@ -21,14 +21,40 @@ import { I18nProvider } from "@/lib/i18n/context"; vi.mock("next/dynamic", () => ({ default: () => { - const Stub = ({ title }: { title?: string }) => ( -
    {title ?? "chart"}
    + const Stub = ({ + title, + unit, + valueScale, + valueOffset, + }: { + title?: string; + unit?: string; + valueScale?: number; + valueOffset?: number; + }) => ( +
    + {title ?? "chart"} +
    ); Stub.displayName = "TrendsRowChartStub"; return Stub; }, })); +const account = vi.hoisted(() => ({ + user: null as Record | null, +})); +vi.mock("@/hooks/use-auth", async (importOriginal) => ({ + ...(await importOriginal()), + useAuth: () => ({ user: account.user, isAuthenticated: true }), + useAccountOnceMounted: () => account.user, +})); + import { TrendsRow } from "../trends-row"; import type { DailyBriefing } from "@/lib/ai/schema"; @@ -306,4 +332,27 @@ describe("", () => { expect(html).toMatch(/data-metric="steps"/); expect(html).toContain('href="/insights/steps"'); }); + + it("plots a transformed slot in the reader's unit, scale and offset included", () => { + account.user = { unitPreference: "imperial", glucoseUnit: null }; + try { + const html = render( + , + ); + expect(html).toContain('data-unit="lb"'); + expect(html).toContain('data-unit="mi"'); + expect(html).toContain('data-unit="°F"'); + expect(html).toContain('data-offset="32"'); + expect(html).not.toContain('data-unit="kg"'); + expect(html).not.toContain('data-unit="km"'); + } finally { + account.user = null; + } + }); + + it("plots distance in kilometres, scaled from metres, for a metric reader", () => { + const html = render(); + expect(html).toContain('data-unit="km"'); + expect(html).toContain('data-scale="0.001"'); + }); }); diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx index 5ce48d013..5b72e9ba9 100644 --- a/src/components/insights/trend-annotation.tsx +++ b/src/components/insights/trend-annotation.tsx @@ -20,6 +20,7 @@ import { import { cn } from "@/lib/utils"; import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge"; import { apiGet } from "@/lib/api/api-fetch"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; /** * v1.4.20 phase B3 — single-sentence AI annotation rendered directly @@ -264,6 +265,7 @@ export function TrendDescriptorCaption({ }) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); + const { toDisplay, transformFor, unitFor } = useUnitDisplay(); const isMood = kind === "mood"; const primaryType = types[0] ?? ""; @@ -323,18 +325,28 @@ export function TrendDescriptorCaption({ } // The numeric series is already server-bounded to the trailing // 30-day window by the fetch, so every returned row is in-window. + // Described in the reader's unit, the same one the chart above plots. return (numericQuery.data ?? []).map((row) => ({ timestamp: new Date(row.measuredAt).getTime(), - value: row.value, + value: toDisplay(primaryType, row.value), })); - }, [isMood, moodQuery.data, numericQuery.data]); + }, [isMood, moodQuery.data, numericQuery.data, toDisplay, primaryType]); const descriptor = useMemo(() => { + const meta = TREND_SLOT_DESCRIPTOR_META[metric]; + // A slot that converts its series converts its noise floor with it (a + // floor is a difference: factor only). const config = isMood ? MOOD_DESCRIPTOR_CONFIG - : TREND_SLOT_DESCRIPTOR_META[metric]?.config; + : meta?.unitType + ? { + ...meta.config, + absoluteFloor: + meta.config.absoluteFloor * transformFor(meta.unitType).factor, + } + : meta?.config; return computeTrendDescriptor(points, config); - }, [points, isMood, metric]); + }, [points, isMood, metric, transformFor]); // Tier 4 — genuinely too few points (also covers the in-flight window // before the small series lands). Surface the real empty hint so the @@ -361,7 +373,7 @@ export function TrendDescriptorCaption({ // copy defensively (should not happen for the legacy triple). const copy = isMood ? moodDescriptorCopy(descriptor) - : (numericDescriptorCopy(metric, descriptor) ?? + : (numericDescriptorCopy(metric, descriptor, unitFor) ?? moodDescriptorCopy(descriptor)); return ( diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index a7056fd80..9e7b730fa 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -22,6 +22,7 @@ import { type TrendAnnotationConfidenceBand, type TrendAnnotationStatus, } from "./trend-annotation"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; /** * Trends row — a small chart per metric above a one-sentence @@ -116,17 +117,29 @@ function MetricChart({ title: string; }) { const { user } = useAuth(); + const { transformFor, isTransformed } = useUnitDisplay(); const userTimezone = user?.timezone; if (config.kind === "mood") { return ; } + // A single transformed series (weight, distance, temperature) plots in + // the reader's unit: the chart folds scale and offset in at its read + // boundary, exactly as the metric's own insights page does. Distance + // used to plot raw metres under a "km" label for every reader. + const primary = config.types[0] ?? ""; + const transform = + config.types.length === 1 && isTransformed(primary) + ? transformFor(primary) + : null; return ( diff --git a/src/lib/insights/__tests__/trend-descriptor.test.ts b/src/lib/insights/__tests__/trend-descriptor.test.ts index 9b46547e2..cf7258f7b 100644 --- a/src/lib/insights/__tests__/trend-descriptor.test.ts +++ b/src/lib/insights/__tests__/trend-descriptor.test.ts @@ -106,13 +106,31 @@ describe("computeTrendDescriptor", () => { }); describe("numericDescriptorCopy", () => { + it("states a preference-bound slot in the unit the caller resolves", () => { + const d = computeTrendDescriptor(series([181.6, 178.5]), { + absoluteFloor: 0.66, + relativeFloor: 0.01, + decimals: 1, + })!; + const copy = numericDescriptorCopy("weight", d, (type) => + type === "WEIGHT" ? "lb" : "?", + ); + expect(copy?.params.unit).toBe(" lb"); + expect(TREND_SLOT_DESCRIPTOR_META.distance.unitType).toBe( + "WALKING_RUNNING_DISTANCE", + ); + expect(TREND_SLOT_DESCRIPTOR_META.body_temp.unitType).toBe( + "BODY_TEMPERATURE", + ); + }); + it("resolves the rising template with a signed delta and spaced unit", () => { const d = computeTrendDescriptor(series([120, 128]), { absoluteFloor: 2, relativeFloor: 0.02, decimals: 0, })!; - const copy = numericDescriptorCopy("bp", d); + const copy = numericDescriptorCopy("bp", d, () => "unused"); expect(copy?.key).toBe("insights.trendDescriptor.rising"); expect(copy?.params.delta).toBe("+8"); expect(copy?.params.unit).toBe(" mmHg"); @@ -124,7 +142,7 @@ describe("numericDescriptorCopy", () => { relativeFloor: 0.05, decimals: 0, })!; - const copy = numericDescriptorCopy("steps", d); + const copy = numericDescriptorCopy("steps", d, () => "unused"); expect(copy?.key).toBe("insights.trendDescriptor.rising"); expect(copy?.params.unit).toBe(""); }); @@ -135,14 +153,14 @@ describe("numericDescriptorCopy", () => { relativeFloor: 0.01, decimals: 1, })!; - const copy = numericDescriptorCopy("weight", d); + const copy = numericDescriptorCopy("weight", d, () => "kg"); expect(copy?.key).toBe("insights.trendDescriptor.falling"); expect(copy?.params.delta).toBe("−1.4"); }); it("returns null for a slot with no numeric meta (mood)", () => { const d = computeTrendDescriptor(series([3, 4]), MOOD_DESCRIPTOR_CONFIG)!; - expect(numericDescriptorCopy("mood", d)).toBeNull(); + expect(numericDescriptorCopy("mood", d, () => "")).toBeNull(); }); it("covers every numeric slot in the descriptor meta", () => { @@ -154,7 +172,7 @@ describe("numericDescriptorCopy", () => { relativeFloor: 0, decimals: 1, })!; - expect(numericDescriptorCopy(metric, d)).not.toBeNull(); + expect(numericDescriptorCopy(metric, d, () => "u")).not.toBeNull(); } }); }); diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts index fc87a27c2..1ed5b8bc9 100644 --- a/src/lib/insights/trend-chart-select.ts +++ b/src/lib/insights/trend-chart-select.ts @@ -51,7 +51,12 @@ export interface TrendChartConfig { types: string[]; /** Recharts stroke colours, positionally matched to `types`. */ colors: string[]; - /** Display unit (chart legend / tooltip). */ + /** + * Display unit (chart legend / tooltip) for a metric no preference + * changes. A single-series slot whose type has a display transform + * (weight, distance, body temperature) carries none: the renderer takes + * the unit, scale and offset from the reader's preference instead. + */ unit?: string; /** Y-axis unit label when it differs from the value unit. */ yAxisUnit?: string; @@ -114,7 +119,6 @@ export const TREND_CHART_CONFIG: Record< kind: "health-chart", types: ["WEIGHT"], colors: ["var(--chart-1)"], - unit: "kg", titleKey: "charts.weight", captionKey: "insights.trendsRow.caption.weight", annotationKey: "weight", @@ -212,8 +216,6 @@ export const TREND_CHART_CONFIG: Record< kind: "health-chart", types: ["WALKING_RUNNING_DISTANCE"], colors: ["var(--chart-4)"], - unit: "km", - yAxisUnit: "km", titleKey: "charts.distance", captionKey: "insights.trendsRow.caption.distance", detailHref: "/insights/walking-distance", @@ -234,8 +236,6 @@ export const TREND_CHART_CONFIG: Record< kind: "health-chart", types: ["BODY_TEMPERATURE"], colors: ["var(--destructive)"], - unit: "°C", - yAxisUnit: "°C", titleKey: "charts.bodyTemperature", captionKey: "insights.trendsRow.caption.body_temp", detailHref: "/insights/body-temperature", diff --git a/src/lib/insights/trend-descriptor.ts b/src/lib/insights/trend-descriptor.ts index b01ee8ffb..f42dd31aa 100644 --- a/src/lib/insights/trend-descriptor.ts +++ b/src/lib/insights/trend-descriptor.ts @@ -140,8 +140,17 @@ export function computeTrendDescriptor( * dedicated copy rather than the numeric `{delta} {unit}` template. */ export interface TrendSlotDescriptorMeta { - /** Display unit interpolated into the numeric descriptor template. */ - unit: string; + /** + * Display unit interpolated into the numeric descriptor template, for a + * metric no preference changes. + */ + unit?: string; + /** + * For a metric whose unit follows the reader's preference: the + * measurement type the caller resolves the unit of (and converts the + * series and the noise floor through) before describing it. + */ + unitType?: string; config: TrendDescriptorConfig; } @@ -154,7 +163,7 @@ export const TREND_SLOT_DESCRIPTOR_META: Record< config: { absoluteFloor: 2, relativeFloor: 0.02, decimals: 0 }, }, weight: { - unit: "kg", + unitType: "WEIGHT", config: { absoluteFloor: 0.3, relativeFloor: 0.01, decimals: 1 }, }, pulse: { @@ -186,7 +195,7 @@ export const TREND_SLOT_DESCRIPTOR_META: Record< config: { absoluteFloor: 1, relativeFloor: 0.1, decimals: 0 }, }, distance: { - unit: "km", + unitType: "WALKING_RUNNING_DISTANCE", config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 }, }, vo2_max: { @@ -194,7 +203,7 @@ export const TREND_SLOT_DESCRIPTOR_META: Record< config: { absoluteFloor: 0.5, relativeFloor: 0.02, decimals: 1 }, }, body_temp: { - unit: "°C", + unitType: "BODY_TEMPERATURE", config: { absoluteFloor: 0.2, relativeFloor: 0.005, decimals: 1 }, }, }; @@ -231,9 +240,12 @@ export interface TrendDescriptorCopy { export function numericDescriptorCopy( metric: string, descriptor: TrendDescriptor, + /** Resolves a `unitType` slot's unit under the reader's preference. */ + unitFor: (type: string) => string, ): TrendDescriptorCopy | null { const meta = TREND_SLOT_DESCRIPTOR_META[metric]; if (!meta) return null; + const unit = meta.unitType ? unitFor(meta.unitType) : (meta.unit ?? ""); // Signed, formatted magnitude (e.g. "+8", "-1.4"). `stable` still // carries its small delta so the caption can read "stable (±0 …)" if a @@ -255,7 +267,7 @@ export function numericDescriptorCopy( // reads "({delta}{unit})" → "(+8 mmHg)" with a unit, "(+1200)" // without one — no dangling space inside the parens for unit-less // metrics (steps, flights). - unit: meta.unit ? ` ${meta.unit}` : "", + unit: unit ? ` ${unit}` : "", }, }; } From 8a11e60bb30aeab5f38a81f81bcdc678b02cb287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:10:16 +0200 Subject: [PATCH 09/13] fix(cycle): state the cycle-phase glucose row in the reader's unit The cycle-phase table printed the bundle's mg/dL label beside raw mg/dL values for every reader. Weight, temperature and now glucose rows take their unit and conversion from the one reading resolver; every other row keeps its bundle label. Refs #1067 --- .../__tests__/cycle-phase-crosstab.test.tsx | 27 +++++++ src/components/cycle/cycle-phase-crosstab.tsx | 73 +++++++++++++------ 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx index 82aec2b28..1e16d70e6 100644 --- a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx +++ b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx @@ -6,12 +6,17 @@ import { renderToStaticMarkup } from "react-dom/server"; // QueryClient, so mock the hook to a real metric-preference implementation — // metric is the identity on values and yields the canonical display symbols, // so the assertions below (kg, °C, unchanged numbers) hold. +const reader = vi.hoisted(() => ({ + glucoseUnit: "mg/dL" as "mg/dL" | "mmol/L", +})); + vi.mock("@/hooks/use-unit-display", async () => { const dt = await import("@/lib/measurements/display-transform"); const forType = (t: string) => dt.getDisplayTransform(t, "metric"); return { useUnitDisplay: () => ({ preference: "metric" as const, + glucoseUnit: reader.glucoseUnit, transformFor: forType, toDisplay: (t: string, v: number) => dt.applyDisplayTransform(v, forType(t)), @@ -107,4 +112,26 @@ describe("", () => { const html = render(); expect(html).not.toContain("undefined"); }); + + it("states a glucose row in mmol/L for an mmol/L reader", () => { + reader.glucoseUnit = "mmol/L"; + try { + const glucose: CyclePhaseCrosstabRow = { + ...row("bloodGlucose", "glucose"), + lutealAvg: 108, + follicularAvg: 90, + delta: 18, + }; + const html = render(); + // 18 mg/dL is 1.0 mmol/L; 108 / 90 mg/dL are 6.0 / 5.0 mmol/L. + expect(html).toContain("+1.0 mmol/L"); + expect(html).toContain("6.0"); + expect(html).not.toContain("mg/dL"); + expect(render()).toContain( + "mmol/L", + ); + } finally { + reader.glucoseUnit = "mg/dL"; + } + }); }); diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx index 8303c18d3..0a9fe400d 100644 --- a/src/components/cycle/cycle-phase-crosstab.tsx +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -1,7 +1,12 @@ "use client"; import { useTranslations } from "@/lib/i18n/context"; -import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display"; +import { + applyDisplayTransform, + applyDisplayTransformDelta, + getReadingTransform, +} from "@/lib/measurements/display-transform"; import { cn } from "@/lib/utils"; /** @@ -88,8 +93,47 @@ function fmt(value: number, display: CyclePhaseCrosstabDisplay): string { const DISPLAY_TO_TYPE: Partial> = { kg: "WEIGHT", celsius: "BODY_TEMPERATURE", + glucose: "BLOOD_GLUCOSE", }; +/** + * Unit symbol and conversions for one row, from the reader's preferences. + * Glucose follows the glucose unit (it used to print the mg/dL label from + * the bundle beside raw mg/dL values for every reader); weight and + * temperature follow metric/imperial; every other display keeps its bundle + * label and its raw value. + */ +function rowUnits( + display: CyclePhaseCrosstabDisplay, + unitDisplay: UnitDisplay, + t: (key: string) => string, +): { + unit: string; + abs: (v: number) => number; + delta: (v: number) => number; +} { + const type = DISPLAY_TO_TYPE[display]; + if (!type) { + // Defensive: an unmapped display must never pass undefined to `t` + // (whose resolver does `key.split(".")`). Empty unit on a miss. + const unitKey = UNIT_KEY[display]; + return { + unit: unitKey ? t(unitKey) : "", + abs: (v) => v, + delta: (v) => v, + }; + } + const transform = getReadingTransform(type, { + system: unitDisplay.preference, + glucoseUnit: unitDisplay.glucoseUnit, + }); + return { + unit: transform.displayUnit, + abs: (v) => applyDisplayTransform(v, transform), + delta: (v) => applyDisplayTransformDelta(v, transform), + }; +} + const HEADLINE_KEY: Record = { restingHeartRate: "cycle.insights.headline.restingHeartRate", heartRateVariability: "cycle.insights.headline.heartRateVariability", @@ -120,11 +164,7 @@ export function CyclePhaseHeadline({ const metricLabel = t( METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey, ); - // Defensive: an unmapped display must never pass undefined to `t` (whose - // resolver does `key.split(".")`). An empty unit is the honest degrade. - const mt = DISPLAY_TO_TYPE[headline.display]; - const unitKey = UNIT_KEY[headline.display]; - const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : ""; + const { unit, delta: deltaConv } = rowUnits(headline.display, unitDisplay, t); const up = headline.delta >= 0; const dir = t( up @@ -134,12 +174,7 @@ export function CyclePhaseHeadline({ const params = { metric: metricLabel, // A phase delta → factor-only conversion (no affine offset). - delta: fmt( - Math.abs( - mt ? unitDisplay.toDisplayDelta(mt, headline.delta) : headline.delta, - ), - headline.display, - ), + delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display), unit, dir, }; @@ -175,15 +210,11 @@ export function CyclePhaseCrosstab({ const metricLabel = t( METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey, ); - // Defensive: an unmapped display must never pass undefined to `t` - // (whose resolver does `key.split(".")`). Empty unit on a miss. - const mt = DISPLAY_TO_TYPE[row.display]; - const unitKey = UNIT_KEY[row.display]; - const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : ""; - const absConv = (v: number) => - mt ? unitDisplay.toDisplay(mt, v) : v; - const deltaConv = (v: number) => - mt ? unitDisplay.toDisplayDelta(mt, v) : v; + const { + unit, + abs: absConv, + delta: deltaConv, + } = rowUnits(row.display, unitDisplay, t); // `delta` = lutealAvg − follicularAvg: positive = the vital runs // higher in the luteal phase. The number stays NEUTRAL — higher is // good for steps but bad for resting HR, and this board is From b788b5b8b2b981df6fe3c17bdc8e5de8adbdf08c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:10:17 +0200 Subject: [PATCH 10/13] test(units): sweep every display tree for unit literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit-preference guard covered the dashboard, the insights pages and the target surfaces, and the class kept turning up beside them. It now also walks src/components, src/app, src/lib/insights and the doctor report and export modules for the glucose units anywhere in code and for kg, km and °C in display strings, with comments stripped. Each file that legitimately carries one is listed with its exact line budget and the reason: the conversion and the pickers, input parsing, display-kind tags, canonical wire and registry units, the SI policy for clinician artefacts, and four prompt texts still to move. The sweep asserts a match floor, so a broken walk cannot pass as a clean tree. Refs #1067 --- .../unit-preference-display-guard.test.ts | 219 +++++++++++++++++- 1 file changed, 218 insertions(+), 1 deletion(-) diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index a90ff2dbb..693048d76 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -13,7 +13,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; import { describe, it, expect } from "vitest"; @@ -207,3 +207,220 @@ describe("unit-preference display guard", () => { } }); }); + +/** + * The wide sweep (#1067). The block above guards the surfaces that were + * wired for metric/imperial; the class kept reappearing everywhere else — a + * clinician view without units, status notes and narratives pinned to mg/dL + * and kilograms, a glucose threshold editor in mg/dL for an mmol/L account, + * workout distances in kilometres for an imperial reader. Each one was a + * unit literal in a display string. + * + * So every display-bearing tree is swept for the literals a preference + * changes: the two glucose units anywhere in code, and kilograms, + * kilometres and degrees Celsius where they sit in a display string (a bare + * quoted unit, a unit after an interpolation or a number, a unit closing a + * string, a per-kilometre pace). Comments are stripped first. A file that + * legitimately carries one is listed below with the exact number of matching + * lines and the reason; any other file, or one more line in a listed file, + * fails. A listed file that drops a line fails too, so the budget follows + * the code down instead of leaving headroom for the next literal. + * + * The limit, stated: this is a line scanner. A unit spelled through a + * variable named after it, or a literal split across lines, passes. The + * message bundles are not swept either (the fever and cycle-glucose labels + * lived there). + */ +const WIDE_SWEEP_ROOTS = [ + "components/", + "app/", + "lib/insights/", + "lib/doctor-report", + "lib/export", +]; + +const UNIT_LITERAL_PATTERNS: readonly RegExp[] = [ + // Glucose, anywhere in code. + /mg\/dL|mmol\/L/, + // A bare quoted unit: "kg", 'km', `°C`. + /(["'`])\s*(?:kg|km|°C)\s*\1/, + // A unit after an interpolation: `${x} kg`, {value} km in JSX. + /\}\s?(?:kg|km|°C)(?![\w/²])/, + // A unit after a number: "75 kg", "37.0 °C". + /\d\s?(?:kg|km|°C)(?![\w/²])/, + // A unit closing a string or a JSX text run: " km<", " kg\"". + /\s(?:kg|km|°C)(?=["'`<])/, + // A per-kilometre pace. + /\/km\b/, +]; + +/** + * Files allowed to carry a unit literal, with the exact number of matching + * lines and why. `kg/m²` (BMI) never matches; every other hit is listed. + */ +const UNIT_LITERAL_ALLOWLIST: Record< + string, + { lines: number; reason: string } +> = { + // ── The conversion and the choice themselves ── + "components/settings/glucose-unit-select.tsx": { + lines: 2, + reason: "the glucose unit picker: its options are the two units", + }, + "components/onboarding/units-screen.tsx": { + lines: 3, + reason: "the onboarding unit picker: its options are the two units", + }, + "components/measurements/measurement-form.tsx": { + lines: 11, + reason: + "input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save", + }, + "components/measurements/measurement-list.tsx": { + lines: 2, + reason: "input parsing: the inline edit inverts an mmol/L entry to mg/dL", + }, + "components/settings/import-panel/import-examples.ts": { + lines: 3, + reason: "input parsing: CSV examples showing the units an import accepts", + }, + "components/onboarding/first-result-screen.tsx": { + lines: 1, + reason: "decimals chosen by the resolved glucose unit, not a label", + }, + "app/insights/blood-glucose/page.tsx": { + lines: 1, + reason: "branches on the resolved glucose unit to scale the chart", + }, + "components/insights/glucose/glucose-clinical-panel.tsx": { + lines: 1, + reason: "branches on the resolved glucose unit for its decimals", + }, + "components/insights/health-score-pillar-detail.ts": { + lines: 2, + reason: + "branches on the canonical observed unit before converting to the reader's", + }, + // ── Display-kind tags, converted through the preference at render ── + "components/cycle/cycle-phase-crosstab.tsx": { + lines: 1, + reason: "a display-kind tag; rows convert through getReadingTransform", + }, + "lib/insights/mood-crosstab.ts": { + lines: 2, + reason: "a display-kind tag; the weight row converts through the hook", + }, + "components/insights/mood/mood-factor-metric-crosstab.tsx": { + lines: 2, + reason: "a display-kind tag; the weight row converts through the hook", + }, + // ── Canonical by contract ── + "lib/insights/metric-status-registry.ts": { + lines: 11, + reason: + "canonical registry units; metric-status.ts converts to the reader's before writing", + }, + "lib/export.ts": { + lines: 1, + reason: "canonical default when an export caller passes no glucose unit", + }, + "app/api/export/route.ts": { + lines: 1, + reason: "the legacy export endpoint's canonical storage-unit contract", + }, + "app/api/measurements/series/route.ts": { + lines: 4, + reason: + "canonical wire units; glucose is overridden to the reader's unit per request", + }, + "lib/doctor-report/stat-display.ts": { + lines: 3, + reason: + "SI by policy: the clinician artefacts keep mass in kg (glucose follows the owner)", + }, + "components/insights/workout-detail/splits.tsx": { + lines: 1, + reason: "kilometre splits are cut server-side; their pace is per km", + }, + "app/privacy/page.tsx": { + lines: 2, + reason: "prose naming both glucose units the app offers", + }, + // ── Still canonical; the prompt-unit pass for these is open (#1067) ── + "lib/insights/comprehensive-generate.ts": { + lines: 1, + reason: "comprehensive prompt still states weight in kg", + }, + "lib/insights/signals-of-day.ts": { + lines: 1, + reason: "briefing signal still states weight in kg", + }, + "lib/insights/weight-status.ts": { + lines: 1, + reason: "weight status snapshot still states kg", + }, + "lib/insights/glp1-plateau.ts": { + lines: 2, + reason: "GLP-1 plateau prompt context still states kg", + }, +}; + +function unitLiteralLines(): Map { + const files = walkSourceFiles(SRC, { floor: 1400 }).filter( + (rel) => + WIDE_SWEEP_ROOTS.some((root) => rel.startsWith(root)) && + !rel.includes("__tests__") && + !/\.test\.tsx?$/.test(rel), + ); + if (files.length < 1200) { + throw new Error( + `unit sweep narrowed to ${files.length} files; a sweep this small is a broken filter`, + ); + } + const hits = new Map(); + for (const rel of files) { + const lines = stripComments(readFileSync(join(SRC, rel), "utf8")) + .split("\n") + .filter((line) => UNIT_LITERAL_PATTERNS.some((p) => p.test(line))) + .map((line) => line.trim()); + if (lines.length > 0) hits.set(rel, lines); + } + return hits; +} + +describe("unit-preference display guard — the wide sweep", () => { + const hits = unitLiteralLines(); + + it("finds the literals it is meant to find (a sweep that matches nothing proves nothing)", () => { + const total = [...hits.values()].reduce((n, l) => n + l.length, 0); + expect(total).toBeGreaterThanOrEqual(40); + expect(hits.size).toBeGreaterThanOrEqual(15); + }); + + it("no display surface carries a unit literal outside the allowlist", () => { + const offenders: string[] = []; + for (const [rel, lines] of hits) { + const allowed = UNIT_LITERAL_ALLOWLIST[rel]; + if (!allowed) { + offenders.push(`${rel}: ${lines.join(" | ")}`); + } else if (lines.length > allowed.lines) { + offenders.push( + `${rel}: ${lines.length} unit-literal lines, allowlisted ${allowed.lines}: ${lines.join(" | ")}`, + ); + } + } + expect(offenders).toEqual([]); + }); + + it("every allowlist entry still matches exactly its budget, with a reason", () => { + const stale: string[] = []; + for (const [rel, allowed] of Object.entries(UNIT_LITERAL_ALLOWLIST)) { + expect(allowed.reason.length).toBeGreaterThan(10); + const found = hits.get(rel)?.length ?? 0; + if (found !== allowed.lines) { + stale.push(`${rel}: allowlisted ${allowed.lines}, found ${found}`); + } + } + expect(stale).toEqual([]); + }); +}); From 0039bd1d1f8e1f95f5610fef02ff44ff928c9d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:11:47 +0200 Subject: [PATCH 11/13] refactor(insights): use the shared rescale predicate in the status note Refs #1067 --- src/lib/insights/metric-status.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index b937b689c..8ee1c927c 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -679,7 +679,7 @@ function readingDisplay( meta.measurementType, preferences, ); - const converts = transform.factor !== 1 || (transform.offset ?? 0) !== 0; + const converts = transformRescales(transform); return { unit: transform.displayUnit, decimals: transform.decimals, From 9a282ed698ebd7661e079b8ec3094b4a901c2c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:17:44 +0200 Subject: [PATCH 12/13] fix(units): keep converted values on the rounding helpers The narrative, the workout distance and the elevation chart converted through the unrounded escape hatch, and the trend caption scaled its noise floor off the raw factor. They now take the rounded helpers, so a narrated pound or mmol/L figure reads at the grain every screen shows it, and the caption's floors are declared canonically (the distance floor in metres). The trends row joins the chart-feeding surfaces that may hand a raw scale to a chart. Refs #1067 --- .../display-transform-rounding-guard.test.ts | 5 ++++- src/components/charts/workout-elevation-chart.tsx | 6 +++--- src/components/insights/trend-annotation.tsx | 14 ++++++++------ src/components/insights/workout-detail/format.ts | 3 +-- .../narrative/__tests__/period-narrative.test.ts | 14 +++++++------- src/lib/insights/narrative/period-narrative.ts | 9 ++++++--- src/lib/insights/trend-descriptor.ts | 6 ++++-- 7 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts index 7db775374..73b928296 100644 --- a/src/__tests__/display-transform-rounding-guard.test.ts +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -52,7 +52,7 @@ const REGISTRY = "lib/measurements/display-transform.ts"; const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"]; /** - * The surfaces allowed to read a transform's raw `factor` / `offset`. All four + * The surfaces allowed to read a transform's raw `factor` / `offset`. All five * do it for the same reason: the chart takes the multiplier as a prop, folds * it into its own series at the single read boundary, and formats the result * itself. Everything those files RENDER directly goes through the rounding @@ -61,6 +61,9 @@ const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"]; */ const RAW_SCALE_SURFACES = [ "app/page-client.tsx", + // The insights trends row: a single transformed series hands its scale + // and offset to the mini chart, which formats every value itself. + "components/insights/trends-row.tsx", "app/insights/weight/page.tsx", "components/insights/healthkit-metric-page.tsx", "components/insights/device-score-tile.tsx", diff --git a/src/components/charts/workout-elevation-chart.tsx b/src/components/charts/workout-elevation-chart.tsx index 3fd0cfa93..dbf0382fc 100644 --- a/src/components/charts/workout-elevation-chart.tsx +++ b/src/components/charts/workout-elevation-chart.tsx @@ -13,7 +13,7 @@ import { import { useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { - applyDisplayTransformUnrounded, + applyDisplayTransform, getQuantityTransform, } from "@/lib/measurements/display-transform"; @@ -41,8 +41,8 @@ export function WorkoutElevationChart({ const distance = getQuantityTransform("distance", preference); const elevation = getQuantityTransform("elevation", preference); const data = points.map((p) => ({ - dist: applyDisplayTransformUnrounded(p.distanceM, distance), - alt: applyDisplayTransformUnrounded(p.altitude, elevation), + dist: applyDisplayTransform(p.distanceM, distance), + alt: applyDisplayTransform(p.altitude, elevation), })); return ( diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx index 5b72e9ba9..7551a88cc 100644 --- a/src/components/insights/trend-annotation.tsx +++ b/src/components/insights/trend-annotation.tsx @@ -265,7 +265,7 @@ export function TrendDescriptorCaption({ }) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); - const { toDisplay, transformFor, unitFor } = useUnitDisplay(); + const { toDisplay, toDisplayDelta, unitFor } = useUnitDisplay(); const isMood = kind === "mood"; const primaryType = types[0] ?? ""; @@ -334,19 +334,21 @@ export function TrendDescriptorCaption({ const descriptor = useMemo(() => { const meta = TREND_SLOT_DESCRIPTOR_META[metric]; - // A slot that converts its series converts its noise floor with it (a - // floor is a difference: factor only). + // A slot that converts its series converts its noise floor with it: the + // floor is declared canonically and is a difference (factor only). const config = isMood ? MOOD_DESCRIPTOR_CONFIG : meta?.unitType ? { ...meta.config, - absoluteFloor: - meta.config.absoluteFloor * transformFor(meta.unitType).factor, + absoluteFloor: toDisplayDelta( + meta.unitType, + meta.config.absoluteFloor, + ), } : meta?.config; return computeTrendDescriptor(points, config); - }, [points, isMood, metric, transformFor]); + }, [points, isMood, metric, toDisplayDelta]); // Tier 4 — genuinely too few points (also covers the in-flight window // before the small series lands). Surface the real empty hint so the diff --git a/src/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts index 03e559ea1..9dfb6eadd 100644 --- a/src/components/insights/workout-detail/format.ts +++ b/src/components/insights/workout-detail/format.ts @@ -1,7 +1,6 @@ import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { applyDisplayTransform, - applyDisplayTransformUnrounded, getQuantityTransform, paceSecondsPerDistanceUnit, type UnitPreference, @@ -46,7 +45,7 @@ export function formatDistance( preference: UnitPreference, ): string { const transform = getQuantityTransform("distance", preference); - const value = applyDisplayTransformUnrounded(meters, transform); + const value = applyDisplayTransform(meters, transform); const formatted = getNumberFormat(locale, { maximumFractionDigits: 2, minimumFractionDigits: value < 10 ? 2 : 1, diff --git a/src/lib/insights/narrative/__tests__/period-narrative.test.ts b/src/lib/insights/narrative/__tests__/period-narrative.test.ts index 99e3c7e68..83abb0ed5 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative.test.ts @@ -156,15 +156,15 @@ describe("assemblePeriodNarrativeContext — the reader's units", () => { ); const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!; expect(g.unit).toBe("mmol/L"); - expect(g.current).toBe(5.99); - expect(g.prior).toBe(4.99); + expect(g.current).toBe(6); + expect(g.prior).toBe(5); expect(g.delta).toBe(1); // Percent is unit-free. expect(g.deltaPercent).toBe(20); const w = ctx.metricDeltas.find((d) => d.type === "WEIGHT")!; expect(w.unit).toBe("lb"); - expect(w.current).toBe(180.78); - expect(w.delta).toBe(4.41); + expect(w.current).toBe(180.8); + expect(w.delta).toBe(4.4); const prose = buildDeterministicNarrative(ctx, "en"); expect(prose).toContain("mmol/L"); @@ -172,7 +172,7 @@ describe("assemblePeriodNarrativeContext — the reader's units", () => { expect(prose).not.toContain("mg/dL"); expect(prose).not.toContain("kg"); const prompt = buildNarrativeUserPrompt(ctx, "en"); - expect(prompt).toContain("BLOOD_GLUCOSE: 5.99 mmol/L"); + expect(prompt).toContain("BLOOD_GLUCOSE: 6 mmol/L"); expect(prompt).not.toContain("mg/dL"); }); @@ -231,10 +231,10 @@ describe("assemblePeriodNarrativeContext — the reader's units", () => { ); const t = ctx.bandTransitions.find((b) => b.type === "BLOOD_GLUCOSE")!; expect(t.unit).toBe("mmol/L"); - expect(t.center).toBe(7.77); + expect(t.center).toBe(7.8); expect(t.movedOut).toBe(true); expect(buildNarrativeUserPrompt(ctx, "en")).toContain( - "BLOOD_GLUCOSE: 7.77 mmol/L", + "BLOOD_GLUCOSE: 7.8 mmol/L", ); }); }); diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index c03deab01..807ade7f6 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -57,7 +57,8 @@ import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; import { resolveModuleMap } from "@/lib/modules/gate"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { - applyDisplayTransformUnrounded, + applyDisplayTransform, + applyDisplayTransformDelta, getReadingTransform, isUnitSensitiveType, resolveUnitPreferences, @@ -135,8 +136,10 @@ function narratedUnit( const transform = getReadingTransform(type, units); return { unit: transform.displayUnit, - abs: (v) => round2(applyDisplayTransformUnrounded(v, transform)), - delta: (v) => round2(v * transform.factor), + // Rounded to the unit's own grain (one decimal for lb and mmol/L), the + // grain every screen shows the same reading at. + abs: (v) => round2(applyDisplayTransform(v, transform)), + delta: (v) => round2(applyDisplayTransformDelta(v, transform)), }; } if (type === "SLEEP_DURATION") { diff --git a/src/lib/insights/trend-descriptor.ts b/src/lib/insights/trend-descriptor.ts index f42dd31aa..fea9ec7a7 100644 --- a/src/lib/insights/trend-descriptor.ts +++ b/src/lib/insights/trend-descriptor.ts @@ -148,7 +148,8 @@ export interface TrendSlotDescriptorMeta { /** * For a metric whose unit follows the reader's preference: the * measurement type the caller resolves the unit of (and converts the - * series and the noise floor through) before describing it. + * series and the noise floor through) before describing it. Such a slot + * declares its `absoluteFloor` in the CANONICAL unit (kg, m, °C). */ unitType?: string; config: TrendDescriptorConfig; @@ -196,7 +197,8 @@ export const TREND_SLOT_DESCRIPTOR_META: Record< }, distance: { unitType: "WALKING_RUNNING_DISTANCE", - config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 }, + // 300 m — canonical metres, converted to the reader's km or mi. + config: { absoluteFloor: 300, relativeFloor: 0.05, decimals: 1 }, }, vo2_max: { unit: "", From a1fa84dcdf92d6ba42bafa73444bde4b020e29ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:53:45 +0200 Subject: [PATCH 13/13] chore(units): drop the preference loader nothing calls yet --- src/lib/measurements/unit-preferences.ts | 26 ------------------------ 1 file changed, 26 deletions(-) delete mode 100644 src/lib/measurements/unit-preferences.ts diff --git a/src/lib/measurements/unit-preferences.ts b/src/lib/measurements/unit-preferences.ts deleted file mode 100644 index 5dadc9b7b..000000000 --- a/src/lib/measurements/unit-preferences.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Server-side read of a record's two unit preferences. - * - * Every text the server writes for a person to read — a status note, a period - * narrative, a score label — quotes numbers, and a number is only right in the - * unit its reader chose. This is the one read those producers share, so none - * of them can load `unitPreference` and forget `glucoseUnit` or the reverse. - */ -import { prisma } from "@/lib/db"; - -import { - DEFAULT_UNIT_PREFERENCES, - resolveUnitPreferences, - type UnitPreferences, -} from "./display-transform"; - -/** Both preferences of one record; the defaults when the row is gone. */ -export async function loadUnitPreferences( - userId: string, -): Promise { - const row = await prisma.user.findUnique({ - where: { id: userId }, - select: { unitPreference: true, glucoseUnit: true }, - }); - return row ? resolveUnitPreferences(row) : DEFAULT_UNIT_PREFERENCES; -}