mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
dd7213db5c
commit
e1ca523e21
20 files changed
+279
-129
No files matched your search
@@ -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={
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user