mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(coach): carry glucose in the user's display unit
This commit is contained in:
1 parent
55f4406247
commit
69e72c7fcc
3 files changed
+77
-10
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