Merge remote-tracking branch 'origin/main' into fix/v1397-derive

This commit is contained in:
Marc-André Bombeck committed 2026-10-01 10:34:17 +02:00
commit da8cc7a6ee
74 files changed
+2304 -292

No files matched your search

@@ -11,6 +11,11 @@ import {
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
applyDisplayTransform,
getQuantityTransform,
} from "@/lib/measurements/display-transform";
/**
* Workout elevation profile — a small cumulative-distance / altitude
@@ -30,7 +35,15 @@ export function WorkoutElevationChart({
points: WorkoutElevationPoint[];
}) {
const { t } = useTranslations();
const data = points.map((p) => ({ km: p.distanceM / 1000, alt: p.altitude }));
const { preference } = useUnitDisplay();
// Distance along the route in km or mi, altitude in m or ft — the same
// registry the stat tiles read, so the axis and the tiles agree.
const distance = getQuantityTransform("distance", preference);
const elevation = getQuantityTransform("elevation", preference);
const data = points.map((p) => ({
dist: applyDisplayTransform(p.distanceM, distance),
alt: applyDisplayTransform(p.altitude, elevation),
}));
return (
<div className="h-24 w-full" data-slot="workout-elevation-chart">
@@ -57,7 +70,7 @@ export function WorkoutElevationChart({
opacity={0.4}
/>
<XAxis
dataKey="km"
dataKey="dist"
type="number"
domain={[0, "dataMax"]}
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
@@ -80,9 +93,11 @@ export function WorkoutElevationChart({
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
labelFormatter={(km) => `${Number(km).toFixed(2)} km`}
labelFormatter={(d) =>
`${Number(d).toFixed(2)} ${distance.displayUnit}`
}
formatter={(value) => [
`${Math.round(Number(value))} m`,
`${Math.round(Number(value))} ${elevation.displayUnit}`,
t("insights.workouts.detail.elevationTitle"),
]}
/>
@@ -225,6 +225,49 @@ describe("<ClinicianView>", () => {
expect(html).not.toContain(">WEIGHT<");
});
it("prints every measurement group with its unit", () => {
const html = render(
makeReport({
stats: {
WEIGHT: { avg: 80, min: 78, max: 82, count: 12, latest: 79 },
// Per-night asleep minutes; the report reads them in hours.
SLEEP_DURATION: {
avg: 450,
min: 420,
max: 480,
count: 7,
latest: 450,
},
},
}),
);
expect(html).toContain("latest 79 kg (avg 80 kg, range 78–82 kg)");
expect(html).toContain("latest 7.5 h (avg 7.5 h, range 7–8 h)");
});
it("shows glucose in the record owner's unit, never raw mg/dL", () => {
const html = render(
makeReport({
glucoseUnit: "mmol/L",
stats: {
BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 },
},
glucoseStats: {
FASTING: { avg: 99, min: 90, max: 108, count: 5, latest: 95 },
},
}),
);
// 99 mg/dL = 5.5 mmol/L, 108 = 6.0, 90 = 5.0, 126 = 7.0, 95 = 5.3.
expect(html).toContain(
"latest 5.5 mmol/L (avg 6 mmol/L, range 5–7 mmol/L)",
);
expect(html).toContain(
"latest 5.3 mmol/L (avg 5.5 mmol/L, range 5–6 mmol/L)",
);
expect(html).not.toContain("mg/dL");
expect(html).not.toContain("latest 99");
});
it("omits the wellness card when there are no scores", () => {
const html = render(makeReport({ wellnessScores: [] }));
expect(html).not.toContain("Wellness scores");
+39 -10
View File
@@ -15,6 +15,11 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import {
reportStatUnit,
reportStatValue,
} from "@/lib/doctor-report/stat-display";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import {
isReportLeafId,
@@ -212,6 +217,13 @@ export function MeasurementGroups({
if (groups.length === 0) return null;
// The same unit and the same number the PDF prints for the row: SI for
// mass, length and temperature, the owner's unit for glucose, hours for
// sleep. A clinician must never read a figure without knowing its unit.
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
const show = (type: string, value: number) =>
fmtNum(reportStatValue(type, value, glucoseUnit));
return (
<>
{groups.map((group) => (
@@ -224,11 +236,11 @@ export function MeasurementGroups({
<StatRow
key={type}
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
value={t("clinicianView.statSummary", {
latest: fmtNum(stat!.latest),
avg: fmtNum(stat!.avg),
min: fmtNum(stat!.min),
max: fmtNum(stat!.max),
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
latest: show(type, stat!.latest),
avg: show(type, stat!.avg),
min: show(type, stat!.min),
max: show(type, stat!.max),
})}
/>
))}
@@ -238,6 +250,20 @@ export function MeasurementGroups({
);
}
/**
* "latest 79 kg (avg 80 kg, range 78–82 kg)". A type with no unit (a score, a
* count) keeps the unit-less sentence rather than printing a dangling space.
*/
function statSummary(
t: Translate,
unit: string,
values: { latest: number; avg: number; min: number; max: number },
): string {
return unit
? t("clinicianView.statSummaryWithUnit", { ...values, unit })
: t("clinicianView.statSummary", values);
}
/**
* Per-context glucose stats. The heading used to read "Lab values", which was
* the wrong name for it even before the record's actual lab results reached
@@ -258,6 +284,9 @@ export function GlucoseSection({
const entries = Object.entries(report.glucoseStats).filter(
([, s]) => s.count > 0,
);
// Stored in mg/dL; shown in the record owner's unit, as the PDF shows it.
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
const show = (value: number) => fmtNum(convertGlucose(value, glucoseUnit));
return (
<LeafSection
t={t}
@@ -270,11 +299,11 @@ export function GlucoseSection({
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
value={statSummary(t, glucoseUnit, {
latest: show(s.latest),
avg: show(s.avg),
min: show(s.min),
max: show(s.max),
})}
/>
))}
@@ -6,12 +6,17 @@ import { renderToStaticMarkup } from "react-dom/server";
// QueryClient, so mock the hook to a real metric-preference implementation —
// metric is the identity on values and yields the canonical display symbols,
// so the assertions below (kg, °C, unchanged numbers) hold.
const reader = vi.hoisted(() => ({
glucoseUnit: "mg/dL" as "mg/dL" | "mmol/L",
}));
vi.mock("@/hooks/use-unit-display", async () => {
const dt = await import("@/lib/measurements/display-transform");
const forType = (t: string) => dt.getDisplayTransform(t, "metric");
return {
useUnitDisplay: () => ({
preference: "metric" as const,
glucoseUnit: reader.glucoseUnit,
transformFor: forType,
toDisplay: (t: string, v: number) =>
dt.applyDisplayTransform(v, forType(t)),
@@ -107,4 +112,26 @@ describe("<CyclePhaseCrosstab>", () => {
const html = render(<CyclePhaseCrosstab rows={[unknown]} />);
expect(html).not.toContain("undefined");
});
it("states a glucose row in mmol/L for an mmol/L reader", () => {
reader.glucoseUnit = "mmol/L";
try {
const glucose: CyclePhaseCrosstabRow = {
...row("bloodGlucose", "glucose"),
lutealAvg: 108,
follicularAvg: 90,
delta: 18,
};
const html = render(<CyclePhaseCrosstab rows={[glucose]} />);
// 18 mg/dL is 1.0 mmol/L; 108 / 90 mg/dL are 6.0 / 5.0 mmol/L.
expect(html).toContain("+1.0 mmol/L");
expect(html).toContain("6.0");
expect(html).not.toContain("mg/dL");
expect(render(<CyclePhaseHeadline headline={glucose} />)).toContain(
"mmol/L",
);
} finally {
reader.glucoseUnit = "mg/dL";
}
});
});
+52 -21
View File
@@ -1,7 +1,12 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getReadingTransform,
} from "@/lib/measurements/display-transform";
import { cn } from "@/lib/utils";
/**
@@ -88,8 +93,47 @@ function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
const DISPLAY_TO_TYPE: Partial<Record<CyclePhaseCrosstabDisplay, string>> = {
kg: "WEIGHT",
celsius: "BODY_TEMPERATURE",
glucose: "BLOOD_GLUCOSE",
};
/**
* Unit symbol and conversions for one row, from the reader's preferences.
* Glucose follows the glucose unit (it used to print the mg/dL label from
* the bundle beside raw mg/dL values for every reader); weight and
* temperature follow metric/imperial; every other display keeps its bundle
* label and its raw value.
*/
function rowUnits(
display: CyclePhaseCrosstabDisplay,
unitDisplay: UnitDisplay,
t: (key: string) => string,
): {
unit: string;
abs: (v: number) => number;
delta: (v: number) => number;
} {
const type = DISPLAY_TO_TYPE[display];
if (!type) {
// Defensive: an unmapped display must never pass undefined to `t`
// (whose resolver does `key.split(".")`). Empty unit on a miss.
const unitKey = UNIT_KEY[display];
return {
unit: unitKey ? t(unitKey) : "",
abs: (v) => v,
delta: (v) => v,
};
}
const transform = getReadingTransform(type, {
system: unitDisplay.preference,
glucoseUnit: unitDisplay.glucoseUnit,
});
return {
unit: transform.displayUnit,
abs: (v) => applyDisplayTransform(v, transform),
delta: (v) => applyDisplayTransformDelta(v, transform),
};
}
const HEADLINE_KEY: Record<string, string> = {
restingHeartRate: "cycle.insights.headline.restingHeartRate",
heartRateVariability: "cycle.insights.headline.heartRateVariability",
@@ -120,11 +164,7 @@ export function CyclePhaseHeadline({
const metricLabel = t(
METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey,
);
// Defensive: an unmapped display must never pass undefined to `t` (whose
// resolver does `key.split(".")`). An empty unit is the honest degrade.
const mt = DISPLAY_TO_TYPE[headline.display];
const unitKey = UNIT_KEY[headline.display];
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
const { unit, delta: deltaConv } = rowUnits(headline.display, unitDisplay, t);
const up = headline.delta >= 0;
const dir = t(
up
@@ -134,12 +174,7 @@ export function CyclePhaseHeadline({
const params = {
metric: metricLabel,
// A phase delta → factor-only conversion (no affine offset).
delta: fmt(
Math.abs(
mt ? unitDisplay.toDisplayDelta(mt, headline.delta) : headline.delta,
),
headline.display,
),
delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display),
unit,
dir,
};
@@ -175,15 +210,11 @@ export function CyclePhaseCrosstab({
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
// Defensive: an unmapped display must never pass undefined to `t`
// (whose resolver does `key.split(".")`). Empty unit on a miss.
const mt = DISPLAY_TO_TYPE[row.display];
const unitKey = UNIT_KEY[row.display];
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
const absConv = (v: number) =>
mt ? unitDisplay.toDisplay(mt, v) : v;
const deltaConv = (v: number) =>
mt ? unitDisplay.toDisplayDelta(mt, v) : v;
const {
unit,
abs: absConv,
delta: deltaConv,
} = rowUnits(row.display, unitDisplay, t);
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
// higher in the luteal phase. The number stays NEUTRAL — higher is
// good for steps but bad for resting HR, and this board is
@@ -0,0 +1,70 @@
/**
* A logged fever reads in the reader's temperature unit. The timeline and the
* day sheet printed a hardcoded °C, so an imperial account read its own
* fever in Celsius.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const account = vi.hoisted(() => ({
user: { unitPreference: "imperial", glucoseUnit: null } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManageDomain: () => true }),
}));
vi.mock("../use-illness", () => ({
useIllnessDayLogList: () => ({
isLoading: false,
isError: false,
data: {
dayLogs: [
{
id: "d1",
episodeId: "e1",
date: "2026-09-30",
functionalImpact: null,
feverC: 38.5,
symptoms: [],
note: null,
updatedAt: "2026-09-30T08:00:00.000Z",
},
],
},
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { IllnessDayTimeline } from "../illness-day-timeline";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<IllnessDayTimeline episodeId="e1" onLogDay={() => {}} />
</I18nProvider>,
);
}
describe("<IllnessDayTimeline> fever unit", () => {
it("shows the fever in °F for an imperial reader", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
const html = render();
// 38.5 °C = 101.3 °F.
expect(html).toContain("Temperature: 101.3 °F");
expect(html).not.toContain("°C");
});
it("keeps °C for a metric reader", () => {
account.user = { unitPreference: "metric", glucoseUnit: null };
expect(render()).toContain("Temperature: 38.5 °C");
});
});
@@ -19,6 +19,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
import { useIllnessDayLogList } from "./use-illness";
@@ -32,6 +33,7 @@ function symptomLabelKey(key: string): string | null {
function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
const { t } = useTranslations();
const fmt = useFormatters();
const { toDisplay, unitFor } = useUnitDisplay();
return (
<div className="border-border/60 space-y-2 border-l-2 pl-3">
@@ -72,7 +74,10 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
{log.feverC !== null ? (
<p className="text-foreground text-sm">
{t("illness.timeline.fever", { value: fmt.number(log.feverC, 1) })}
{t("illness.timeline.fever", {
value: fmt.number(toDisplay("BODY_TEMPERATURE", log.feverC), 1),
unit: unitFor("BODY_TEMPERATURE"),
})}
</p>
) : null}
+16 -4
View File
@@ -25,6 +25,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
import { useIllnessDayLog, useUpsertDayLog } from "./use-illness";
@@ -140,6 +141,10 @@ export function LogDaySheet({
onsetDate,
}: LogDaySheetProps) {
const { t } = useTranslations();
// The fever is stored in °C and typed in the reader's temperature unit:
// seeded through the transform, inverted on save.
const { toDisplay, fromDisplay, unitFor } = useUnitDisplay();
const feverUnit = unitFor("BODY_TEMPERATURE");
// The day being logged — seeded from `date` (today), but the picker lets the
// user backdate within [onset, today] for the retrospective journal.
@@ -178,7 +183,11 @@ export function LogDaySheet({
new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity ?? null])),
);
setFunctionalImpact(dto?.functionalImpact ?? null);
setFever(dto?.feverC != null ? String(dto.feverC) : "");
setFever(
dto?.feverC != null
? String(toDisplay("BODY_TEMPERATURE", dto.feverC))
: "",
);
setNote(dto?.note ?? "");
} else if (!open && lastFormKey !== null) {
setLastFormKey(null);
@@ -213,7 +222,10 @@ export function LogDaySheet({
const input: IllnessDayLogInput = {
date: selectedDate,
functionalImpact,
feverC: feverValue,
feverC:
feverValue == null
? null
: Math.round(fromDisplay("BODY_TEMPERATURE", feverValue) * 100) / 100,
symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({
key,
severity: severity ?? undefined,
@@ -333,7 +345,7 @@ export function LogDaySheet({
summary={
feverValue != null ? (
<span className="text-muted-foreground text-xs">
{feverValue.toFixed(1)} °C
{feverValue.toFixed(1)} {feverUnit}
</span>
) : null
}
@@ -345,7 +357,7 @@ export function LogDaySheet({
<Input
id="illness-fever"
inputMode="decimal"
placeholder="37.0"
placeholder={toDisplay("BODY_TEMPERATURE", 37).toFixed(1)}
value={fever}
onChange={(e) => setFever(e.target.value)}
className="max-w-32"
@@ -21,14 +21,40 @@ import { I18nProvider } from "@/lib/i18n/context";
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = ({ title }: { title?: string }) => (
<div data-slot="trends-row-chart-stub">{title ?? "chart"}</div>
const Stub = ({
title,
unit,
valueScale,
valueOffset,
}: {
title?: string;
unit?: string;
valueScale?: number;
valueOffset?: number;
}) => (
<div
data-slot="trends-row-chart-stub"
data-unit={unit}
data-scale={valueScale}
data-offset={valueOffset}
>
{title ?? "chart"}
</div>
);
Stub.displayName = "TrendsRowChartStub";
return Stub;
},
}));
const account = vi.hoisted(() => ({
user: null as Record<string, unknown> | null,
}));
vi.mock("@/hooks/use-auth", async (importOriginal) => ({
...(await importOriginal<typeof import("@/hooks/use-auth")>()),
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
import { TrendsRow } from "../trends-row";
import type { DailyBriefing } from "@/lib/ai/schema";
@@ -306,4 +332,27 @@ describe("<TrendsRow>", () => {
expect(html).toMatch(/data-metric="steps"/);
expect(html).toContain('href="/insights/steps"');
});
it("plots a transformed slot in the reader's unit, scale and offset included", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
try {
const html = render(
<TrendsRow briefing={briefing(["weight", "distance", "body_temp"])} />,
);
expect(html).toContain('data-unit="lb"');
expect(html).toContain('data-unit="mi"');
expect(html).toContain('data-unit="°F"');
expect(html).toContain('data-offset="32"');
expect(html).not.toContain('data-unit="kg"');
expect(html).not.toContain('data-unit="km"');
} finally {
account.user = null;
}
});
it("plots distance in kilometres, scaled from metres, for a metric reader", () => {
const html = render(<TrendsRow briefing={briefing(["distance"])} />);
expect(html).toContain('data-unit="km"');
expect(html).toContain('data-scale="0.001"');
});
});
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -16,6 +16,19 @@ import { WorkoutInsightCard } from "../workout-detail/insight-slot";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
// The unit preference comes from the account payload; a mutable stub lets a
// test switch the reader to imperial.
const account = vi.hoisted(() => ({
user: { unitPreference: "metric", glucoseUnit: null } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
/**
* #67 — `<WorkoutDetail*>` unit tests over the split `workout-detail/`
* directory. Each primitive runs through SSR with a canonical fixture;
@@ -149,6 +162,44 @@ describe("<WorkoutDetailStats>", () => {
expect(html).toContain("148 bpm");
});
it("states distance, pace, elevation and the average line in imperial units", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
try {
const html = render(
<WorkoutDetailStats
workout={{
...FIXTURE,
sportContext: {
count: 8,
avgDurationSec: 2040,
avgDistanceM: 5800,
avgAvgHr: 148,
},
}}
/>,
);
// 5200 m = 3.23 mi; 1800 s over it = 9:17 per mile; 12.5 m = 41.0 ft.
expect(html).toContain("3.23 mi");
expect(html).toContain("9:17 /mi");
expect(html).toContain("41.0 ft");
// The sport-average line: 5800 m = 3.60 mi.
expect(html).toContain("3.60 mi");
expect(html).not.toContain(" km");
expect(html).not.toContain("/km");
const header = render(<WorkoutDetailHeader workout={FIXTURE} />);
expect(header).toContain("3.23 mi");
} finally {
account.user = { unitPreference: "metric", glucoseUnit: null };
}
});
it("keeps kilometres, minutes per km and metres for a metric reader", () => {
const html = render(<WorkoutDetailStats workout={FIXTURE} />);
expect(html).toContain("5.20 km");
expect(html).toContain("5:46 /km");
expect(html).toContain("12.5 m");
});
it("hides the average line for a lone session", () => {
const lone: WorkoutDetailPayload = {
...FIXTURE,
@@ -1,10 +1,23 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { WorkoutList } from "../workout-list";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
// The unit preference comes from the account payload; a mutable stub lets a
// test switch the reader to imperial.
const account = vi.hoisted(() => ({
user: { unitPreference: "metric", glucoseUnit: null } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
/**
* v1.4.32 — `<WorkoutList>` unit tests.
*
@@ -47,6 +60,18 @@ describe("<WorkoutList>", () => {
expect(html).toContain("320 kcal");
});
it("states the distance in miles for an imperial reader", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
try {
const html = render(<WorkoutList workouts={[ROW]} />);
// 5200 m = 3.23 mi.
expect(html).toContain("3.23 mi");
expect(html).not.toContain(" km");
} finally {
account.user = { unitPreference: "metric", glucoseUnit: null };
}
});
it("renders aggregate average HR without claiming an HR series", () => {
const html = render(
<WorkoutList workouts={[{ ...ROW, hasHrSeries: false }]} />,
@@ -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
? {
+19 -5
View File
@@ -20,6 +20,7 @@ import {
import { cn } from "@/lib/utils";
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
import { apiGet } from "@/lib/api/api-fetch";
import { useUnitDisplay } from "@/hooks/use-unit-display";
/**
* v1.4.20 phase B3 — single-sentence AI annotation rendered directly
@@ -264,6 +265,7 @@ export function TrendDescriptorCaption({
}) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { toDisplay, toDisplayDelta, unitFor } = useUnitDisplay();
const isMood = kind === "mood";
const primaryType = types[0] ?? "";
@@ -323,18 +325,30 @@ export function TrendDescriptorCaption({
}
// The numeric series is already server-bounded to the trailing
// 30-day window by the fetch, so every returned row is in-window.
// Described in the reader's unit, the same one the chart above plots.
return (numericQuery.data ?? []).map((row) => ({
timestamp: new Date(row.measuredAt).getTime(),
value: row.value,
value: toDisplay(primaryType, row.value),
}));
}, [isMood, moodQuery.data, numericQuery.data]);
}, [isMood, moodQuery.data, numericQuery.data, toDisplay, primaryType]);
const descriptor = useMemo(() => {
const meta = TREND_SLOT_DESCRIPTOR_META[metric];
// A slot that converts its series converts its noise floor with it: the
// floor is declared canonically and is a difference (factor only).
const config = isMood
? MOOD_DESCRIPTOR_CONFIG
: TREND_SLOT_DESCRIPTOR_META[metric]?.config;
: meta?.unitType
? {
...meta.config,
absoluteFloor: toDisplayDelta(
meta.unitType,
meta.config.absoluteFloor,
),
}
: meta?.config;
return computeTrendDescriptor(points, config);
}, [points, isMood, metric]);
}, [points, isMood, metric, toDisplayDelta]);
// Tier 4 — genuinely too few points (also covers the in-flight window
// before the small series lands). Surface the real empty hint so the
@@ -361,7 +375,7 @@ export function TrendDescriptorCaption({
// copy defensively (should not happen for the legacy triple).
const copy = isMood
? moodDescriptorCopy(descriptor)
: (numericDescriptorCopy(metric, descriptor) ??
: (numericDescriptorCopy(metric, descriptor, unitFor) ??
moodDescriptorCopy(descriptor));
return (
+15 -2
View File
@@ -22,6 +22,7 @@ import {
type TrendAnnotationConfidenceBand,
type TrendAnnotationStatus,
} from "./trend-annotation";
import { useUnitDisplay } from "@/hooks/use-unit-display";
/**
* Trends row — a small chart per metric above a one-sentence
@@ -116,17 +117,29 @@ function MetricChart({
title: string;
}) {
const { user } = useAuth();
const { transformFor, isTransformed } = useUnitDisplay();
const userTimezone = user?.timezone;
if (config.kind === "mood") {
return <MoodChart title={title} mini />;
}
// A single transformed series (weight, distance, temperature) plots in
// the reader's unit: the chart folds scale and offset in at its read
// boundary, exactly as the metric's own insights page does. Distance
// used to plot raw metres under a "km" label for every reader.
const primary = config.types[0] ?? "";
const transform =
config.types.length === 1 && isTransformed(primary)
? transformFor(primary)
: null;
return (
<HealthChartDynamicMini
types={config.types}
title={title}
colors={config.colors}
unit={config.unit}
yAxisUnit={config.yAxisUnit}
unit={transform ? transform.displayUnit : config.unit}
yAxisUnit={transform ? undefined : config.yAxisUnit}
valueScale={transform?.factor}
valueOffset={transform?.offset}
mini
userTimezone={userTimezone}
/>
@@ -1,4 +1,10 @@
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
import {
applyDisplayTransform,
getQuantityTransform,
paceSecondsPerDistanceUnit,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import {
hourCycleOptions,
type TimeFormatPreference,
@@ -27,12 +33,38 @@ export function formatDurationMinutes(seconds: number, locale: string): string {
return formatNumber(minutes, locale);
}
export function formatDistanceKm(meters: number, locale: string): string {
const km = meters / 1000;
return getNumberFormat(locale, {
/**
* A workout distance in the reader's unit, with its symbol: "5.80 km" or
* "3.60 mi". The workout list, the detail header, the stat tile and the
* sport-average line all print through this, so none of them can keep
* kilometres for an imperial reader.
*/
export function formatDistance(
meters: number,
locale: string,
preference: UnitPreference,
): string {
const transform = getQuantityTransform("distance", preference);
const value = applyDisplayTransform(meters, transform);
const formatted = getNumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: km < 10 ? 2 : 1,
}).format(km);
minimumFractionDigits: value < 10 ? 2 : 1,
}).format(value);
return `${formatted} ${transform.displayUnit}`;
}
/** Elevation gain in the reader's unit with its symbol: "123.4 m" / "404.9 ft". */
export function formatElevation(
meters: number,
locale: string,
preference: UnitPreference,
): string {
const transform = getQuantityTransform("elevation", preference);
return `${formatNumber(
applyDisplayTransform(meters, transform),
locale,
transform.decimals,
)} ${transform.displayUnit}`;
}
export function formatNumber(
@@ -46,21 +78,36 @@ export function formatNumber(
}).format(value);
}
export function formatPaceMinPerKm(
/**
* Average pace per kilometre or per mile, by the reader's preference. Only
* meaningful for run / walk / hike / ride; the caller gates on distance +
* sport.
*/
export function formatPace(
durationSec: number,
meters: number,
preference: UnitPreference,
): string {
// Seconds-per-kilometre. Only meaningful for run / walk / hike / ride;
// the caller gates on distance + sport.
const secPerKm = (durationSec / meters) * 1000;
return formatPaceSeconds(secPerKm);
return formatPaceSeconds(
paceSecondsPerDistanceUnit(durationSec, meters, preference),
getQuantityTransform("distance", preference).displayUnit,
);
}
/** "m:ss /km" from a seconds-per-km value (used by pace + splits). */
export function formatPaceSeconds(secPerKm: number): string {
const m = Math.floor(secPerKm / 60);
const s = Math.round(secPerKm % 60);
return `${m}:${s.toString().padStart(2, "0")} /km`;
/**
* "m:ss /km" from a seconds-per-unit value. The splits table passes "km"
* explicitly: its rows are kilometre segments cut server-side, so their
* pace is per kilometre whatever the preference.
*/
export function formatPaceSeconds(secPerUnit: number, unit: string): string {
let m = Math.floor(secPerUnit / 60);
let s = Math.round(secPerUnit % 60);
// 359.6 s is 6:00, not 5:60.
if (s === 60) {
m += 1;
s = 0;
}
return `${m}:${s.toString().padStart(2, "0")} /${unit}`;
}
export function formatDateRange(
@@ -3,8 +3,9 @@
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDuration, formatDistanceKm, formatDateRange } from "./format";
import { formatDuration, formatDistance, formatDateRange } from "./format";
/**
* Hero header — the at-a-glance verdict row. Sport icon badge, source
@@ -30,6 +31,7 @@ function renderSportIconBadge(sportType: string) {
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const { preference } = useUnitDisplay();
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
@@ -67,7 +69,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
</span>
{workout.distanceM != null ? (
<span className="text-muted-foreground text-xs tabular-nums">
{formatDistanceKm(workout.distanceM, locale)} km
{formatDistance(workout.distanceM, locale, preference)}
</span>
) : null}
</div>
@@ -70,7 +70,7 @@ export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) {
: "py-1.5 text-right tabular-nums"
}
>
{formatPaceSeconds(s.paceSecPerKm)}
{formatPaceSeconds(s.paceSecPerKm, "km")}
</td>
</tr>
))}
@@ -17,12 +17,15 @@ import { TileHeader } from "@/components/insights/tile-header";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import type { UnitPreference } from "@/lib/measurements/display-transform";
import {
formatDuration,
formatDistanceKm,
formatDistance,
formatElevation,
formatNumber,
formatPaceMinPerKm,
formatPace,
formatDurationMinutes,
} from "./format";
@@ -69,12 +72,13 @@ const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
function sportAverageLine(
workout: WorkoutDetailPayload,
locale: string,
preference: UnitPreference,
): string | null {
const ctx = workout.sportContext;
if (!ctx || ctx.count < 2) return null;
const parts: string[] = [];
if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) {
parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`);
parts.push(formatDistance(ctx.avgDistanceM, locale, preference));
}
parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`);
if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`);
@@ -83,6 +87,7 @@ function sportAverageLine(
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
const { t, locale } = useTranslations();
const { preference } = useUnitDisplay();
const tiles: StatTileProps[] = [];
tiles.push({
@@ -95,7 +100,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Map className="size-4" />,
label: t("insights.workouts.detail.statsDistance"),
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
value: formatDistance(workout.distanceM, locale, preference),
});
}
@@ -139,7 +144,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Mountain className="size-4" />,
label: t("insights.workouts.detail.statsElevation"),
value: `${formatNumber(workout.elevationM, locale, 1)} m`,
value: formatElevation(workout.elevationM, locale, preference),
});
}
@@ -151,11 +156,11 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsPace"),
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM),
value: formatPace(workout.durationSec, workout.distanceM, preference),
});
}
const averageLine = sportAverageLine(workout, locale);
const averageLine = sportAverageLine(workout, locale, preference);
return (
<Card data-slot="workout-detail-stats">
+4 -9
View File
@@ -13,6 +13,8 @@ import {
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
import { Button } from "@/components/ui/button";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDistance } from "@/components/insights/workout-detail/format";
/**
* v1.4.32 — workout list row primitive.
@@ -33,14 +35,6 @@ function formatDuration(seconds: number): string {
return `${m}m`;
}
function formatDistanceKm(meters: number, locale: string): string {
const km = meters / 1000;
return getNumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: km < 10 ? 2 : 1,
}).format(km);
}
function formatEnergy(kcal: number, locale: string): string {
return getNumberFormat(locale, {
maximumFractionDigits: 0,
@@ -86,6 +80,7 @@ export function WorkoutList({
}: WorkoutListProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const { preference } = useUnitDisplay();
return (
<ul
@@ -150,7 +145,7 @@ export function WorkoutList({
<span className="text-muted-foreground flex flex-wrap justify-end gap-x-2 tabular-nums">
{workout.distanceM != null ? (
<span data-slot="workout-list-distance">
{formatDistanceKm(workout.distanceM, locale)} km
{formatDistance(workout.distanceM, locale, preference)}
</span>
) : null}
{workout.activeEnergyKcal != null ? (
@@ -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 };
+26 -39
View File
@@ -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`