mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* A real `UnitDisplay` for one fixed preference, for component tests that
|
||||
* render without a QueryClient (`useUnitDisplay` reads the account through
|
||||
* react-query). Built on the production transforms, so a test asserts the
|
||||
* same numbers and symbols the hook would give.
|
||||
* react-query). Built on the production transforms and unit labels, so a
|
||||
* test asserts the same numbers and symbols the hook would give (labels in
|
||||
* English, the bundle the component tests render in).
|
||||
*
|
||||
* vi.mock("@/hooks/use-unit-display", async () => {
|
||||
* const { unitDisplayFor } = await import("@/__tests__/helpers/unit-display-mock");
|
||||
@@ -19,6 +20,22 @@ import {
|
||||
invertDisplayTransform,
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import { measurementUnitLabel } from "@/lib/measurements/unit-label";
|
||||
|
||||
import en from "../../../messages/en.json";
|
||||
|
||||
function english(key: string): string {
|
||||
const leaf = key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
en,
|
||||
);
|
||||
return typeof leaf === "string" ? leaf : key;
|
||||
}
|
||||
|
||||
export function unitDisplayFor(
|
||||
preference: UnitPreference,
|
||||
@@ -35,7 +52,8 @@ export function unitDisplayFor(
|
||||
fromDisplay: (type, value) => invertDisplayTransform(value, forType(type)),
|
||||
toDisplayDelta: (type, value) =>
|
||||
applyDisplayTransformDelta(value, forType(type)),
|
||||
unitFor: (type) => forType(type).displayUnit,
|
||||
unitFor: (type) =>
|
||||
measurementUnitLabel(type, forType(type).displayUnit, english),
|
||||
decimalsFor: (type) => forType(type).decimals,
|
||||
isTransformed: (type) => hasDisplayTransform(type),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: null }),
|
||||
useAccountOnceMounted: () => null,
|
||||
}));
|
||||
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
import de from "../../../messages/de.json";
|
||||
import en from "../../../messages/en.json";
|
||||
|
||||
const BUNDLES = { de, en } as const;
|
||||
|
||||
function Units({ types }: { types: string[] }) {
|
||||
const units = useUnitDisplay();
|
||||
return <>{types.map((type) => units.unitFor(type)).join("|")}</>;
|
||||
}
|
||||
|
||||
function unitsIn(locale: Locale & keyof typeof BUNDLES, types: string[]) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale} initialMessages={BUNDLES[locale]}>
|
||||
<Units types={types} />
|
||||
</I18nProvider>,
|
||||
).split("|");
|
||||
}
|
||||
|
||||
// The display unit is the reader's, not the storage token: a German
|
||||
// account read "15,4 breaths/min" on the vitals tile.
|
||||
describe("useUnitDisplay().unitFor", () => {
|
||||
it("speaks the reader's language for word-shaped units", () => {
|
||||
expect(
|
||||
unitsIn("de", ["RESPIRATORY_RATE", "VASCULAR_AGE", "TIME_IN_DAYLIGHT"]),
|
||||
).toEqual(["Atemzüge/min", "Jahre", "Min."]);
|
||||
expect(unitsIn("en", ["RESPIRATORY_RATE"])).toEqual(["breaths/min"]);
|
||||
});
|
||||
|
||||
it("keeps symbols and drops kind markers", () => {
|
||||
expect(
|
||||
unitsIn("de", ["WEIGHT", "BODY_TEMPERATURE", "RECOVERY_SCORE"]),
|
||||
).toEqual(["kg", "°C", ""]);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { measurementUnitLabel } from "@/lib/measurements/unit-label";
|
||||
|
||||
export interface UnitDisplay {
|
||||
/** Resolved preference ("metric" by default / on a stale payload). */
|
||||
@@ -46,7 +48,12 @@ export interface UnitDisplay {
|
||||
fromDisplay: (type: string, displayValue: number) => number;
|
||||
/** Convert a DELTA / slope / SD — factor only, never the affine offset. */
|
||||
toDisplayDelta: (type: string, deltaValue: number) => number;
|
||||
/** The display unit symbol for a type under the current preference. */
|
||||
/**
|
||||
* The unit shown beside a value of `type` under the current preference, in
|
||||
* the reader's language: a symbol ("kg", "°F"), a localised word
|
||||
* ("Atemzüge/min"), or "" for a bare score or rating. Display only; the
|
||||
* canonical token stays `getUnitForType`.
|
||||
*/
|
||||
unitFor: (type: string) => string;
|
||||
/** Display decimal places for a type under the current preference. */
|
||||
decimalsFor: (type: string) => number;
|
||||
@@ -84,6 +91,7 @@ function useDisplayForPreference(
|
||||
rawPreference: string | null | undefined,
|
||||
rawGlucoseUnit: string | null | undefined,
|
||||
): UnitDisplay {
|
||||
const { t } = useTranslations();
|
||||
const preference = resolveUnitPreference(rawPreference);
|
||||
const glucoseUnit = resolveGlucoseUnit(rawGlucoseUnit);
|
||||
|
||||
@@ -100,9 +108,10 @@ function useDisplayForPreference(
|
||||
invertDisplayTransform(displayValue, transformFor(type)),
|
||||
toDisplayDelta: (type, deltaValue) =>
|
||||
applyDisplayTransformDelta(deltaValue, transformFor(type)),
|
||||
unitFor: (type) => transformFor(type).displayUnit,
|
||||
unitFor: (type) =>
|
||||
measurementUnitLabel(type, transformFor(type).displayUnit, t),
|
||||
decimalsFor: (type) => transformFor(type).decimals,
|
||||
isTransformed: (type) => hasDisplayTransform(type),
|
||||
};
|
||||
}, [preference, glucoseUnit]);
|
||||
}, [preference, glucoseUnit, t]);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* The unit a reader sees beside a value is a symbol every locale shares or a
|
||||
* word from the bundle, never a storage token. "15,4 breaths/min" on a German
|
||||
* screen came from a tile that printed the canonical unit as it is stored.
|
||||
*
|
||||
* Two halves: every canonical unit resolves to a known symbol, nothing, or a
|
||||
* key present in all eight bundles; and no display source spells a word
|
||||
* unit as a literal prop.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
stripComments,
|
||||
walkSourceFiles,
|
||||
} from "@/__tests__/helpers/source-files";
|
||||
import {
|
||||
getDisplayTransform,
|
||||
TRANSFORMED_TYPES,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import {
|
||||
measurementUnitLabel,
|
||||
measurementUnitMetaLead,
|
||||
} from "@/lib/measurements/unit-label";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
|
||||
import de from "../../../../messages/de.json";
|
||||
import en from "../../../../messages/en.json";
|
||||
import es from "../../../../messages/es.json";
|
||||
import fr from "../../../../messages/fr.json";
|
||||
import it_ from "../../../../messages/it.json";
|
||||
import ko from "../../../../messages/ko.json";
|
||||
import nl from "../../../../messages/nl.json";
|
||||
import pl from "../../../../messages/pl.json";
|
||||
|
||||
const BUNDLES = { de, en, es, fr, it: it_, ko, nl, pl } as const;
|
||||
|
||||
/**
|
||||
* Unit symbols that read the same in every supported language. A token
|
||||
* outside this set that is not resolved through the bundle is an English
|
||||
* word on a non-English screen.
|
||||
*/
|
||||
const SHARED_SYMBOLS: ReadonlySet<string> = new Set([
|
||||
"%",
|
||||
"kg",
|
||||
"lb",
|
||||
"g",
|
||||
"mmHg",
|
||||
"bpm",
|
||||
"ms",
|
||||
"mg/dL",
|
||||
"mmol/L",
|
||||
"kcal",
|
||||
"kJ",
|
||||
"m",
|
||||
"km",
|
||||
"mi",
|
||||
"ft",
|
||||
"cm",
|
||||
"in",
|
||||
"m/s",
|
||||
"km/h",
|
||||
"mph",
|
||||
"°C",
|
||||
"°F",
|
||||
"dBA",
|
||||
"mL/(kg·min)",
|
||||
"kg/m²",
|
||||
"mL",
|
||||
"h",
|
||||
"min",
|
||||
"/ 5",
|
||||
"",
|
||||
]);
|
||||
|
||||
const KEY_MARK = "key:";
|
||||
const markKey = (key: string) => `${KEY_MARK}${key}`;
|
||||
|
||||
function resolve(bundle: unknown, key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
bundle,
|
||||
);
|
||||
}
|
||||
|
||||
describe("measurementUnitLabel", () => {
|
||||
const t = (key: string) => `t:${key}`;
|
||||
|
||||
it("names what a count-shaped type counts", () => {
|
||||
expect(measurementUnitLabel("FLIGHTS_CLIMBED", "count", t)).toBe(
|
||||
"t:insights.units.flights",
|
||||
);
|
||||
expect(measurementUnitLabel("RESPIRATORY_RATE", "count/min", t)).toBe(
|
||||
"t:insights.units.respiratoryRate",
|
||||
);
|
||||
});
|
||||
|
||||
it("localises a word-shaped canonical token", () => {
|
||||
expect(measurementUnitLabel("RESPIRATORY_RATE", "breaths/min", t)).toBe(
|
||||
"t:insights.units.respiratoryRate",
|
||||
);
|
||||
expect(measurementUnitLabel("VASCULAR_AGE", "years", t)).toBe(
|
||||
"t:insights.units.years",
|
||||
);
|
||||
expect(measurementUnitLabel("TIME_IN_DAYLIGHT", "minutes", t)).toBe(
|
||||
"t:insights.deviceScore.unitMinutes",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes a shared symbol through", () => {
|
||||
expect(measurementUnitLabel("WEIGHT", "kg", t)).toBe("kg");
|
||||
expect(measurementUnitLabel("SKIN_TEMPERATURE", "celsius", t)).toBe("°C");
|
||||
});
|
||||
|
||||
it("drops the kind markers", () => {
|
||||
expect(measurementUnitLabel("RECOVERY_SCORE", "score", t)).toBe("");
|
||||
expect(measurementUnitLabel("VISCERAL_FAT", "rating", t)).toBe("");
|
||||
expect(measurementUnitLabel("SLEEP_DISTURBANCE_COUNT", "count", t)).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("leads a meta line only when there is a unit to show", () => {
|
||||
expect(measurementUnitMetaLead("WEIGHT", "kg", t)).toBe("kg · ");
|
||||
expect(measurementUnitMetaLead("PHQ9_SCORE", "score", t)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("every canonical unit reaches the reader in their language", () => {
|
||||
const types = measurementTypeEnum.options;
|
||||
|
||||
it("walks the whole type list", () => {
|
||||
expect(types.length).toBeGreaterThan(60);
|
||||
});
|
||||
|
||||
it.each(types)("%s resolves to a shared symbol or a bundle key", (type) => {
|
||||
const shown = [
|
||||
getUnitForType(type),
|
||||
...(TRANSFORMED_TYPES.has(type)
|
||||
? [
|
||||
getDisplayTransform(type, "metric").displayUnit,
|
||||
getDisplayTransform(type, "imperial").displayUnit,
|
||||
]
|
||||
: []),
|
||||
].map((unit) => measurementUnitLabel(type, unit, markKey));
|
||||
|
||||
for (const label of shown) {
|
||||
if (label.startsWith(KEY_MARK)) {
|
||||
const key = label.slice(KEY_MARK.length);
|
||||
for (const [locale, bundle] of Object.entries(BUNDLES)) {
|
||||
expect(
|
||||
typeof resolve(bundle, key),
|
||||
`${type}: ${key} missing in ${locale}`,
|
||||
).toBe("string");
|
||||
}
|
||||
} else {
|
||||
expect(
|
||||
SHARED_SYMBOLS.has(label),
|
||||
`${type} shows "${label}" as is. Give it a bundle key in unit-label.ts, or add it to SHARED_SYMBOLS if every locale reads it unchanged.`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("the German bundle does not spell the respiratory unit in English", () => {
|
||||
const t = (key: string) => String(resolve(de, key));
|
||||
expect(
|
||||
measurementUnitLabel("RESPIRATORY_RATE", "breaths/min", t),
|
||||
).not.toMatch(/breaths/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no display source spells a word unit as a literal", () => {
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const roots = ["app", "components", "hooks"];
|
||||
// `unit="…"` as a prop or `unit: "…"` in an object literal.
|
||||
const LITERAL = /\bunit(?:=|:\s*)"([^"]*)"/g;
|
||||
|
||||
const offenders: string[] = [];
|
||||
let scanned = 0;
|
||||
for (const root of roots) {
|
||||
const dir = join(SRC, root);
|
||||
for (const rel of walkSourceFiles(dir, {
|
||||
floor: root === "hooks" ? 50 : 500,
|
||||
})) {
|
||||
if (rel.includes("__tests__/") || rel.startsWith("api/")) continue;
|
||||
scanned += 1;
|
||||
const source = stripComments(readFileSync(join(dir, rel), "utf8"));
|
||||
for (const match of source.matchAll(LITERAL)) {
|
||||
if (!SHARED_SYMBOLS.has(match[1])) {
|
||||
offenders.push(`${root}/${rel}: unit "${match[1]}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("reads the display tree", () => {
|
||||
expect(scanned).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("finds only shared symbols", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("would catch a word unit", () => {
|
||||
const planted = stripComments(`<Tile unit="breaths/min" />`);
|
||||
expect(
|
||||
[...planted.matchAll(LITERAL)]
|
||||
.map((m) => m[1])
|
||||
.filter((u) => !SHARED_SYMBOLS.has(u)),
|
||||
).toEqual(["breaths/min"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The unit a person reads beside a measurement value.
|
||||
*
|
||||
* The canonical storage units in `unit-map.ts` are tokens, not labels. Most
|
||||
* are symbols every locale reads as they are (kg, mmHg, bpm, ms, %), but a
|
||||
* handful are English words ("breaths/min", "steps", "flights", "years",
|
||||
* "minutes") or bare kind markers ("score", "rating", "count", "event",
|
||||
* "level", "ratio", "unknown"). Passed straight through, the words printed
|
||||
* "15,4 breaths/min" on a German screen. Every display path resolves the
|
||||
* token here, so a new word-shaped unit is a one-line addition in one place
|
||||
* and `unit-label.test.ts` refuses a canonical unit that is neither a known
|
||||
* symbol nor resolved.
|
||||
*
|
||||
* Pure and server-safe: the caller hands in its own `t`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Types whose unit word depends on what is counted, not on the token. A
|
||||
* device sends "count" for both falls and breathing disturbances; the reader
|
||||
* should see "falls" and "events".
|
||||
*/
|
||||
const TYPE_UNIT_LABEL_KEYS: Readonly<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",
|
||||
};
|
||||
|
||||
/** Word-shaped tokens that read the same whatever the type. */
|
||||
const TOKEN_UNIT_LABEL_KEYS: Readonly<Record<string, string>> = {
|
||||
"breaths/min": "insights.units.respiratoryRate",
|
||||
steps: "insights.units.steps",
|
||||
flights: "insights.units.flights",
|
||||
years: "insights.units.years",
|
||||
minutes: "insights.deviceScore.unitMinutes",
|
||||
};
|
||||
|
||||
/** Spelled-out tokens whose symbol every locale reads. */
|
||||
const TOKEN_SYMBOLS: Readonly<Record<string, string>> = {
|
||||
celsius: "°C",
|
||||
};
|
||||
|
||||
/**
|
||||
* Kind markers with no unit to show: a score or a rating is a bare number,
|
||||
* and "72 score" reads as a stray English token next to a German label.
|
||||
*/
|
||||
const UNITLESS_UNIT_TOKENS: ReadonlySet<string> = new Set([
|
||||
"score",
|
||||
"rating",
|
||||
"count",
|
||||
"event",
|
||||
"level",
|
||||
"ratio",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
/** The unit shown beside a value of `type` stored or sent in `unit`. */
|
||||
export function measurementUnitLabel(
|
||||
type: string,
|
||||
unit: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const typeKey = TYPE_UNIT_LABEL_KEYS[type];
|
||||
if (typeKey) return t(typeKey);
|
||||
const tokenKey = TOKEN_UNIT_LABEL_KEYS[unit];
|
||||
if (tokenKey) return t(tokenKey);
|
||||
const symbol = TOKEN_SYMBOLS[unit];
|
||||
if (symbol) return symbol;
|
||||
if (UNITLESS_UNIT_TOKENS.has(unit)) return "";
|
||||
return unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in new issue
Block a user