fix(units): show every measurement unit in the reader's language

The vitals tiles printed the canonical storage token beside the value, so
a German screen read "15,4 breaths/min". The list view had a private map
for a few count types; every other path passed the token through.

useUnitDisplay().unitFor now resolves the token through one module
(lib/measurements/unit-label): word tokens map to bundle keys, "celsius"
to its symbol, kind markers (score, rating, count, event, level, ratio) to
nothing. The list, the day view and the vitals tiles all read it. A guard
walks every measurement type and every display source and refuses a unit
that is neither a shared symbol nor resolved through the bundle.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 15:07:11 +02:00
1 parent 1d9758e3e8
commit a934e948d8
10 files changed
+406 -91

No files matched your search

+5 -2
View File
@@ -7,6 +7,7 @@ import type { DayValue } from "@/lib/day/contract";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { formatDurationMinutes } from "@/lib/i18n/duration";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import { measurementUnitLabel } from "@/lib/measurements/unit-label";
import { getUnitForType } from "@/lib/measurements/unit-map";
import type { DayValueTile } from "./day-values-model";
@@ -102,9 +103,11 @@ export function useDayValueFormat() {
const unitFor = useCallback(
(row: Pick<DayValue, "type" | "unit">): string => {
if (row.type === "SLEEP_DURATION") return "";
const unit = isCanonicalUnit(row) ? units.unitFor(row.type) : row.unit;
// A device score reads in points, as on its chart.
return unit === "score" ? t("insights.deviceScore.unitScore") : unit;
if (row.unit === "score") return t("insights.deviceScore.unitScore");
return isCanonicalUnit(row)
? units.unitFor(row.type)
: measurementUnitLabel(row.type, row.unit, t);
},
[t, units],
);
@@ -12,7 +12,8 @@ import { cn } from "@/lib/utils";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { getUnitForType } from "@/lib/validations/measurement";
import { measurementUnitLabel } from "@/lib/measurements/unit-label";
import { getUnitForType } from "@/lib/measurements/unit-map";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
MEASUREMENT_TYPE_ICONS,
@@ -136,6 +137,11 @@ const CUMULATIVE_TILE_LAYOUT_ID: Record<string, string> = {
* `null` layout (caller passed none) is treated as everything-visible so the
* default behaviour is unchanged.
*/
/** The reader's unit for a tile whose value has no unit preference. */
function unitLabel(type: string, t: (key: string) => string): string {
return measurementUnitLabel(type, getUnitForType(type), t);
}
function tileVisible(
layout: InsightsLayout | undefined,
tileId: string,
@@ -431,7 +437,7 @@ function SixMinuteWalkTile({ read, isLoading }: TileProps) {
<SparklineDeltaTile
label={t("measurements.typeSixMinuteWalkDistance")}
value={v.distanceM}
unit={getUnitForType("SIX_MINUTE_WALK_DISTANCE")}
unit={unitLabel("SIX_MINUTE_WALK_DISTANCE", t)}
icon={MEASUREMENT_TYPE_ICONS.SIX_MINUTE_WALK_DISTANCE ?? Footprints}
series={v.series}
delta={v.trendDelta}
@@ -472,7 +478,7 @@ function FitnessAgeTile({ read, isLoading }: TileProps) {
<SparklineDeltaTile
label={t("measurements.typeVo2Max")}
value={v.vo2Max}
unit={getUnitForType("VO2_MAX")}
unit={unitLabel("VO2_MAX", t)}
icon={Gauge}
delta={v.trendDelta}
directionSentiment="up-good"
@@ -513,7 +519,7 @@ function VascularAgeTile({ read, isLoading }: TileProps) {
<SparklineDeltaTile
label={t("measurements.typeVascularAge")}
value={v.vascularAge}
unit={getUnitForType("VASCULAR_AGE")}
unit={unitLabel("VASCULAR_AGE", t)}
icon={HeartPulse}
delta={v.trendDelta}
directionSentiment="up-bad"
@@ -565,7 +571,7 @@ function HrvBalanceTile({ read, isLoading }: TileProps) {
<SparklineDeltaTile
label={t("measurements.typeHeartRateVariability")}
value={v.recentAvg}
unit={getUnitForType("HEART_RATE_VARIABILITY")}
unit={unitLabel("HEART_RATE_VARIABILITY", t)}
icon={HeartPulse}
series={v.series}
directionSentiment="up-good"
@@ -591,7 +597,7 @@ function BmiTile({ read, isLoading }: TileProps) {
<SparklineDeltaTile
label={t("measurements.typeBodyMassIndex")}
value={v.bmi}
unit={getUnitForType("BODY_MASS_INDEX")}
unit={unitLabel("BODY_MASS_INDEX", t)}
icon={Scale}
series={v.series}
directionSentiment="neutral"
@@ -4,8 +4,6 @@ import {
MEASUREMENT_TYPE_LABEL_KEYS,
MEASUREMENT_TYPE_ICONS,
MEASUREMENT_TYPE_COLORS,
measurementUnitLabel,
measurementUnitMetaLead,
} from "../measurement-list-meta";
import enMessages from "../../../../messages/en.json";
import deMessages from "../../../../messages/de.json";
@@ -43,33 +41,3 @@ describe("measurement-list-meta", () => {
}
});
});
describe("measurementUnitLabel", () => {
const t = (key: string) => `t:${key}`;
it("shows the localised word for count-shaped source units", () => {
expect(measurementUnitLabel("FLIGHTS_CLIMBED", "count", t)).toBe(
"t:insights.units.flights",
);
expect(measurementUnitLabel("RESPIRATORY_RATE", "count/min", t)).toBe(
"t:insights.units.respiratoryRate",
);
});
it("passes every other unit through", () => {
expect(measurementUnitLabel("WEIGHT", "kg", t)).toBe("kg");
});
it("drops the score and rating tokens", () => {
expect(measurementUnitLabel("RECOVERY_SCORE", "score", t)).toBe("");
expect(measurementUnitLabel("VISCERAL_FAT", "rating", t)).toBe("");
});
});
describe("measurementUnitMetaLead", () => {
const t = (key: string) => `t:${key}`;
it("leads the meta line with the unit and its separator", () => {
expect(measurementUnitMetaLead("WEIGHT", "kg", t)).toBe("kg · ");
});
it("leaves no bare separator for a score or a rating", () => {
expect(measurementUnitMetaLead("PHQ9_SCORE", "score", t)).toBeNull();
expect(measurementUnitMetaLead("VISCERAL_FAT", "rating", t)).toBeNull();
});
});
@@ -272,47 +272,3 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
// The colour map lives in its own icon-free module so a surface that only
// needs the colours (the timeline's value lines) does not pull every icon.
export { MEASUREMENT_TYPE_COLORS } from "./measurement-type-colors";
/**
* Count-shaped types whose stored unit is a source token ("count",
* "count/min", "steps") rather than a word a reader should see. The list
* shows the same localised unit the type's insights page uses, so a row
* reads "13 Etagen", not "13 count".
*/
export const MEASUREMENT_UNIT_LABEL_KEYS: Partial<Record<string, string>> = {
ACTIVITY_STEPS: "insights.units.steps",
FLIGHTS_CLIMBED: "insights.units.flights",
FALL_COUNT: "insights.units.falls",
BREATHING_DISTURBANCES: "insights.units.breathingEvents",
RESPIRATORY_RATE: "insights.units.respiratoryRate",
};
/** The unit a list row shows for `type`: the localised word, else `unit`. */
export function measurementUnitLabel(
type: string,
unit: string,
t: (key: string) => string,
): string {
const key = MEASUREMENT_UNIT_LABEL_KEYS[type];
if (key) return t(key);
// A score or a rating is a bare number; "72 score" reads as a stray
// English token next to the German label, not as a unit.
if (UNITLESS_TOKENS.has(unit)) return "";
return unit;
}
const UNITLESS_TOKENS: ReadonlySet<string> = new Set(["score", "rating"]);
/**
* The unit as the lead of a row's meta line ("kg · 2 days ago"), or null
* when the row has no unit word to show: a score or a rating would otherwise
* open the line with a bare separator.
*/
export function measurementUnitMetaLead(
type: string,
unit: string,
t: (key: string) => string,
): string | null {
const label = measurementUnitLabel(type, unit, t);
return label ? `${label} · ` : null;
}
@@ -104,9 +104,11 @@ import {
MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS,
MEASUREMENT_TYPE_ICONS as TYPE_ICONS,
MEASUREMENT_TYPE_COLORS as TYPE_COLORS,
} from "./measurement-list-meta";
import {
measurementUnitLabel,
measurementUnitMetaLead,
} from "./measurement-list-meta";
} from "@/lib/measurements/unit-label";
import {
measurementListFiltersToSearch,
numericInputOrUndefined,