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
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 09:00:53 +02:00
1 parent d0a9086538
commit 5486014360
13 files changed
+89 -11

No files matched your search

+7 -1
View File
@@ -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:
+2 -1
View File
@@ -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,
@@ -240,6 +240,7 @@ function score(args: {
timezone: "UTC",
sourcePriorityJson: null,
thresholdsJson: null,
glucoseUnit: "mg/dL" as const,
},
modules: args.modules,
healthScoreConfigJson: args.healthScoreConfigJson,
@@ -318,6 +318,7 @@ function scoreInput(healthScoreConfigJson: unknown) {
timezone: "UTC",
sourcePriorityJson: null,
thresholdsJson: null,
glucoseUnit: "mg/dL" as const,
},
modules: {
glucose: true,
@@ -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,
@@ -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,
@@ -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) {
+3
View File
@@ -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,
+6 -2
View File
@@ -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)),
+8
View File
@@ -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),
+7
View File
@@ -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[];
+2 -1
View File
@@ -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,
+14 -6
View File
@@ -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