diff --git a/src/__tests__/helpers/unit-display-mock.ts b/src/__tests__/helpers/unit-display-mock.ts index 60a90a914..3d95f3460 100644 --- a/src/__tests__/helpers/unit-display-mock.ts +++ b/src/__tests__/helpers/unit-display-mock.ts @@ -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( + (node, part) => + node && typeof node === "object" + ? (node as Record)[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), }; diff --git a/src/components/day/use-day-value-format.ts b/src/components/day/use-day-value-format.ts index 13f03251d..4600ce951 100644 --- a/src/components/day/use-day-value-format.ts +++ b/src/components/day/use-day-value-format.ts @@ -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): 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], ); diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 562d96733..9bbc52402 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -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 = { * `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) { { } }); }); - -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(); - }); -}); diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 2f8aa81a9..e190c189a 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -272,47 +272,3 @@ export const MEASUREMENT_TYPE_ICONS: Record = { // 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> = { - 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 = 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; -} diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 49ca00a81..207b30e81 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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, diff --git a/src/hooks/__tests__/use-unit-display-label.test.tsx b/src/hooks/__tests__/use-unit-display-label.test.tsx new file mode 100644 index 000000000..59793b302 --- /dev/null +++ b/src/hooks/__tests__/use-unit-display-label.test.tsx @@ -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( + + + , + ).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", ""]); + }); +}); diff --git a/src/hooks/use-unit-display.ts b/src/hooks/use-unit-display.ts index 212823877..4de6493ed 100644 --- a/src/hooks/use-unit-display.ts +++ b/src/hooks/use-unit-display.ts @@ -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]); } diff --git a/src/lib/measurements/__tests__/unit-label.test.ts b/src/lib/measurements/__tests__/unit-label.test.ts new file mode 100644 index 000000000..cdcd8fc89 --- /dev/null +++ b/src/lib/measurements/__tests__/unit-label.test.ts @@ -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 = 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( + (node, part) => + node && typeof node === "object" + ? (node as Record)[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(``); + expect( + [...planted.matchAll(LITERAL)] + .map((m) => m[1]) + .filter((u) => !SHARED_SYMBOLS.has(u)), + ).toEqual(["breaths/min"]); + }); +}); diff --git a/src/lib/measurements/unit-label.ts b/src/lib/measurements/unit-label.ts new file mode 100644 index 000000000..c6373967a --- /dev/null +++ b/src/lib/measurements/unit-label.ts @@ -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> = { + 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> = { + "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> = { + 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 = 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; +}