mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in new issue
Block a user