mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(glucose): compute series stats over raw mg/dL then convert once
This commit is contained in:
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({
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user