fix(glucose): carry the unit into the coach and align stat rounding

Convert the glucose figures the coach snapshot builds to the user's unit and
carry the unit into the prompt, so the coach speaks mmol/L to a mmol/L user
instead of a bare mg/dL number — the one glucose surface the unit work had not
reached. Aggregate the series stats over raw values and convert each figure
once, matching the detail page and the FHIR export so a multi-reading mean no
longer differs in the last decimal between surfaces. Drop the fixed day count
from the median label, which the windowed median value cannot honour.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:49:10 +02:00
commit 901faaa096
13 files changed
+184 -34

No files matched your search

@@ -236,6 +236,51 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () =>
expect(out.provenance.metrics).toContain("glucose");
});
it("carries glucose in the user's display unit (mmol/L) — v1.16.16", async () => {
// A mmol/L-preference user's Coach must read 5.5, not the stored 100.
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
glucoseUnit: "mmol/L",
});
prismaMock.measurement.findMany.mockResolvedValue([
{ ...daysAgo(1, 100, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" },
]);
const out = await buildCoachSnapshot("user-1", {
sources: ["glucose"],
window: "last30days",
});
const snapshot = JSON.parse(out.snapshotJson) as {
glucose?: {
unit?: string;
byContext?: Record<string, { recent?: Array<{ value: number }> }>;
};
};
expect(snapshot.glucose?.unit).toBe("mmol/L");
expect(snapshot.glucose?.byContext?.fasting?.recent?.[0]?.value).toBe(5.5);
});
it("keeps mg/dL glucose values byte-identical with a unit token — v1.16.16", async () => {
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
glucoseUnit: "mg/dL",
});
prismaMock.measurement.findMany.mockResolvedValue([
{ ...daysAgo(1, 100, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" },
]);
const out = await buildCoachSnapshot("user-1", {
sources: ["glucose"],
window: "last30days",
});
const snapshot = JSON.parse(out.snapshotJson) as {
glucose?: {
unit?: string;
byContext?: Record<string, { recent?: Array<{ value: number }> }>;
};
};
expect(snapshot.glucose?.unit).toBe("mg/dL");
expect(snapshot.glucose?.byContext?.fasting?.recent?.[0]?.value).toBe(100);
});
it("caps the workouts block and rolls up per sport", async () => {
const workouts = Array.from({ length: 25 }, (_, i) => ({
sportType: i % 2 === 0 ? "RUNNING" : "CYCLING",
+29 -8
View File
@@ -23,6 +23,7 @@ import {
type CoachDataCluster,
} from "@/lib/validations/coach-prefs";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import {
reconstructSleepNights,
type SleepStageRow,
@@ -481,6 +482,10 @@ async function buildCoachSnapshotImpl(
// both columns here so a non-cycle account pays no extra round-trip.
gender: true,
cycleProfile: { select: { cycleTrackingEnabled: true } },
// v1.16.16 — the glucose block converts canonical mg/dL to the user's
// display unit so the Coach reads the same number every other surface
// shows. Read it on this existing prefs hop (no extra round-trip).
glucoseUnit: true,
},
});
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
@@ -494,6 +499,7 @@ async function buildCoachSnapshotImpl(
clusterDefault,
);
const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE;
const glucoseUnit = resolveGlucoseUnit(prefsRow?.glucoseUnit ?? null);
const excluded = new Set<CoachExcludeMetric>(prefs.excludeMetrics);
// v1.4.36 W3 T2 — `medications` and `anthropometrics` are
// exclude-only toggles (not in `CoachScopeSource`); they gate the
@@ -1317,15 +1323,30 @@ async function buildCoachSnapshotImpl(
}
const contexts: Record<string, unknown> = {};
for (const [ctx, rows] of byContext) {
contexts[ctx] = {
recent: buildDailyValueRows(rows, recentCutoff, userTz),
weekly: bucketWeekly(
rows.filter((r) => r.measuredAt < recentCutoff),
userTz,
),
};
// v1.16.16 — glucose is stored canonical mg/dL. A mmol/L-preference
// user's Coach must read the same number every other surface shows
// (5.5, not 100). Aggregate the per-day / weekly means in raw mg/dL,
// then convert each resulting figure ONCE (parity with the series
// DTO + detail page + FHIR). mg/dL users stay byte-identical because
// the conversion is skipped entirely.
const recent = buildDailyValueRows(rows, recentCutoff, userTz).map((d) =>
glucoseUnit === "mmol/L"
? { ...d, value: convertGlucose(d.value, glucoseUnit) }
: d,
);
const weekly = bucketWeekly(
rows.filter((r) => r.measuredAt < recentCutoff),
userTz,
).map((w) =>
glucoseUnit === "mmol/L"
? { ...w, mean: convertGlucose(w.mean, glucoseUnit) }
: w,
);
contexts[ctx] = { recent, weekly };
}
snapshot.glucose = { byContext: contexts };
// The display unit travels with the block so the prompt renders
// "<value> <unit>" and the EVIDENCE BLOCK tags glucose lines correctly.
snapshot.glucose = { unit: glucoseUnit, byContext: contexts };
metrics.add("glucose");
counts.glucose = glucoseRows.length;
registerBlock("glucose", "glucose");
+3 -2
View File
@@ -213,7 +213,8 @@ ISO-week means.
- The SNAPSHOT is ADDITIVE. Beyond blood pressure, weight, pulse,
mood, and medication, it MAY carry body-composition, activity,
workouts, sleep (with per-stage minutes), glucose (split by
context: fasting / postprandial / random / bedtime), mobility &
context: fasting / postprandial / random / bedtime, carrying its
own display "unit" of mg/dL or mmol/L), mobility &
gait, and environmental-exposure blocks — but only when the user
enabled the cluster AND has data. A block that is absent means the
user has no such data or did not share it; never invent or assume a
@@ -257,7 +258,7 @@ Rules:
or a day-pin ("Tue 6 May").
- "value" is a pre-formatted display string ("138/85", "84.2",
"4.1"). Format mood as N/5 when numeric.
- "unit" is one of mmHg, kg, bpm, /5, %.
- "unit" is one of mmHg, kg, bpm, /5, %, mg/dL, mmol/L.
- "window" is one of last7days, last30days, last90days, allTime;
omit it for day-level pins.
- Omit the entire block (no ---KEYVALUES--- line, no ---END--- line)