From a934e948d8ae185300bac3fbc3a86c2377632532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 15:07:11 +0200 Subject: [PATCH 1/7] 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. --- src/__tests__/helpers/unit-display-mock.ts | 24 +- src/components/day/use-day-value-format.ts | 7 +- .../insights/derived/vitals-dashboard.tsx | 18 +- .../__tests__/measurement-list-meta.test.ts | 32 --- .../measurements/measurement-list-meta.ts | 44 ---- .../measurements/measurement-list.tsx | 4 +- .../__tests__/use-unit-display-label.test.tsx | 46 ++++ src/hooks/use-unit-display.ts | 15 +- .../measurements/__tests__/unit-label.test.ts | 221 ++++++++++++++++++ src/lib/measurements/unit-label.ts | 86 +++++++ 10 files changed, 406 insertions(+), 91 deletions(-) create mode 100644 src/hooks/__tests__/use-unit-display-label.test.tsx create mode 100644 src/lib/measurements/__tests__/unit-label.test.ts create mode 100644 src/lib/measurements/unit-label.ts 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; +} From 74244efbe13c267c19abddf8df8073bb92b20d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 15:07:51 +0200 Subject: [PATCH 2/7] fix(targets): label the systolic bar on the blood-pressure target card The card stacks two range bars for blood pressure. The diastolic bar carried its label; the systolic one had none and no key to render, so the pair read as one unnamed bar above a "Diastolic" one. Both bars now name their reading (new key targets.systolic in every locale). --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + .../__tests__/metric-target-summary.test.tsx | 11 ++++++++ .../insights/metric-target-summary.tsx | 27 ++++++++++++------- 10 files changed, 37 insertions(+), 9 deletions(-) diff --git a/messages/de.json b/messages/de.json index bc7ac86ec..3aff7d309 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8901,6 +8901,7 @@ "inTarget": "Im Zielbereich", "aboveTarget": "{delta} {unit} über Zielbereich", "belowTarget": "{delta} {unit} unter Zielbereich", + "systolic": "Systolisch", "diastolic": "Diastolisch", "glucoseFasting": "Glukose, nüchtern", "glucosePostprandial": "Glukose, postprandial", diff --git a/messages/en.json b/messages/en.json index 780787051..b58302e22 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8901,6 +8901,7 @@ "inTarget": "Within target range", "aboveTarget": "{delta} {unit} above target", "belowTarget": "{delta} {unit} below target", + "systolic": "Systolic", "diastolic": "Diastolic", "glucoseFasting": "Glucose (fasting)", "glucosePostprandial": "Glucose (post-meal)", diff --git a/messages/es.json b/messages/es.json index 8782a47a7..a682b92ab 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8901,6 +8901,7 @@ "inTarget": "En rango objetivo", "aboveTarget": "{delta} {unit} por encima del rango", "belowTarget": "{delta} {unit} por debajo del rango", + "systolic": "Sistólica", "diastolic": "Diastólica", "glucoseFasting": "Glucemia (en ayunas)", "glucosePostprandial": "Glucemia (posprandial)", diff --git a/messages/fr.json b/messages/fr.json index f2db6c1a6..e0fd482d5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8901,6 +8901,7 @@ "inTarget": "Dans la plage cible", "aboveTarget": "{delta} {unit} au-dessus de la plage", "belowTarget": "{delta} {unit} en dessous de la plage", + "systolic": "Systolique", "diastolic": "Diastolique", "glucoseFasting": "Glycémie (à jeun)", "glucosePostprandial": "Glycémie (postprandiale)", diff --git a/messages/it.json b/messages/it.json index ea19c378e..19db4220d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8901,6 +8901,7 @@ "inTarget": "Nel range obiettivo", "aboveTarget": "{delta} {unit} sopra il range", "belowTarget": "{delta} {unit} sotto il range", + "systolic": "Sistolica", "diastolic": "Diastolica", "glucoseFasting": "Glicemia (a digiuno)", "glucosePostprandial": "Glicemia (postprandiale)", diff --git a/messages/ko.json b/messages/ko.json index 3a753a8f9..fb03673be 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8901,6 +8901,7 @@ "inTarget": "목표 범위 안", "aboveTarget": "목표보다 {delta} {unit} 높음", "belowTarget": "목표보다 {delta} {unit} 낮음", + "systolic": "수축기", "diastolic": "이완기", "glucoseFasting": "혈당 (공복)", "glucosePostprandial": "혈당 (식후)", diff --git a/messages/nl.json b/messages/nl.json index 3b80f1854..b70878026 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -8901,6 +8901,7 @@ "inTarget": "Binnen het streefbereik", "aboveTarget": "{delta} {unit} boven het doel", "belowTarget": "{delta} {unit} onder het doel", + "systolic": "Systolisch", "diastolic": "Diastolisch", "glucoseFasting": "Glucose (nuchter)", "glucosePostprandial": "Glucose (na de maaltijd)", diff --git a/messages/pl.json b/messages/pl.json index 78b02dc65..7c0d439f1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8901,6 +8901,7 @@ "inTarget": "W zakresie docelowym", "aboveTarget": "{delta} {unit} powyżej zakresu", "belowTarget": "{delta} {unit} poniżej zakresu", + "systolic": "Skurczowe", "diastolic": "Rozkurczowe", "glucoseFasting": "Glikemia: na czczo", "glucosePostprandial": "Glikemia: po posiłku", diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 2491772cf..61651bf8a 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -214,10 +214,21 @@ describe("", () => { // The diastolic range bar renders alongside the systolic one. const barCount = (html.match(/data-slot="target-range-bar"/g) ?? []).length; expect(barCount).toBe(2); + // Each stacked bar names its reading, the systolic one first. The + // systolic label was missing while the diastolic one rendered. + const sys = html.indexOf(">Systolic

"); + const dia = html.indexOf(">Diastolic

"); + expect(sys).toBeGreaterThan(-1); + expect(dia).toBeGreaterThan(sys); // 30-day average stitches the diastolic pair — kept on the BP panel. expect(html).toContain("30-day average: 122/76 mmHg"); }); + it("labels a single-band bar with nothing extra", () => { + const html = renderWith("weight", WEIGHT_DATA); + expect(html).not.toContain(">Systolic

"); + }); + it("keeps the adjust-target affordance out of the card body", () => { // The "Adjust target range" affordance moved to the page-header gear // (`` via `TargetAdjustProvider`); the card is now diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index 872632957..b2e5c9484 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -367,16 +367,25 @@ function TargetReferencePanel({ ) : null} - {/* Row 2: range bar — where today's value sits inside the band. */} + {/* Row 2: range bar — where today's value sits inside the band. + Blood pressure stacks two bars, so each one names its reading; + an unlabelled first bar read as the diastolic one's sibling. */} {currentValue != null ? ( - +
+ {isBp ? ( +

+ {t("targets.systolic")} +

+ ) : null} + +
) : null} {/* Row 2b: diastolic range bar for blood pressure. */} From c245a3f2af0103815ab80847b4fc5300a488f8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 15:09:47 +0200 Subject: [PATCH 3/7] fix(sleep): list only the stages the window shows in the composition legend The legend lists every mounted bar, filled or not, so a week of staged nights still showed the green "Asleep" swatch for a band that appears in no column. Each stage band now mounts only when the visible window holds minutes for it, as the nap band already did. Columns are unchanged: an empty band drew nothing. --- .../sleep-stage-stacked-bar.test.tsx | 47 +++++++++++++++++++ .../insights/sleep-stage-stacked-bar.tsx | 44 ++++++++++------- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index 59fd59051..c38bf6bbd 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -8,6 +8,7 @@ import { STAGE_ORDER, buildCompositionRows, compositionTotals, + stagesInWindow, windowHasNap, type SleepStageBreakdown, type SleepStageNight, @@ -343,3 +344,49 @@ describe("nap band", () => { ); }); }); + +describe("legend follows the visible stages", () => { + const label = (dayKey: string) => dayKey; + + it("leaves a stage the window never holds out of the mounted bands", () => { + // A staged week: deep, REM, core and wake bouts, no unspecified "asleep" + // minutes. Every mounted band is a legend entry, so ASLEEP must not be one. + const perNight: SleepStageNight[] = [ + { + dayKey: "2026-06-03", + stages: { DEEP: 60, REM: 90, CORE: 250, AWAKE: 15 }, + }, + { dayKey: "2026-06-04", stages: { DEEP: 55, REM: 80, CORE: 260 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["DEEP", "REM", "CORE", "AWAKE"]); + }); + + it("keeps the unspecified stage when an older source sends it", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-06-03", stages: { ASLEEP: 410, AWAKE: 20 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["ASLEEP", "AWAKE"]); + }); + + it("keeps the stack order whatever order the payload uses", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-06-03", stages: { AWAKE: 10, CORE: 200, DEEP: 40 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["DEEP", "CORE", "AWAKE"]); + }); + + it("follows the window, not the whole series", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-05-01", stages: { ASLEEP: 400 } }, + ...Array.from({ length: 7 }, (_, i) => ({ + dayKey: `2026-06-0${i + 1}`, + stages: { CORE: 250, DEEP: 50 }, + })), + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["DEEP", "CORE"]); + }); +}); diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index 6e9e0ef86..362fd6cc6 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -241,6 +241,26 @@ export function windowHasNap(rows: readonly CompositionRow[]): boolean { ); } +/** + * The stages the visible window actually holds, in stack order. + * + * Recharts lists every mounted `` in the legend, filled or not. With all + * five stages mounted, a week of staged nights (deep, core, REM, awake) still + * showed a green "Asleep" swatch for a band that appears nowhere in the + * columns: ASLEEP is the unspecified stage only older sources send. A stage + * with no minutes in the window draws nothing, so leaving it unmounted + * changes no column and keeps the legend to what is on screen. + */ +export function stagesInWindow( + rows: readonly CompositionRow[], +): Array<(typeof STAGE_ORDER)[number]> { + return STAGE_ORDER.filter((stage) => + rows.some( + (row) => typeof row[stage] === "number" && (row[stage] as number) > 0, + ), + ); +} + /** * Totals for one hovered column, split the way the tooltip reports them. * @@ -313,17 +333,13 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { ).rows; }, [breakdown, windowDays, locale, t]); - // Mount the nap band only when the VISIBLE window holds one. + // Mount the nap band only when the VISIBLE window holds one, and each + // stage band likewise: the legend lists whatever is mounted. const hasNap = useMemo(() => windowHasNap(data), [data]); + const visibleStages = useMemo(() => stagesInWindow(data), [data]); // Empty-state guard — no perNight rows AND no aggregate. - const hasData = - data.length > 0 && - data.some((row) => - STAGE_ORDER.some( - (stage) => typeof row[stage] === "number" && (row[stage] as number) > 0, - ), - ); + const hasData = visibleStages.length > 0; // v1.42 — each night's column opens the day it ended on (the day the // per-night key names, as `/api/sleep/night` does), through the same doors @@ -347,13 +363,9 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { : null; }, }); - const tableColumns = [...STAGE_ORDER, ...(hasNap ? [NAP_KEY] : [])] - .filter((stage) => - data.some( - (row) => typeof row[stage] === "number" && (row[stage] as number) > 0, - ), - ) - .map((stage) => ({ key: stage, label: stageLabels[stage] ?? stage })); + const tableColumns = [...visibleStages, ...(hasNap ? [NAP_KEY] : [])].map( + (stage) => ({ key: stage, label: stageLabels[stage] ?? stage }), + ); const ariaLabel = t("insights.sleep.compositionAriaLabel", { nights: breakdown.nights, @@ -563,7 +575,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { wrapperStyle={{ fontSize: 11 }} formatter={(value: string) => stageLabels[value] ?? value} /> - {STAGE_ORDER.map((stage) => ( + {visibleStages.map((stage) => ( Date: Tue, 20 Oct 2026 15:17:37 +0200 Subject: [PATCH 4/7] fix(ui): let tile and card titles wrap instead of cutting them Seven tiles passed titleClassName="truncate" to TileHeader, so German compounds ended in an ellipsis on a narrow tile ("Erholungs-..."). The title is the only thing naming the figure. TileHeader now owns the wrap (min-w-0, break-words, hyphens-auto from the document lang) and has no class override left to truncate with; CardTitle carries the same break-words/hyphens-auto. A guard refuses truncate or nowrap on any CardTitle or TileHeader in the tree. --- src/components/cycle/cycle-ring-tile.tsx | 6 +- src/components/encounters/visit-card.tsx | 1 - .../__tests__/tile-header-wrap.test.tsx | 99 +++++++++++++++++++ .../insights/derived/sparkline-delta-tile.tsx | 1 - .../insights/derived/vitals-dashboard.tsx | 7 +- .../insights/derived/wellness-scores.tsx | 2 +- src/components/insights/device-score-tile.tsx | 1 - .../insights/recovery/resilience-tile.tsx | 1 - src/components/insights/tile-header.tsx | 19 ++-- src/components/ui/card.tsx | 5 +- 10 files changed, 119 insertions(+), 23 deletions(-) create mode 100644 src/components/insights/__tests__/tile-header-wrap.test.tsx diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index b09155700..1e7c79c55 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -108,11 +108,7 @@ export function CycleRingTile({ className }: { className?: string }) { className, )} > - +
{encounterKindText(t, encounter.kind as EncounterKind)} diff --git a/src/components/insights/__tests__/tile-header-wrap.test.tsx b/src/components/insights/__tests__/tile-header-wrap.test.tsx new file mode 100644 index 000000000..e578f4879 --- /dev/null +++ b/src/components/insights/__tests__/tile-header-wrap.test.tsx @@ -0,0 +1,99 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Heart } from "lucide-react"; + +import { + stripComments, + walkSourceFiles, +} from "@/__tests__/helpers/source-files"; + +import { TileHeader } from "../tile-header"; + +/** + * A tile title names the figure under it, so it wraps and never truncates. + * Seven callers passed `titleClassName="truncate"` and German compounds + * ("Erholungs-Score", "Herzfrequenzvariabilität") ended in an ellipsis on a + * narrow tile. The class override is gone; the primitive owns the wrap. + */ +function titleClasses(html: string): string { + const match = html.match( + /data-slot="card-title"[^>]*class="([^"]*)"|class="([^"]*)"[^>]*data-slot="card-title"/, + ); + expect(match, "card title not rendered").not.toBeNull(); + return match![1] ?? match![2]; +} + +describe(" title", () => { + it.each([ + ["default", undefined], + ["h2 heading", "h2" as const], + ])("wraps at a syllable as a %s", (_name, titleAs) => { + const html = renderToStaticMarkup( + , + ); + const classes = titleClasses(html); + expect(classes).toContain("min-w-0"); + expect(classes).toContain("break-words"); + expect(classes).toContain("hyphens-auto"); + expect(classes).not.toContain("truncate"); + expect(classes).not.toContain("whitespace-nowrap"); + }); + + it("keeps the compact variant on the same contract", () => { + const html = renderToStaticMarkup( + , + ); + const classes = titleClasses(html); + expect(classes).toContain("text-sm"); + expect(classes).toContain("hyphens-auto"); + expect(classes).not.toContain("truncate"); + }); +}); + +describe("no card title is cut", () => { + // A `CardTitle` or a tile `TileHeader` that truncates hides the one word + // that says what the card is. `truncate` stays legal on rows and meta + // lines that carry their full text in `title`; never on a card title. + const SRC = join(process.cwd(), "src"); + const TITLE_TAG = /<(CardTitle|TileHeader)\b[^>]*?>/g; + const CUT = /\b(truncate|whitespace-nowrap|titleClassName)\b/; + + const offenders: string[] = []; + let tags = 0; + for (const root of ["components", "app"]) { + for (const rel of walkSourceFiles(join(SRC, root), { + floor: root === "app" ? 100 : 500, + extensions: [".tsx"], + })) { + if (rel.includes("__tests__/")) continue; + const source = stripComments(readFileSync(join(SRC, root, rel), "utf8")); + for (const match of source.matchAll(TITLE_TAG)) { + tags += 1; + if (CUT.test(match[0])) offenders.push(`${root}/${rel}: ${match[1]}`); + } + } + } + + it("reads the title tags in the tree", () => { + expect(tags).toBeGreaterThan(60); + }); + + it("finds none that cut", () => { + expect(offenders).toEqual([]); + }); + + it("would catch one", () => { + const planted = `x`; + const found = [...planted.matchAll(TITLE_TAG)].filter((m) => + CUT.test(m[0]), + ); + expect(found).toHaveLength(1); + }); +}); diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index 9adccc5e5..db4e5e6e8 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -164,7 +164,6 @@ export function SparklineDeltaTile({ title={label} titleAs="h2" className="min-w-0" - titleClassName="min-w-0 truncate whitespace-nowrap" right={ provenance ? ( - +

{message}

diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx index 71e76e550..e198469e5 100644 --- a/src/components/insights/derived/wellness-scores.tsx +++ b/src/components/insights/derived/wellness-scores.tsx @@ -154,7 +154,7 @@ function RingTile({ animate && "wellness-tile-rise", )} > - +
{/* The ring keeps just the number centred; the metric name lives in the header above. The per-metric `hue` gradient leans the arc diff --git a/src/components/insights/device-score-tile.tsx b/src/components/insights/device-score-tile.tsx index 39da9a327..ca7ca2323 100644 --- a/src/components/insights/device-score-tile.tsx +++ b/src/components/insights/device-score-tile.tsx @@ -118,7 +118,6 @@ export function DeviceScoreTile({ icon={Icon} title={title} className="min-w-0" - titleClassName="min-w-0 truncate" right={ latest != null ? ( {title} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 60f98110b..e64293365 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -63,7 +63,10 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) { return (
); From e312d99162cb298cd79e56446526dc6f737d393e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 15:17:37 +0200 Subject: [PATCH 5/7] fix(dashboard): reserve two label lines on trend tiles instead of truncating The strip tile pinned its label to one line so the value rows share a baseline, and cut anything longer: uppercase compounds and glucose contexts ("HERZFREQUENZVARIABILITAET", "GLUKOSE, VOR DEM SCHLAFEN") ended in an ellipsis. The heading row now reserves two lines at a fixed height, so the baseline promise holds and the label wraps at a syllable; a label that would need a third line clamps with its full text in title. The skeleton reserves the same height. --- .../trend-card-baseline-alignment.test.tsx | 48 ++++++++++++------- src/components/charts/trend-card-skeleton.tsx | 9 ++-- src/components/charts/trend-card.tsx | 25 ++++++---- 3 files changed, 53 insertions(+), 29 deletions(-) diff --git a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx index 3cb247c7b..e14a1b2af 100644 --- a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx +++ b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx @@ -11,10 +11,13 @@ import { I18nProvider } from "@/lib/i18n/context"; * Maintainer directive 2026-05-14 named three issues with the 5-tile top row: * * 1. Headings wrapped to two lines at narrow viewports (e.g. "Blood - * Pressure" on Pixel 5 / 393 px / 2-col grid). Locked the heading - * row to a deterministic `h-5` with `whitespace-nowrap` so even an - * unexpectedly long string never breaks the baseline of the value - * row below. + * Pressure" on Pixel 5 / 393 px / 2-col grid), moving the value row. + * The heading row is a deterministic height so the value row keeps + * its baseline. It reserves TWO lines (`h-10`, v1.43.8): pinned to one + * line it could only keep that promise by cutting the label, and long + * German compounds and glucose contexts ended in an ellipsis on every + * dashboard. A label wraps (hyphenated) inside the reserve; the rare + * third line clamps with the full text in `title`. * 2. Trend arrow rendered as a wrapping flex child after the unit — * sometimes wrapped to its own line, sometimes missing entirely * when slope30 was null. Now sits in a fixed `h-4 w-4` slot with @@ -27,8 +30,8 @@ import { I18nProvider } from "@/lib/i18n/context"; * tile in the strip to share the same baseline. * * These tests pin the load-bearing class names so a future refactor - * that drops `h-5` / `items-baseline` / `whitespace-nowrap` triggers a - * red CI before it reaches the dashboard. + * that drops `h-10` / `items-baseline` / `line-clamp-2` triggers a red CI + * before it reaches the dashboard. */ const RISING = { slope: 0.5, direction: "up" as const, confidence: 0.8 }; @@ -53,13 +56,14 @@ describe(" W20a baseline alignment", () => { />, ); - // h-5 is the load-bearing class — keeps the value row below at the - // same y-coordinate regardless of the label string's actual width. - expect(html).toContain("h-5"); - expect(html).toContain('data-slot="trend-card-label"'); + // h-10 is the load-bearing class — keeps the value row below at the + // same y-coordinate whether the label takes one line or two. + expect(html).toMatch( + /class="flex h-10 [^"]*"[^>]*>]*data-slot="trend-card-label"/, + ); }); - it("forbids wrapping on the heading row even with a long label", () => { + it("wraps a long label inside the reserve instead of cutting it", () => { const html = render( W20a baseline alignment", () => { />, ); - expect(html).toContain("whitespace-nowrap"); - expect(html).toContain("truncate"); + const label = html.match( + / { @@ -166,9 +181,10 @@ describe(" W20a baseline alignment", () => { />, ); - // Same heading-row height contract — `h-5` + `whitespace-nowrap`. - expect(html).toContain("h-5"); - expect(html).toContain("whitespace-nowrap"); + // Same heading-row height contract — two reserved label lines. + expect(html).toMatch( + /class="flex h-10 [^"]*"[^>]*>]*data-slot="trend-card-label"/, + ); // Same value-row baseline contract — `items-baseline` + `text-3xl` // + `leading-none`. Identical font geometry across all five tiles // means the digits land at the same y-coordinate inside the grid. diff --git a/src/components/charts/trend-card-skeleton.tsx b/src/components/charts/trend-card-skeleton.tsx index 7527983dc..97bd8e5d6 100644 --- a/src/components/charts/trend-card-skeleton.tsx +++ b/src/components/charts/trend-card-skeleton.tsx @@ -31,10 +31,11 @@ export function TrendCardSkeleton({ className }: { className?: string }) { // legacy empty silhouette reserved. className={cn("min-h-[8rem]", className)} > - {/* Heading row — label + icon (TrendCard pins this row at h-5). */} -
- - + {/* Heading row — label + icon (TrendCard reserves two label lines, + h-10, so the loaded tile does not move the value row). */} +
+ +
{/* Headline value row — `text-3xl leading-none` ≈ 30 px, plus the unit slot, baseline-aligned exactly like the loaded tile. */} diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 24a13cacb..d131706ac 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -206,20 +206,27 @@ export function TrendCard({ return ( - {/* v1.4.25 — single-line discipline + deterministic height for - the heading row so the value row below sits at the same baseline - across every tile. The label is locale-abbreviated upstream (see - `dashboard.*Short` keys) so `truncate` is a layout safeguard - rather than the primary defence against wraps. `h-5` matches - `text-xs leading-5` ≈ 20 px; identical across every tile. */} -
+ {/* The heading row has a deterministic height so the value row below + sits at the same baseline across every tile in the strip. It + reserves two lines, not one: a one-line row could only keep that + promise by cutting the label, and the label is the only thing that + names the figure. An uppercase German compound + ("HERZFREQUENZVARIABILITÄT") or a glucose context ("GLUKOSE, VOR + DEM SCHLAFEN") does not fit a strip tile on one line in any width + the grid hands out, and ended in an ellipsis. `hyphens-auto` splits + a compound at a syllable (driven by the document's `lang`), + `break-words` is the floor, and the rare label that needs a third + line clamps with its full text in `title`. `h-10` is two + `text-xs leading-5` lines; identical across every tile. */} +
{label} - +
{/* v1.4.25 — baseline-aligned value row. The headline value, unit, and trend arrow share a single flex line with From 3d494697f71e14c5bdfb67094d747fa40f047342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 15:17:37 +0200 Subject: [PATCH 6/7] fix(health-score): wrap a long pillar name instead of cutting it "Koerperfettverteilung" ended in an ellipsis in the score panel. The label now wraps inside its fixed track with hyphenation, so the row grows a line and the fill bar keeps its width. The geometry check claimed to measure every locale but rendered each one with the English fallback catalog, so no German name was ever laid out. It now primes every catalog and asserts that a name is never cut, takes at most two lines and never widens its track. --- .../health-score-card-geometry.test.tsx | 33 ++++++++++++++----- src/components/insights/health-score-card.tsx | 16 ++++----- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx index 448e53764..500824677 100644 --- a/src/components/insights/__tests__/health-score-card-geometry.test.tsx +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -11,6 +11,7 @@ import type { } from "@/lib/analytics/score/types"; import { SCORE_VERSION } from "@/lib/analytics/score/types"; import { HeroStrip } from "../hero-strip"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; /** * The hero band's geometry, measured rather than asserted from source. @@ -42,6 +43,11 @@ import { HeroStrip } from "../hero-strip"; * chromium` once to measure for real. */ +// Every locale's own catalog, not the English fallback: without it the +// per-locale sweep below measured the English names eight times over and +// never saw a German compound. +primeLocales(...locales); + vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { unitPreference: "metric", glucoseUnit: "mg/dL" } }), })); @@ -52,6 +58,8 @@ const COLUMN_PX = 352; const VIEWPORT_PX = 1024; /** One `text-xs` row is a 16 px line; two lines would exceed this. */ const ONE_LINE_PX = 20; +/** Two `text-xs` lines; a third would mean the name no longer fits. */ +const TWO_LINES_PX = 2 * ONE_LINE_PX; /** * The row's info trigger carries `-mx-2` so its 44 px tap target does not * inflate a 20 px row: it reaches 8 px into the card's own padding on each @@ -358,7 +366,7 @@ describe("hero band geometry", () => { ).toBeLessThanOrEqual(heights.rows6); }, 60_000); - it("keeps every row on one line in every locale", async (ctx) => { + it("keeps every row inside its column and every name whole in every locale", async (ctx) => { if (skipReason) ctx.skip(skipReason); const widest: Record = {}; @@ -408,6 +416,8 @@ describe("hero band geometry", () => { labelOverflow: label ? getComputedStyle(label).textOverflow : "none", + labelScroll: label?.scrollWidth ?? 0, + labelClient: label?.clientWidth ?? 0, barWidth: Math.round(bar?.getBoundingClientRect().width ?? 0), valueScroll: value?.scrollWidth ?? 0, valueClient: value?.clientWidth ?? 0, @@ -416,10 +426,11 @@ describe("hero band geometry", () => { }; }); - // The row is the invariant, not the label: a long name may ellipsise, - // but it may never widen the row or wrap it onto a second line. Drop - // `min-w-0` or `truncate` from the label and the first grid track - // grows to the longest word instead, which is exactly this. + // The row's width is the invariant, and the name is read whole: a + // long name breaks onto a second line at a syllable, it is never cut. + // Drop `min-w-0` from the label and the first grid track grows to the + // longest word instead; drop `hyphens-auto` and a German compound + // overflows its track. expect( m.rowScroll, `${locale}/${m.id}: the row is pushed wider than its column by "${m.text}"`, @@ -428,14 +439,18 @@ describe("hero band geometry", () => { m.labelWidth, `${locale}/${m.id}: the label track grew past 7rem for "${m.text}"`, ).toBeLessThanOrEqual(112 + 1); + expect( + m.labelScroll, + `${locale}/${m.id}: "${m.text}" is cut off inside its track`, + ).toBeLessThanOrEqual(m.labelClient + 1); expect( m.labelHeight, - `${locale}/${m.id}: "${m.text}" wrapped onto a second line`, - ).toBeLessThanOrEqual(ONE_LINE_PX); + `${locale}/${m.id}: "${m.text}" needs more than two lines`, + ).toBeLessThanOrEqual(TWO_LINES_PX); expect( m.labelOverflow, - `${locale}/${m.id}: "${m.text}" is cut without an ellipsis`, - ).toBe("ellipsis"); + `${locale}/${m.id}: "${m.text}" ends in an ellipsis`, + ).not.toBe("ellipsis"); // The label column may not eat the two things the row exists to show. expect( m.barWidth, diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 182082e0e..bb29f39fe 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -860,14 +860,14 @@ function PillarRow({ data-status="ok" className="grid grid-cols-[minmax(0,7rem)_1fr_2rem_auto] items-center gap-2 text-xs" > - {/* The label truncates; the row never grows. `minmax(0,…)` plus - `min-w-0 truncate` is what makes that structural rather than a - promise — drop either and the track grows to the longest word and - pushes the row. Widening the column does not rescue the long names: - the longest label in five of the six locales overruns any column - this panel can afford, so the extra width would come off the fill - bar and buy a few more characters of one language. */} - + {/* The label wraps inside a fixed track; the row grows a line rather + than cutting the name. `minmax(0,…)` plus `min-w-0` keeps the track + from growing to the longest word, and `hyphens-auto` splits a + compound ("Körperfett-verteilung") at a syllable instead of ending + it in an ellipsis. Widening the column does not rescue the long + names: the longest label in most locales overruns any column this + panel can afford, and the width would come off the fill bar. */} + {t(PILLAR_LABEL_KEY[pillar.id])}
Date: Tue, 20 Oct 2026 15:17:38 +0200 Subject: [PATCH 7/7] fix(ui): show mood tags and document titles in full Mood tags are the person's own words and were cut to one line in the list; they wrap now. A document title wraps to two lines with hyphenation and carries its full text in title past that; the filename line keeps its truncation, which already shows the full name on hover. --- src/components/documents/document-card.tsx | 7 ++++++- src/components/mood/mood-list.tsx | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index 62af84843..1fa4b2f2b 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -180,7 +180,12 @@ export function DocumentCard({ const titleBlock = (
-

{title}

+

+ {title} +

{/* v1.42 — a dated document's date opens its day, above the whole-card button. It follows the grid's roving tab stop, so the diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index f5fc5a9a0..67102c5f7 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -846,7 +846,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { - + {entry.tags.length > 0 ? entry.tags.join(", ") : "-"} @@ -960,7 +960,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {

{entry.tags.length > 0 && ( -

+

{entry.tags.join(", ")}

)}