feat(coach): carry glucose in the user's display unit

This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:45:55 +02:00
1 parent 55f4406247
commit 69e72c7fcc
3 files changed
+77 -10

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)