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