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 ? (