mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
1 parent
1d9758e3e8
commit
a934e948d8
10 files changed
+406
-91
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user