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

+1 -1
View File
@@ -2791,7 +2791,7 @@
"title": "Blutzucker",
"description": "Deine Blutzuckerwerte im Zeitverlauf, in mg/dL.",
"chartTitle": "Blutzucker",
"medianLabel": "Median Blutzucker (90 Tage)",
"medianLabel": "Median Blutzucker",
"emptyState": {
"title": "Noch keine Glukosedaten",
"description": "Blutzuckerwerte erscheinen hier, sobald du einen erfasst oder ein Gerät synchronisiert."
+1 -1
View File
@@ -2791,7 +2791,7 @@
"title": "Blood Glucose",
"description": "Your blood-glucose readings over time, in mg/dL.",
"chartTitle": "Blood glucose",
"medianLabel": "Median glucose (90 days)",
"medianLabel": "Median glucose",
"emptyState": {
"title": "No glucose data yet",
"description": "Blood-glucose readings appear here once you log one or a connected device syncs."
+1 -1
View File
@@ -2791,7 +2791,7 @@
"title": "Glucosa en sangre",
"description": "Tus lecturas de glucosa en el tiempo, en mg/dL.",
"chartTitle": "Glucosa en sangre",
"medianLabel": "Glucosa mediana (90 días)",
"medianLabel": "Glucosa mediana",
"emptyState": {
"title": "Aún no hay datos de glucosa",
"description": "Las lecturas de glucosa aparecen aquí cuando registras una o un dispositivo sincroniza."
+1 -1
View File
@@ -2791,7 +2791,7 @@
"title": "Glycémie",
"description": "Vos relevés de glycémie au fil du temps, en mg/dL.",
"chartTitle": "Glycémie",
"medianLabel": "Glycémie médiane (90 jours)",
"medianLabel": "Glycémie médiane",
"emptyState": {
"title": "Aucune donnée de glycémie",
"description": "Les relevés de glycémie apparaissent ici dès que vous en saisissez un ou qu’un appareil se synchronise."
+1 -1
View File
@@ -2791,7 +2791,7 @@
"title": "Glicemia",
"description": "Le tue letture di glicemia nel tempo, in mg/dL.",
"chartTitle": "Glicemia",
"medianLabel": "Glicemia mediana (90 giorni)",
"medianLabel": "Glicemia mediana",
"emptyState": {
"title": "Nessun dato di glicemia",
"description": "Le letture di glicemia appaiono qui quando ne registri una o un dispositivo si sincronizza."
+1 -1
View File
@@ -2791,7 +2791,7 @@
"title": "Poziom glukozy",
"description": "Twoje odczyty glukozy w czasie, w mg/dL.",
"chartTitle": "Poziom glukozy",
"medianLabel": "Mediana glukozy (90 dni)",
"medianLabel": "Mediana glukozy",
"emptyState": {
"title": "Brak danych o glukozie",
"description": "Odczyty glukozy pojawią się tutaj po zapisaniu jednego lub synchronizacji urządzenia."
@@ -222,6 +222,43 @@ describe("GET /api/measurements/series", () => {
expect(body.data.stats.max).toBe(7);
});
it("computes mmol/L stats over raw mg/dL then converts once (v1.16.16 parity)", async () => {
// Parity with the detail page + FHIR convention: aggregate the raw mg/dL
// values, then convert each stat figure ONCE. Deriving the mean from the
// already-converted points (5.5, 7.0 → 6.25) double-rounds; the raw path
// (mean 113 mg/dL → 6.3 mmol/L) matches the detail page.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "g1", value: 100, measuredAt: new Date("2026-06-01T08:00:00Z") },
{ id: "g2", value: 126, measuredAt: new Date("2026-06-02T08:00:00Z") },
] as never);
const res = await GET(req("kind=glucose&days=30"));
const body = (await res.json()) as {
data: { stats: { mean: number } };
};
// mean(100,126) = 113 mg/dL → 6.3 mmol/L (NOT mean(5.5,7.0) = 6.25).
expect(body.data.stats.mean).toBe(6.3);
});
it("keeps a single mmol/L reading's stats identical to its point (v1.16.16)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "g1", value: 100, measuredAt: new Date("2026-06-01T08:00:00Z") },
] as never);
const res = await GET(req("kind=glucose&days=30"));
const body = (await res.json()) as {
data: { points: Array<{ value: number }>; stats: { mean: number } };
};
expect(body.data.points[0].value).toBe(5.5);
expect(body.data.stats.mean).toBe(5.5);
});
it("defaults glucose to mg/dL when the user has no preference (v1.16.16)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
+54 -9
View File
@@ -189,6 +189,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
let unit = SERIES_UNIT[kind];
let points: SeriesPoint[] = [];
// v1.16.16 — when glucose stats are computed over RAW mg/dL (parity with the
// detail page + FHIR), the branch fills this so the response below skips the
// generic over-points path that would double-round mmol/L figures.
let glucoseStats: {
mean: number;
min: number;
max: number;
stdDev: number;
count: number;
} | null = null;
if (kind === "sleep") {
// SLEEP_DURATION is stored one row per STAGE per night (minutes).
// Collapse the stage rows into ONE point per night carrying the
@@ -326,6 +336,39 @@ export const GET = apiHandler(async (request: NextRequest) => {
value: convertGlucose(r.value, glucoseUnit),
secondary: null,
}));
// v1.16.16 — stat parity. The stat strip's mean/min/max/stdDev must
// match the detail-page + FHIR convention: aggregate over RAW mg/dL,
// then convert each resulting figure ONCE. Deriving the stats from the
// already-converted+rounded points double-rounds the mean and stdDev
// for mmol/L users, drifting the last decimal off the detail page.
// Single-reading sets stay identical (100 → 5.5). Hand back stats here
// so the shared block below leaves a populated `glucoseStats` alone.
const rawDataPoints: DataPoint[] = rows.map((r) => ({
date: r.measuredAt,
value: r.value,
}));
if (rawDataPoints.length > 0) {
const rawSummary = summarize(rawDataPoints);
const rawValues = rawDataPoints.map((p) => p.value);
glucoseStats = {
mean:
rawSummary.mean === null
? 0
: convertGlucose(rawSummary.mean, glucoseUnit),
min:
rawSummary.min === null
? 0
: convertGlucose(rawSummary.min, glucoseUnit),
max:
rawSummary.max === null
? 0
: convertGlucose(rawSummary.max, glucoseUnit),
// Glucose mg/dL↔mmol/L is purely multiplicative (no offset), so a
// spread converts with the same factor as a level.
stdDev: convertGlucose(stdDev(rawValues), glucoseUnit),
count: rawSummary.count,
};
}
} else {
points = rows.map((r) => ({
id: r.id,
@@ -352,14 +395,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
kind,
unit,
points,
stats: summary
? {
mean: summary.mean,
min: summary.min,
max: summary.max,
stdDev: stdDev(values),
count: summary.count,
}
: { mean: 0, min: 0, max: 0, stdDev: 0, count: 0 },
stats:
glucoseStats ??
(summary
? {
mean: summary.mean,
min: summary.min,
max: summary.max,
stdDev: stdDev(values),
count: summary.count,
}
: { mean: 0, min: 0, max: 0, stdDev: 0, count: 0 }),
});
});
@@ -75,17 +75,17 @@ describe("<MetricStatStrip>", () => {
expect(html).toContain("Median");
});
// v1.16.16 — glucose declares the trailing-90-day p50 window in the label
// so the median is not read as an all-time central value.
// v1.16.16 — the median cell follows the chart's active range, so the label
// names the metric without asserting a fixed window it can't honor.
it("renders an overridden median label when medianLabel is provided", () => {
const html = render(
<MetricStatStrip
summary={populated}
unit="mg/dL"
medianLabel="Median glucose (90 days)"
medianLabel="Median glucose"
/>,
);
expect(html).toContain("Median glucose (90 days)");
expect(html).toContain("Median glucose");
});
it("honours fractionDigits for integer-unit metrics (0 decimals)", () => {
@@ -93,11 +93,12 @@ export interface MetricStatSeries {
*/
dataKey?: string;
/**
* v1.16.16 — optional override for the "Median" cell label. The strip's
* median is a trailing-90-day p50 (see `DataSummary.median` /
* `summaries-slice.ts`); the default generic "Median" label leaves that
* window undeclared. Blood glucose passes a window- + context-declaring
* string (e.g. "Median glucose (90 days)") so the number is not read as an
* v1.16.16 — optional override for the "Median" cell label. The median
* cell follows the chart's ACTIVE range (`windowStats`) when a range is
* selected and only falls back to the full-range `summary` p50 otherwise,
* so the label must NOT assert a fixed day-count it can't honor. Blood
* glucose passes a metric-naming string (e.g. "Median glucose") so the
* number reads as that metric's median for the visible range, not an
* all-time central value. Omit it to keep the generic label.
*/
medianLabel?: string;
@@ -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)