diff --git a/src/lib/ai/coach/__tests__/reference-grounding.test.ts b/src/lib/ai/coach/__tests__/reference-grounding.test.ts index 8881d7b52..f867e815c 100644 --- a/src/lib/ai/coach/__tests__/reference-grounding.test.ts +++ b/src/lib/ai/coach/__tests__/reference-grounding.test.ts @@ -113,6 +113,39 @@ describe("buildReferenceGroundingBlock", () => { expect(outOfGoal).toContain("outside the typical diabetes management goal"); }); + it("states the glucose bands in the reader's glucose unit and still places on mg/dL", () => { + const units = { system: "metric", glucoseUnit: "mmol/L" } as const; + const general = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_GLUCOSE", value: 92 }], + hasDiabetes: false, + units, + })!; + expect(general).toContain("≤5.5 mmol/L"); + expect(general).not.toContain("mg/dL"); + // 92 mg/dL is inside the fasting band: the placement is canonical. + expect(general).toContain("sits inside the general reference band"); + + const goal = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_GLUCOSE", value: 200 }], + hasDiabetes: true, + units, + })!; + expect(goal).toContain("4.4–7.2 mmol/L"); + expect(goal).not.toContain("mg/dL"); + expect(goal).toContain("outside the typical diabetes management goal"); + }); + + it("states the body-temperature band in °F for an imperial reader", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "BODY_TEMPERATURE", value: 36.8 }], + hasDiabetes: false, + units: { system: "imperial", glucoseUnit: "mg/dL" }, + })!; + expect(block).toContain("96.3–99.3 °F"); + expect(block).not.toContain("°C"); + expect(block).toContain("sits inside the general reference band"); + }); + it("grounds BMI against the WHO normal band, not the first (Underweight) band", () => { // The headline band rendered must be the 18.5–24.9 normal range — the // index-0 bug surfaced the Underweight "≤18.5" ceiling instead. diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts index 50208afb9..41f47706c 100644 --- a/src/lib/ai/coach/__tests__/system-prompt.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -113,6 +113,13 @@ describe("getCoachSystemPrompt — EN", () => { expect(prompt).toMatch(/isSpotEstimate/); }); + it("names the time-in-range band through the block, never in one fixed unit", () => { + // The glucose block is in the reader's unit; a band stated here in + // mg/dL would sit beside mmol/L numbers for a mmol/L reader. + expect(prompt).toMatch(/"tirRange"/); + expect(prompt).not.toMatch(/70–180 mg\/dL/); + }); + it("documents the WHOOP-native dayStrain block vs the computed proxy", () => { // v1.17.0 — native 0–21 strain preferred over the computed 0–100 proxy. expect(prompt).toMatch(/dayStrain/); diff --git a/src/lib/ai/coach/reference-grounding.ts b/src/lib/ai/coach/reference-grounding.ts index 00af268fe..166405427 100644 --- a/src/lib/ai/coach/reference-grounding.ts +++ b/src/lib/ai/coach/reference-grounding.ts @@ -47,6 +47,12 @@ import { type ReferenceMetric, type ReferencePlacement, } from "@/lib/reference-ranges"; +import { + applyDisplayTransform, + DEFAULT_UNIT_PREFERENCES, + getReadingTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; /** * One metric the snapshot carries, paired with the user's current @@ -74,6 +80,44 @@ export interface ReferenceGroundingInput { * for the glucose line ONLY. Never inferred from a reading. */ readonly hasDiabetes: boolean; + /** + * The reader's two unit choices. The band text is printed in them, so the + * line sits beside the glucose block (already in the reader's glucose unit) + * without handing the model a second unit for the same quantity. The + * placement is still classified on the canonical value. Defaults to metric + * and mg/dL. + */ + readonly units?: UnitPreferences; +} + +/** + * The reference metrics whose band is a stored measurement read in a + * preference-dependent unit, keyed to that measurement type. Every other + * band (mmHg, bpm, %, h, kg/m²) reads the same for everyone. + */ +const UNIT_SENSITIVE_REFERENCE_TYPE: Partial> = + { + BLOOD_GLUCOSE: "BLOOD_GLUCOSE", + BODY_TEMPERATURE: "BODY_TEMPERATURE", + }; + +/** A canonical band edge and the unit symbol, in the reader's units. */ +function bandEdges( + metric: ReferenceMetric, + units: UnitPreferences, +): { edge: (value: number) => number; unit: string } { + const type = UNIT_SENSITIVE_REFERENCE_TYPE[metric]; + if (!type) { + return { + edge: (value) => value, + unit: getReferenceRange(metric)?.unit ?? "", + }; + } + const transform = getReadingTransform(type, units); + return { + edge: (value) => applyDisplayTransform(value, transform), + unit: transform.displayUnit, + }; } /** @@ -140,17 +184,20 @@ function placementPhrase(placement: ReferencePlacement): string { * pulse-pressure Narrow). Open-ended bounds render with a leading/trailing * ≤ / ≥. */ -function headlineBandText(metric: ReferenceMetric): string | null { +function headlineBandText( + metric: ReferenceMetric, + units: UnitPreferences, +): string | null { const range = getReferenceRange(metric); if (!range || range.bands.length === 0) return null; const idx = normalBandIndex(metric); const band = range.bands[idx]; - const unit = range.unit; + const { edge, unit } = bandEdges(metric, units); if (band.low != null && band.high != null) { - return `${band.low}–${band.high} ${unit}`; + return `${edge(band.low)}–${edge(band.high)} ${unit}`; } - if (band.high != null) return `≤${band.high} ${unit}`; - if (band.low != null) return `≥${band.low} ${unit}`; + if (band.high != null) return `≤${edge(band.high)} ${unit}`; + if (band.low != null) return `≥${edge(band.low)} ${unit}`; return null; } @@ -160,9 +207,14 @@ function headlineBandText(metric: ReferenceMetric): string | null { * GOAL band when the user declared diabetes. The diabetic branch frames the * band as a clinician-set GOAL, never a screening threshold. */ -function glucoseLine(value: number | null, hasDiabetes: boolean): string { +function glucoseLine( + value: number | null, + hasDiabetes: boolean, + units: UnitPreferences, +): string { const label = METRIC_LABEL.BLOOD_GLUCOSE; if (hasDiabetes) { + const { edge, unit } = bandEdges("BLOOD_GLUCOSE", units); const { low, high } = ADA_DIABETIC_FASTING_GOAL; // Placement against the diabetic GOAL band, framed as a target the // user's clinician individualises — never a diagnostic call. @@ -174,11 +226,11 @@ function glucoseLine(value: number | null, hasDiabetes: boolean): string { } else { where = "is outside the typical diabetes management goal"; } - return `- ${label}: a common diabetes management goal is ${low}–${high} mg/dL fasting (${referenceLabel( + return `- ${label}: a common diabetes management goal is ${edge(low)}–${edge(high)} ${unit} fasting (${referenceLabel( "BLOOD_GLUCOSE", )} — clinician-set goal, individualised, not a screening line). Yours ${where}.`; } - const band = headlineBandText("BLOOD_GLUCOSE"); + const band = headlineBandText("BLOOD_GLUCOSE", units); const placement = classifyReference("BLOOD_GLUCOSE", value); return `- ${label}: general non-diabetic normal is ${band} (${referenceLabel( "BLOOD_GLUCOSE", @@ -186,9 +238,13 @@ function glucoseLine(value: number | null, hasDiabetes: boolean): string { } /** Build one standard (non-glucose) grounding line for a metric. */ -function standardLine(metric: ReferenceMetric, value: number | null): string { +function standardLine( + metric: ReferenceMetric, + value: number | null, + units: UnitPreferences, +): string { const label = METRIC_LABEL[metric]; - const band = headlineBandText(metric); + const band = headlineBandText(metric, units); const placement = classifyReference(metric, value); const cite = referenceLabel(metric); // Blood pressure cites ESH 2023 by construction (the reference-range @@ -234,10 +290,11 @@ export function buildReferenceGroundingBlock( } if (ordered.length === 0) return null; + const units = input.units ?? DEFAULT_UNIT_PREFERENCES; const lines = ordered.map((m) => m.metric === "BLOOD_GLUCOSE" - ? glucoseLine(m.value, input.hasDiabetes) - : standardLine(m.metric, m.value), + ? glucoseLine(m.value, input.hasDiabetes, units) + : standardLine(m.metric, m.value, units), ); // The preamble + closing caveat carry the firm general-guidance framing. diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/glucose-block-units.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/glucose-block-units.test.ts new file mode 100644 index 000000000..d42febc45 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/__tests__/glucose-block-units.test.ts @@ -0,0 +1,62 @@ +/** + * The glucose block states its time-in-range band in the block's own unit, + * so the prompt never has to name the band in mg/dL beside mmol/L numbers. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { buildGlucoseBlock } from "../glucose-block"; +import type { GlucoseUnit } from "@/lib/glucose"; + +const NOW = new Date("2026-06-14T12:00:00Z"); +const DAY = 24 * 60 * 60 * 1000; +const VALUES = [ + 60, 90, 120, 150, 180, 200, 100, 110, 95, 130, 170, 220, 80, 140, 160, 105, + 115, 125, 135, 145, +]; + +function build(glucoseUnit: GlucoseUnit) { + const rows = VALUES.map((value, i) => ({ + type: "BLOOD_GLUCOSE", + value, + measuredAt: new Date(NOW.getTime() - (VALUES.length - 1 - i) * DAY), + glucoseContext: null, + })); + const snapshot: Record = {}; + buildGlucoseBlock({ + measurementRows: rows, + glucoseCutoff: new Date(NOW.getTime() - 60 * DAY), + glucoseClinicalRows: rows.map(({ value, measuredAt }) => ({ + value, + measuredAt, + })), + glucoseUnit, + recentCutoff: new Date(NOW.getTime() - 7 * DAY), + userTz: "UTC", + now: NOW, + snapshot, + metrics: new Set(), + counts: {}, + registerBlock: () => {}, + groundingValues: new Map(), + }); + return snapshot.glucose as { + unit: string; + clinical: { stillLearning: boolean; tirRange?: unknown }; + }; +} + +describe("buildGlucoseBlock — time-in-range band", () => { + it("carries the band in mmol/L for a mmol/L reader", () => { + const block = build("mmol/L"); + expect(block.unit).toBe("mmol/L"); + expect(block.clinical.stillLearning).toBe(false); + expect(block.clinical.tirRange).toEqual({ low: 3.9, high: 10 }); + }); + + it("carries the band in mg/dL for a mg/dL reader", () => { + const block = build("mg/dL"); + expect(block.clinical.tirRange).toEqual({ low: 70, high: 180 }); + }); +}); diff --git a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts index cd8ac806f..e95d4e3b3 100644 --- a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts @@ -20,6 +20,7 @@ import { import { computeGlucoseClinicalMetrics, GLUCOSE_PANEL_WINDOW_DAYS, + TIME_IN_RANGE_MGDL, } from "@/lib/analytics/glucose-metrics"; import { annotate } from "@/lib/logging/context"; import type { ReferenceMetric } from "@/lib/reference-ranges"; @@ -162,6 +163,12 @@ export function buildGlucoseBlock(ctx: Readonly): void { tirPercent: clinicalRaw.distribution ? Math.round(clinicalRaw.distribution.tir * 100) : null, + // The band the percentage counts, in the block's unit, so the + // prompt never states it in a unit the reader does not use. + tirRange: { + low: convertGlucose(TIME_IN_RANGE_MGDL.low, glucoseUnit), + high: convertGlucose(TIME_IN_RANGE_MGDL.high, glucoseUnit), + }, timeBelowPercent: clinicalRaw.distribution ? Math.round(clinicalRaw.distribution.tbrLevel1 * 100) : null, diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index be95be3fc..bcc502e9f 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -23,7 +23,7 @@ import { } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { locales, defaultLocale, type Locale } from "@/lib/i18n/config"; -import { resolveGlucoseUnit } from "@/lib/glucose"; +import { resolveUnitPreferences } from "@/lib/measurements/display-transform"; import type { SleepStageRow } from "@/lib/analytics/sleep-night"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { annotate } from "@/lib/logging/context"; @@ -303,6 +303,9 @@ async function buildCoachSnapshotImpl( // 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, + // The metric/imperial choice: the grounding bands and the blocks + // that carry a mass, length or temperature are written in it. + unitPreference: true, // v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects // the tighter ADA glycemic GOAL band for the glucose reference-grounding // line only; never inferred from a reading, never a diagnosis. Read on @@ -326,7 +329,11 @@ async function buildCoachSnapshotImpl( clusterDefault, ); const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE; - const glucoseUnit = resolveGlucoseUnit(prefsRow?.glucoseUnit ?? null); + const units = resolveUnitPreferences({ + unitPreference: prefsRow?.unitPreference, + glucoseUnit: prefsRow?.glucoseUnit, + }); + const glucoseUnit = units.glucoseUnit; // v1.18.0 — fold disabled data-domain modules into the system exclusion. // `moduleMap[key] === false` means the user turned that module off; the // gate has already resolved every delegation (cycle/coach) so this map @@ -1206,6 +1213,7 @@ async function buildCoachSnapshotImpl( const referenceGrounding = buildReferenceGroundingBlock({ metrics: groundingMetrics, hasDiabetes: prefsRow?.hasDiabetes ?? false, + units, }); if (referenceGrounding) { annotate({ diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 939b0ae4c..d1e9427fe 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -382,7 +382,8 @@ ISO-week means. - The glucose block MAY carry a "clinical" sub-key with the same panel numbers the app's glucose page renders over a fixed 30-day window — so cite them as the user sees them and never recompute. It holds: - - "tirPercent": time-in-range (70–180 mg/dL) as a percentage. + - "tirPercent": time-in-range as a percentage — the share of readings + inside "tirRange", whose bounds are in the block's "unit". - "gmi": the glucose-management indicator (a %, an HbA1c estimate derived from mean glucose) and "estimatedA1c": the eA1C in % — read both as estimates, never as a lab HbA1c. diff --git a/src/lib/analytics/glucose-metrics.ts b/src/lib/analytics/glucose-metrics.ts index 94a7d833f..332ef484f 100644 --- a/src/lib/analytics/glucose-metrics.ts +++ b/src/lib/analytics/glucose-metrics.ts @@ -85,6 +85,11 @@ const TBR_LEVEL1_MAX = 70; // low: G < 70 (level-1 sub-band is 54–69) const TAR_LEVEL1_MIN = 180; // high: G > 180 (level-1 sub-band is 181–250) const TAR_LEVEL2_MIN = 250; // very high: G > 250 // Time-in-range target band is [70, 180] inclusive. +/** The time-in-range band, mg/dL, inclusive at both ends. */ +export const TIME_IN_RANGE_MGDL = { + low: TBR_LEVEL1_MAX, + high: TAR_LEVEL1_MIN, +} as const; /** Monnier 2017 variability cutoff: CV% ≥ 36 is "unstable". */ export const CV_INSTABILITY_THRESHOLD = 36;