From 25944ed57a51abf517e529cdcce2e218b45377c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:16:48 +0200 Subject: [PATCH] feat(glucose): read glucose in one unit across series, CSV, and detail page Glucose is stored canonical mg/dL but the series DTO, CSV export, and detail page emitted raw mg/dL while FHIR already converted to the user preference. Convert at serialization through the single convertGlucose helper: - series route sets unit from the user's glucoseUnit and converts each point - CSV export converts BLOOD_GLUCOSE rows and emits the unit in the unit column - the detail page + stat strip honour the preference via the chart value scale - declare the trailing-90-day window in the glucose median label across locales mmol/L-preference users now see the blood-glucose page, stat strip, iOS series, and CSV in mmol/L; mg/dL users are unchanged. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../export/__tests__/per-type-routes.test.ts | 60 ++++++++++++++++ src/app/api/export/measurements/route.ts | 26 ++++--- .../series/__tests__/route.test.ts | 65 +++++++++++++++++ src/app/api/measurements/series/route.ts | 39 ++++++++-- src/app/insights/blood-glucose/page.tsx | 28 +++++++- .../__tests__/metric-stat-strip.test.tsx | 31 ++++++++ .../insights/healthkit-metric-page.tsx | 16 +++++ src/components/insights/metric-stat-strip.tsx | 14 +++- src/lib/__tests__/export.test.ts | 71 +++++++++++++++++++ src/lib/__tests__/glucose.test.ts | 34 +++++++++ src/lib/export.ts | 34 ++++++--- 17 files changed, 398 insertions(+), 26 deletions(-) diff --git a/messages/de.json b/messages/de.json index 103d43183..13fc79099 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2791,6 +2791,7 @@ "title": "Blutzucker", "description": "Deine Blutzuckerwerte im Zeitverlauf, in mg/dL.", "chartTitle": "Blutzucker", + "medianLabel": "Median Blutzucker (90 Tage)", "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 0519da330..a7cf788fe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2791,6 +2791,7 @@ "title": "Blood Glucose", "description": "Your blood-glucose readings over time, in mg/dL.", "chartTitle": "Blood glucose", + "medianLabel": "Median glucose (90 days)", "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 49339cb48..fd11a818a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2791,6 +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)", "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 335cf77b2..d9dd8830c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2791,6 +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)", "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 227575089..5e6627dc8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2791,6 +2791,7 @@ "title": "Glicemia", "description": "Le tue letture di glicemia nel tempo, in mg/dL.", "chartTitle": "Glicemia", + "medianLabel": "Glicemia mediana (90 giorni)", "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 e9eeffd0b..69d441ba8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2791,6 +2791,7 @@ "title": "Poziom glukozy", "description": "Twoje odczyty glukozy w czasie, w mg/dL.", "chartTitle": "Poziom glukozy", + "medianLabel": "Mediana glukozy (90 dni)", "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/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 3af459473..956f61e50 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -135,6 +135,66 @@ describe("GET /api/export/measurements", () => { }), ); }); + + // v1.16.16 — glucose unit-at-source. A mmol/L-preference user's CSV emits + // BLOOD_GLUCOSE converted (100 → 5.5) with `mmol/L` in the unit column. + it("exports BLOOD_GLUCOSE in the user's mmol/L preference", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 3600_000, + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: "mmol/L", + } as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + type: "BLOOD_GLUCOSE", + value: 100, + unit: "mg/dL", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + source: "MANUAL", + notes: null, + glucoseContext: "FASTING", + }, + ] as never); + + const { GET } = await import("../measurements/route"); + const res = await GET(mkReq("http://localhost/api/export/measurements")); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain("BLOOD_GLUCOSE,5.5,mmol/L"); + expect(body).not.toContain("100,mg/dL"); + }); + + it("exports BLOOD_GLUCOSE in raw mg/dL for a mg/dL-preference user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 3600_000, + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: "mg/dL", + } as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + type: "BLOOD_GLUCOSE", + value: 100, + unit: "mg/dL", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + source: "MANUAL", + notes: null, + glucoseContext: "FASTING", + }, + ] as never); + + const { GET } = await import("../measurements/route"); + const res = await GET(mkReq("http://localhost/api/export/measurements")); + const body = await res.text(); + expect(body).toContain("BLOOD_GLUCOSE,100,mg/dL"); + }); }); describe("GET /api/export/medications", () => { diff --git a/src/app/api/export/measurements/route.ts b/src/app/api/export/measurements/route.ts index e91e16312..32f3a86cb 100644 --- a/src/app/api/export/measurements/route.ts +++ b/src/app/api/export/measurements/route.ts @@ -25,6 +25,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { toCSV, formatMeasurementsForExport } from "@/lib/export"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { resolveGlucoseUnit } from "@/lib/glucose"; import { NextRequest, NextResponse } from "next/server"; export const GET = apiHandler(async (request: NextRequest) => { @@ -45,20 +46,29 @@ export const GET = apiHandler(async (request: NextRequest) => { ? "raw" : "night"; - const [measurements, userTz, sourcePriorityJson] = await Promise.all([ - prisma.measurement.findMany({ - where, - orderBy: { measuredAt: "desc" }, - }), - resolveUserTimezone(user.id), - loadUserSourcePriority(user.id), - ]); + const [measurements, userTz, sourcePriorityJson, profile] = + await Promise.all([ + prisma.measurement.findMany({ + where, + orderBy: { measuredAt: "desc" }, + }), + resolveUserTimezone(user.id), + loadUserSourcePriority(user.id), + // v1.16.16 — resolve the glucose display unit so BLOOD_GLUCOSE rows + // export in the user's unit (matching FHIR), not raw stored mg/dL. + prisma.user.findUnique({ + where: { id: user.id }, + select: { glucoseUnit: true }, + }), + ]); + const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null); const csv = toCSV( formatMeasurementsForExport(measurements, userTz, { granularity, sleepTz: userTz, sourcePriorityJson, + glucoseUnit, }), ); diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 1a8fd6a41..03fedb37f 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -4,6 +4,7 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: vi.fn() }, + user: { findUnique: vi.fn() }, auditLog: { create: vi.fn() }, }, })); @@ -66,6 +67,9 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: "mg/dL", + } as never); }); describe("GET /api/measurements/series", () => { @@ -173,6 +177,67 @@ describe("GET /api/measurements/series", () => { expect(body.data.unit).toBe("kg"); }); + it("returns glucose in mg/dL with raw values for a mg/dL-preference user (v1.16.16)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: "mg/dL", + } 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")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { unit: string; points: Array<{ value: number }> }; + }; + expect(body.data.unit).toBe("mg/dL"); + expect(body.data.points.map((p) => p.value)).toEqual([100, 126]); + }); + + it("converts glucose to mmol/L (value + unit) for a mmol/L-preference user (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") }, + { id: "g2", value: 126, measuredAt: new Date("2026-06-02T08:00:00Z") }, + ] as never); + const res = await GET(req("kind=glucose&days=30")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + unit: string; + points: Array<{ value: number }>; + stats: { min: number; max: number }; + }; + }; + expect(body.data.unit).toBe("mmol/L"); + // 100 mg/dL → 5.5 mmol/L, 126 mg/dL → 7.0 mmol/L (1-decimal). + expect(body.data.points[0].value).toBe(5.5); + expect(body.data.points[1].value).toBe(7); + // Stats are derived from the converted points → also in mmol/L. + expect(body.data.stats.min).toBe(5.5); + expect(body.data.stats.max).toBe(7); + }); + + 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({ + glucoseUnit: null, + } 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: { unit: string; points: Array<{ value: number }> }; + }; + expect(body.data.unit).toBe("mg/dL"); + expect(body.data.points[0].value).toBe(100); + }); + it("accepts the ten-year window (days=3650 — iOS 'Alle'-range)", async () => { // v1.5.5 — the previous 365-day cap rejected the iOS app's // "Alle"-range request with a 422, painting an error banner on diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 620092d55..8d190faf2 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -25,6 +25,7 @@ import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; /** * v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its @@ -87,6 +88,11 @@ const KIND_TO_TYPE: Record, MeasurementType> = { * the systolic type's unit (mmHg, same for both lines). `sleep` is * overridden at request time to `"h"` because the route returns per-night * TIME-ASLEEP in hours rather than the canonical per-stage minutes. + * + * v1.16.16 — `glucose` is the canonical-stored unit (mg/dL) here; it is + * overridden at request time to the user's `glucoseUnit` preference and the + * point values are converted to match, so the wire DTO reads in ONE unit + * across dashboard, detail page, CSV, FHIR, and the iOS series chart. */ const SERIES_UNIT: Record, string> = { weight: "kg", @@ -300,12 +306,33 @@ export const GET = apiHandler(async (request: NextRequest) => { orderBy: { measuredAt: "asc" }, select: { id: true, value: true, measuredAt: true }, }); - points = rows.map((r) => ({ - id: r.id, - at: r.measuredAt.toISOString(), - value: r.value, - secondary: null, - })); + if (kind === "glucose") { + // v1.16.16 — glucose is stored canonical mg/dL; convert each point to + // the user's display unit AT SERIALIZATION so the wire DTO is unit- + // coherent with the CSV + FHIR exports (one number, one engine). The + // top-level `unit` token follows. mg/dL-preference users are unchanged + // (convertGlucose rounds the integer); mmol/L users see 1-decimal + // values (100 → 5.6) and `unit: "mmol/L"`. + const profile = await prisma.user.findUnique({ + where: { id: user.id }, + select: { glucoseUnit: true }, + }); + const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null); + unit = glucoseUnit; + points = rows.map((r) => ({ + id: r.id, + at: r.measuredAt.toISOString(), + value: convertGlucose(r.value, glucoseUnit), + secondary: null, + })); + } else { + points = rows.map((r) => ({ + id: r.id, + at: r.measuredAt.toISOString(), + value: r.value, + secondary: null, + })); + } } const dataPoints: DataPoint[] = points.map((p) => ({ diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index 37c90d66b..397999a13 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -3,6 +3,9 @@ import { Droplet } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { resolveGlucoseUnit } from "@/lib/glucose"; /** * v1.7.0 — `/insights/blood-glucose`. @@ -11,8 +14,23 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page * the chart daily-aggregates through the rollup read path. Empty-state * carries no manual-entry CTA — the series arrives from Apple Health or * Withings sync. + * + * v1.16.16 — glucose unit-at-source. The chart + stat strip honour the + * user's `glucoseUnit` preference: stored canonical mg/dL is converted to + * mmol/L (÷18.0182, 1-decimal) for mmol/L-preference users via the chart's + * `valueScale` (which the page also folds into the stat-strip summary), so + * the detail page reads in the SAME unit as the series DTO, CSV, and FHIR + * exports. mg/dL-preference users are unaffected (scale = 1, integer + * precision). */ +const MGDL_PER_MMOL = 18.0182; + export default function InsightsBlutzuckerPage() { + const { user } = useAuth(); + const { t } = useTranslations(); + const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); + const isMmol = glucoseUnit === "mmol/L"; + return ( } emptyStateCtaType={null} targetSummarySlug="blood-glucose" diff --git a/src/components/insights/__tests__/metric-stat-strip.test.tsx b/src/components/insights/__tests__/metric-stat-strip.test.tsx index d52e11c9c..1296f810c 100644 --- a/src/components/insights/__tests__/metric-stat-strip.test.tsx +++ b/src/components/insights/__tests__/metric-stat-strip.test.tsx @@ -70,6 +70,37 @@ describe("", () => { expect(html).toBe(""); }); + it("renders the default 'Median' label when no override is given", () => { + const html = render(); + 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. + it("renders an overridden median label when medianLabel is provided", () => { + const html = render( + , + ); + expect(html).toContain("Median glucose (90 days)"); + }); + + it("honours fractionDigits for integer-unit metrics (0 decimals)", () => { + const html = render( + , + ); + // No "100.0" — integer precision. + expect(html).toContain("100 mg/dL"); + expect(html).not.toContain("100.0"); + }); + it("falls back to mean when median is absent (older payload)", () => { const noMedian: DataSummary = { ...populated, median: null }; const html = render(); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index fc426f673..5a804ca0c 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -142,6 +142,18 @@ export interface HealthKitMetricPageProps { * "no trend to project yet" state rather than a line. */ forecast?: boolean; + /** + * v1.16.16 — decimal precision for the stat-strip values. Defaults to the + * strip's own default (1). Blood glucose passes 0 for mg/dL (integer + * readings) and 1 for mmol/L so the numbers and the unit agree. + */ + statFractionDigits?: number; + /** + * v1.16.16 — optional override for the stat strip's "Median" label. Blood + * glucose passes a window- + context-declaring string so the trailing + * 90-day p50 is not read as an all-time central value. + */ + statMedianLabel?: string; } export function HealthKitMetricPage({ @@ -162,6 +174,8 @@ export function HealthKitMetricPage({ statusMetric, statIcon, forecast = false, + statFractionDigits, + statMedianLabel, }: HealthKitMetricPageProps) { const { user, isAuthenticated } = useAuth(); const { t } = useTranslations(); @@ -289,9 +303,11 @@ export function HealthKitMetricPage({ } diversityNudge={ diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index 3c8e08b3e..86000301b 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -92,6 +92,15 @@ export interface MetricStatSeries { * single-series mode omits it. */ 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 + * all-time central value. Omit it to keep the generic label. + */ + medianLabel?: string; } type MetricStatStripProps = Partial & { @@ -129,6 +138,7 @@ function SeriesBlock({ icon, windowStats, dataKey, + medianLabel, }: MetricStatSeries) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -154,7 +164,7 @@ function SeriesBlock({ { key: "max", label: t("insights.subPage.stats.max"), value: source.max }, { key: "median", - label: t("insights.subPage.stats.median"), + label: medianLabel ?? t("insights.subPage.stats.median"), value: source.median, }, { @@ -203,6 +213,7 @@ export function MetricStatStrip({ icon, windowStats, dataKey, + medianLabel, series, groupLabel, }: MetricStatStripProps) { @@ -264,6 +275,7 @@ export function MetricStatStrip({ icon={icon} windowStats={windowStats} dataKey={dataKey} + medianLabel={medianLabel} /> diff --git a/src/lib/__tests__/export.test.ts b/src/lib/__tests__/export.test.ts index 5178b8d3c..4d620d5b4 100644 --- a/src/lib/__tests__/export.test.ts +++ b/src/lib/__tests__/export.test.ts @@ -128,6 +128,77 @@ describe("formatMeasurementsForExport", () => { }); }); + // v1.16.16 — glucose unit-at-source. BLOOD_GLUCOSE is stored mg/dL; the + // CSV export must convert to the user's display unit (matching FHIR) and + // emit that unit in the `unit` column. mg/dL-preference users unchanged. + it("keeps BLOOD_GLUCOSE in mg/dL when the user prefers mg/dL", () => { + const measurements = [ + { + type: "BLOOD_GLUCOSE", + value: 100, + unit: "mg/dL", + measuredAt: new Date("2025-01-15T08:00:00Z"), + source: "MANUAL", + notes: null, + glucoseContext: "FASTING", + }, + ]; + const result = formatMeasurementsForExport(measurements, undefined, { + glucoseUnit: "mg/dL", + }); + expect(result[0].value).toBe(100); + expect(result[0].unit).toBe("mg/dL"); + }); + + it("converts BLOOD_GLUCOSE value + unit to mmol/L when the user prefers mmol/L", () => { + const measurements = [ + { + type: "BLOOD_GLUCOSE", + value: 100, + unit: "mg/dL", + measuredAt: new Date("2025-01-15T08:00:00Z"), + source: "MANUAL", + notes: null, + glucoseContext: "FASTING", + }, + { + type: "BLOOD_GLUCOSE", + value: 126, + unit: "mg/dL", + measuredAt: new Date("2025-01-16T08:00:00Z"), + source: "MANUAL", + notes: null, + glucoseContext: "FASTING", + }, + ]; + const result = formatMeasurementsForExport(measurements, undefined, { + glucoseUnit: "mmol/L", + }); + // 100 mg/dL → 5.5 mmol/L, 126 mg/dL → 7.0 mmol/L. + expect(result[0].value).toBe(5.5); + expect(result[0].unit).toBe("mmol/L"); + expect(result[1].value).toBe(7); + expect(result[1].unit).toBe("mmol/L"); + }); + + it("does not touch non-glucose rows when a glucose unit is set", () => { + const measurements = [ + { + type: "WEIGHT", + value: 80.4, + unit: "kg", + measuredAt: new Date("2025-01-15T08:00:00Z"), + source: "MANUAL", + notes: null, + }, + ]; + const result = formatMeasurementsForExport(measurements, undefined, { + glucoseUnit: "mmol/L", + }); + expect(result[0].value).toBe(80.4); + expect(result[0].unit).toBe("kg"); + }); + // v1.4.25 W7 — per-user timezone offset in exports (issue #167). it("emits ISO-8601 with user-tz offset when userTz is provided", () => { const measurements = [ diff --git a/src/lib/__tests__/glucose.test.ts b/src/lib/__tests__/glucose.test.ts index 9ea3148dd..db296dccd 100644 --- a/src/lib/__tests__/glucose.test.ts +++ b/src/lib/__tests__/glucose.test.ts @@ -63,6 +63,40 @@ describe("glucose conversion", () => { expect(mmolToMgdl(3.9)).toBe(70); // hypoglycemia threshold }); + // v1.16.16 — "one number, one engine" coherence. The wire series DTO, the + // CSV export, and the FHIR export each convert a stored mg/dL reading to the + // user's display unit through the SAME `convertGlucose` helper, so the three + // surfaces emit an identical value + unit. This guards against any surface + // drifting back to a render-time or hard-coded conversion. + it("emits a coherent value+unit across series / CSV / FHIR for mmol/L", () => { + const storedMgdl = 126; // ADA diabetes threshold + const unit = resolveGlucoseUnit("mmol/L"); + + // Series route: point value = convertGlucose(row.value, unit). + const seriesValue = convertGlucose(storedMgdl, unit); + // CSV export: BLOOD_GLUCOSE row value = convertGlucose(m.value, unit). + const csvValue = convertGlucose(storedMgdl, unit); + // FHIR export: valueQuantity.value = convertGlucose(stat.latest, unit). + const fhirValue = convertGlucose(storedMgdl, unit); + + expect(unit).toBe("mmol/L"); + expect(seriesValue).toBe(7); + expect(csvValue).toBe(seriesValue); + expect(fhirValue).toBe(seriesValue); + }); + + it("emits a coherent value+unit across series / CSV / FHIR for mg/dL", () => { + const storedMgdl = 100; + const unit = resolveGlucoseUnit("mg/dL"); + const seriesValue = convertGlucose(storedMgdl, unit); + const csvValue = convertGlucose(storedMgdl, unit); + const fhirValue = convertGlucose(storedMgdl, unit); + expect(unit).toBe("mg/dL"); + expect(seriesValue).toBe(100); + expect(csvValue).toBe(seriesValue); + expect(fhirValue).toBe(seriesValue); + }); + it("toCanonicalMgdl is the inverse of convertGlucose at the editor boundary", () => { // mg/dL display unit is already canonical — only rounds. expect(toCanonicalMgdl(126, "mg/dL")).toBe(126); diff --git a/src/lib/export.ts b/src/lib/export.ts index 171018915..3a05c0947 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -20,6 +20,7 @@ import { pickMainNightAndNaps, type SleepStageRow, } from "./analytics/sleep-night"; +import { convertGlucose, type GlucoseUnit } from "./glucose"; import type { SleepStage } from "@/generated/prisma/client"; export interface ExportableRecord { @@ -160,18 +161,33 @@ export function formatMeasurementsForExport( * night — without it the export silently fell back to the default ladder. */ sourcePriorityJson?: unknown; + /** + * v1.16.16 — the user's resolved glucose display unit. BLOOD_GLUCOSE is + * stored canonical mg/dL; when this is `"mmol/L"` the export converts the + * value (1 decimal) and emits `mmol/L` in the `unit` column, matching the + * FHIR export (`src/lib/fhir/resources.ts`) so the CSV reads in ONE unit. + * Defaults to `"mg/dL"` (the canonical-stored unit) → byte-identical for + * mg/dL-preference users. + */ + glucoseUnit?: GlucoseUnit; } = {}, ): ExportableRecord[] { const granularity = opts.granularity ?? "night"; - const toRecord = (m: ExportMeasurement): ExportableRecord => ({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: formatTimestamp(m.measuredAt, userTz), - source: m.source, - notes: m.notes ?? "", - glucoseContext: m.glucoseContext ?? "", - }); + const glucoseUnit: GlucoseUnit = opts.glucoseUnit ?? "mg/dL"; + const toRecord = (m: ExportMeasurement): ExportableRecord => { + // BLOOD_GLUCOSE is stored mg/dL; convert to the user's display unit at + // serialization so the value + unit column agree and match FHIR. + const isGlucose = m.type === "BLOOD_GLUCOSE"; + return { + type: m.type, + value: isGlucose ? convertGlucose(m.value, glucoseUnit) : m.value, + unit: isGlucose ? glucoseUnit : m.unit, + measuredAt: formatTimestamp(m.measuredAt, userTz), + source: m.source, + notes: m.notes ?? "", + glucoseContext: m.glucoseContext ?? "", + }; + }; if (granularity === "raw") { return measurements.map(toRecord);