mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'fix/v1438-ui2' into release/v1.43.8
This commit is contained in:
commit
fed7090702
38 files changed
+730
-188
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -8901,6 +8901,7 @@
|
||||
"inTarget": "목표 범위 안",
|
||||
"aboveTarget": "목표보다 {delta} {unit} 높음",
|
||||
"belowTarget": "목표보다 {delta} {unit} 낮음",
|
||||
"systolic": "수축기",
|
||||
"diastolic": "이완기",
|
||||
"glucoseFasting": "혈당 (공복)",
|
||||
"glucosePostprandial": "혈당 (식후)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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", ""]);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { measurementUnitLabel } from "@/lib/measurements/unit-label";
|
||||
|
||||
export interface UnitDisplay {
|
||||
/** Resolved preference ("metric" by default / on a stale payload). */
|
||||
@@ -46,7 +48,12 @@ export interface UnitDisplay {
|
||||
fromDisplay: (type: string, displayValue: number) => number;
|
||||
/** Convert a DELTA / slope / SD — factor only, never the affine offset. */
|
||||
toDisplayDelta: (type: string, deltaValue: number) => number;
|
||||
/** The display unit symbol for a type under the current preference. */
|
||||
/**
|
||||
* The unit shown beside a value of `type` under the current preference, in
|
||||
* the reader's language: a symbol ("kg", "°F"), a localised word
|
||||
* ("Atemzüge/min"), or "" for a bare score or rating. Display only; the
|
||||
* canonical token stays `getUnitForType`.
|
||||
*/
|
||||
unitFor: (type: string) => string;
|
||||
/** Display decimal places for a type under the current preference. */
|
||||
decimalsFor: (type: string) => number;
|
||||
@@ -84,6 +91,7 @@ function useDisplayForPreference(
|
||||
rawPreference: string | null | undefined,
|
||||
rawGlucoseUnit: string | null | undefined,
|
||||
): UnitDisplay {
|
||||
const { t } = useTranslations();
|
||||
const preference = resolveUnitPreference(rawPreference);
|
||||
const glucoseUnit = resolveGlucoseUnit(rawGlucoseUnit);
|
||||
|
||||
@@ -100,9 +108,10 @@ function useDisplayForPreference(
|
||||
invertDisplayTransform(displayValue, transformFor(type)),
|
||||
toDisplayDelta: (type, deltaValue) =>
|
||||
applyDisplayTransformDelta(deltaValue, transformFor(type)),
|
||||
unitFor: (type) => transformFor(type).displayUnit,
|
||||
unitFor: (type) =>
|
||||
measurementUnitLabel(type, transformFor(type).displayUnit, t),
|
||||
decimalsFor: (type) => transformFor(type).decimals,
|
||||
isTransformed: (type) => hasDisplayTransform(type),
|
||||
};
|
||||
}, [preference, glucoseUnit]);
|
||||
}, [preference, glucoseUnit, t]);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* The unit a reader sees beside a value is a symbol every locale shares or a
|
||||
* word from the bundle, never a storage token. "15,4 breaths/min" on a German
|
||||
* screen came from a tile that printed the canonical unit as it is stored.
|
||||
*
|
||||
* Two halves: every canonical unit resolves to a known symbol, nothing, or a
|
||||
* key present in all eight bundles; and no display source spells a word
|
||||
* unit as a literal prop.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
stripComments,
|
||||
walkSourceFiles,
|
||||
} from "@/__tests__/helpers/source-files";
|
||||
import {
|
||||
getDisplayTransform,
|
||||
TRANSFORMED_TYPES,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import {
|
||||
measurementUnitLabel,
|
||||
measurementUnitMetaLead,
|
||||
} from "@/lib/measurements/unit-label";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
|
||||
import de from "../../../../messages/de.json";
|
||||
import en from "../../../../messages/en.json";
|
||||
import es from "../../../../messages/es.json";
|
||||
import fr from "../../../../messages/fr.json";
|
||||
import it_ from "../../../../messages/it.json";
|
||||
import ko from "../../../../messages/ko.json";
|
||||
import nl from "../../../../messages/nl.json";
|
||||
import pl from "../../../../messages/pl.json";
|
||||
|
||||
const BUNDLES = { de, en, es, fr, it: it_, ko, nl, pl } as const;
|
||||
|
||||
/**
|
||||
* Unit symbols that read the same in every supported language. A token
|
||||
* outside this set that is not resolved through the bundle is an English
|
||||
* word on a non-English screen.
|
||||
*/
|
||||
const SHARED_SYMBOLS: ReadonlySet<string> = new Set([
|
||||
"%",
|
||||
"kg",
|
||||
"lb",
|
||||
"g",
|
||||
"mmHg",
|
||||
"bpm",
|
||||
"ms",
|
||||
"mg/dL",
|
||||
"mmol/L",
|
||||
"kcal",
|
||||
"kJ",
|
||||
"m",
|
||||
"km",
|
||||
"mi",
|
||||
"ft",
|
||||
"cm",
|
||||
"in",
|
||||
"m/s",
|
||||
"km/h",
|
||||
"mph",
|
||||
"°C",
|
||||
"°F",
|
||||
"dBA",
|
||||
"mL/(kg·min)",
|
||||
"kg/m²",
|
||||
"mL",
|
||||
"h",
|
||||
"min",
|
||||
"/ 5",
|
||||
"",
|
||||
]);
|
||||
|
||||
const KEY_MARK = "key:";
|
||||
const markKey = (key: string) => `${KEY_MARK}${key}`;
|
||||
|
||||
function resolve(bundle: unknown, key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
bundle,
|
||||
);
|
||||
}
|
||||
|
||||
describe("measurementUnitLabel", () => {
|
||||
const t = (key: string) => `t:${key}`;
|
||||
|
||||
it("names what a count-shaped type counts", () => {
|
||||
expect(measurementUnitLabel("FLIGHTS_CLIMBED", "count", t)).toBe(
|
||||
"t:insights.units.flights",
|
||||
);
|
||||
expect(measurementUnitLabel("RESPIRATORY_RATE", "count/min", t)).toBe(
|
||||
"t:insights.units.respiratoryRate",
|
||||
);
|
||||
});
|
||||
|
||||
it("localises a word-shaped canonical token", () => {
|
||||
expect(measurementUnitLabel("RESPIRATORY_RATE", "breaths/min", t)).toBe(
|
||||
"t:insights.units.respiratoryRate",
|
||||
);
|
||||
expect(measurementUnitLabel("VASCULAR_AGE", "years", t)).toBe(
|
||||
"t:insights.units.years",
|
||||
);
|
||||
expect(measurementUnitLabel("TIME_IN_DAYLIGHT", "minutes", t)).toBe(
|
||||
"t:insights.deviceScore.unitMinutes",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes a shared symbol through", () => {
|
||||
expect(measurementUnitLabel("WEIGHT", "kg", t)).toBe("kg");
|
||||
expect(measurementUnitLabel("SKIN_TEMPERATURE", "celsius", t)).toBe("°C");
|
||||
});
|
||||
|
||||
it("drops the kind markers", () => {
|
||||
expect(measurementUnitLabel("RECOVERY_SCORE", "score", t)).toBe("");
|
||||
expect(measurementUnitLabel("VISCERAL_FAT", "rating", t)).toBe("");
|
||||
expect(measurementUnitLabel("SLEEP_DISTURBANCE_COUNT", "count", t)).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("leads a meta line only when there is a unit to show", () => {
|
||||
expect(measurementUnitMetaLead("WEIGHT", "kg", t)).toBe("kg · ");
|
||||
expect(measurementUnitMetaLead("PHQ9_SCORE", "score", t)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("every canonical unit reaches the reader in their language", () => {
|
||||
const types = measurementTypeEnum.options;
|
||||
|
||||
it("walks the whole type list", () => {
|
||||
expect(types.length).toBeGreaterThan(60);
|
||||
});
|
||||
|
||||
it.each(types)("%s resolves to a shared symbol or a bundle key", (type) => {
|
||||
const shown = [
|
||||
getUnitForType(type),
|
||||
...(TRANSFORMED_TYPES.has(type)
|
||||
? [
|
||||
getDisplayTransform(type, "metric").displayUnit,
|
||||
getDisplayTransform(type, "imperial").displayUnit,
|
||||
]
|
||||
: []),
|
||||
].map((unit) => measurementUnitLabel(type, unit, markKey));
|
||||
|
||||
for (const label of shown) {
|
||||
if (label.startsWith(KEY_MARK)) {
|
||||
const key = label.slice(KEY_MARK.length);
|
||||
for (const [locale, bundle] of Object.entries(BUNDLES)) {
|
||||
expect(
|
||||
typeof resolve(bundle, key),
|
||||
`${type}: ${key} missing in ${locale}`,
|
||||
).toBe("string");
|
||||
}
|
||||
} else {
|
||||
expect(
|
||||
SHARED_SYMBOLS.has(label),
|
||||
`${type} shows "${label}" as is. Give it a bundle key in unit-label.ts, or add it to SHARED_SYMBOLS if every locale reads it unchanged.`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("the German bundle does not spell the respiratory unit in English", () => {
|
||||
const t = (key: string) => String(resolve(de, key));
|
||||
expect(
|
||||
measurementUnitLabel("RESPIRATORY_RATE", "breaths/min", t),
|
||||
).not.toMatch(/breaths/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no display source spells a word unit as a literal", () => {
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const roots = ["app", "components", "hooks"];
|
||||
// `unit="…"` as a prop or `unit: "…"` in an object literal.
|
||||
const LITERAL = /\bunit(?:=|:\s*)"([^"]*)"/g;
|
||||
|
||||
const offenders: string[] = [];
|
||||
let scanned = 0;
|
||||
for (const root of roots) {
|
||||
const dir = join(SRC, root);
|
||||
for (const rel of walkSourceFiles(dir, {
|
||||
floor: root === "hooks" ? 50 : 500,
|
||||
})) {
|
||||
if (rel.includes("__tests__/") || rel.startsWith("api/")) continue;
|
||||
scanned += 1;
|
||||
const source = stripComments(readFileSync(join(dir, rel), "utf8"));
|
||||
for (const match of source.matchAll(LITERAL)) {
|
||||
if (!SHARED_SYMBOLS.has(match[1])) {
|
||||
offenders.push(`${root}/${rel}: unit "${match[1]}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("reads the display tree", () => {
|
||||
expect(scanned).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("finds only shared symbols", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("would catch a word unit", () => {
|
||||
const planted = stripComments(`<Tile unit="breaths/min" />`);
|
||||
expect(
|
||||
[...planted.matchAll(LITERAL)]
|
||||
.map((m) => m[1])
|
||||
.filter((u) => !SHARED_SYMBOLS.has(u)),
|
||||
).toEqual(["breaths/min"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The unit a person reads beside a measurement value.
|
||||
*
|
||||
* The canonical storage units in `unit-map.ts` are tokens, not labels. Most
|
||||
* are symbols every locale reads as they are (kg, mmHg, bpm, ms, %), but a
|
||||
* handful are English words ("breaths/min", "steps", "flights", "years",
|
||||
* "minutes") or bare kind markers ("score", "rating", "count", "event",
|
||||
* "level", "ratio", "unknown"). Passed straight through, the words printed
|
||||
* "15,4 breaths/min" on a German screen. Every display path resolves the
|
||||
* token here, so a new word-shaped unit is a one-line addition in one place
|
||||
* and `unit-label.test.ts` refuses a canonical unit that is neither a known
|
||||
* symbol nor resolved.
|
||||
*
|
||||
* Pure and server-safe: the caller hands in its own `t`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Types whose unit word depends on what is counted, not on the token. A
|
||||
* device sends "count" for both falls and breathing disturbances; the reader
|
||||
* should see "falls" and "events".
|
||||
*/
|
||||
const TYPE_UNIT_LABEL_KEYS: Readonly<Record<string, string>> = {
|
||||
ACTIVITY_STEPS: "insights.units.steps",
|
||||
FLIGHTS_CLIMBED: "insights.units.flights",
|
||||
FALL_COUNT: "insights.units.falls",
|
||||
BREATHING_DISTURBANCES: "insights.units.breathingEvents",
|
||||
RESPIRATORY_RATE: "insights.units.respiratoryRate",
|
||||
};
|
||||
|
||||
/** Word-shaped tokens that read the same whatever the type. */
|
||||
const TOKEN_UNIT_LABEL_KEYS: Readonly<Record<string, string>> = {
|
||||
"breaths/min": "insights.units.respiratoryRate",
|
||||
steps: "insights.units.steps",
|
||||
flights: "insights.units.flights",
|
||||
years: "insights.units.years",
|
||||
minutes: "insights.deviceScore.unitMinutes",
|
||||
};
|
||||
|
||||
/** Spelled-out tokens whose symbol every locale reads. */
|
||||
const TOKEN_SYMBOLS: Readonly<Record<string, string>> = {
|
||||
celsius: "°C",
|
||||
};
|
||||
|
||||
/**
|
||||
* Kind markers with no unit to show: a score or a rating is a bare number,
|
||||
* and "72 score" reads as a stray English token next to a German label.
|
||||
*/
|
||||
const UNITLESS_UNIT_TOKENS: ReadonlySet<string> = new Set([
|
||||
"score",
|
||||
"rating",
|
||||
"count",
|
||||
"event",
|
||||
"level",
|
||||
"ratio",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
/** The unit shown beside a value of `type` stored or sent in `unit`. */
|
||||
export function measurementUnitLabel(
|
||||
type: string,
|
||||
unit: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const typeKey = TYPE_UNIT_LABEL_KEYS[type];
|
||||
if (typeKey) return t(typeKey);
|
||||
const tokenKey = TOKEN_UNIT_LABEL_KEYS[unit];
|
||||
if (tokenKey) return t(tokenKey);
|
||||
const symbol = TOKEN_SYMBOLS[unit];
|
||||
if (symbol) return symbol;
|
||||
if (UNITLESS_UNIT_TOKENS.has(unit)) return "";
|
||||
return unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit as the lead of a row's meta line ("kg · 2 days ago"), or null
|
||||
* when the row has no unit word to show: a score or a rating would otherwise
|
||||
* open the line with a bare separator.
|
||||
*/
|
||||
export function measurementUnitMetaLead(
|
||||
type: string,
|
||||
unit: string,
|
||||
t: (key: string) => string,
|
||||
): string | null {
|
||||
const label = measurementUnitLabel(type, unit, t);
|
||||
return label ? `${label} · ` : null;
|
||||
}
|
||||
Reference in new issue
Block a user