mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
fix(glucose): carry the unit into the coach and align stat rounding
Convert the glucose figures the coach snapshot builds to the user's unit and carry the unit into the prompt, so the coach speaks mmol/L to a mmol/L user instead of a bare mg/dL number — the one glucose surface the unit work had not reached. Aggregate the series stats over raw values and convert each figure once, matching the detail page and the FHIR export so a multi-reading mean no longer differs in the last decimal between surfaces. Drop the fixed day count from the median label, which the windowed median value cannot honour.
This commit is contained in:
commit
901faaa096
13 files changed
+184
-34
No files matched your search
@@ -236,6 +236,51 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () =>
|
||||
expect(out.provenance.metrics).toContain("glucose");
|
||||
});
|
||||
|
||||
it("carries glucose in the user's display unit (mmol/L) — v1.16.16", async () => {
|
||||
// A mmol/L-preference user's Coach must read 5.5, not the stored 100.
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: null,
|
||||
glucoseUnit: "mmol/L",
|
||||
});
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
{ ...daysAgo(1, 100, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" },
|
||||
]);
|
||||
const out = await buildCoachSnapshot("user-1", {
|
||||
sources: ["glucose"],
|
||||
window: "last30days",
|
||||
});
|
||||
const snapshot = JSON.parse(out.snapshotJson) as {
|
||||
glucose?: {
|
||||
unit?: string;
|
||||
byContext?: Record<string, { recent?: Array<{ value: number }> }>;
|
||||
};
|
||||
};
|
||||
expect(snapshot.glucose?.unit).toBe("mmol/L");
|
||||
expect(snapshot.glucose?.byContext?.fasting?.recent?.[0]?.value).toBe(5.5);
|
||||
});
|
||||
|
||||
it("keeps mg/dL glucose values byte-identical with a unit token — v1.16.16", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: null,
|
||||
glucoseUnit: "mg/dL",
|
||||
});
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
{ ...daysAgo(1, 100, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" },
|
||||
]);
|
||||
const out = await buildCoachSnapshot("user-1", {
|
||||
sources: ["glucose"],
|
||||
window: "last30days",
|
||||
});
|
||||
const snapshot = JSON.parse(out.snapshotJson) as {
|
||||
glucose?: {
|
||||
unit?: string;
|
||||
byContext?: Record<string, { recent?: Array<{ value: number }> }>;
|
||||
};
|
||||
};
|
||||
expect(snapshot.glucose?.unit).toBe("mg/dL");
|
||||
expect(snapshot.glucose?.byContext?.fasting?.recent?.[0]?.value).toBe(100);
|
||||
});
|
||||
|
||||
it("caps the workouts block and rolls up per sport", async () => {
|
||||
const workouts = Array.from({ length: 25 }, (_, i) => ({
|
||||
sportType: i % 2 === 0 ? "RUNNING" : "CYCLING",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type CoachDataCluster,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
@@ -481,6 +482,10 @@ async function buildCoachSnapshotImpl(
|
||||
// both columns here so a non-cycle account pays no extra round-trip.
|
||||
gender: true,
|
||||
cycleProfile: { select: { cycleTrackingEnabled: true } },
|
||||
// v1.16.16 — the glucose block converts canonical mg/dL to the user's
|
||||
// display unit so the Coach reads the same number every other surface
|
||||
// shows. Read it on this existing prefs hop (no extra round-trip).
|
||||
glucoseUnit: true,
|
||||
},
|
||||
});
|
||||
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
|
||||
@@ -494,6 +499,7 @@ async function buildCoachSnapshotImpl(
|
||||
clusterDefault,
|
||||
);
|
||||
const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE;
|
||||
const glucoseUnit = resolveGlucoseUnit(prefsRow?.glucoseUnit ?? null);
|
||||
const excluded = new Set<CoachExcludeMetric>(prefs.excludeMetrics);
|
||||
// v1.4.36 W3 T2 — `medications` and `anthropometrics` are
|
||||
// exclude-only toggles (not in `CoachScopeSource`); they gate the
|
||||
@@ -1317,15 +1323,30 @@ async function buildCoachSnapshotImpl(
|
||||
}
|
||||
const contexts: Record<string, unknown> = {};
|
||||
for (const [ctx, rows] of byContext) {
|
||||
contexts[ctx] = {
|
||||
recent: buildDailyValueRows(rows, recentCutoff, userTz),
|
||||
weekly: bucketWeekly(
|
||||
rows.filter((r) => r.measuredAt < recentCutoff),
|
||||
userTz,
|
||||
),
|
||||
};
|
||||
// v1.16.16 — glucose is stored canonical mg/dL. A mmol/L-preference
|
||||
// user's Coach must read the same number every other surface shows
|
||||
// (5.5, not 100). Aggregate the per-day / weekly means in raw mg/dL,
|
||||
// then convert each resulting figure ONCE (parity with the series
|
||||
// DTO + detail page + FHIR). mg/dL users stay byte-identical because
|
||||
// the conversion is skipped entirely.
|
||||
const recent = buildDailyValueRows(rows, recentCutoff, userTz).map((d) =>
|
||||
glucoseUnit === "mmol/L"
|
||||
? { ...d, value: convertGlucose(d.value, glucoseUnit) }
|
||||
: d,
|
||||
);
|
||||
const weekly = bucketWeekly(
|
||||
rows.filter((r) => r.measuredAt < recentCutoff),
|
||||
userTz,
|
||||
).map((w) =>
|
||||
glucoseUnit === "mmol/L"
|
||||
? { ...w, mean: convertGlucose(w.mean, glucoseUnit) }
|
||||
: w,
|
||||
);
|
||||
contexts[ctx] = { recent, weekly };
|
||||
}
|
||||
snapshot.glucose = { byContext: contexts };
|
||||
// The display unit travels with the block so the prompt renders
|
||||
// "<value> <unit>" and the EVIDENCE BLOCK tags glucose lines correctly.
|
||||
snapshot.glucose = { unit: glucoseUnit, byContext: contexts };
|
||||
metrics.add("glucose");
|
||||
counts.glucose = glucoseRows.length;
|
||||
registerBlock("glucose", "glucose");
|
||||
|
||||
@@ -213,7 +213,8 @@ ISO-week means.
|
||||
- The SNAPSHOT is ADDITIVE. Beyond blood pressure, weight, pulse,
|
||||
mood, and medication, it MAY carry body-composition, activity,
|
||||
workouts, sleep (with per-stage minutes), glucose (split by
|
||||
context: fasting / postprandial / random / bedtime), mobility &
|
||||
context: fasting / postprandial / random / bedtime, carrying its
|
||||
own display "unit" of mg/dL or mmol/L), mobility &
|
||||
gait, and environmental-exposure blocks — but only when the user
|
||||
enabled the cluster AND has data. A block that is absent means the
|
||||
user has no such data or did not share it; never invent or assume a
|
||||
@@ -257,7 +258,7 @@ Rules:
|
||||
or a day-pin ("Tue 6 May").
|
||||
- "value" is a pre-formatted display string ("138/85", "84.2",
|
||||
"4.1"). Format mood as N/5 when numeric.
|
||||
- "unit" is one of mmHg, kg, bpm, /5, %.
|
||||
- "unit" is one of mmHg, kg, bpm, /5, %, mg/dL, mmol/L.
|
||||
- "window" is one of last7days, last30days, last90days, allTime;
|
||||
omit it for day-level pins.
|
||||
- Omit the entire block (no ---KEYVALUES--- line, no ---END--- line)
|
||||
|
||||
Reference in new issue
Block a user