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