mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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
This commit is contained in:
1 parent
1058859730
commit
d0a9086538
6 files changed
+270
-76
No files matched your search
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ThresholdsEditorSection id="thresholds" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ThresholdsEditorSection> 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"');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user