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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 15:17:37 +02:00
1 parent e312d99162
commit 3d494697f7
2 files changed
+32 -17

No files matched your search

@@ -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<string, { label: string; natural: number }> = {};
@@ -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,
@@ -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. */}
<span className="text-muted-foreground min-w-0 truncate">
{/* 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. */}
<span className="text-muted-foreground min-w-0 break-words hyphens-auto">
{t(PILLAR_LABEL_KEY[pillar.id])}
</span>
<div