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