diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 331d3d85c..b21305370 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -55791,11 +55791,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 @@ -55824,12 +55827,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/messages/de.json b/messages/de.json index 6bf4db0c6..365f608ae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8168,6 +8168,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": { @@ -9406,7 +9407,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 04555344a..e04b37a24 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8168,6 +8168,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": { @@ -9406,7 +9407,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 c90525051..97027af1f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8168,6 +8168,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": { @@ -9406,7 +9407,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 1ebea9c37..ffb3f280a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8168,6 +8168,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": { @@ -9406,7 +9407,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 c163f1bef..66e7ee9db 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8168,6 +8168,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": { @@ -9406,7 +9407,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 08156e094..fd7eb9f96 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8168,6 +8168,7 @@ "medications": "복약 및 복약 순응도", "bmi": "체질량지수(BMI)", "statSummary": "최근 {latest} (평균 {avg}, 범위 {min}–{max})", + "statSummaryWithUnit": "최근 {latest} {unit} (평균 {avg} {unit}, 범위 {min}–{max} {unit})", "adherence": "복약 순응도 {rate}", "noAdherence": "—", "glucose": { @@ -9406,7 +9407,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 c0ecb344a..122c19159 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8168,6 +8168,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": { @@ -9406,7 +9407,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/__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/__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([]); + }); +}); 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/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/components/charts/workout-elevation-chart.tsx b/src/components/charts/workout-elevation-chart.tsx index 8c565bd11..dbf0382fc 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 { + applyDisplayTransform, + 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: applyDisplayTransform(p.distanceM, distance), + alt: applyDisplayTransform(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/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/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 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/__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/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/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx index 5ce48d013..7551a88cc 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, toDisplayDelta, unitFor } = useUnitDisplay(); const isMood = kind === "mood"; const primaryType = types[0] ?? ""; @@ -323,18 +325,30 @@ 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: the + // floor is declared canonically and is a difference (factor only). const config = isMood ? MOOD_DESCRIPTOR_CONFIG - : TREND_SLOT_DESCRIPTOR_META[metric]?.config; + : meta?.unitType + ? { + ...meta.config, + absoluteFloor: toDisplayDelta( + meta.unitType, + meta.config.absoluteFloor, + ), + } + : meta?.config; return computeTrendDescriptor(points, config); - }, [points, isMood, metric]); + }, [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 @@ -361,7 +375,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/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts index 50fc6f775..9dfb6eadd 100644 --- a/src/components/insights/workout-detail/format.ts +++ b/src/components/insights/workout-detail/format.ts @@ -1,4 +1,10 @@ import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; +import { + applyDisplayTransform, + getQuantityTransform, + paceSecondsPerDistanceUnit, + type UnitPreference, +} from "@/lib/measurements/display-transform"; import { hourCycleOptions, type TimeFormatPreference, @@ -27,12 +33,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 = applyDisplayTransform(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 +78,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/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/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/__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/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/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/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; +} 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__/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/__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..8ee1c927c 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 = transformRescales(transform); + 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/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..83abb0ed5 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(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.8); + expect(w.delta).toBe(4.4); + + 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: 6 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.8); + expect(t.movedOut).toBe(true); + expect(buildNarrativeUserPrompt(ctx, "en")).toContain( + "BLOOD_GLUCOSE: 7.8 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..807ade7f6 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -56,6 +56,14 @@ 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 { + applyDisplayTransform, + applyDisplayTransformDelta, + getReadingTransform, + isUnitSensitiveType, + resolveUnitPreferences, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -74,20 +82,80 @@ 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, + // 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") { + 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 +182,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 +313,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 +338,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 +358,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 +407,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 +564,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 +612,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 +639,6 @@ export async function buildPeriodNarrativeContext( discoverySeries, locale: opts.locale, computedAt: now.toISOString(), + units, }); } 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/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..fea9ec7a7 100644 --- a/src/lib/insights/trend-descriptor.ts +++ b/src/lib/insights/trend-descriptor.ts @@ -140,8 +140,18 @@ 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. Such a slot + * declares its `absoluteFloor` in the CANONICAL unit (kg, m, °C). + */ + unitType?: string; config: TrendDescriptorConfig; } @@ -154,7 +164,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,15 +196,16 @@ export const TREND_SLOT_DESCRIPTOR_META: Record< config: { absoluteFloor: 1, relativeFloor: 0.1, decimals: 0 }, }, distance: { - unit: "km", - config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 }, + unitType: "WALKING_RUNNING_DISTANCE", + // 300 m — canonical metres, converted to the reader's km or mi. + config: { absoluteFloor: 300, relativeFloor: 0.05, decimals: 1 }, }, vo2_max: { unit: "", 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 +242,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 +269,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}` : "", }, }; } 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. + } +} diff --git a/src/lib/measurements/__tests__/display-transform.test.ts b/src/lib/measurements/__tests__/display-transform.test.ts index ccb7c5d79..62fa98d33 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.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( + 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..72b7426d1 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: 1 }, + imperial: { factor: M_TO_FT, displayUnit: "ft", decimals: 1 }, + }, +}; + +/** 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/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 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);