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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:16:48 +02:00
1 parent a2260d5bb8
commit 25944ed57a
17 files changed
+398 -26

No files matched your search

@@ -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", () => {
+18 -8
View File
@@ -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,
}),
);
@@ -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
+33 -6
View File
@@ -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<z.infer<typeof kindEnum>, 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<z.infer<typeof kindEnum>, 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) => ({