diff --git a/messages/de.json b/messages/de.json index bc7ac86ec..3aff7d309 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8901,6 +8901,7 @@ "inTarget": "Im Zielbereich", "aboveTarget": "{delta} {unit} über Zielbereich", "belowTarget": "{delta} {unit} unter Zielbereich", + "systolic": "Systolisch", "diastolic": "Diastolisch", "glucoseFasting": "Glukose, nüchtern", "glucosePostprandial": "Glukose, postprandial", diff --git a/messages/en.json b/messages/en.json index 780787051..b58302e22 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8901,6 +8901,7 @@ "inTarget": "Within target range", "aboveTarget": "{delta} {unit} above target", "belowTarget": "{delta} {unit} below target", + "systolic": "Systolic", "diastolic": "Diastolic", "glucoseFasting": "Glucose (fasting)", "glucosePostprandial": "Glucose (post-meal)", diff --git a/messages/es.json b/messages/es.json index 8782a47a7..a682b92ab 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8901,6 +8901,7 @@ "inTarget": "En rango objetivo", "aboveTarget": "{delta} {unit} por encima del rango", "belowTarget": "{delta} {unit} por debajo del rango", + "systolic": "Sistólica", "diastolic": "Diastólica", "glucoseFasting": "Glucemia (en ayunas)", "glucosePostprandial": "Glucemia (posprandial)", diff --git a/messages/fr.json b/messages/fr.json index f2db6c1a6..e0fd482d5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8901,6 +8901,7 @@ "inTarget": "Dans la plage cible", "aboveTarget": "{delta} {unit} au-dessus de la plage", "belowTarget": "{delta} {unit} en dessous de la plage", + "systolic": "Systolique", "diastolic": "Diastolique", "glucoseFasting": "Glycémie (à jeun)", "glucosePostprandial": "Glycémie (postprandiale)", diff --git a/messages/it.json b/messages/it.json index ea19c378e..19db4220d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8901,6 +8901,7 @@ "inTarget": "Nel range obiettivo", "aboveTarget": "{delta} {unit} sopra il range", "belowTarget": "{delta} {unit} sotto il range", + "systolic": "Sistolica", "diastolic": "Diastolica", "glucoseFasting": "Glicemia (a digiuno)", "glucosePostprandial": "Glicemia (postprandiale)", diff --git a/messages/ko.json b/messages/ko.json index 3a753a8f9..fb03673be 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8901,6 +8901,7 @@ "inTarget": "목표 범위 안", "aboveTarget": "목표보다 {delta} {unit} 높음", "belowTarget": "목표보다 {delta} {unit} 낮음", + "systolic": "수축기", "diastolic": "이완기", "glucoseFasting": "혈당 (공복)", "glucosePostprandial": "혈당 (식후)", diff --git a/messages/nl.json b/messages/nl.json index 3b80f1854..b70878026 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -8901,6 +8901,7 @@ "inTarget": "Binnen het streefbereik", "aboveTarget": "{delta} {unit} boven het doel", "belowTarget": "{delta} {unit} onder het doel", + "systolic": "Systolisch", "diastolic": "Diastolisch", "glucoseFasting": "Glucose (nuchter)", "glucosePostprandial": "Glucose (na de maaltijd)", diff --git a/messages/pl.json b/messages/pl.json index 78b02dc65..7c0d439f1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8901,6 +8901,7 @@ "inTarget": "W zakresie docelowym", "aboveTarget": "{delta} {unit} powyżej zakresu", "belowTarget": "{delta} {unit} poniżej zakresu", + "systolic": "Skurczowe", "diastolic": "Rozkurczowe", "glucoseFasting": "Glikemia: na czczo", "glucosePostprandial": "Glikemia: po posiłku", diff --git a/src/__tests__/helpers/unit-display-mock.ts b/src/__tests__/helpers/unit-display-mock.ts index 60a90a914..3d95f3460 100644 --- a/src/__tests__/helpers/unit-display-mock.ts +++ b/src/__tests__/helpers/unit-display-mock.ts @@ -1,8 +1,9 @@ /** * A real `UnitDisplay` for one fixed preference, for component tests that * render without a QueryClient (`useUnitDisplay` reads the account through - * react-query). Built on the production transforms, so a test asserts the - * same numbers and symbols the hook would give. + * react-query). Built on the production transforms and unit labels, so a + * test asserts the same numbers and symbols the hook would give (labels in + * English, the bundle the component tests render in). * * vi.mock("@/hooks/use-unit-display", async () => { * const { unitDisplayFor } = await import("@/__tests__/helpers/unit-display-mock"); @@ -19,6 +20,22 @@ import { invertDisplayTransform, type UnitPreference, } from "@/lib/measurements/display-transform"; +import { measurementUnitLabel } from "@/lib/measurements/unit-label"; + +import en from "../../../messages/en.json"; + +function english(key: string): string { + const leaf = key + .split(".") + .reduce( + (node, part) => + node && typeof node === "object" + ? (node as Record)[part] + : undefined, + en, + ); + return typeof leaf === "string" ? leaf : key; +} export function unitDisplayFor( preference: UnitPreference, @@ -35,7 +52,8 @@ export function unitDisplayFor( fromDisplay: (type, value) => invertDisplayTransform(value, forType(type)), toDisplayDelta: (type, value) => applyDisplayTransformDelta(value, forType(type)), - unitFor: (type) => forType(type).displayUnit, + unitFor: (type) => + measurementUnitLabel(type, forType(type).displayUnit, english), decimalsFor: (type) => forType(type).decimals, isTransformed: (type) => hasDisplayTransform(type), }; diff --git a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx index 3cb247c7b..e14a1b2af 100644 --- a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx +++ b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx @@ -11,10 +11,13 @@ import { I18nProvider } from "@/lib/i18n/context"; * Maintainer directive 2026-05-14 named three issues with the 5-tile top row: * * 1. Headings wrapped to two lines at narrow viewports (e.g. "Blood - * Pressure" on Pixel 5 / 393 px / 2-col grid). Locked the heading - * row to a deterministic `h-5` with `whitespace-nowrap` so even an - * unexpectedly long string never breaks the baseline of the value - * row below. + * Pressure" on Pixel 5 / 393 px / 2-col grid), moving the value row. + * The heading row is a deterministic height so the value row keeps + * its baseline. It reserves TWO lines (`h-10`, v1.43.8): pinned to one + * line it could only keep that promise by cutting the label, and long + * German compounds and glucose contexts ended in an ellipsis on every + * dashboard. A label wraps (hyphenated) inside the reserve; the rare + * third line clamps with the full text in `title`. * 2. Trend arrow rendered as a wrapping flex child after the unit — * sometimes wrapped to its own line, sometimes missing entirely * when slope30 was null. Now sits in a fixed `h-4 w-4` slot with @@ -27,8 +30,8 @@ import { I18nProvider } from "@/lib/i18n/context"; * tile in the strip to share the same baseline. * * These tests pin the load-bearing class names so a future refactor - * that drops `h-5` / `items-baseline` / `whitespace-nowrap` triggers a - * red CI before it reaches the dashboard. + * that drops `h-10` / `items-baseline` / `line-clamp-2` triggers a red CI + * before it reaches the dashboard. */ const RISING = { slope: 0.5, direction: "up" as const, confidence: 0.8 }; @@ -53,13 +56,14 @@ describe(" W20a baseline alignment", () => { />, ); - // h-5 is the load-bearing class — keeps the value row below at the - // same y-coordinate regardless of the label string's actual width. - expect(html).toContain("h-5"); - expect(html).toContain('data-slot="trend-card-label"'); + // h-10 is the load-bearing class — keeps the value row below at the + // same y-coordinate whether the label takes one line or two. + expect(html).toMatch( + /class="flex h-10 [^"]*"[^>]*>]*data-slot="trend-card-label"/, + ); }); - it("forbids wrapping on the heading row even with a long label", () => { + it("wraps a long label inside the reserve instead of cutting it", () => { const html = render( W20a baseline alignment", () => { />, ); - expect(html).toContain("whitespace-nowrap"); - expect(html).toContain("truncate"); + const label = html.match( + / { @@ -166,9 +181,10 @@ describe(" W20a baseline alignment", () => { />, ); - // Same heading-row height contract — `h-5` + `whitespace-nowrap`. - expect(html).toContain("h-5"); - expect(html).toContain("whitespace-nowrap"); + // Same heading-row height contract — two reserved label lines. + expect(html).toMatch( + /class="flex h-10 [^"]*"[^>]*>]*data-slot="trend-card-label"/, + ); // Same value-row baseline contract — `items-baseline` + `text-3xl` // + `leading-none`. Identical font geometry across all five tiles // means the digits land at the same y-coordinate inside the grid. diff --git a/src/components/charts/trend-card-skeleton.tsx b/src/components/charts/trend-card-skeleton.tsx index 7527983dc..97bd8e5d6 100644 --- a/src/components/charts/trend-card-skeleton.tsx +++ b/src/components/charts/trend-card-skeleton.tsx @@ -31,10 +31,11 @@ export function TrendCardSkeleton({ className }: { className?: string }) { // legacy empty silhouette reserved. className={cn("min-h-[8rem]", className)} > - {/* Heading row — label + icon (TrendCard pins this row at h-5). */} -
- - + {/* Heading row — label + icon (TrendCard reserves two label lines, + h-10, so the loaded tile does not move the value row). */} +
+ +
{/* Headline value row — `text-3xl leading-none` ≈ 30 px, plus the unit slot, baseline-aligned exactly like the loaded tile. */} diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 24a13cacb..d131706ac 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -206,20 +206,27 @@ export function TrendCard({ return ( - {/* v1.4.25 — single-line discipline + deterministic height for - the heading row so the value row below sits at the same baseline - across every tile. The label is locale-abbreviated upstream (see - `dashboard.*Short` keys) so `truncate` is a layout safeguard - rather than the primary defence against wraps. `h-5` matches - `text-xs leading-5` ≈ 20 px; identical across every tile. */} -
+ {/* The heading row has a deterministic height so the value row below + sits at the same baseline across every tile in the strip. It + reserves two lines, not one: a one-line row could only keep that + promise by cutting the label, and the label is the only thing that + names the figure. An uppercase German compound + ("HERZFREQUENZVARIABILITÄT") or a glucose context ("GLUKOSE, VOR + DEM SCHLAFEN") does not fit a strip tile on one line in any width + the grid hands out, and ended in an ellipsis. `hyphens-auto` splits + a compound at a syllable (driven by the document's `lang`), + `break-words` is the floor, and the rare label that needs a third + line clamps with its full text in `title`. `h-10` is two + `text-xs leading-5` lines; identical across every tile. */} +
{label} - +
{/* v1.4.25 — baseline-aligned value row. The headline value, unit, and trend arrow share a single flex line with diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index b09155700..1e7c79c55 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -108,11 +108,7 @@ export function CycleRingTile({ className }: { className?: string }) { className, )} > - +
): string => { if (row.type === "SLEEP_DURATION") return ""; - const unit = isCanonicalUnit(row) ? units.unitFor(row.type) : row.unit; // A device score reads in points, as on its chart. - return unit === "score" ? t("insights.deviceScore.unitScore") : unit; + if (row.unit === "score") return t("insights.deviceScore.unitScore"); + return isCanonicalUnit(row) + ? units.unitFor(row.type) + : measurementUnitLabel(row.type, row.unit, t); }, [t, units], ); diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index 62af84843..1fa4b2f2b 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -180,7 +180,12 @@ export function DocumentCard({ const titleBlock = (
-

{title}

+

+ {title} +

{/* v1.42 — a dated document's date opens its day, above the whole-card button. It follows the grid's roving tab stop, so the diff --git a/src/components/encounters/visit-card.tsx b/src/components/encounters/visit-card.tsx index da3e9b999..ca5b80f18 100644 --- a/src/components/encounters/visit-card.tsx +++ b/src/components/encounters/visit-card.tsx @@ -102,7 +102,6 @@ export function VisitCard({ size="sm" icon={CalendarClock} title={heading} - titleClassName="min-w-0 truncate" right={ {encounterKindText(t, encounter.kind as EncounterKind)} diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx index 448e53764..500824677 100644 --- a/src/components/insights/__tests__/health-score-card-geometry.test.tsx +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -11,6 +11,7 @@ import type { } from "@/lib/analytics/score/types"; import { SCORE_VERSION } from "@/lib/analytics/score/types"; import { HeroStrip } from "../hero-strip"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; /** * The hero band's geometry, measured rather than asserted from source. @@ -42,6 +43,11 @@ import { HeroStrip } from "../hero-strip"; * chromium` once to measure for real. */ +// Every locale's own catalog, not the English fallback: without it the +// per-locale sweep below measured the English names eight times over and +// never saw a German compound. +primeLocales(...locales); + vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { unitPreference: "metric", glucoseUnit: "mg/dL" } }), })); @@ -52,6 +58,8 @@ const COLUMN_PX = 352; const VIEWPORT_PX = 1024; /** One `text-xs` row is a 16 px line; two lines would exceed this. */ const ONE_LINE_PX = 20; +/** Two `text-xs` lines; a third would mean the name no longer fits. */ +const TWO_LINES_PX = 2 * ONE_LINE_PX; /** * The row's info trigger carries `-mx-2` so its 44 px tap target does not * inflate a 20 px row: it reaches 8 px into the card's own padding on each @@ -358,7 +366,7 @@ describe("hero band geometry", () => { ).toBeLessThanOrEqual(heights.rows6); }, 60_000); - it("keeps every row on one line in every locale", async (ctx) => { + it("keeps every row inside its column and every name whole in every locale", async (ctx) => { if (skipReason) ctx.skip(skipReason); const widest: Record = {}; @@ -408,6 +416,8 @@ describe("hero band geometry", () => { labelOverflow: label ? getComputedStyle(label).textOverflow : "none", + labelScroll: label?.scrollWidth ?? 0, + labelClient: label?.clientWidth ?? 0, barWidth: Math.round(bar?.getBoundingClientRect().width ?? 0), valueScroll: value?.scrollWidth ?? 0, valueClient: value?.clientWidth ?? 0, @@ -416,10 +426,11 @@ describe("hero band geometry", () => { }; }); - // The row is the invariant, not the label: a long name may ellipsise, - // but it may never widen the row or wrap it onto a second line. Drop - // `min-w-0` or `truncate` from the label and the first grid track - // grows to the longest word instead, which is exactly this. + // The row's width is the invariant, and the name is read whole: a + // long name breaks onto a second line at a syllable, it is never cut. + // Drop `min-w-0` from the label and the first grid track grows to the + // longest word instead; drop `hyphens-auto` and a German compound + // overflows its track. expect( m.rowScroll, `${locale}/${m.id}: the row is pushed wider than its column by "${m.text}"`, @@ -428,14 +439,18 @@ describe("hero band geometry", () => { m.labelWidth, `${locale}/${m.id}: the label track grew past 7rem for "${m.text}"`, ).toBeLessThanOrEqual(112 + 1); + expect( + m.labelScroll, + `${locale}/${m.id}: "${m.text}" is cut off inside its track`, + ).toBeLessThanOrEqual(m.labelClient + 1); expect( m.labelHeight, - `${locale}/${m.id}: "${m.text}" wrapped onto a second line`, - ).toBeLessThanOrEqual(ONE_LINE_PX); + `${locale}/${m.id}: "${m.text}" needs more than two lines`, + ).toBeLessThanOrEqual(TWO_LINES_PX); expect( m.labelOverflow, - `${locale}/${m.id}: "${m.text}" is cut without an ellipsis`, - ).toBe("ellipsis"); + `${locale}/${m.id}: "${m.text}" ends in an ellipsis`, + ).not.toBe("ellipsis"); // The label column may not eat the two things the row exists to show. expect( m.barWidth, diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 2491772cf..61651bf8a 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -214,10 +214,21 @@ describe("", () => { // The diastolic range bar renders alongside the systolic one. const barCount = (html.match(/data-slot="target-range-bar"/g) ?? []).length; expect(barCount).toBe(2); + // Each stacked bar names its reading, the systolic one first. The + // systolic label was missing while the diastolic one rendered. + const sys = html.indexOf(">Systolic

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

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

"); + }); + it("keeps the adjust-target affordance out of the card body", () => { // The "Adjust target range" affordance moved to the page-header gear // (`` via `TargetAdjustProvider`); the card is now diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index 59fd59051..c38bf6bbd 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -8,6 +8,7 @@ import { STAGE_ORDER, buildCompositionRows, compositionTotals, + stagesInWindow, windowHasNap, type SleepStageBreakdown, type SleepStageNight, @@ -343,3 +344,49 @@ describe("nap band", () => { ); }); }); + +describe("legend follows the visible stages", () => { + const label = (dayKey: string) => dayKey; + + it("leaves a stage the window never holds out of the mounted bands", () => { + // A staged week: deep, REM, core and wake bouts, no unspecified "asleep" + // minutes. Every mounted band is a legend entry, so ASLEEP must not be one. + const perNight: SleepStageNight[] = [ + { + dayKey: "2026-06-03", + stages: { DEEP: 60, REM: 90, CORE: 250, AWAKE: 15 }, + }, + { dayKey: "2026-06-04", stages: { DEEP: 55, REM: 80, CORE: 260 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["DEEP", "REM", "CORE", "AWAKE"]); + }); + + it("keeps the unspecified stage when an older source sends it", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-06-03", stages: { ASLEEP: 410, AWAKE: 20 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["ASLEEP", "AWAKE"]); + }); + + it("keeps the stack order whatever order the payload uses", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-06-03", stages: { AWAKE: 10, CORE: 200, DEEP: 40 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["DEEP", "CORE", "AWAKE"]); + }); + + it("follows the window, not the whole series", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-05-01", stages: { ASLEEP: 400 } }, + ...Array.from({ length: 7 }, (_, i) => ({ + dayKey: `2026-06-0${i + 1}`, + stages: { CORE: 250, DEEP: 50 }, + })), + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["DEEP", "CORE"]); + }); +}); diff --git a/src/components/insights/__tests__/tile-header-wrap.test.tsx b/src/components/insights/__tests__/tile-header-wrap.test.tsx new file mode 100644 index 000000000..e578f4879 --- /dev/null +++ b/src/components/insights/__tests__/tile-header-wrap.test.tsx @@ -0,0 +1,99 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Heart } from "lucide-react"; + +import { + stripComments, + walkSourceFiles, +} from "@/__tests__/helpers/source-files"; + +import { TileHeader } from "../tile-header"; + +/** + * A tile title names the figure under it, so it wraps and never truncates. + * Seven callers passed `titleClassName="truncate"` and German compounds + * ("Erholungs-Score", "Herzfrequenzvariabilität") ended in an ellipsis on a + * narrow tile. The class override is gone; the primitive owns the wrap. + */ +function titleClasses(html: string): string { + const match = html.match( + /data-slot="card-title"[^>]*class="([^"]*)"|class="([^"]*)"[^>]*data-slot="card-title"/, + ); + expect(match, "card title not rendered").not.toBeNull(); + return match![1] ?? match![2]; +} + +describe(" title", () => { + it.each([ + ["default", undefined], + ["h2 heading", "h2" as const], + ])("wraps at a syllable as a %s", (_name, titleAs) => { + const html = renderToStaticMarkup( + , + ); + const classes = titleClasses(html); + expect(classes).toContain("min-w-0"); + expect(classes).toContain("break-words"); + expect(classes).toContain("hyphens-auto"); + expect(classes).not.toContain("truncate"); + expect(classes).not.toContain("whitespace-nowrap"); + }); + + it("keeps the compact variant on the same contract", () => { + const html = renderToStaticMarkup( + , + ); + const classes = titleClasses(html); + expect(classes).toContain("text-sm"); + expect(classes).toContain("hyphens-auto"); + expect(classes).not.toContain("truncate"); + }); +}); + +describe("no card title is cut", () => { + // A `CardTitle` or a tile `TileHeader` that truncates hides the one word + // that says what the card is. `truncate` stays legal on rows and meta + // lines that carry their full text in `title`; never on a card title. + const SRC = join(process.cwd(), "src"); + const TITLE_TAG = /<(CardTitle|TileHeader)\b[^>]*?>/g; + const CUT = /\b(truncate|whitespace-nowrap|titleClassName)\b/; + + const offenders: string[] = []; + let tags = 0; + for (const root of ["components", "app"]) { + for (const rel of walkSourceFiles(join(SRC, root), { + floor: root === "app" ? 100 : 500, + extensions: [".tsx"], + })) { + if (rel.includes("__tests__/")) continue; + const source = stripComments(readFileSync(join(SRC, root, rel), "utf8")); + for (const match of source.matchAll(TITLE_TAG)) { + tags += 1; + if (CUT.test(match[0])) offenders.push(`${root}/${rel}: ${match[1]}`); + } + } + } + + it("reads the title tags in the tree", () => { + expect(tags).toBeGreaterThan(60); + }); + + it("finds none that cut", () => { + expect(offenders).toEqual([]); + }); + + it("would catch one", () => { + const planted = `x`; + const found = [...planted.matchAll(TITLE_TAG)].filter((m) => + CUT.test(m[0]), + ); + expect(found).toHaveLength(1); + }); +}); diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index 9adccc5e5..db4e5e6e8 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -164,7 +164,6 @@ export function SparklineDeltaTile({ title={label} titleAs="h2" className="min-w-0" - titleClassName="min-w-0 truncate whitespace-nowrap" right={ provenance ? ( = { * `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) { - +

{message}

diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx index 71e76e550..e198469e5 100644 --- a/src/components/insights/derived/wellness-scores.tsx +++ b/src/components/insights/derived/wellness-scores.tsx @@ -154,7 +154,7 @@ function RingTile({ animate && "wellness-tile-rise", )} > - +
{/* The ring keeps just the number centred; the metric name lives in the header above. The per-metric `hue` gradient leans the arc diff --git a/src/components/insights/device-score-tile.tsx b/src/components/insights/device-score-tile.tsx index 39da9a327..ca7ca2323 100644 --- a/src/components/insights/device-score-tile.tsx +++ b/src/components/insights/device-score-tile.tsx @@ -118,7 +118,6 @@ export function DeviceScoreTile({ icon={Icon} title={title} className="min-w-0" - titleClassName="min-w-0 truncate" right={ latest != null ? ( - {/* The label truncates; the row never grows. `minmax(0,…)` plus - `min-w-0 truncate` is what makes that structural rather than a - promise — drop either and the track grows to the longest word and - pushes the row. Widening the column does not rescue the long names: - the longest label in five of the six locales overruns any column - this panel can afford, so the extra width would come off the fill - bar and buy a few more characters of one language. */} - + {/* The label wraps inside a fixed track; the row grows a line rather + than cutting the name. `minmax(0,…)` plus `min-w-0` keeps the track + from growing to the longest word, and `hyphens-auto` splits a + compound ("Körperfett-verteilung") at a syllable instead of ending + it in an ellipsis. Widening the column does not rescue the long + names: the longest label in most locales overruns any column this + panel can afford, and the width would come off the fill bar. */} + {t(PILLAR_LABEL_KEY[pillar.id])}
- {/* Row 2: range bar — where today's value sits inside the band. */} + {/* Row 2: range bar — where today's value sits inside the band. + Blood pressure stacks two bars, so each one names its reading; + an unlabelled first bar read as the diastolic one's sibling. */} {currentValue != null ? ( - +
+ {isBp ? ( +

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

+ ) : null} + +
) : null} {/* Row 2b: diastolic range bar for blood pressure. */} diff --git a/src/components/insights/recovery/resilience-tile.tsx b/src/components/insights/recovery/resilience-tile.tsx index 2fd357fbd..a52224f02 100644 --- a/src/components/insights/recovery/resilience-tile.tsx +++ b/src/components/insights/recovery/resilience-tile.tsx @@ -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 ? ( ` 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) => ( {title} diff --git a/src/components/measurements/__tests__/measurement-list-meta.test.ts b/src/components/measurements/__tests__/measurement-list-meta.test.ts index 89e8e01c5..d5e19e2c3 100644 --- a/src/components/measurements/__tests__/measurement-list-meta.test.ts +++ b/src/components/measurements/__tests__/measurement-list-meta.test.ts @@ -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(); - }); -}); diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 2f8aa81a9..e190c189a 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -272,47 +272,3 @@ export const MEASUREMENT_TYPE_ICONS: Record = { // The colour map lives in its own icon-free module so a surface that only // needs the colours (the timeline's value lines) does not pull every icon. export { MEASUREMENT_TYPE_COLORS } from "./measurement-type-colors"; - -/** - * Count-shaped types whose stored unit is a source token ("count", - * "count/min", "steps") rather than a word a reader should see. The list - * shows the same localised unit the type's insights page uses, so a row - * reads "13 Etagen", not "13 count". - */ -export const MEASUREMENT_UNIT_LABEL_KEYS: Partial> = { - ACTIVITY_STEPS: "insights.units.steps", - FLIGHTS_CLIMBED: "insights.units.flights", - FALL_COUNT: "insights.units.falls", - BREATHING_DISTURBANCES: "insights.units.breathingEvents", - RESPIRATORY_RATE: "insights.units.respiratoryRate", -}; - -/** The unit a list row shows for `type`: the localised word, else `unit`. */ -export function measurementUnitLabel( - type: string, - unit: string, - t: (key: string) => string, -): string { - const key = MEASUREMENT_UNIT_LABEL_KEYS[type]; - if (key) return t(key); - // A score or a rating is a bare number; "72 score" reads as a stray - // English token next to the German label, not as a unit. - if (UNITLESS_TOKENS.has(unit)) return ""; - return unit; -} - -const UNITLESS_TOKENS: ReadonlySet = new Set(["score", "rating"]); - -/** - * The unit as the lead of a row's meta line ("kg · 2 days ago"), or null - * when the row has no unit word to show: a score or a rating would otherwise - * open the line with a bare separator. - */ -export function measurementUnitMetaLead( - type: string, - unit: string, - t: (key: string) => string, -): string | null { - const label = measurementUnitLabel(type, unit, t); - return label ? `${label} · ` : null; -} diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 49ca00a81..207b30e81 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -104,9 +104,11 @@ import { MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS, MEASUREMENT_TYPE_ICONS as TYPE_ICONS, MEASUREMENT_TYPE_COLORS as TYPE_COLORS, +} from "./measurement-list-meta"; +import { measurementUnitLabel, measurementUnitMetaLead, -} from "./measurement-list-meta"; +} from "@/lib/measurements/unit-label"; import { measurementListFiltersToSearch, numericInputOrUndefined, diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index f5fc5a9a0..67102c5f7 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -846,7 +846,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { - + {entry.tags.length > 0 ? entry.tags.join(", ") : "-"} @@ -960,7 +960,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {

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

+

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

)} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 60f98110b..e64293365 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -63,7 +63,10 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) { return (
); diff --git a/src/hooks/__tests__/use-unit-display-label.test.tsx b/src/hooks/__tests__/use-unit-display-label.test.tsx new file mode 100644 index 000000000..59793b302 --- /dev/null +++ b/src/hooks/__tests__/use-unit-display-label.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: null }), + useAccountOnceMounted: () => null, +})); + +import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { Locale } from "@/lib/i18n/config"; + +import de from "../../../messages/de.json"; +import en from "../../../messages/en.json"; + +const BUNDLES = { de, en } as const; + +function Units({ types }: { types: string[] }) { + const units = useUnitDisplay(); + return <>{types.map((type) => units.unitFor(type)).join("|")}; +} + +function unitsIn(locale: Locale & keyof typeof BUNDLES, types: string[]) { + return renderToStaticMarkup( + + + , + ).split("|"); +} + +// The display unit is the reader's, not the storage token: a German +// account read "15,4 breaths/min" on the vitals tile. +describe("useUnitDisplay().unitFor", () => { + it("speaks the reader's language for word-shaped units", () => { + expect( + unitsIn("de", ["RESPIRATORY_RATE", "VASCULAR_AGE", "TIME_IN_DAYLIGHT"]), + ).toEqual(["Atemzüge/min", "Jahre", "Min."]); + expect(unitsIn("en", ["RESPIRATORY_RATE"])).toEqual(["breaths/min"]); + }); + + it("keeps symbols and drops kind markers", () => { + expect( + unitsIn("de", ["WEIGHT", "BODY_TEMPERATURE", "RECOVERY_SCORE"]), + ).toEqual(["kg", "°C", ""]); + }); +}); diff --git a/src/hooks/use-unit-display.ts b/src/hooks/use-unit-display.ts index 212823877..4de6493ed 100644 --- a/src/hooks/use-unit-display.ts +++ b/src/hooks/use-unit-display.ts @@ -28,6 +28,8 @@ import { type UnitPreference, } from "@/lib/measurements/display-transform"; import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose"; +import { useTranslations } from "@/lib/i18n/context"; +import { measurementUnitLabel } from "@/lib/measurements/unit-label"; export interface UnitDisplay { /** Resolved preference ("metric" by default / on a stale payload). */ @@ -46,7 +48,12 @@ export interface UnitDisplay { fromDisplay: (type: string, displayValue: number) => number; /** Convert a DELTA / slope / SD — factor only, never the affine offset. */ toDisplayDelta: (type: string, deltaValue: number) => number; - /** The display unit symbol for a type under the current preference. */ + /** + * The unit shown beside a value of `type` under the current preference, in + * the reader's language: a symbol ("kg", "°F"), a localised word + * ("Atemzüge/min"), or "" for a bare score or rating. Display only; the + * canonical token stays `getUnitForType`. + */ unitFor: (type: string) => string; /** Display decimal places for a type under the current preference. */ decimalsFor: (type: string) => number; @@ -84,6 +91,7 @@ function useDisplayForPreference( rawPreference: string | null | undefined, rawGlucoseUnit: string | null | undefined, ): UnitDisplay { + const { t } = useTranslations(); const preference = resolveUnitPreference(rawPreference); const glucoseUnit = resolveGlucoseUnit(rawGlucoseUnit); @@ -100,9 +108,10 @@ function useDisplayForPreference( invertDisplayTransform(displayValue, transformFor(type)), toDisplayDelta: (type, deltaValue) => applyDisplayTransformDelta(deltaValue, transformFor(type)), - unitFor: (type) => transformFor(type).displayUnit, + unitFor: (type) => + measurementUnitLabel(type, transformFor(type).displayUnit, t), decimalsFor: (type) => transformFor(type).decimals, isTransformed: (type) => hasDisplayTransform(type), }; - }, [preference, glucoseUnit]); + }, [preference, glucoseUnit, t]); } diff --git a/src/lib/measurements/__tests__/unit-label.test.ts b/src/lib/measurements/__tests__/unit-label.test.ts new file mode 100644 index 000000000..cdcd8fc89 --- /dev/null +++ b/src/lib/measurements/__tests__/unit-label.test.ts @@ -0,0 +1,221 @@ +/** + * The unit a reader sees beside a value is a symbol every locale shares or a + * word from the bundle, never a storage token. "15,4 breaths/min" on a German + * screen came from a tile that printed the canonical unit as it is stored. + * + * Two halves: every canonical unit resolves to a known symbol, nothing, or a + * key present in all eight bundles; and no display source spells a word + * unit as a literal prop. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + stripComments, + walkSourceFiles, +} from "@/__tests__/helpers/source-files"; +import { + getDisplayTransform, + TRANSFORMED_TYPES, +} from "@/lib/measurements/display-transform"; +import { + measurementUnitLabel, + measurementUnitMetaLead, +} from "@/lib/measurements/unit-label"; +import { getUnitForType } from "@/lib/measurements/unit-map"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; + +import de from "../../../../messages/de.json"; +import en from "../../../../messages/en.json"; +import es from "../../../../messages/es.json"; +import fr from "../../../../messages/fr.json"; +import it_ from "../../../../messages/it.json"; +import ko from "../../../../messages/ko.json"; +import nl from "../../../../messages/nl.json"; +import pl from "../../../../messages/pl.json"; + +const BUNDLES = { de, en, es, fr, it: it_, ko, nl, pl } as const; + +/** + * Unit symbols that read the same in every supported language. A token + * outside this set that is not resolved through the bundle is an English + * word on a non-English screen. + */ +const SHARED_SYMBOLS: ReadonlySet = new Set([ + "%", + "kg", + "lb", + "g", + "mmHg", + "bpm", + "ms", + "mg/dL", + "mmol/L", + "kcal", + "kJ", + "m", + "km", + "mi", + "ft", + "cm", + "in", + "m/s", + "km/h", + "mph", + "°C", + "°F", + "dBA", + "mL/(kg·min)", + "kg/m²", + "mL", + "h", + "min", + "/ 5", + "", +]); + +const KEY_MARK = "key:"; +const markKey = (key: string) => `${KEY_MARK}${key}`; + +function resolve(bundle: unknown, key: string): unknown { + return key + .split(".") + .reduce( + (node, part) => + node && typeof node === "object" + ? (node as Record)[part] + : undefined, + bundle, + ); +} + +describe("measurementUnitLabel", () => { + const t = (key: string) => `t:${key}`; + + it("names what a count-shaped type counts", () => { + expect(measurementUnitLabel("FLIGHTS_CLIMBED", "count", t)).toBe( + "t:insights.units.flights", + ); + expect(measurementUnitLabel("RESPIRATORY_RATE", "count/min", t)).toBe( + "t:insights.units.respiratoryRate", + ); + }); + + it("localises a word-shaped canonical token", () => { + expect(measurementUnitLabel("RESPIRATORY_RATE", "breaths/min", t)).toBe( + "t:insights.units.respiratoryRate", + ); + expect(measurementUnitLabel("VASCULAR_AGE", "years", t)).toBe( + "t:insights.units.years", + ); + expect(measurementUnitLabel("TIME_IN_DAYLIGHT", "minutes", t)).toBe( + "t:insights.deviceScore.unitMinutes", + ); + }); + + it("passes a shared symbol through", () => { + expect(measurementUnitLabel("WEIGHT", "kg", t)).toBe("kg"); + expect(measurementUnitLabel("SKIN_TEMPERATURE", "celsius", t)).toBe("°C"); + }); + + it("drops the kind markers", () => { + expect(measurementUnitLabel("RECOVERY_SCORE", "score", t)).toBe(""); + expect(measurementUnitLabel("VISCERAL_FAT", "rating", t)).toBe(""); + expect(measurementUnitLabel("SLEEP_DISTURBANCE_COUNT", "count", t)).toBe( + "", + ); + }); + + it("leads a meta line only when there is a unit to show", () => { + expect(measurementUnitMetaLead("WEIGHT", "kg", t)).toBe("kg · "); + expect(measurementUnitMetaLead("PHQ9_SCORE", "score", t)).toBeNull(); + }); +}); + +describe("every canonical unit reaches the reader in their language", () => { + const types = measurementTypeEnum.options; + + it("walks the whole type list", () => { + expect(types.length).toBeGreaterThan(60); + }); + + it.each(types)("%s resolves to a shared symbol or a bundle key", (type) => { + const shown = [ + getUnitForType(type), + ...(TRANSFORMED_TYPES.has(type) + ? [ + getDisplayTransform(type, "metric").displayUnit, + getDisplayTransform(type, "imperial").displayUnit, + ] + : []), + ].map((unit) => measurementUnitLabel(type, unit, markKey)); + + for (const label of shown) { + if (label.startsWith(KEY_MARK)) { + const key = label.slice(KEY_MARK.length); + for (const [locale, bundle] of Object.entries(BUNDLES)) { + expect( + typeof resolve(bundle, key), + `${type}: ${key} missing in ${locale}`, + ).toBe("string"); + } + } else { + expect( + SHARED_SYMBOLS.has(label), + `${type} shows "${label}" as is. Give it a bundle key in unit-label.ts, or add it to SHARED_SYMBOLS if every locale reads it unchanged.`, + ).toBe(true); + } + } + }); + + it("the German bundle does not spell the respiratory unit in English", () => { + const t = (key: string) => String(resolve(de, key)); + expect( + measurementUnitLabel("RESPIRATORY_RATE", "breaths/min", t), + ).not.toMatch(/breaths/); + }); +}); + +describe("no display source spells a word unit as a literal", () => { + const SRC = join(process.cwd(), "src"); + const roots = ["app", "components", "hooks"]; + // `unit="…"` as a prop or `unit: "…"` in an object literal. + const LITERAL = /\bunit(?:=|:\s*)"([^"]*)"/g; + + const offenders: string[] = []; + let scanned = 0; + for (const root of roots) { + const dir = join(SRC, root); + for (const rel of walkSourceFiles(dir, { + floor: root === "hooks" ? 50 : 500, + })) { + if (rel.includes("__tests__/") || rel.startsWith("api/")) continue; + scanned += 1; + const source = stripComments(readFileSync(join(dir, rel), "utf8")); + for (const match of source.matchAll(LITERAL)) { + if (!SHARED_SYMBOLS.has(match[1])) { + offenders.push(`${root}/${rel}: unit "${match[1]}"`); + } + } + } + } + + it("reads the display tree", () => { + expect(scanned).toBeGreaterThan(1000); + }); + + it("finds only shared symbols", () => { + expect(offenders).toEqual([]); + }); + + it("would catch a word unit", () => { + const planted = stripComments(``); + expect( + [...planted.matchAll(LITERAL)] + .map((m) => m[1]) + .filter((u) => !SHARED_SYMBOLS.has(u)), + ).toEqual(["breaths/min"]); + }); +}); diff --git a/src/lib/measurements/unit-label.ts b/src/lib/measurements/unit-label.ts new file mode 100644 index 000000000..c6373967a --- /dev/null +++ b/src/lib/measurements/unit-label.ts @@ -0,0 +1,86 @@ +/** + * The unit a person reads beside a measurement value. + * + * The canonical storage units in `unit-map.ts` are tokens, not labels. Most + * are symbols every locale reads as they are (kg, mmHg, bpm, ms, %), but a + * handful are English words ("breaths/min", "steps", "flights", "years", + * "minutes") or bare kind markers ("score", "rating", "count", "event", + * "level", "ratio", "unknown"). Passed straight through, the words printed + * "15,4 breaths/min" on a German screen. Every display path resolves the + * token here, so a new word-shaped unit is a one-line addition in one place + * and `unit-label.test.ts` refuses a canonical unit that is neither a known + * symbol nor resolved. + * + * Pure and server-safe: the caller hands in its own `t`. + */ + +/** + * Types whose unit word depends on what is counted, not on the token. A + * device sends "count" for both falls and breathing disturbances; the reader + * should see "falls" and "events". + */ +const TYPE_UNIT_LABEL_KEYS: Readonly> = { + ACTIVITY_STEPS: "insights.units.steps", + FLIGHTS_CLIMBED: "insights.units.flights", + FALL_COUNT: "insights.units.falls", + BREATHING_DISTURBANCES: "insights.units.breathingEvents", + RESPIRATORY_RATE: "insights.units.respiratoryRate", +}; + +/** Word-shaped tokens that read the same whatever the type. */ +const TOKEN_UNIT_LABEL_KEYS: Readonly> = { + "breaths/min": "insights.units.respiratoryRate", + steps: "insights.units.steps", + flights: "insights.units.flights", + years: "insights.units.years", + minutes: "insights.deviceScore.unitMinutes", +}; + +/** Spelled-out tokens whose symbol every locale reads. */ +const TOKEN_SYMBOLS: Readonly> = { + celsius: "°C", +}; + +/** + * Kind markers with no unit to show: a score or a rating is a bare number, + * and "72 score" reads as a stray English token next to a German label. + */ +const UNITLESS_UNIT_TOKENS: ReadonlySet = new Set([ + "score", + "rating", + "count", + "event", + "level", + "ratio", + "unknown", +]); + +/** The unit shown beside a value of `type` stored or sent in `unit`. */ +export function measurementUnitLabel( + type: string, + unit: string, + t: (key: string) => string, +): string { + const typeKey = TYPE_UNIT_LABEL_KEYS[type]; + if (typeKey) return t(typeKey); + const tokenKey = TOKEN_UNIT_LABEL_KEYS[unit]; + if (tokenKey) return t(tokenKey); + const symbol = TOKEN_SYMBOLS[unit]; + if (symbol) return symbol; + if (UNITLESS_UNIT_TOKENS.has(unit)) return ""; + return unit; +} + +/** + * The unit as the lead of a row's meta line ("kg · 2 days ago"), or null + * when the row has no unit word to show: a score or a rating would otherwise + * open the line with a bare separator. + */ +export function measurementUnitMetaLead( + type: string, + unit: string, + t: (key: string) => string, +): string | null { + const label = measurementUnitLabel(type, unit, t); + return label ? `${label} · ` : null; +}