diff --git a/messages/de.json b/messages/de.json index 13fc79099..cfea1b804 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." diff --git a/messages/en.json b/messages/en.json index a7cf788fe..0e147aab4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." diff --git a/messages/es.json b/messages/es.json index fd11a818a..ee2ea77c3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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." diff --git a/messages/fr.json b/messages/fr.json index d9dd8830c..54b3b084b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." diff --git a/messages/it.json b/messages/it.json index 5e6627dc8..04730edac 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." diff --git a/messages/pl.json b/messages/pl.json index 69d441ba8..e3915160a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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." diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 03fedb37f..8636f7356 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -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({ diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 87ab1b1a5..916f9a82e 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -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 }), }); }); diff --git a/src/components/insights/__tests__/metric-stat-strip.test.tsx b/src/components/insights/__tests__/metric-stat-strip.test.tsx index 1296f810c..c85632ecc 100644 --- a/src/components/insights/__tests__/metric-stat-strip.test.tsx +++ b/src/components/insights/__tests__/metric-stat-strip.test.tsx @@ -75,17 +75,17 @@ describe("", () => { 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( , ); - expect(html).toContain("Median glucose (90 days)"); + expect(html).toContain("Median glucose"); }); it("honours fractionDigits for integer-unit metrics (0 decimals)", () => { diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index 86000301b..2ad72e687 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -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; 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)