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 }), }); });