From 69e72c7fcc12bb0f9c89e0b21428cff2566b22c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:45:55 +0200 Subject: [PATCH] feat(coach): carry glucose in the user's display unit --- .../__tests__/snapshot-new-metrics.test.ts | 45 +++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 37 +++++++++++---- src/lib/ai/coach/system-prompt.ts | 5 ++- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index df2c12a89..64762479b 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -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 }>; + }; + }; + 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 }>; + }; + }; + 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", diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 970727e6c..c5a052a99 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -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(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 = {}; 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 + // " " and the EVIDENCE BLOCK tags glucose lines correctly. + snapshot.glucose = { unit: glucoseUnit, byContext: contexts }; metrics.add("glucose"); counts.glucose = glucoseRows.length; registerBlock("glucose", "glucose"); diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 32f981633..4298db3b8 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -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)