From d0a9086538884540fe06360423b45c76113a70d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:58:24 +0200 Subject: [PATCH] fix(targets): edit glucose thresholds in the reader's glucose unit The settings threshold editor took the identity path for the glucose thresholds, so an account on mmol/L was shown a mg/dL default and asked for mg/dL figures against 40-400 bounds, while the insights sheet for the same threshold already spoke mmol/L. The shared target adapter now owns the glucose unit as it owns pounds: fields, inward-rounded bounds, the default hint and the canonical write all go through it, and the insights panel and its sheet use the same branch instead of their own conversion. Refs #1067 --- .../insights/metric-target-summary.tsx | 49 ++++----- .../thresholds-editor-glucose-unit.test.tsx | 76 +++++++++++++ .../settings/thresholds-editor-section.tsx | 8 +- src/components/targets/target-edit-sheet.tsx | 65 +++++------ .../__tests__/target-unit-display.test.ts | 101 ++++++++++++++++-- src/lib/targets/target-unit-display.ts | 47 +++++++- 6 files changed, 270 insertions(+), 76 deletions(-) create mode 100644 src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index cfdaa0be4..845b1b8d6 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -8,7 +8,6 @@ import { useAuth } from "@/hooks/use-auth"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; -import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { RangeBar } from "@/components/targets/range-bar"; @@ -18,6 +17,7 @@ import { TileHeader } from "@/components/insights/tile-header"; import { useTargetAdjust } from "@/lib/insights/target-adjust-context"; import { getTargetSourceLink } from "@/lib/targets/source-link"; import { apiGet } from "@/lib/api/api-fetch"; +import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose"; /** * v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each @@ -152,28 +152,17 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { // mg/dL values to the user's display unit. The route's per-context // labels are i18n keys, so resolve them. if (isGlucose) { - const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null); - const convert = (v: number | null) => - v == null ? null : convertGlucose(v, displayUnit); + // The payload stays canonical mg/dL here; each panel converts through + // the shared target adapter, which owns the glucose unit as it owns + // pounds, so the panel and its edit sheet cannot convert twice. + const glucoseUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null); const panels = GLUCOSE_TARGET_TYPES.map((type) => data.targets.find((entry) => entry.type === type), ) .filter( (entry): entry is TargetItem => entry != null && entry.range != null, ) - .map((entry) => ({ - ...entry, - label: t(entry.label), - unit: displayUnit, - current: convert(entry.current), - average30: convert(entry.average30), - range: entry.range - ? { - min: convertGlucose(entry.range.min, displayUnit), - max: convertGlucose(entry.range.max, displayUnit), - } - : null, - })); + .map((entry) => ({ ...entry, label: t(entry.label) })); if (panels.length === 0) return null; @@ -188,6 +177,7 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { key={panel.type} target={panel} heading={panel.label} + glucoseUnit={glucoseUnit} /> ))} @@ -212,6 +202,12 @@ interface TargetReferencePanelProps { bpDiastolic?: TargetsResponse["bpDiastolic"]; /** Optional sub-heading shown above the panel (used for glucose contexts). */ heading?: string; + /** + * The record's glucose unit as the targets payload resolved it. The + * glucose panels pass it so the band reads in the unit the server + * resolved for this record; every other panel leaves it to the hook. + */ + glucoseUnit?: GlucoseUnit; } /** @@ -232,21 +228,26 @@ function TargetReferencePanel({ target, bpDiastolic, heading, + glucoseUnit: payloadGlucoseUnit, }: TargetReferencePanelProps) { const { t } = useTranslations(); const adjust = useTargetAdjust(); - const { preference } = useUnitDisplay(); + const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay(); + const glucoseUnit = payloadGlucoseUnit ?? accountGlucoseUnit; // v1.32.27 — the panel renders in the user's preferred unit. The // `/api/insights/targets` payload is canonical (kg for weight); the // adapter converts it once, here, and everything downstream — the // range bar, the status pill, the 30-day average, and the edit - // sheet's seed — consumes the converted pair. Glucose contexts are - // pre-converted by the caller above and carry no display transform, - // so they take the adapter's identity path and are never scaled - // twice. For a metric account the adapter is the exact identity, so - // this panel is byte-identical to the previous release. - const units = resolveTargetUnitAdapter(target.type, target.unit, preference); + // sheet's seed — consumes the converted pair. The glucose contexts + // arrive canonical too and convert here to the reader's glucose unit. + // On the default preferences the adapter is the exact identity. + const units = resolveTargetUnitAdapter( + target.type, + target.unit, + preference, + glucoseUnit, + ); const unit = units.unit; const range = target.range ? { diff --git a/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx b/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx new file mode 100644 index 000000000..362d029b6 --- /dev/null +++ b/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx @@ -0,0 +1,76 @@ +/** + * The settings threshold editor reads glucose in the reader's unit. + * + * It used to pass every glucose threshold through the identity path, so an + * mmol/L account was shown "Default: 70–99 mg/dL" and mg/dL input fields + * bounded at 40–400, while the insights sheet for the same threshold already + * spoke mmol/L. The stored value stays canonical mg/dL either way. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const authUser = vi.hoisted(() => ({ + current: { glucoseUnit: "mmol/L", unitPreference: "metric" } as Record< + string, + unknown + >, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: authUser.current, isAuthenticated: true }), + useAccountOnceMounted: () => authUser.current, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + isLoading: false, + data: { + effective: { + BLOOD_GLUCOSE_FASTING: { + range: { greenMin: 72, greenMax: 108 }, + isOverride: true, + default: { greenMin: 70, greenMax: 99 }, + bounds: { min: 40, max: 400, unit: "mg/dL" }, + }, + }, + overrides: { BLOOD_GLUCOSE_FASTING: { min: 72, max: 108 } }, + }, + }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ThresholdsEditorSection } from "../thresholds-editor-section"; + +function render(): string { + return renderToStaticMarkup( + + + , + ); +} + +describe(" glucose unit", () => { + it("states the default, the fields and the bounds in mmol/L", () => { + authUser.current = { glucoseUnit: "mmol/L", unitPreference: "metric" }; + const html = render(); + // 70–99 mg/dL is 3.9–5.5 mmol/L. + expect(html).toMatch(/3\.9–5\.5 mmol\/L/); + expect(html).not.toMatch(/70(\.0)?–99(\.0)? mg\/dL/); + // The stored 72–108 mg/dL override seeds 4–6 mmol/L. + expect(html).toContain('value="4"'); + expect(html).toContain('value="6"'); + // Inward-rounded guardrail of 40–400 mg/dL. + expect(html).toContain('min="2.3"'); + expect(html).toContain('max="22.1"'); + }); + + it("keeps mg/dL for an mg/dL reader", () => { + authUser.current = { glucoseUnit: "mg/dL", unitPreference: "metric" }; + const html = render(); + expect(html).toMatch(/70(\.0)?–99(\.0)? mg\/dL/); + expect(html).toContain('value="72"'); + expect(html).toContain('min="40"'); + }); +}); diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx index 0d638024b..8a50c0fb8 100644 --- a/src/components/settings/thresholds-editor-section.tsx +++ b/src/components/settings/thresholds-editor-section.tsx @@ -237,7 +237,7 @@ function MetricRow({ }: MetricRowProps) { const { t } = useTranslations(); const fmt = useFormatters(); - const { preference } = useUnitDisplay(); + const { preference, glucoseUnit } = useUnitDisplay(); const canonicalBounds = METRIC_BOUNDS[metric]; // v1.32.27 — the row reads and writes in the user's preferred unit @@ -246,12 +246,14 @@ function MetricRow({ // canonical, so it converts on the way into the fields and inverts on // the way back out in `onSave`. Guardrails round inward so a value // typed at the displayed limit still passes the server's canonical - // check. Metrics without a transform — and every metric for a metric - // user — take the adapter's identity path untouched. + // check. The glucose thresholds read in the reader's glucose unit the + // same way. Metrics without a transform — and every metric on the default + // preferences — take the adapter's identity path untouched. const units = resolveTargetUnitAdapter( metric, canonicalBounds.unit, preference, + glucoseUnit, ); const bounds = { ...units.bounds(canonicalBounds), unit: units.unit }; diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index d3a788d42..bddd162e0 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -17,8 +17,11 @@ import { type ThresholdMetric, type EffectiveRange, } from "@/lib/analytics/effective-range"; -import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose"; -import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display"; +import { + isGlucoseTargetMetric, + resolveTargetUnitAdapter, +} from "@/lib/targets/target-unit-display"; +import { resolveGlucoseUnit } from "@/lib/glucose"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; @@ -129,49 +132,33 @@ function TargetEditSheetBody({ const metric = TARGET_TYPE_TO_METRIC[targetType] ?? null; const isDerivedMetric = !isBp && metric == null; - // Glucose is the one metric whose display unit can differ from the - // canonical storage unit: HealthLog stores mg/dL, but a user on the - // mmol/L preference sees — and types — mmol/L. The parent already - // hands us `unit="mmol/L"` and a `initialRange` pre-converted to that - // unit. We must therefore (a) seed the mg/dL persisted override into - // the display unit, (b) validate the typed value against bounds - // expressed in the display unit, and (c) convert the typed value back - // to canonical mg/dL before the PUT — otherwise a `5.5 mmol/L` target - // is rejected by the 40–400 mg/dL bounds or stored verbatim as 5.5. - const isGlucoseMmol = - metric != null && metric.startsWith("BLOOD_GLUCOSE") && unit === "mmol/L"; - - // v1.32.27 — the same coupled treatment now covers the metric/imperial - // preference. Weight-class thresholds are stored in kg; an imperial - // user seeds, validates, and types in lb, and the adapter inverts the - // typed number back to kg before the PUT. Glucose keeps its own - // mg/dL ↔ mmol/L dialect (it carries no display transform, so the two - // never compose); every other metric takes the adapter's identity - // path and is numerically untouched. - const { preference } = useUnitDisplay(); + // One adapter owns every direction of the unit conversion: an imperial + // user seeds, validates and types weight-class thresholds in pounds, an + // mmol/L user types glucose thresholds in mmol/L, and the typed number is + // inverted back to the canonical kg / mg/dL before the PUT. The parent + // hands `unit` and `initialRange` already in the display unit; the + // persisted override and `METRIC_BOUNDS` are canonical and go through the + // adapter. Every other metric takes the identity path. + // A glucose sheet speaks the unit its panel registered, so the sheet and + // the band it edits can never disagree. + const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay(); + const adapterKey = isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? ""); const units = resolveTargetUnitAdapter( - isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? ""), + adapterKey, unit, preference, + isGlucoseTargetMetric(adapterKey) + ? resolveGlucoseUnit(unit) + : accountGlucoseUnit, ); - const toDisplay = (canonical: number) => - isGlucoseMmol - ? convertGlucose(canonical, "mmol/L") - : units.toDisplay(canonical); - const toCanonical = (displayValue: number) => - isGlucoseMmol - ? toCanonicalMgdl(displayValue, "mmol/L") - : units.toCanonical(displayValue); - // Guardrails the typed value is checked against, expressed in the - // display unit. Glucose projects its mg/dL window through the same - // conversion the fields use; every other metric rounds the window - // INWARD so a value typed at the display bound never inverts to a - // canonical value the server rejects. + const toDisplay = (canonical: number) => units.toDisplay(canonical); + const toCanonical = (displayValue: number) => units.toCanonical(displayValue); + // Guardrails the typed value is checked against, expressed in the display + // unit and rounded INWARD so a value typed at the display bound never + // inverts to a canonical value the server rejects. const toDisplayBounds = (canonical: { min: number; max: number }) => - isGlucoseMmol - ? { min: toDisplay(canonical.min), max: toDisplay(canonical.max) } - : units.bounds(canonical); + units.bounds(canonical); // Lazy-load the thresholds payload so the dialog also catches any // already-persisted override even when the seeded `initialRange` diff --git a/src/lib/targets/__tests__/target-unit-display.test.ts b/src/lib/targets/__tests__/target-unit-display.test.ts index da8702179..3d2fa1522 100644 --- a/src/lib/targets/__tests__/target-unit-display.test.ts +++ b/src/lib/targets/__tests__/target-unit-display.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { resolveTargetUnitAdapter } from "../target-unit-display"; +import { toCanonicalMgdl } from "@/lib/glucose"; /** * v1.32.27 — the target/threshold unit adapter. @@ -18,7 +19,7 @@ const KG_PER_LB = 0.45359237; describe("resolveTargetUnitAdapter — metric account (identity)", () => { it("leaves a weight threshold bit-for-bit untouched", () => { - const units = resolveTargetUnitAdapter("WEIGHT", "kg", "metric"); + const units = resolveTargetUnitAdapter("WEIGHT", "kg", "metric", "mg/dL"); expect(units.rescales).toBe(false); expect(units.unit).toBe("kg"); // Not "close to" — exactly the same double, in both directions. @@ -37,6 +38,7 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => { "BLOOD_PRESSURE_SYS", "mmHg", preference, + "mg/dL", ); expect(units.rescales).toBe(false); expect(units.unit).toBe("mmHg"); @@ -51,11 +53,14 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => { it("is inert for the keys the edit sheet passes on its non-metric paths", () => { // A derived card (BMI, mood, medication compliance) has no editable - // threshold and the sheet resolves the adapter with an empty key; - // glucose keeps its own mg/dL ↔ mmol/L dialect and must never pick - // up a second conversion on top. - for (const key of ["", "BMI", "BLOOD_GLUCOSE_FASTING"]) { - const units = resolveTargetUnitAdapter(key, "mmol/L", "imperial"); + // threshold and the sheet resolves the adapter with an empty key. + for (const key of ["", "BMI"]) { + const units = resolveTargetUnitAdapter( + key, + "mmol/L", + "imperial", + "mg/dL", + ); expect(units.rescales).toBe(false); expect(units.unit).toBe("mmol/L"); expect(units.toDisplay(5.5)).toBe(5.5); @@ -66,14 +71,15 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => { it("keeps the whole-hundred step for a step-count target", () => { for (const preference of ["metric", "imperial"] as const) { expect( - resolveTargetUnitAdapter("ACTIVITY_STEPS", "steps", preference).step, + resolveTargetUnitAdapter("ACTIVITY_STEPS", "steps", preference, "mg/dL") + .step, ).toBe(100); } }); }); describe("resolveTargetUnitAdapter — imperial weight class", () => { - const units = resolveTargetUnitAdapter("WEIGHT", "kg", "imperial"); + const units = resolveTargetUnitAdapter("WEIGHT", "kg", "imperial", "mg/dL"); it("announces pounds and a one-decimal step", () => { expect(units.rescales).toBe(true); @@ -145,7 +151,12 @@ describe("resolveTargetUnitAdapter — imperial weight class", () => { it("carries the same treatment across the whole weight class", () => { for (const metric of ["TOTAL_BODY_WATER", "BONE_MASS"] as const) { - const adapter = resolveTargetUnitAdapter(metric, "kg", "imperial"); + const adapter = resolveTargetUnitAdapter( + metric, + "kg", + "imperial", + "mg/dL", + ); expect(adapter.unit).toBe("lb"); const bounds = adapter.bounds(METRIC_BOUNDS[metric]); expect(adapter.toCanonical(bounds.min)).toBeGreaterThanOrEqual( @@ -169,6 +180,7 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => { "BODY_TEMPERATURE", "celsius", "imperial", + "mg/dL", ); it("shifts absolute readings but never a difference", () => { @@ -208,6 +220,7 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => { "BODY_TEMPERATURE", "celsius", "metric", + "mg/dL", ); expect(metricUnits.rescales).toBe(false); expect(metricUnits.toDisplay(37)).toBe(37); @@ -224,3 +237,73 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => { expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual(45); }); }); + +describe("resolveTargetUnitAdapter — glucose thresholds follow the glucose unit", () => { + const contexts = [ + "BLOOD_GLUCOSE_FASTING", + "BLOOD_GLUCOSE_POSTPRANDIAL", + "BLOOD_GLUCOSE_RANDOM", + "BLOOD_GLUCOSE_BEDTIME", + ] as const; + + it("reads mmol/L for an mmol/L reader, whatever the metric/imperial branch", () => { + for (const metric of contexts) { + for (const preference of ["metric", "imperial"] as const) { + const units = resolveTargetUnitAdapter( + metric, + "mg/dL", + preference, + "mmol/L", + ); + expect(units.unit).toBe("mmol/L"); + expect(units.rescales).toBe(true); + expect(units.step).toBe(0.1); + // The 70–99 mg/dL fasting default reads 3.9–5.5. + expect(units.toDisplay(70)).toBe(3.9); + expect(units.toDisplay(99)).toBe(5.5); + } + } + }); + + it("round-trips a typed mmol/L value to whole mg/dL and back", () => { + const units = resolveTargetUnitAdapter( + "BLOOD_GLUCOSE_FASTING", + "mg/dL", + "metric", + "mmol/L", + ); + expect(units.toCanonical(5.5)).toBe(99); + expect(units.toDisplay(units.toCanonical(5.5))).toBe(5.5); + expect(units.toCanonical(3.9)).toBe(toCanonicalMgdl(3.9, "mmol/L")); + }); + + it("rounds the canonical 40–400 mg/dL guardrail inward", () => { + const units = resolveTargetUnitAdapter( + "BLOOD_GLUCOSE_FASTING", + "mg/dL", + "metric", + "mmol/L", + ); + const bounds = units.bounds(METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING); + expect(bounds).toEqual({ min: 2.3, max: 22.1 }); + expect(units.toCanonical(bounds.min)).toBeGreaterThanOrEqual( + METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING.min, + ); + expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual( + METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING.max, + ); + }); + + it("stays the exact identity for an mg/dL reader", () => { + const units = resolveTargetUnitAdapter( + "BLOOD_GLUCOSE_FASTING", + "mg/dL", + "imperial", + "mg/dL", + ); + expect(units.unit).toBe("mg/dL"); + expect(units.rescales).toBe(false); + expect(units.toDisplay(99)).toBe(99); + expect(units.toCanonical(99)).toBe(99); + }); +}); diff --git a/src/lib/targets/target-unit-display.ts b/src/lib/targets/target-unit-display.ts index a92218e4e..0afb78a7d 100644 --- a/src/lib/targets/target-unit-display.ts +++ b/src/lib/targets/target-unit-display.ts @@ -48,6 +48,12 @@ import { transformRescales, type UnitPreference, } from "@/lib/measurements/display-transform"; +import { + MGDL_PER_MMOL, + convertGlucose, + toCanonicalMgdl, + type GlucoseUnit, +} from "@/lib/glucose"; /** Canonical decimals a converted threshold value persists with. */ const CANONICAL_DECIMALS = 2; @@ -119,6 +125,41 @@ function identityAdapter(metric: string, unit: string): TargetUnitAdapter { }; } +/** + * The glucose thresholds (`BLOOD_GLUCOSE_FASTING` and its siblings) are + * stored in mg/dL and read in the reader's glucose unit. The settings editor + * used to take the identity path for them, so an mmol/L account was asked + * for mg/dL figures under a label that said nothing, against 40–400 bounds + * and a "default: 70–99" hint — while the insights sheet for the same + * threshold already spoke mmol/L. + * + * mmol/L reads at one decimal; the canonical write rounds to whole mg/dL + * (`toCanonicalMgdl`), the precision every glucose path stores. Guardrails + * round inward exactly like the imperial branch: 40 mg/dL is 2.22 mmol/L, + * shown as 2.3 so that the typed bound inverts to 41 mg/dL and passes. + */ +function glucoseAdapter(glucoseUnit: GlucoseUnit): TargetUnitAdapter { + if (glucoseUnit === "mg/dL") return identityAdapter("BLOOD_GLUCOSE", "mg/dL"); + const toMmol = (mgdl: number) => mgdl / MGDL_PER_MMOL; + return { + unit: "mmol/L", + step: 0.1, + rescales: true, + toDisplay: (canonical) => convertGlucose(canonical, "mmol/L"), + toCanonical: (display) => toCanonicalMgdl(display, "mmol/L"), + toDisplayDelta: (canonicalDelta) => roundTo(toMmol(canonicalDelta), 1), + bounds: (canonical) => ({ + min: ceilTo(toMmol(canonical.min), 1), + max: floorTo(toMmol(canonical.max), 1), + }), + }; +} + +/** True for a glucose threshold metric or target-card type. */ +export function isGlucoseTargetMetric(metric: string): boolean { + return metric === "BLOOD_GLUCOSE" || metric.startsWith("BLOOD_GLUCOSE_"); +} + /** * Resolve the unit adapter for one threshold metric / target-card type. * @@ -128,13 +169,17 @@ function identityAdapter(metric: string, unit: string): TargetUnitAdapter { * on, so a metric participates exactly when it has a registered * transform. `canonicalUnit` is the unit the surface announced before * this release (`METRIC_BOUNDS[metric].unit` or the targets payload's - * `unit`) and is what an untransformed metric keeps. + * `unit`) and is what an untransformed metric keeps. `glucoseUnit` is + * the reader's glucose preference; it decides the glucose thresholds and + * nothing else. */ export function resolveTargetUnitAdapter( metric: string, canonicalUnit: string, preference: UnitPreference, + glucoseUnit: GlucoseUnit, ): TargetUnitAdapter { + if (isGlucoseTargetMetric(metric)) return glucoseAdapter(glucoseUnit); if (!hasDisplayTransform(metric)) return identityAdapter(metric, canonicalUnit);