Merge branch 'fix/v1438-ui2' into release/v1.43.8

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 15:23:22 +02:00
commit fed7090702
38 files changed
+730 -188

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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)",
+1
View File
@@ -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)",
+1
View File
@@ -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)",
+1
View File
@@ -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)",
+1
View File
@@ -8901,6 +8901,7 @@
"inTarget": "목표 범위 안",
"aboveTarget": "목표보다 {delta} {unit} 높음",
"belowTarget": "목표보다 {delta} {unit} 낮음",
"systolic": "수축기",
"diastolic": "이완기",
"glucoseFasting": "혈당 (공복)",
"glucosePostprandial": "혈당 (식후)",
+1
View File
@@ -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)",
+1
View File
@@ -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",
+21 -3
View File
@@ -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),
};
@@ -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("<TrendCard> 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 [^"]*"[^>]*><span[^>]*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(
<TrendCard
// Stress test — the live tile uses dashboard.*Short keys but
@@ -75,8 +79,19 @@ describe("<TrendCard> W20a baseline alignment", () => {
/>,
);
expect(html).toContain("whitespace-nowrap");
expect(html).toContain("truncate");
const label = html.match(
/<span class="([^"]*)" data-slot="trend-card-label" title="([^"]*)"/,
);
expect(label, "label span not found").not.toBeNull();
const [, classes, title] = label!;
// Wraps at a syllable, clamps only past the two reserved lines, and the
// clamp keeps the whole name reachable.
expect(classes).toContain("hyphens-auto");
expect(classes).toContain("break-words");
expect(classes).toContain("line-clamp-2");
expect(classes).not.toContain("truncate");
expect(classes).not.toContain("whitespace-nowrap");
expect(title).toBe("A really long blood pressure metric name");
});
it("renders the value row with baseline alignment", () => {
@@ -166,9 +181,10 @@ describe("<TrendCard> 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 [^"]*"[^>]*><span[^>]*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.
@@ -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). */}
<div className="flex h-5 items-center justify-between gap-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-4 shrink-0" />
{/* Heading row — label + icon (TrendCard reserves two label lines,
h-10, so the loaded tile does not move the value row). */}
<div className="flex h-10 items-start justify-between gap-2">
<Skeleton className="mt-1 h-3 w-16" />
<Skeleton className="mt-0.5 h-4 w-4 shrink-0" />
</div>
{/* Headline value row — `text-3xl leading-none` ≈ 30 px, plus the
unit slot, baseline-aligned exactly like the loaded tile. */}
+16 -9
View File
@@ -206,20 +206,27 @@ export function TrendCard({
return (
<TrendTile data-slot="trend-card">
{/* 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. */}
<div className="flex h-5 min-w-0 items-center justify-between gap-2">
{/* 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. */}
<div className="flex h-10 min-w-0 items-start justify-between gap-2">
<span
className="text-muted-foreground min-w-0 flex-1 truncate text-xs leading-5 font-medium tracking-wide whitespace-nowrap uppercase"
className="text-muted-foreground line-clamp-2 min-w-0 flex-1 text-xs leading-5 font-medium tracking-wide break-words hyphens-auto uppercase"
data-slot="trend-card-label"
title={label}
>
{label}
</span>
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
<Icon className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
</div>
{/* v1.4.25 — baseline-aligned value row. The headline value,
unit, and trend arrow share a single flex line with
+1 -5
View File
@@ -108,11 +108,7 @@ export function CycleRingTile({ className }: { className?: string }) {
className,
)}
>
<TileHeader
icon={Droplets}
title={t("cycle.wellnessRing.label")}
titleClassName="truncate"
/>
<TileHeader icon={Droplets} title={t("cycle.wellnessRing.label")} />
<div className="flex flex-col items-center gap-1.5">
<CycleRing
dayOfCycle={verdict.dayOfCycle}
+5 -2
View File
@@ -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],
);
+6 -1
View File
@@ -180,7 +180,12 @@ export function DocumentCard({
const titleBlock = (
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm font-medium">{title}</p>
<p
className="line-clamp-2 text-sm font-medium break-words hyphens-auto"
title={title}
>
{title}
</p>
<p className="text-muted-foreground truncate text-xs">
{/* 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
-1
View File
@@ -102,7 +102,6 @@ export function VisitCard({
size="sm"
icon={CalendarClock}
title={heading}
titleClassName="min-w-0 truncate"
right={
<Badge variant="outline" className="shrink-0">
{encounterKindText(t, encounter.kind as EncounterKind)}
@@ -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,
@@ -214,10 +214,21 @@ describe("<MetricTargetSummary>", () => {
// 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</p>");
const dia = html.indexOf(">Diastolic</p>");
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</p>");
});
it("keeps the adjust-target affordance out of the card body", () => {
// The "Adjust target range" affordance moved to the page-header gear
// (`<TargetAdjustButton>` via `TargetAdjustProvider`); the card is now
@@ -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"]);
});
});
@@ -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("<TileHeader> title", () => {
it.each([
["default", undefined],
["h2 heading", "h2" as const],
])("wraps at a syllable as a %s", (_name, titleAs) => {
const html = renderToStaticMarkup(
<TileHeader
icon={Heart}
title="Herzfrequenzvariabilität"
titleAs={titleAs}
/>,
);
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(
<TileHeader size="sm" icon={Heart} title="Körperfettverteilung" />,
);
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 = `<CardTitle className="min-w-0 truncate">x</CardTitle>`;
const found = [...planted.matchAll(TITLE_TAG)].filter((m) =>
CUT.test(m[0]),
);
expect(found).toHaveLength(1);
});
});
@@ -164,7 +164,6 @@ export function SparklineDeltaTile({
title={label}
titleAs="h2"
className="min-w-0"
titleClassName="min-w-0 truncate whitespace-nowrap"
right={
provenance ? (
<span
@@ -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"
@@ -636,12 +642,7 @@ function BuildingTile({
data-state={state}
className="gap-3"
>
<TileHeader
icon={icon}
title={title}
titleAs="h2"
titleClassName="truncate"
/>
<TileHeader icon={icon} title={title} titleAs="h2" />
<p className="text-muted-foreground text-sm" data-slot={messageSlot}>
{message}
</p>
@@ -154,7 +154,7 @@ function RingTile({
animate && "wellness-tile-rise",
)}
>
<TileHeader icon={Icon} title={label} titleClassName="truncate" />
<TileHeader icon={Icon} title={label} />
<div className="flex flex-col items-center gap-1.5">
{/* The ring keeps just the number centred; the metric name lives in
the header above. The per-metric `hue` gradient leans the arc
@@ -118,7 +118,6 @@ export function DeviceScoreTile({
icon={Icon}
title={title}
className="min-w-0"
titleClassName="min-w-0 truncate"
right={
latest != null ? (
<span
@@ -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
@@ -367,16 +367,25 @@ function TargetReferencePanel({
) : null}
</CardHeader>
<CardContent className="space-y-3">
{/* 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 ? (
<RangeBar
value={currentValue}
min={range.min}
max={range.max}
unit={unit}
orangeMin={isMedicationCompliance ? 70 : undefined}
orangeMax={isMedicationCompliance ? 100 : undefined}
/>
<div className={isBp ? "space-y-1" : undefined}>
{isBp ? (
<p className="text-muted-foreground text-xs">
{t("targets.systolic")}
</p>
) : null}
<RangeBar
value={currentValue}
min={range.min}
max={range.max}
unit={unit}
orangeMin={isMedicationCompliance ? 70 : undefined}
orangeMax={isMedicationCompliance ? 100 : undefined}
/>
</div>
) : null}
{/* Row 2b: diastolic range bar for blood pressure. */}
@@ -98,7 +98,6 @@ export function ResilienceTile() {
icon={ShieldCheck}
title={t("measurements.typeResilience")}
className="min-w-0"
titleClassName="min-w-0 truncate"
right={
bandKey != null ? (
<span
@@ -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 `<Bar>` 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) => (
<Bar
key={stage}
dataKey={stage}
+13 -6
View File
@@ -43,12 +43,10 @@ interface TileHeaderProps {
* The ONE sanctioned compact variant: icon `h-4 w-4`, title `text-sm` —
* for dense correlation/stat tiles that sit several to a row where the
* full `text-base` header visually overpowers the tile body. Everything
* else stays on the default; do not express a third size via
* `titleClassName`.
* else stays on the default.
*/
size?: "default" | "sm";
className?: string;
titleClassName?: string;
/** Optional id on the `CardTitle`, e.g. for an `aria-labelledby` link. */
id?: string;
/**
@@ -71,14 +69,23 @@ export function TileHeader({
right,
size = "default",
className,
titleClassName,
id,
titleAs,
}: TileHeaderProps) {
const Heading = titleAs ?? "h2";
// The title wraps; it never truncates. A tile title is the only thing that
// says what the number below it is, and German compounds
// ("Herzfrequenzvariabilität", "Körperfettverteilung") overrun a narrow
// tile in one word. `min-w-0` lets it shrink beside the icon and the
// `right` slot, `hyphens-auto` breaks a compound at a syllable (the
// document's `lang` drives the dictionary) and `break-words` is the floor
// for a word the dictionary does not know. `leading-tight` keeps a second
// line clear of the first; on one line it matches the 20 px icon box, so a
// single-line header is the height it was. There is no class override:
// every caller used one to add `truncate`.
const titleClassNames = cn(
"min-w-0 leading-tight break-words hyphens-auto",
size === "sm" ? "text-sm" : "text-base",
titleClassName,
);
return (
<div
@@ -102,7 +109,7 @@ export function TileHeader({
<Heading
id={id}
data-slot="card-title"
className={cn("leading-none font-semibold", titleClassNames)}
className={cn("font-semibold", titleClassNames)}
>
{title}
</Heading>
@@ -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,
+2 -2
View File
@@ -846,7 +846,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
</span>
</TableCell>
<TableCell className="text-muted-foreground max-w-[18rem] text-sm">
<span className="block truncate">
<span className="block break-words">
{entry.tags.length > 0
? entry.tags.join(", ")
: "-"}
@@ -960,7 +960,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
</DayLink>
</p>
{entry.tags.length > 0 && (
<p className="text-muted-foreground truncate text-xs">
<p className="text-muted-foreground text-xs break-words">
{entry.tags.join(", ")}
</p>
)}
+4 -1
View File
@@ -63,7 +63,10 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
className={cn(
"leading-none font-semibold break-words hyphens-auto",
className,
)}
{...props}
/>
);
@@ -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", ""]);
});
});
+12 -3
View File
@@ -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"]);
});
});
+86
View File
@@ -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;
}