fix(glucose): compute series stats over raw mg/dL then convert once

This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:42:33 +02:00
1 parent 555240cd28
commit 55f4406247
2 files changed
+91 -9

No files matched your search

@@ -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({
+54 -9
View File
@@ -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 }),
});
});