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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 00:10:53 +02:00
1 parent dd7213db5c
commit e1ca523e21
20 files changed
+279 -129

No files matched your search

@@ -22,8 +22,6 @@ export default function InsightsBodyTemperaturePage() {
i18nPrefix="insights.bodyTemperature"
explainerMetric="bodyTemperature"
color="var(--chart-3)"
unit="°C"
yAxisUnit="°C"
emptyStateIcon={<Thermometer className="size-6" />}
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?"
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsKoerperwasserPage() {
i18nPrefix="insights.totalBodyWater"
explainerMetric="bodyWater"
color="var(--info)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType={null}
captureType="TOTAL_BODY_WATER"
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsKnochenmassePage() {
i18nPrefix="insights.boneMass"
explainerMetric="boneMass"
color="var(--warning)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Bone className="size-6" />}
emptyStateCtaType={null}
captureType="BONE_MASS"
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsFettfreieMassePage() {
i18nPrefix="insights.fatFreeMass"
explainerMetric="fatFreeMass"
color="var(--chart-1)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Scale className="size-6" />}
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?"
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsFettmassePage() {
i18nPrefix="insights.fatMass"
explainerMetric="fatMass"
color="var(--warning)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Droplets className="size-6" />}
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?"
-2
View File
@@ -23,8 +23,6 @@ export default function InsightsGripStrengthPage() {
chartKey="gripStrength"
i18nPrefix="insights.gripStrength"
color="var(--success)"
unit="kg"
yAxisUnit="kg"
statIcon={Dumbbell}
emptyStateIcon={<Dumbbell className="size-6" />}
emptyStateCtaType="GRIP_STRENGTH"
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsMagermassePage() {
i18nPrefix="insights.leanBodyMass"
explainerMetric="leanBodyMass"
color="var(--chart-1)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Scale className="size-6" />}
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?"
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsMuskelmassePage() {
i18nPrefix="insights.muscleMass"
explainerMetric="muscleMass"
color="var(--chart-1)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Dumbbell className="size-6" />}
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?"
@@ -30,8 +30,6 @@ export default function InsightsHauttemperaturPage() {
i18nPrefix="insights.skinTemperature"
explainerMetric="skinTemperature"
color="var(--warning)"
unit="°C"
yAxisUnit="°C"
emptyStateIcon={<Thermometer className="size-6" />}
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}
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsWaistPage() {
chartKey="waistCircumference"
i18nPrefix="insights.waist"
color="var(--info)"
unit="cm"
yAxisUnit="cm"
statIcon={Ruler}
emptyStateIcon={<Ruler className="size-6" />}
emptyStateCtaType="WAIST_CIRCUMFERENCE"
@@ -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={<Footprints className="size-6" />}
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?"
-3
View File
@@ -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={<Gauge className="size-6" />}
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?"
+45 -5
View File
@@ -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 (
<SubPageShell
@@ -81,14 +120,14 @@ export default function InsightsGewichtPage() {
explainerMetric="weight"
statStrip={
<MetricStatStrip
summary={weightSummary}
unit="kg"
summary={displaySummary}
unit={weightUnit}
seriesLabel={t("insights.weightSectionTitle")}
icon={Scale}
windowStats={statsByType?.WEIGHT ?? null}
/>
}
coachReadStrip={<CoachReadStrip metricType="WEIGHT" unit="kg" />}
coachReadStrip={<CoachReadStrip metricType="WEIGHT" unit={weightUnit} />}
diversityNudge={
<MeasurementDiversityNudge
measurementType="WEIGHT"
@@ -106,10 +145,11 @@ export default function InsightsGewichtPage() {
title={t("charts.weight")}
titleIcon={Scale}
colors={["var(--chart-1)"]}
unit="kg"
valueBands={weightBands}
unit={weightUnit}
valueBands={displayBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
valueScale={weightScale}
onVisibleStats={onVisibleStats}
/>
@@ -22,8 +22,6 @@ export default function InsightsWristTemperaturePage() {
i18nPrefix="insights.wristTemperature"
explainerMetric="wristTemperature"
color="var(--warning)"
unit="°C"
yAxisUnit="°C"
emptyStateIcon={<Thermometer className="size-6" />}
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?"
+96 -36
View File
@@ -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<QuickEntryDialog>(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({
<TrendCard
key="weight"
label={t("dashboard.weightShort")}
latest={w?.latest ?? null}
unit="kg"
avg7={w?.avg7 ?? null}
avg30={w?.avg30 ?? null}
latest={td("WEIGHT", w?.latest)}
unit={unitDisplay.unitFor("WEIGHT")}
avg7={td("WEIGHT", w?.avg7)}
avg30={td("WEIGHT", w?.avg30)}
avg7ColorClass={getRangeColorClass(w?.avg7, {
range: weightRange,
})}
@@ -908,23 +943,23 @@ export default function DashboardPageClient({
range: weightRange,
})}
avg7Hint={getRangeHint(
"kg",
{ range: weightRange },
unitDisplay.unitFor("WEIGHT"),
{ range: weightHintRange },
t,
fmt.number,
)}
avg30Hint={getRangeHint(
"kg",
{ range: weightRange },
unitDisplay.unitFor("WEIGHT"),
{ range: weightHintRange },
t,
fmt.number,
)}
slope30={w?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(w)}
trend7Delta={tdd("WEIGHT", summaryToTrend7Delta(w))}
icon={Activity}
directionSentiment="up-bad"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(w)}
compareDelta={tdd("WEIGHT", tileCompareDelta(w))}
staleDays={tileStaleDays("WEIGHT")}
/>
),
@@ -1276,16 +1311,22 @@ export default function DashboardPageClient({
<TrendCard
key="wristTemperature"
label={t("measurements.typeWristTemperature")}
latest={wristTempSummary?.latest ?? null}
unit="°C"
avg7={wristTempSummary?.avg7 ?? null}
avg30={wristTempSummary?.avg30 ?? null}
latest={td("WRIST_TEMPERATURE", wristTempSummary?.latest)}
unit={unitDisplay.unitFor("WRIST_TEMPERATURE")}
avg7={td("WRIST_TEMPERATURE", wristTempSummary?.avg7)}
avg30={td("WRIST_TEMPERATURE", wristTempSummary?.avg30)}
slope30={wristTempSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(wristTempSummary)}
trend7Delta={tdd(
"WRIST_TEMPERATURE",
summaryToTrend7Delta(wristTempSummary),
)}
icon={Thermometer}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(wristTempSummary)}
compareDelta={tdd(
"WRIST_TEMPERATURE",
tileCompareDelta(wristTempSummary),
)}
staleDays={tileStaleDays("WRIST_TEMPERATURE")}
/>
),
@@ -1302,16 +1343,22 @@ export default function DashboardPageClient({
<TrendCard
key="muscleMass"
label={t("measurements.typeMuscleMass")}
latest={muscleMassSummary?.latest ?? null}
unit="kg"
avg7={muscleMassSummary?.avg7 ?? null}
avg30={muscleMassSummary?.avg30 ?? null}
latest={td("MUSCLE_MASS", muscleMassSummary?.latest)}
unit={unitDisplay.unitFor("MUSCLE_MASS")}
avg7={td("MUSCLE_MASS", muscleMassSummary?.avg7)}
avg30={td("MUSCLE_MASS", muscleMassSummary?.avg30)}
slope30={muscleMassSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(muscleMassSummary)}
trend7Delta={tdd(
"MUSCLE_MASS",
summaryToTrend7Delta(muscleMassSummary),
)}
icon={Dumbbell}
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(muscleMassSummary)}
compareDelta={tdd(
"MUSCLE_MASS",
tileCompareDelta(muscleMassSummary),
)}
staleDays={tileStaleDays("MUSCLE_MASS")}
/>
),
@@ -1325,16 +1372,22 @@ export default function DashboardPageClient({
<TrendCard
key="totalBodyWater"
label={t("measurements.typeTotalBodyWater")}
latest={bodyWaterSummary?.latest ?? null}
unit="kg"
avg7={bodyWaterSummary?.avg7 ?? null}
avg30={bodyWaterSummary?.avg30 ?? null}
latest={td("TOTAL_BODY_WATER", bodyWaterSummary?.latest)}
unit={unitDisplay.unitFor("TOTAL_BODY_WATER")}
avg7={td("TOTAL_BODY_WATER", bodyWaterSummary?.avg7)}
avg30={td("TOTAL_BODY_WATER", bodyWaterSummary?.avg30)}
slope30={bodyWaterSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(bodyWaterSummary)}
trend7Delta={tdd(
"TOTAL_BODY_WATER",
summaryToTrend7Delta(bodyWaterSummary),
)}
icon={Droplet}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(bodyWaterSummary)}
compareDelta={tdd(
"TOTAL_BODY_WATER",
tileCompareDelta(bodyWaterSummary),
)}
staleDays={tileStaleDays("TOTAL_BODY_WATER")}
/>
),
@@ -1348,16 +1401,22 @@ export default function DashboardPageClient({
<TrendCard
key="boneMass"
label={t("measurements.typeBoneMass")}
latest={boneMassSummary?.latest ?? null}
unit="kg"
avg7={boneMassSummary?.avg7 ?? null}
avg30={boneMassSummary?.avg30 ?? null}
latest={td("BONE_MASS", boneMassSummary?.latest)}
unit={unitDisplay.unitFor("BONE_MASS")}
avg7={td("BONE_MASS", boneMassSummary?.avg7)}
avg30={td("BONE_MASS", boneMassSummary?.avg30)}
slope30={boneMassSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(boneMassSummary)}
trend7Delta={tdd(
"BONE_MASS",
summaryToTrend7Delta(boneMassSummary),
)}
icon={Bone}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(boneMassSummary)}
compareDelta={tdd(
"BONE_MASS",
tileCompareDelta(boneMassSummary),
)}
staleDays={tileStaleDays("BONE_MASS")}
/>
),
@@ -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}
/>
),
});
+14 -4
View File
@@ -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
@@ -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,
});
})();
@@ -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<string, string> = {
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<VitalsBaselineValue>(
@@ -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 (
<div data-slot="vitals-tile" data-metric={tileId} data-state="ok">
<SparklineDeltaTile
label={label}
value={v.center}
value={toShown(v.center)}
unit={unit}
icon={Icon}
series={v.series}
series={displaySeries}
framing={framing}
directionSentiment={vitalSentiment(type)}
provenance={
+24 -8
View File
@@ -3,6 +3,7 @@
import type { ComponentType } from "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 { resolveIntlLocale } from "@/lib/format-locale";
@@ -75,6 +76,16 @@ export function DeviceScoreTile({
}: DeviceScoreTileProps) {
const { t, locale } = useTranslations();
const { user } = useAuth();
// v1.32.26 — a type with a registered metric/imperial transform resolves its
// display unit + conversion from the user's preference (e.g. the Oura body-
// temperature DEVIATION renders in Δ°F for an imperial user). The affine
// `toDisplay` is correct for the readout: the deviation transform carries no
// offset (a Δ°C → Δ°F is factor-only), so no absolute shift leaks in.
const unitDisplay = useUnitDisplay();
const transform = unitDisplay.isTransformed(type)
? unitDisplay.transformFor(type)
: null;
const resolvedUnit = transform ? transform.displayUnit : unit;
const count = summary?.count ?? 0;
if (count === 0) return null;
@@ -84,10 +95,13 @@ export function DeviceScoreTile({
const isLearning = count < learningThreshold;
const fmt = (value: number) =>
value.toLocaleString(resolveIntlLocale(locale), {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
(transform ? unitDisplay.toDisplay(type, value) : value).toLocaleString(
resolveIntlLocale(locale),
{
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
},
);
return (
<Card
@@ -112,9 +126,9 @@ export function DeviceScoreTile({
className="text-foreground text-lg font-semibold tabular-nums"
>
{fmt(latest)}
{unit ? (
{resolvedUnit ? (
<span className="text-muted-foreground ml-1 text-xs font-normal">
{unit}
{resolvedUnit}
</span>
) : null}
</span>
@@ -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}
/>
</div>
@@ -147,7 +163,7 @@ export function DeviceScoreTile({
className="text-muted-foreground text-xs"
>
{t("insights.deviceScore.average", { value: fmt(mean) })}
{unit ? ` ${unit}` : ""}
{resolvedUnit ? ` ${resolvedUnit}` : ""}
</p>
) : null}
</>
@@ -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={
<MetricStatStrip
summary={summary}
unit={yAxisUnit ?? unit}
fractionDigits={statFractionDigits}
unit={resolvedYAxisUnit ?? resolvedUnit ?? ""}
fractionDigits={resolvedFractionDigits}
seriesLabel={title}
icon={statIcon}
windowStats={statsByType?.[effectiveType] ?? null}
@@ -364,9 +409,9 @@ export function HealthKitMetricPage({
coachReadStrip={
<CoachReadStrip
metricType={effectiveType}
unit={yAxisUnit ?? unit}
fractionDigits={statFractionDigits}
valueScale={valueScale}
unit={resolvedYAxisUnit ?? resolvedUnit ?? ""}
fractionDigits={resolvedFractionDigits}
valueScale={resolvedScale}
/>
}
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 ? (