From 5486014360038369308bc17a94b729dc139179c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:00:53 +0200 Subject: [PATCH] fix(score): write the glycaemia labels in the reader's glucose unit The fasting-glucose pillar served its observed and reference labels as "Fasting median 99 mg/dL" and "70-99 mg/dL fasting" whatever the account's unit, and clients print those labels as they come. They are now written in the record's glucose unit. The scored value, its unit and the band edges stay canonical, so the score does not move and the web, which converts from the value, cannot convert twice. The schema now says which fields are canonical and which are display text. Refs #1067 --- docs/api/openapi.yaml | 8 ++++- src/app/api/analytics/route.ts | 3 +- .../__tests__/composition-source.test.ts | 1 + ...onfig-change-is-not-a-health-event.test.ts | 1 + .../analytics/score/__tests__/pillars.test.ts | 36 +++++++++++++++++++ .../analytics/score/__tests__/reader.test.ts | 1 + .../reference-citation-guard.test.ts | 1 + src/lib/analytics/score/derived.ts | 3 ++ src/lib/analytics/score/glycaemia.ts | 8 +++-- src/lib/analytics/score/reader.ts | 8 +++++ src/lib/analytics/score/types.ts | 7 ++++ src/lib/dashboard/snapshot.ts | 3 +- src/lib/openapi/routes/analytics.ts | 20 +++++++---- 13 files changed, 89 insertions(+), 11 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20072acf6..8ca9ebec5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -55784,11 +55784,14 @@ components: properties: value: type: number + description: The scored value in its canonical unit (fasting glucose in mg/dL). unit: type: string + description: The canonical unit of `value`. label: type: string - description: The complete display value, paired or panel values included. + description: The complete display value, paired or panel values included, written in the reader's units (fasting glucose + in the account's glucose unit). asOf: type: string format: date-time @@ -55817,12 +55820,15 @@ components: anyOf: - type: number - type: "null" + description: Lower band edge, canonical unit. high: anyOf: - type: number - type: "null" + description: Upper band edge, canonical unit. label: type: string + description: The band as display text, in the reader's units. source: type: string required: diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 02056525c..de8e909bd 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -34,7 +34,7 @@ import { GLUCOSE_PANEL_WINDOW_DAYS, type GlucoseClinicalMetrics, } from "@/lib/analytics/glucose-metrics"; -import { groupByGlucoseContext } from "@/lib/glucose"; +import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose"; import { decisionForEvidence, PATTERN_FAMILIES, @@ -619,6 +619,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { timezone: userTz, sourcePriorityJson: scoreSourcePriority, thresholdsJson: user.thresholdsJson ?? null, + glucoseUnit: resolveGlucoseUnit(user.glucoseUnit), }, modules: { glucose: glucoseEnabled, diff --git a/src/lib/analytics/score/__tests__/composition-source.test.ts b/src/lib/analytics/score/__tests__/composition-source.test.ts index e28140842..89af33f19 100644 --- a/src/lib/analytics/score/__tests__/composition-source.test.ts +++ b/src/lib/analytics/score/__tests__/composition-source.test.ts @@ -240,6 +240,7 @@ function score(args: { timezone: "UTC", sourcePriorityJson: null, thresholdsJson: null, + glucoseUnit: "mg/dL" as const, }, modules: args.modules, healthScoreConfigJson: args.healthScoreConfigJson, diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index 171e54fec..d031de698 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -318,6 +318,7 @@ function scoreInput(healthScoreConfigJson: unknown) { timezone: "UTC", sourcePriorityJson: null, thresholdsJson: null, + glucoseUnit: "mg/dL" as const, }, modules: { glucose: true, diff --git a/src/lib/analytics/score/__tests__/pillars.test.ts b/src/lib/analytics/score/__tests__/pillars.test.ts index 67f173c85..939558c0d 100644 --- a/src/lib/analytics/score/__tests__/pillars.test.ts +++ b/src/lib/analytics/score/__tests__/pillars.test.ts @@ -14,6 +14,7 @@ const live = { source: "live" as const, readFailed: false, timezone: "Europe/Berlin", + glucoseUnit: "mg/dL" as const, }; function days(count: number, value: number) { @@ -160,6 +161,41 @@ describe("reference-score pillars", () => { expect(eligible.status).toBe("ok"); }); + it("writes the fasting labels in the reader's glucose unit, value and band canonical", () => { + const fasting = days(8, 99).map((point) => ({ + value: point.value, + at: new Date(`${point.day}T08:00:00Z`), + source: "APPLE_HEALTH", + })); + const mmol = computeGlycaemiaPillar({ + ...live, + glucoseUnit: "mmol/L", + asOf: NOW, + hba1c: [], + fastingGlucose: fasting, + }); + const mgdl = computeGlycaemiaPillar({ + ...live, + asOf: NOW, + hba1c: [], + fastingGlucose: fasting, + }); + expect(mmol.status).toBe("ok"); + expect(mgdl.status).toBe("ok"); + if (mmol.status !== "ok" || mgdl.status !== "ok") return; + // These labels reach the app as finished text. + expect(mmol.value.observed.label).toBe("Fasting median 5.5 mmol/L"); + expect(mmol.value.reference.label).toBe("3.9–5.5 mmol/L fasting"); + expect(mgdl.value.observed.label).toBe("Fasting median 99 mg/dL"); + expect(mgdl.value.reference.label).toBe("70–99 mg/dL fasting"); + // The score and the numbers the web converts from do not move. + expect(mmol.value.observed.value).toBe(99); + expect(mmol.value.observed.unit).toBe("mg/dL"); + expect(mmol.value.reference.low).toBe(70); + expect(mmol.value.reference.high).toBe(99); + expect(mmol.value.score).toBe(mgdl.value.score); + }); + it("does not use fasting fallback when the preferred lab read failed", () => { const result = computeGlycaemiaPillar({ ...live, diff --git a/src/lib/analytics/score/__tests__/reader.test.ts b/src/lib/analytics/score/__tests__/reader.test.ts index 972fd9190..af86b62af 100644 --- a/src/lib/analytics/score/__tests__/reader.test.ts +++ b/src/lib/analytics/score/__tests__/reader.test.ts @@ -137,6 +137,7 @@ describe("score reader bounds and source parity", () => { timezone: "Europe/Berlin", sourcePriorityJson: null, thresholdsJson: null, + glucoseUnit: "mg/dL" as const, }, modules: { glucose: true, diff --git a/src/lib/analytics/score/__tests__/reference-citation-guard.test.ts b/src/lib/analytics/score/__tests__/reference-citation-guard.test.ts index 75a459e86..a88abdb95 100644 --- a/src/lib/analytics/score/__tests__/reference-citation-guard.test.ts +++ b/src/lib/analytics/score/__tests__/reference-citation-guard.test.ts @@ -233,6 +233,7 @@ const live = { source: "live" as const, readFailed: false, timezone: "Europe/Berlin", + glucoseUnit: "mg/dL" as const, }; function days(count: number, value: number) { diff --git a/src/lib/analytics/score/derived.ts b/src/lib/analytics/score/derived.ts index 4065c65a1..5a70969f7 100644 --- a/src/lib/analytics/score/derived.ts +++ b/src/lib/analytics/score/derived.ts @@ -5,6 +5,7 @@ import { getBpTargets } from "@/lib/analytics/bp-targets"; import { resolveModuleMap } from "@/lib/modules/gate"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { resolveGlucoseUnit } from "@/lib/glucose"; import { computeAndRecordUserHealthScore } from "./record"; import type { CompositeValue } from "./types"; @@ -23,6 +24,7 @@ export async function computeHealthScoreDerived( timezone: true, thresholdsJson: true, healthScoreConfigJson: true, + glucoseUnit: true, }, }); const now = new Date(); @@ -59,6 +61,7 @@ export async function computeHealthScoreDerived( timezone, sourcePriorityJson, thresholdsJson: user.thresholdsJson, + glucoseUnit: resolveGlucoseUnit(user.glucoseUnit), }, modules: { glucose: modules.glucose !== false, diff --git a/src/lib/analytics/score/glycaemia.ts b/src/lib/analytics/score/glycaemia.ts index 08cb64555..b62462a02 100644 --- a/src/lib/analytics/score/glycaemia.ts +++ b/src/lib/analytics/score/glycaemia.ts @@ -1,4 +1,5 @@ import type { Derived } from "@/lib/insights/derived/types"; +import { convertGlucose } from "@/lib/glucose"; import type { GlycaemiaPillarInput, PillarValue } from "./types"; import { @@ -153,10 +154,13 @@ export function computeGlycaemiaPillar( FASTING_GLUCOSE_REFERENCE.low, FASTING_GLUCOSE_REFERENCE.high, ), + // `value`, `unit` and the band stay canonical: the score is computed on + // them and the web converts from them. The labels are finished text a + // client prints as-is, so they are written in the reader's unit. observed: { value: observed, unit: "mg/dL", - label: `Fasting median ${Math.round(observed)} mg/dL`, + label: `Fasting median ${convertGlucose(observed, input.glucoseUnit)} ${input.glucoseUnit}`, asOf: fasting.at(-1)!.at.toISOString(), sources: uniqueSources(fasting.map((row) => row.source)), }, @@ -164,7 +168,7 @@ export function computeGlycaemiaPillar( kind: "clinical-threshold", low: FASTING_GLUCOSE_REFERENCE.low, high: FASTING_GLUCOSE_REFERENCE.high, - label: `${FASTING_GLUCOSE_REFERENCE.low}–${FASTING_GLUCOSE_REFERENCE.high} mg/dL fasting`, + label: `${convertGlucose(FASTING_GLUCOSE_REFERENCE.low, input.glucoseUnit)}–${convertGlucose(FASTING_GLUCOSE_REFERENCE.high, input.glucoseUnit)} ${input.glucoseUnit} fasting`, source: "ADA 2025; Selvin 2010", }, noiseFloor: Math.max(1, Math.abs(ceilingScore - floorScore)), diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index 7f9e80559..eae8b85a8 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -42,6 +42,7 @@ import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types"; import { computeWeightGoal } from "./weight-goal"; import { DAY_MS, uniqueSources } from "./shared"; import type { DerivedProvenanceSource } from "@/lib/insights/derived/types"; +import type { GlucoseUnit } from "@/lib/glucose"; const HISTORY_OFFSET_DAYS = 14; const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS; @@ -52,6 +53,12 @@ export interface ScoreReaderProfile { timezone: string; sourcePriorityJson: unknown; thresholdsJson: unknown; + /** + * The record's glucose unit, for the glycaemia pillar's display labels. + * Required: those labels are served as finished text (the app prints them + * verbatim), and a caller that forgets the unit must not get mg/dL. + */ + glucoseUnit: GlucoseUnit; } export interface UserHealthScoreInput { @@ -513,6 +520,7 @@ function scoreInputsFor(args: { }, GLYCAEMIA: { source: "live", + glucoseUnit: input.profile.glucoseUnit, readFailed: (!input.modules.glucose || !args.glucose.ok) && (!input.modules.labs || !args.labs.ok), diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index 44dc7ed1b..67c25081a 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -8,6 +8,7 @@ import type { // runtime cycle. The tier's definition belongs beside the rule that // produces it; its NAME belongs here, where the wire shape is declared. import type { ScoreBreadthTier } from "./breadth"; +import type { GlucoseUnit } from "@/lib/glucose"; /** * The scoring method's identity. @@ -346,6 +347,12 @@ export interface GlucoseReading { export interface GlycaemiaPillarInput extends DomainReadState { asOf: Date; + /** + * The unit the fasting-glucose labels are written in. The scored value, + * its `unit` and the reference band stay canonical mg/dL; only the two + * human-readable labels follow the reader. + */ + glucoseUnit: GlucoseUnit; hba1cReadFailed?: boolean; fastingReadFailed?: boolean; hba1c: HbA1cReading[]; diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 6d0f074a7..83dd06f32 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -49,7 +49,7 @@ import { GLUCOSE_PANEL_WINDOW_DAYS, type GlucoseClinicalMetrics, } from "@/lib/analytics/glucose-metrics"; -import { groupByGlucoseContext } from "@/lib/glucose"; +import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose"; import { computeBpInTargetFastPath, type BpInTargetEnvelope, @@ -788,6 +788,7 @@ async function buildExtras( timezone: userTz, sourcePriorityJson: scoreSourcePriority, thresholdsJson: user.thresholdsJson, + glucoseUnit: resolveGlucoseUnit(user.glucoseUnit), }, modules: { glucose: modules.glucose !== false, diff --git a/src/lib/openapi/routes/analytics.ts b/src/lib/openapi/routes/analytics.ts index f51360fee..41dda0037 100644 --- a/src/lib/openapi/routes/analytics.ts +++ b/src/lib/openapi/routes/analytics.ts @@ -239,11 +239,17 @@ const scorePillarIdEnum = z.enum(SCORE_PILLAR_IDS); const pillarValue = z.object({ score: z.number(), observed: z.object({ - value: z.number(), - unit: z.string(), + value: z + .number() + .describe( + "The scored value in its canonical unit (fasting glucose in mg/dL).", + ), + unit: z.string().describe("The canonical unit of `value`."), label: z .string() - .describe("The complete display value, paired or panel values included."), + .describe( + "The complete display value, paired or panel values included, written in the reader's units (fasting glucose in the account's glucose unit).", + ), asOf: z.iso.datetime({ offset: true }), sources: z.array(z.string()), }), @@ -253,9 +259,11 @@ const pillarValue = z.object({ "population-percentile", "guideline-band", ]), - low: z.number().nullable(), - high: z.number().nullable(), - label: z.string(), + low: z.number().nullable().describe("Lower band edge, canonical unit."), + high: z.number().nullable().describe("Upper band edge, canonical unit."), + label: z + .string() + .describe("The band as display text, in the reader's units."), source: z.string(), }), personalReference: z