diff --git a/src/lib/targets/__tests__/glucose-targets.test.ts b/src/lib/targets/__tests__/glucose-targets.test.ts new file mode 100644 index 000000000..1b2c10d1b --- /dev/null +++ b/src/lib/targets/__tests__/glucose-targets.test.ts @@ -0,0 +1,98 @@ +/** + * v1.18.6 — diabetes-aware glucose target resolver. + * + * Pins: flag OFF → general non-diabetic band; flag ON → tighter ADA goal band; + * a user override always wins over BOTH; the flag is consumed verbatim (never + * derived from a value). + */ +import { describe, expect, it } from "vitest"; +import { + resolveGlucoseTarget, + DIABETIC_GOAL_BANDS, +} from "../glucose-targets"; +import type { UserProfileForRange } from "@/lib/analytics/effective-range"; + +const profile: UserProfileForRange = { + heightCm: 178, + dateOfBirth: "1985-01-01", + gender: "MALE", +}; + +describe("resolveGlucoseTarget", () => { + it("uses the general non-diabetic band when the flag is OFF", () => { + const r = resolveGlucoseTarget({ + context: "FASTING", + hasDiabetes: false, + profile, + overrides: null, + }); + expect(r.source).toBe("default"); + expect(r.isOverride).toBe(false); + // General fasting normal band is 70–99 (non-diabetic). + expect(r.range?.greenMin).toBe(70); + expect(r.range?.greenMax).toBe(99); + }); + + it("uses the tighter ADA goal band when the flag is ON (fasting 80-130)", () => { + const r = resolveGlucoseTarget({ + context: "FASTING", + hasDiabetes: true, + profile, + overrides: null, + }); + expect(r.source).toBe("ADA goal (diabetes)"); + expect(r.range?.greenMin).toBe(DIABETIC_GOAL_BANDS.FASTING.min); + expect(r.range?.greenMax).toBe(DIABETIC_GOAL_BANDS.FASTING.max); + expect(r.range?.greenMin).toBe(80); + expect(r.range?.greenMax).toBe(130); + }); + + it("uses the ADA postprandial goal band (< 180) when the flag is ON", () => { + const r = resolveGlucoseTarget({ + context: "POSTPRANDIAL", + hasDiabetes: true, + profile, + overrides: null, + }); + expect(r.range?.greenMin).toBe(80); + expect(r.range?.greenMax).toBe(180); + }); + + it("lets a user override win over the diabetic goal band", () => { + const r = resolveGlucoseTarget({ + context: "FASTING", + hasDiabetes: true, + profile, + overrides: { BLOOD_GLUCOSE_FASTING: { min: 90, max: 120 } }, + }); + expect(r.source).toBe("custom"); + expect(r.isOverride).toBe(true); + expect(r.range?.greenMin).toBe(90); + expect(r.range?.greenMax).toBe(120); + }); + + it("lets a user override win over the general band (flag OFF)", () => { + const r = resolveGlucoseTarget({ + context: "FASTING", + hasDiabetes: false, + profile, + overrides: { BLOOD_GLUCOSE_FASTING: { min: 75, max: 105 } }, + }); + expect(r.source).toBe("custom"); + expect(r.range?.greenMin).toBe(75); + expect(r.range?.greenMax).toBe(105); + }); + + it("resolves the diabetic goal band identically for every context", () => { + for (const ctx of ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const) { + const r = resolveGlucoseTarget({ + context: ctx, + hasDiabetes: true, + profile, + overrides: null, + }); + expect(r.range?.greenMin).toBe(DIABETIC_GOAL_BANDS[ctx].min); + expect(r.range?.greenMax).toBe(DIABETIC_GOAL_BANDS[ctx].max); + } + }); +}); diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 7bae2df9d..1c2489df0 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -46,12 +46,10 @@ import type { MeasurementType, GlucoseContext, } from "@/generated/prisma/client"; -import { - getEffectiveRange, - type ThresholdOverridesJson, -} from "@/lib/analytics/effective-range"; +import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range"; import { getBodyFatTargetRange } from "@/lib/analytics/value-bands"; -import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose"; +import { resolveGlucoseUnit } from "@/lib/glucose"; +import { resolveGlucoseTarget } from "@/lib/targets/glucose-targets"; import { ensureUserMoodRollupsFresh, readMoodDayRollups, @@ -219,6 +217,9 @@ export async function buildTargetsResponse(user: AuthedUser) { dateOfBirth: true, gender: true, glucoseUnit: true, + // v1.18.6 — diabetes opt-in selects the tighter ADA glycemic goal + // band for glucose targets (resolved below via resolveGlucoseTarget). + hasDiabetes: true, thresholdsJson: true, // v1.11.5 — needed to collapse a dual-source sleep night to one // canonical source before reconstructing per-night asleep totals. @@ -1456,20 +1457,28 @@ export async function buildTargetsResponse(user: AuthedUser) { ) / 10 : null; - const metric = thresholdMetricForContext(ctx); - const eff = getEffectiveRange(metric, profileForRange, overrides); - const range = eff.range - ? { min: eff.range.greenMin, max: eff.range.greenMax } + // v1.18.6 — diabetes-aware target band. When the user has declared + // diabetes (and has NOT pinned a custom override) the tighter ADA glycemic + // GOAL band wins; otherwise the general non-diabetic band (or the override) + // is used exactly as before. The flag is never inferred from a value. + const resolved = resolveGlucoseTarget({ + context: ctx, + hasDiabetes: dbUser?.hasDiabetes ?? false, + profile: profileForRange, + overrides, + }); + const effRange = resolved.range; + const range = effRange + ? { min: effRange.greenMin, max: effRange.greenMax } : null; let classification: { category: string; color: string } | null = null; - if (range) { + if (range && effRange) { if (latest >= range.min && latest <= range.max) { classification = { category: "Optimal", color: "#50fa7b" }; } else if ( - eff.range && - latest >= eff.range.orangeMin && - latest <= eff.range.orangeMax + latest >= effRange.orangeMin && + latest <= effRange.orangeMax ) { classification = { category: "Elevated", color: "#f1fa8c" }; } else { @@ -1481,8 +1490,8 @@ export async function buildTargetsResponse(user: AuthedUser) { recent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })), makeRangeClassifier( range, - eff.range - ? { orangeMin: eff.range.orangeMin, orangeMax: eff.range.orangeMax } + effRange + ? { orangeMin: effRange.orangeMin, orangeMax: effRange.orangeMax } : undefined, ), ); @@ -1495,7 +1504,12 @@ export async function buildTargetsResponse(user: AuthedUser) { unit: glucoseUnit, range, classification, - source: eff.isOverride ? "Custom" : "ADA 2024 / DDG", + source: + resolved.source === "custom" + ? "Custom" + : resolved.source === "ADA goal (diabetes)" + ? "ADA goal (diabetes)" + : "ADA 2024 / DDG", ...consistency, }); } diff --git a/src/lib/targets/glucose-targets.ts b/src/lib/targets/glucose-targets.ts new file mode 100644 index 000000000..a52b6dfaa --- /dev/null +++ b/src/lib/targets/glucose-targets.ts @@ -0,0 +1,147 @@ +/** + * Diabetes-aware blood-glucose TARGET resolver (v1.18.6). + * + * One question: "what glucose band should THIS user be judged against on this + * context, right now?" The answer depends on a single explicit, user-declared + * preference — `User.hasDiabetes` — and NEVER on any reading. The flag is set + * through Settings (`PATCH /api/auth/me/diabetes`); it is never inferred from + * a value and never asserts a diagnosis. + * + * - Flag OFF (default): the general non-diabetic normal bands — the existing + * behaviour, resolved by the shared reference-range resolver + * (`getEffectiveRange`, which owns the band display) so a user-override + * still wins and nothing about the calm-state UI changes. + * - Flag ON: the tighter ADA glycemic GOAL bands for people living with + * diabetes — fasting / pre-prandial 80–130 mg/dL, peak post-prandial + * < 180 mg/dL (ADA Standards of Care §6, stable 2024–2026). A user + * override still wins (a clinician may pin an individualised goal), but + * absent an override the diabetic goal band — not the normal band — is the + * target. + * + * The diabetic goal targets are GOALS, not screening thresholds: a fasting 80 + * is "in goal" for someone managing diabetes, where the non-diabetic 70–99 + * normal band would call the same reading low-normal. This is exactly why we + * keep the two resolvers distinct rather than widening the general band. + * + * Pure given its inputs. The route resolves the flag + overrides and passes + * them in. mg/dL throughout (HealthLog canonical store unit); display-time + * conversion to mmol/L is the caller's job (`src/lib/glucose.ts`). + * + * Citation: American Diabetes Association, Standards of Care in Diabetes + * §6 "Glycemic Goals and Hypoglycemia", Diabetes Care 2024 47(Suppl. 1):S111. + * Pre-prandial plasma glucose goal 80–130 mg/dL; peak post-prandial < 180 + * mg/dL. https://diabetesjournals.org/care/article/47/Supplement_1/S111/153957 + */ + +import { + getEffectiveRange, + type ThresholdMetric, + type ThresholdOverridesJson, + type UserProfileForRange, +} from "@/lib/analytics/effective-range"; +import type { TrafficRange } from "@/lib/analytics/value-bands"; +import { thresholdMetricForContext } from "@/lib/glucose"; + +export type GlucoseContextKey = + | "FASTING" + | "POSTPRANDIAL" + | "RANDOM" + | "BEDTIME"; + +/** + * ADA glycemic GOAL bands for people living with diabetes (mg/dL). + * + * FASTING / pre-prandial: 80–130 (ADA pre-prandial plasma glucose goal). + * POSTPRANDIAL: floor 80 (avoid hypoglycemia), ceiling 180 (ADA peak + * post-prandial goal `< 180`; the inclusive 180 is the band edge). + * RANDOM: a random spot reading is not a defined ADA goal context; the + * post-prandial goal is the most clinically defensible comparator (a random + * reading is most often post-meal). Use the same 80–180 band. + * BEDTIME: ADA publishes no separate bedtime goal for adults; the ISPAD + * pediatric bedtime band (90–150) is the closest comparator and the slightly + * higher floor guards against nocturnal hypoglycemia. Kept identical to the + * non-diabetic bedtime band on purpose — the diabetic delta lives in the + * fasting/post-prandial goals, where ADA is explicit. + */ +const DIABETIC_GOAL_BANDS: Record< + GlucoseContextKey, + { min: number; max: number } +> = { + FASTING: { min: 80, max: 130 }, + POSTPRANDIAL: { min: 80, max: 180 }, + RANDOM: { min: 80, max: 180 }, + BEDTIME: { min: 90, max: 150 }, +}; + +/** Source label surfaced on the resolved range for the targets surface. */ +export type GlucoseTargetSource = "ADA goal (diabetes)" | "default" | "custom"; + +export interface ResolvedGlucoseTarget { + /** The green band the reading is judged against (mg/dL). */ + range: TrafficRange | null; + /** True when a user threshold override is in play (override always wins). */ + isOverride: boolean; + /** + * Which band family produced the range: the diabetic ADA goal, the general + * default, or a user custom override. Drives the targets-card source label. + */ + source: GlucoseTargetSource; +} + +/** + * Build the diabetic-goal `TrafficRange`. The orange wings mirror the general + * resolver's glucose convention (floor − 10 below, a modest ceiling above) so + * a reading just outside goal reads "elevated", not "high". + */ +function diabeticGoalRange(ctx: GlucoseContextKey): TrafficRange { + const band = DIABETIC_GOAL_BANDS[ctx]; + return { + greenMin: band.min, + greenMax: band.max, + orangeMin: band.min - 10, + // Above the goal ceiling stays "elevated" up to a modest margin before + // the red "high" band; matches the general resolver's wing width posture. + orangeMax: band.max + 30, + }; +} + +/** + * Resolve the glucose target band for one context. + * + * `hasDiabetes` is the user's explicit, declared preference. When `true` AND + * the user has NOT overridden the metric, the ADA diabetic goal band wins. + * Otherwise we defer entirely to the shared reference-range resolver so the + * general non-diabetic band (and any user override) behaves exactly as before. + * + * The flag is NEVER derived from a reading and asserts no diagnosis — it only + * selects the band the user is measured against. + */ +export function resolveGlucoseTarget(input: { + context: GlucoseContextKey; + hasDiabetes: boolean; + profile: UserProfileForRange; + overrides: ThresholdOverridesJson | null | undefined; +}): ResolvedGlucoseTarget { + const { context, hasDiabetes, profile, overrides } = input; + const metric = thresholdMetricForContext(context) as ThresholdMetric; + const eff = getEffectiveRange(metric, profile, overrides); + + // A user override always wins — a clinician-pinned individualised goal must + // not be silently replaced by the population ADA goal band. + if (eff.isOverride) { + return { range: eff.range, isOverride: true, source: "custom" }; + } + + if (hasDiabetes) { + return { + range: diabeticGoalRange(context), + isOverride: false, + source: "ADA goal (diabetes)", + }; + } + + return { range: eff.range, isOverride: false, source: "default" }; +} + +/** The ADA diabetic goal bands, exported for tests + documentation. */ +export { DIABETIC_GOAL_BANDS };