From e1ca523e21914b0280b8d468d845ecc55eca4e31 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 25 Jul 2026 00:10:53 +0200
Subject: [PATCH] fix(insights): route every weight-class and temperature
surface through the unit transform
Wire the display transform into the chart scaffold, the chart itself (with an
affine offset for the temperature conversion), the dashboard trend tiles and
weight chart, the vitals grid, the coach read strip, and the device-score
tile. Each surface resolves its unit and scale from the user's preference at a
single choke point and stops hardcoding the canonical unit, so an imperial user
sees a consistent value, axis, band, tooltip, and range hint while a metric
user's render stays byte-identical. Retire the local celsius display shim now
that the transform owns the symbol.
---
src/app/insights/body-temperature/page.tsx | 2 -
src/app/insights/body-water/page.tsx | 2 -
src/app/insights/bone-mass/page.tsx | 2 -
src/app/insights/fat-free-mass/page.tsx | 2 -
src/app/insights/fat-mass/page.tsx | 2 -
src/app/insights/grip-strength/page.tsx | 2 -
src/app/insights/lean-body-mass/page.tsx | 2 -
src/app/insights/muscle-mass/page.tsx | 2 -
src/app/insights/skin-temperature/page.tsx | 3 -
src/app/insights/waist/page.tsx | 2 -
src/app/insights/walking-distance/page.tsx | 4 -
src/app/insights/walking-speed/page.tsx | 3 -
src/app/insights/weight/page.tsx | 50 ++++++-
src/app/insights/wrist-temperature/page.tsx | 2 -
src/app/page-client.tsx | 132 +++++++++++++-----
src/components/charts/health-chart.tsx | 18 ++-
.../insights/derived/coach-read-strip.tsx | 18 ++-
.../insights/derived/vitals-dashboard.tsx | 38 +++--
src/components/insights/device-score-tile.tsx | 32 +++--
.../insights/healthkit-metric-page.tsx | 90 +++++++++---
20 files changed, 279 insertions(+), 129 deletions(-)
diff --git a/src/app/insights/body-temperature/page.tsx b/src/app/insights/body-temperature/page.tsx
index db572f899..7c4527b5f 100644
--- a/src/app/insights/body-temperature/page.tsx
+++ b/src/app/insights/body-temperature/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsBodyTemperaturePage() {
i18nPrefix="insights.bodyTemperature"
explainerMetric="bodyTemperature"
color="var(--chart-3)"
- unit="°C"
- yAxisUnit="°C"
emptyStateIcon={}
emptyStateCtaType="BODY_TEMPERATURE"
coachPrefill="I haven't logged any body temperature yet — what's the healthy range, and what should I do if it drifts?"
diff --git a/src/app/insights/body-water/page.tsx b/src/app/insights/body-water/page.tsx
index d9563147f..006cb963b 100644
--- a/src/app/insights/body-water/page.tsx
+++ b/src/app/insights/body-water/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsKoerperwasserPage() {
i18nPrefix="insights.totalBodyWater"
explainerMetric="bodyWater"
color="var(--info)"
- unit="kg"
- yAxisUnit="kg"
emptyStateIcon={}
emptyStateCtaType={null}
captureType="TOTAL_BODY_WATER"
diff --git a/src/app/insights/bone-mass/page.tsx b/src/app/insights/bone-mass/page.tsx
index f5759fafb..d5235de3d 100644
--- a/src/app/insights/bone-mass/page.tsx
+++ b/src/app/insights/bone-mass/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsKnochenmassePage() {
i18nPrefix="insights.boneMass"
explainerMetric="boneMass"
color="var(--warning)"
- unit="kg"
- yAxisUnit="kg"
emptyStateIcon={}
emptyStateCtaType={null}
captureType="BONE_MASS"
diff --git a/src/app/insights/fat-free-mass/page.tsx b/src/app/insights/fat-free-mass/page.tsx
index 54875212f..6daf8006e 100644
--- a/src/app/insights/fat-free-mass/page.tsx
+++ b/src/app/insights/fat-free-mass/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsFettfreieMassePage() {
i18nPrefix="insights.fatFreeMass"
explainerMetric="fatFreeMass"
color="var(--chart-1)"
- unit="kg"
- yAxisUnit="kg"
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat-free mass yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/insights/fat-mass/page.tsx b/src/app/insights/fat-mass/page.tsx
index fa27ad08e..1ae453b10 100644
--- a/src/app/insights/fat-mass/page.tsx
+++ b/src/app/insights/fat-mass/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsFettmassePage() {
i18nPrefix="insights.fatMass"
explainerMetric="fatMass"
color="var(--warning)"
- unit="kg"
- yAxisUnit="kg"
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat mass yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/insights/grip-strength/page.tsx b/src/app/insights/grip-strength/page.tsx
index ae84730cb..52750739b 100644
--- a/src/app/insights/grip-strength/page.tsx
+++ b/src/app/insights/grip-strength/page.tsx
@@ -23,8 +23,6 @@ export default function InsightsGripStrengthPage() {
chartKey="gripStrength"
i18nPrefix="insights.gripStrength"
color="var(--success)"
- unit="kg"
- yAxisUnit="kg"
statIcon={Dumbbell}
emptyStateIcon={}
emptyStateCtaType="GRIP_STRENGTH"
diff --git a/src/app/insights/lean-body-mass/page.tsx b/src/app/insights/lean-body-mass/page.tsx
index e055eeb9c..e31113b3b 100644
--- a/src/app/insights/lean-body-mass/page.tsx
+++ b/src/app/insights/lean-body-mass/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsMagermassePage() {
i18nPrefix="insights.leanBodyMass"
explainerMetric="leanBodyMass"
color="var(--chart-1)"
- unit="kg"
- yAxisUnit="kg"
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any lean body mass yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/insights/muscle-mass/page.tsx b/src/app/insights/muscle-mass/page.tsx
index bf6fb6500..a4dc3c786 100644
--- a/src/app/insights/muscle-mass/page.tsx
+++ b/src/app/insights/muscle-mass/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsMuskelmassePage() {
i18nPrefix="insights.muscleMass"
explainerMetric="muscleMass"
color="var(--chart-1)"
- unit="kg"
- yAxisUnit="kg"
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any muscle mass yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/insights/skin-temperature/page.tsx b/src/app/insights/skin-temperature/page.tsx
index e04a3bcad..065dc2bb8 100644
--- a/src/app/insights/skin-temperature/page.tsx
+++ b/src/app/insights/skin-temperature/page.tsx
@@ -30,8 +30,6 @@ export default function InsightsHauttemperaturPage() {
i18nPrefix="insights.skinTemperature"
explainerMetric="skinTemperature"
color="var(--warning)"
- unit="°C"
- yAxisUnit="°C"
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any skin temperature yet — what does this metric tell me about my health, and how do I improve it?"
@@ -41,7 +39,6 @@ export default function InsightsHauttemperaturPage() {
title={t("measurements.typeBodyTemperatureDeviation")}
icon={ThermometerSnowflake}
color="var(--info)"
- unit="°C"
fractionDigits={2}
sectionTitle={t("insights.bodyTempDeviation.title")}
sectionIcon={ThermometerSnowflake}
diff --git a/src/app/insights/waist/page.tsx b/src/app/insights/waist/page.tsx
index 0d4106339..6c0d74bda 100644
--- a/src/app/insights/waist/page.tsx
+++ b/src/app/insights/waist/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsWaistPage() {
chartKey="waistCircumference"
i18nPrefix="insights.waist"
color="var(--info)"
- unit="cm"
- yAxisUnit="cm"
statIcon={Ruler}
emptyStateIcon={}
emptyStateCtaType="WAIST_CIRCUMFERENCE"
diff --git a/src/app/insights/walking-distance/page.tsx b/src/app/insights/walking-distance/page.tsx
index 0872c7890..26df9821e 100644
--- a/src/app/insights/walking-distance/page.tsx
+++ b/src/app/insights/walking-distance/page.tsx
@@ -22,10 +22,6 @@ export default function InsightsGehstreckePage() {
i18nPrefix="insights.walkingRunningDistance"
explainerMetric="walkingDistance"
color="var(--success)"
- unit="km"
- yAxisUnit="km"
- // Stored in metres; display daily totals in km (1 m = 0.001 km).
- valueScale={0.001}
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking and running distance yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/insights/walking-speed/page.tsx b/src/app/insights/walking-speed/page.tsx
index 8223c557e..bdb43a22f 100644
--- a/src/app/insights/walking-speed/page.tsx
+++ b/src/app/insights/walking-speed/page.tsx
@@ -22,9 +22,6 @@ export default function InsightsGehgeschwindigkeitPage() {
i18nPrefix="insights.walkingSpeed"
explainerMetric="walkingSpeed"
color="var(--success)"
- unit="km/h"
- yAxisUnit="km/h"
- valueScale={3.6}
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking speed yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx
index cfa335005..4bcae9c10 100644
--- a/src/app/insights/weight/page.tsx
+++ b/src/app/insights/weight/page.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Scale } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
@@ -36,10 +37,38 @@ export default function InsightsGewichtPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
+ const unitDisplay = useUnitDisplay();
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
+ // v1.32.26 — resolve the weight display unit + scale from the user's
+ // preference (kg for metric, lb for imperial). This page inlines the shell
+ // rather than riding the scaffold, so it converts the stat strip + chart
+ // series + bands here. Weight has no affine offset, so the factor alone
+ // covers both the values and the bands.
+ const weightTransform = unitDisplay.transformFor("WEIGHT");
+ const weightUnit = weightTransform.displayUnit;
+ const weightScale = weightTransform.factor;
+ const displaySummary =
+ weightSummary && weightScale !== 1
+ ? {
+ ...weightSummary,
+ min:
+ weightSummary.min === null ? null : weightSummary.min * weightScale,
+ max:
+ weightSummary.max === null ? null : weightSummary.max * weightScale,
+ mean:
+ weightSummary.mean === null
+ ? null
+ : weightSummary.mean * weightScale,
+ median:
+ weightSummary.median === null
+ ? null
+ : weightSummary.median * weightScale,
+ }
+ : weightSummary;
+
// v1.12.8 — visible-range stats shared between the chart and the strip.
const { statsByType, onVisibleStats } = useChartDomainStats();
@@ -73,6 +102,16 @@ export default function InsightsGewichtPage() {
upperBound: 250,
})
: undefined;
+ // The bands are kg bounds → scale into the display unit so the shaded zone
+ // tracks the converted line (factor-only; weight carries no offset).
+ const displayBands =
+ weightBands && weightScale !== 1
+ ? weightBands.map((band) => ({
+ ...band,
+ min: band.min * weightScale,
+ max: band.max * weightScale,
+ }))
+ : weightBands;
return (
}
- coachReadStrip={}
+ coachReadStrip={}
diversityNudge={
diff --git a/src/app/insights/wrist-temperature/page.tsx b/src/app/insights/wrist-temperature/page.tsx
index eedb62695..9acb07bd5 100644
--- a/src/app/insights/wrist-temperature/page.tsx
+++ b/src/app/insights/wrist-temperature/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsWristTemperaturePage() {
i18nPrefix="insights.wristTemperature"
explainerMetric="wristTemperature"
color="var(--warning)"
- unit="°C"
- yAxisUnit="°C"
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any wrist-temperature data yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx
index 0b8e4af10..f4c67f5b3 100644
--- a/src/app/page-client.tsx
+++ b/src/app/page-client.tsx
@@ -3,6 +3,7 @@
import React, { Suspense, useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
@@ -225,6 +226,18 @@ export default function DashboardPageClient({
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
+ // v1.32.26 — metric/imperial display choke point for the dashboard tiles +
+ // weight chart. `td` converts an ABSOLUTE reading (affine, so a temperature
+ // picks up the °F offset); `tdd` converts a DELTA / slope (factor only, so a
+ // Δ never inherits the offset). Metric users take the identity path.
+ const unitDisplay = useUnitDisplay();
+ const td = (type: string, value: number | null | undefined): number | null =>
+ value == null ? null : unitDisplay.toDisplay(type, value);
+ const tdd = (
+ type: string,
+ value: number | null | undefined,
+ ): number | null =>
+ value == null ? null : unitDisplay.toDisplayDelta(type, value);
const [quickEntryDialog, setQuickEntryDialog] =
useState(null);
@@ -713,7 +726,29 @@ export default function DashboardPageClient({
const bands = serverBands ?? clientBands;
const bpTargets = bands.bpTargets;
const weightRange = bands.weightRange;
+ // The range hint tooltip prints its band bounds in the display unit, so scale
+ // the kg thresholds by the weight factor (offset-free). The colour-classing
+ // below stays in canonical kg — it compares a canonical avg against the
+ // canonical range, so both sides remain in the same unit.
+ const weightHintRange = weightRange
+ ? {
+ greenMin: unitDisplay.toDisplay("WEIGHT", weightRange.greenMin),
+ greenMax: unitDisplay.toDisplay("WEIGHT", weightRange.greenMax),
+ orangeMin: unitDisplay.toDisplay("WEIGHT", weightRange.orangeMin),
+ orangeMax: unitDisplay.toDisplay("WEIGHT", weightRange.orangeMax),
+ }
+ : weightRange;
const weightBands = bands.weightBands ?? undefined;
+ // Scale the kg chart bands into the display unit so the shaded zone tracks
+ // the converted line (factor-only; weight has no offset). Metric = identity.
+ const weightDisplayBands =
+ weightBands && unitDisplay.preference === "imperial"
+ ? weightBands.map((band) => ({
+ ...band,
+ min: unitDisplay.toDisplay("WEIGHT", band.min),
+ max: unitDisplay.toDisplay("WEIGHT", band.max),
+ }))
+ : weightBands;
const bpTargetZones = bpTargets
? [
{
@@ -897,10 +932,10 @@ export default function DashboardPageClient({
),
@@ -1276,16 +1311,22 @@ export default function DashboardPageClient({
),
@@ -1302,16 +1343,22 @@ export default function DashboardPageClient({
),
@@ -1325,16 +1372,22 @@ export default function DashboardPageClient({
),
@@ -1348,16 +1401,22 @@ export default function DashboardPageClient({
),
@@ -1554,10 +1613,11 @@ export default function DashboardPageClient({
types={["WEIGHT"]}
title={t("dashboard.weight")}
colors={["var(--chart-1)"]}
- unit="kg"
- valueBands={weightBands}
+ unit={unitDisplay.unitFor("WEIGHT")}
+ valueBands={weightDisplayBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
+ valueScale={unitDisplay.transformFor("WEIGHT").factor}
/>
),
});
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index 66e0a22b7..f7d590153 100644
--- a/src/components/charts/health-chart.tsx
+++ b/src/components/charts/health-chart.tsx
@@ -220,6 +220,15 @@ interface HealthChartProps {
* uniformly.
*/
valueScale?: number;
+ /**
+ * v1.32.26 — display-time additive offset folded in alongside
+ * `valueScale` at the same read boundary: `display = raw * valueScale +
+ * valueOffset`. Required for the affine °C→°F conversion (a shift, not
+ * just a scale). Defaults to `0`. Because every tooltip delta / trend
+ * slope is computed as a DIFFERENCE of two offset values, the offset
+ * cancels there automatically — a rate never inherits the +32 shift.
+ */
+ valueOffset?: number;
/**
* v1.12.8 — chart-reactive metric statistics. When supplied, the chart
* reports the per-type Min / Max / Median / Mean of the data currently
@@ -586,6 +595,7 @@ export function HealthChart({
verticalMarkers,
userTimezone = "Europe/Berlin",
valueScale = 1,
+ valueOffset = 0,
onVisibleStats,
titleIcon,
onDataReady,
@@ -832,7 +842,7 @@ export function HealthChart({
}>(`/api/measurements/series?${sleepParams}`);
const points = data?.points ?? [];
for (const point of points) {
- const value = point.value * valueScale;
+ const value = point.value * valueScale + valueOffset;
if (value == null || !Number.isFinite(value)) continue;
const dayKey = toDayKey(point.at, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
@@ -927,7 +937,7 @@ export function HealthChart({
// v1.15.20 — the BMI division moved into the query's `select`
// callback so the cached series stays raw and the WEIGHT and
// BMI views share one cache entry.
- const value = measurement.value * valueScale;
+ const value = measurement.value * valueScale + valueOffset;
if (!Number.isFinite(value)) {
continue;
@@ -957,8 +967,8 @@ export function HealthChart({
typeof measurement.minValue === "number" &&
typeof measurement.maxValue === "number"
) {
- const scaledMin = measurement.minValue * valueScale;
- const scaledMax = measurement.maxValue * valueScale;
+ const scaledMin = measurement.minValue * valueScale + valueOffset;
+ const scaledMax = measurement.maxValue * valueScale + valueOffset;
current.min =
current.min === null
? scaledMin
diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx
index 94a847fee..10c5bf57e 100644
--- a/src/components/insights/derived/coach-read-strip.tsx
+++ b/src/components/insights/derived/coach-read-strip.tsx
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -60,6 +61,15 @@ export function CoachReadStrip({
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
const mounted = useMounted();
+ // v1.32.26 — a type with a registered metric/imperial transform resolves its
+ // display unit + conversion from the user's preference and IGNORES the
+ // page-passed `unit` / `valueScale` (weight labelled "kg" would otherwise
+ // never follow the toggle). The band edges + today's value are ABSOLUTE, so
+ // the affine `toDisplay` (factor + offset) is correct here. Untransformed
+ // metrics keep the props (glucose passes its own unit + reciprocal scale).
+ const unitDisplay = useUnitDisplay();
+ const transformed = unitDisplay.isTransformed(metricType);
+ const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit;
const { data } = useQuery({
queryKey: queryKeys.insightsCoachRead(metricType),
@@ -80,7 +90,11 @@ export function CoachReadStrip({
new Intl.NumberFormat(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits,
- }).format(value * valueScale);
+ }).format(
+ transformed
+ ? unitDisplay.toDisplay(metricType, value)
+ : value * valueScale,
+ );
const baselineLine = ((): string | null => {
if (data.learning || !data.baseline) {
@@ -97,7 +111,7 @@ export function CoachReadStrip({
low: fmt(low),
high: fmt(high),
value: fmt(latest),
- unit,
+ unit: resolvedUnit,
});
})();
diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx
index c671b186b..a643b3c67 100644
--- a/src/components/insights/derived/vitals-dashboard.tsx
+++ b/src/components/insights/derived/vitals-dashboard.tsx
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { getUnitForType } from "@/lib/validations/measurement";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
MEASUREMENT_TYPE_ICONS,
MEASUREMENT_TYPE_LABEL_KEYS,
@@ -70,23 +71,6 @@ const UP_BAD_VITALS = new Set([
/** Up-is-good for the vitals where a rise is favourable. */
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
-/**
- * Storage-unit → display-symbol overrides. `getUnitForType` returns the
- * canonical *storage* string (e.g. the literal "celsius"); a tile must show
- * the symbol a user reads ("°C"). Only the units whose storage string is not
- * already a display symbol need an entry — kg / bpm / ms / % / mg/dL render
- * fine as-is.
- */
-const DISPLAY_UNIT_SYMBOL: Record = {
- celsius: "°C",
- fahrenheit: "°F",
-};
-
-function displayUnit(type: string): string {
- const storage = getUnitForType(type);
- return DISPLAY_UNIT_SYMBOL[storage] ?? storage;
-}
-
function vitalSentiment(type: string): TrendDirectionSentiment {
if (UP_BAD_VITALS.has(type)) return "up-bad";
if (UP_GOOD_VITALS.has(type)) return "up-good";
@@ -229,6 +213,7 @@ function BaselineTile({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
+ const unitDisplay = useUnitDisplay();
// `VITALS_BASELINE` is the type-generic engine and needs the type on the
// token; the dedicated bands pin their own single input, so no `type`.
const data = read(
@@ -247,7 +232,12 @@ function BaselineTile({
const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Gauge;
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
const label = labelKey ? t(labelKey) : type;
- const unit = displayUnit(type);
+ // v1.32.26 — resolve the display unit from the user's preference. For an
+ // absolute temperature this converts celsius→°F (affine); the reporter's
+ // proven canonical-label bug (a value labelled by the storage unit rather
+ // than the preferred one) is fixed here, retiring the local celsius shim.
+ const unit = unitDisplay.unitFor(type);
+ const toShown = (raw: number) => unitDisplay.toDisplay(type, raw);
// `VITALS_BASELINE` tiles tag the DOM by their vital type (the stable
// contract the existing surfaces + tests key on); the dedicated bands tag
// by their own metric id.
@@ -283,19 +273,23 @@ function BaselineTile({
}
const v = data.value!;
+ // Range bounds + centre + sparkline are ABSOLUTE readings → affine-convert.
const framing = t("insights.derived.vitals.typicalRange", {
- low: fmt.number(v.low, 1),
- high: fmt.number(v.high, 1),
+ low: fmt.number(toShown(v.low), 1),
+ high: fmt.number(toShown(v.high), 1),
});
+ const displaySeries = unitDisplay.isTransformed(type)
+ ? v.series.map(toShown)
+ : v.series;
return (
- value.toLocaleString(resolveIntlLocale(locale), {
- minimumFractionDigits: fractionDigits,
- maximumFractionDigits: fractionDigits,
- });
+ (transform ? unitDisplay.toDisplay(type, value) : value).toLocaleString(
+ resolveIntlLocale(locale),
+ {
+ minimumFractionDigits: fractionDigits,
+ maximumFractionDigits: fractionDigits,
+ },
+ );
return (
{fmt(latest)}
- {unit ? (
+ {resolvedUnit ? (
- {unit}
+ {resolvedUnit}
) : null}
@@ -136,8 +150,10 @@ export function DeviceScoreTile({
types={[type]}
title={title}
colors={[color]}
- unit={unit ?? ""}
+ unit={resolvedUnit ?? ""}
mini
+ valueScale={transform ? transform.factor : undefined}
+ valueOffset={transform ? (transform.offset ?? 0) : undefined}
userTimezone={user?.timezone}
/>
@@ -147,7 +163,7 @@ export function DeviceScoreTile({
className="text-muted-foreground text-xs"
>
{t("insights.deviceScore.average", { value: fmt(mean) })}
- {unit ? ` ${unit}` : ""}
+ {resolvedUnit ? ` ${resolvedUnit}` : ""}
) : null}
>
diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx
index d033aaed5..e551109f1 100644
--- a/src/components/insights/healthkit-metric-page.tsx
+++ b/src/components/insights/healthkit-metric-page.tsx
@@ -6,6 +6,7 @@ import type { ComponentProps, ComponentType, ReactNode } from "react";
import { RefreshCw, Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useChartDomainStats } from "@/hooks/use-chart-domain-stats";
@@ -88,8 +89,14 @@ export interface HealthKitMetricPageProps {
* token retune (see UI standards, colour token rules).
*/
color: `var(--${string})`;
- /** Unit suffix the chart renders next to the value. */
- unit: string;
+ /**
+ * Unit suffix the chart renders next to the value. Optional: for a type
+ * with a registered metric/imperial transform (weight-class, temperature,
+ * waist) the scaffold resolves the unit + scale from the user's preference
+ * and IGNORES any page-passed `unit` / `yAxisUnit` / `valueScale`. Pages for
+ * transformed types omit these props; everything else still passes `unit`.
+ */
+ unit?: string;
/** Optional y-axis label shown above the unit. */
yAxisUnit?: string;
/** Optional value bands (Apple-Health-style target zone shading). */
@@ -246,22 +253,60 @@ export function HealthKitMetricPage({
? (fallbackMeasurementType as string)
: measurementType;
+ // v1.32.26 — resolve the display unit + scale from the user's preference for
+ // a type with a registered transform (kg↔lb, cm↔in, °C↔°F). When resolved,
+ // the scaffold OWNS the unit / y-axis unit / value scale / offset / bands +
+ // stat precision and IGNORES the page-passed values, so no page can double-
+ // scale or drift its unit label from the line. Untransformed types keep the
+ // page-passed props exactly (glucose, pulse, %, scores, …).
+ const unitDisplay = useUnitDisplay();
+ const transform = unitDisplay.isTransformed(effectiveType)
+ ? unitDisplay.transformFor(effectiveType)
+ : null;
+ const resolvedUnit = transform ? transform.displayUnit : unit;
+ const resolvedYAxisUnit = transform ? transform.displayUnit : yAxisUnit;
+ const resolvedScale = transform ? transform.factor : (valueScale ?? 1);
+ const resolvedOffset = transform ? (transform.offset ?? 0) : 0;
+ const resolvedFractionDigits = transform
+ ? transform.decimals
+ : statFractionDigits;
+ // Value bands are absolute bounds → affine-convert (factor + offset) so the
+ // shaded zone tracks the converted line. Metric users get the identity.
+ const resolvedBands =
+ transform && valueBands
+ ? valueBands.map((band) => ({
+ ...band,
+ min: unitDisplay.toDisplay(effectiveType, band.min),
+ max: unitDisplay.toDisplay(effectiveType, band.max),
+ }))
+ : valueBands;
+
const rawSummary = analytics?.summaries?.[effectiveType] ?? null;
// The stat strip renders display-unit values. The summary holds stored
- // values, so when the page renders a scaled unit (e.g. WALKING_SPEED
- // stores m/s but displays km/h via `valueScale`), fold the same scale
- // into the strip's min / max / median / mean so the numbers and the
- // unit agree. `valueScale` defaults to 1 (identity) → byte-identical
- // for every non-scaled metric.
- const scale = valueScale ?? 1;
+ // values, so fold the resolved scale + offset (affine) into the strip's
+ // min / max / median / mean so the numbers and the unit agree. All four are
+ // ABSOLUTE values, so they take the offset; an untransformed metric keeps
+ // scale 1 / offset 0 → byte-identical.
const summary =
- rawSummary && scale !== 1
+ rawSummary && (resolvedScale !== 1 || resolvedOffset !== 0)
? {
...rawSummary,
- min: rawSummary.min === null ? null : rawSummary.min * scale,
- max: rawSummary.max === null ? null : rawSummary.max * scale,
- mean: rawSummary.mean === null ? null : rawSummary.mean * scale,
- median: rawSummary.median === null ? null : rawSummary.median * scale,
+ min:
+ rawSummary.min === null
+ ? null
+ : rawSummary.min * resolvedScale + resolvedOffset,
+ max:
+ rawSummary.max === null
+ ? null
+ : rawSummary.max * resolvedScale + resolvedOffset,
+ mean:
+ rawSummary.mean === null
+ ? null
+ : rawSummary.mean * resolvedScale + resolvedOffset,
+ median:
+ rawSummary.median === null
+ ? null
+ : rawSummary.median * resolvedScale + resolvedOffset,
}
: rawSummary;
@@ -353,8 +398,8 @@ export function HealthKitMetricPage({
statStrip={
}
diversityNudge={
@@ -393,12 +438,13 @@ export function HealthKitMetricPage({
}
titleIcon={statIcon}
colors={[color]}
- unit={unit}
- yAxisUnit={yAxisUnit}
- valueBands={valueBands}
+ unit={resolvedUnit}
+ yAxisUnit={resolvedYAxisUnit}
+ valueBands={resolvedBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
- valueScale={valueScale}
+ valueScale={resolvedScale}
+ valueOffset={resolvedOffset}
onVisibleStats={onVisibleStats}
/>
{targetSummarySlug ? (