diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts index be6777ef7..f6d32f37a 100644 --- a/src/__tests__/display-transform-rounding-guard.test.ts +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -204,10 +204,11 @@ describe("display-transform rounding guard — the bypass freeze", () => { // The justification for holding the raw multiplier at all: the chart // takes it as a prop. A file that stops passing it has no reason to be // reading `.factor` any more and belongs back behind the helpers. - // The prop as it is actually passed — a bare substring would still + // The prop as it is actually passed (as a JSX attribute or as a key of + // the props a chart builder spreads) — a bare substring would still // match a renamed identifier that no longer reaches any chart. const offenders = RAW_SCALE_SURFACES.filter( - (rel) => !/\bvalueScale=/.test(read(rel)), + (rel) => !/\bvalueScale[=:]/.test(read(rel)), ); expect(offenders).toEqual([]); }); diff --git a/src/components/dashboard/__tests__/tile-destinations.test.ts b/src/components/dashboard/__tests__/tile-destinations.test.ts index 6ba6c3244..7d2224258 100644 --- a/src/components/dashboard/__tests__/tile-destinations.test.ts +++ b/src/components/dashboard/__tests__/tile-destinations.test.ts @@ -14,8 +14,8 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; -import { readSource } from "@/__tests__/helpers/source-files"; import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles"; +import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context"; import { TILE_CAPABLE_WIDGET_IDS, @@ -78,21 +78,37 @@ const NON_MEASUREMENT_DESTINATION: Partial< const NO_DESTINATION: Partial> = {}; /** - * Card ids the strip tiles paint, read from the registry's card definitions: - * a template id (one card per glucose bucket) comes back as its prefix and a - * `*`. + * Card ids the strip tiles paint, built from the registry with a context in + * which every series has a reading, so every tile paints its cards. */ function renderedTileIds(): string[] { - const source = readSource( - "src/components/dashboard/tiles/strip-tiles.tsx", - "STRIP_TILES", - ); - const ids: string[] = []; - const pattern = /\bid:\s*(?:"([^"]+)"|`([^`$]+)\$\{[^`]*`)/g; - for (const match of source.matchAll(pattern)) { - ids.push(match[1] ?? `${match[2]}*`); - } - return ids; + const reading = { count: 3, latest: 1, avg7: 1, avg30: 1 }; + const summaries = new Proxy({} as Record, { + get: () => reading, + }); + const ctx = { + t: (key: string) => key, + fmt: { number: (v: number) => String(v) }, + td: (_type: string, v: number | null | undefined) => v ?? null, + tdd: (_type: string, v: number | null | undefined) => v ?? null, + unitDisplay: { unitFor: () => "" }, + user: null, + summaries, + moodSummary: reading, + hrv: { summary: reading, type: "HRV_RMSSD", labelKey: "x" }, + data: { bpInTargetPct: 50, bpInTargetPct7d: 50, bpInTargetPct30d: 50 }, + glucoseTiles: [{ bucket: "FASTING", labelKey: "x", summary: reading }], + displayGlucoseUnit: "mg/dL", + weightTrend: { direction: "neutral" }, + compareBaseline: "none", + directionOf: () => "neutral", + tileCompareDelta: () => null, + tileStaleDays: () => null, + } as unknown as DashboardTileContext; + return STRIP_TILES.flatMap((tile) => { + expect(tile.visible(ctx), tile.widget).toBe(true); + return tile.cards(ctx).map((card) => card.id); + }); } describe("dashboard tile destinations", () => { @@ -110,8 +126,7 @@ describe("dashboard tile destinations", () => { const rendered = renderedTileIds(); const widgets = new Set(); for (const id of rendered) { - const sample = id.endsWith("*") ? `${id.slice(0, -1)}X` : id; - const widget = tileWidgetId(sample); + const widget = tileWidgetId(id); expect(widget, `rendered tile "${id}" has no widget`).not.toBeNull(); widgets.add(widget as string); } diff --git a/src/components/dashboard/tiles/chart-tiles.tsx b/src/components/dashboard/tiles/chart-tiles.tsx index 1c4116373..bf05e10bd 100644 --- a/src/components/dashboard/tiles/chart-tiles.tsx +++ b/src/components/dashboard/tiles/chart-tiles.tsx @@ -110,6 +110,38 @@ const BMI_BANDS = [ const hasRestingHr = (ctx: DashboardTileContext) => countOf(ctx.summaries?.RESTING_HEART_RATE) > 0; +/** + * A measurement chart card with the props every dashboard chart shares: its + * reveal report, the comparison baseline, day doors and, unless the card + * says otherwise, the batched series slice and the profile time zone. + */ +function metricChart( + ctx: ChartTileContext, + id: string, + props: Omit< + ComponentProps, + "onDataReady" | "compareBaseline" | "dayLinks" + >, + { batched = true, timezone = true } = {}, +): ReactNode { + return ( + ctx.markChartReady(id)} + {...(batched + ? { + preloadedSeries: ctx.preloadedSeries, + preloadedCoverageDays: BATCH_COVERAGE_DAYS, + } + : {})} + compareBaseline={ctx.compareBaseline} + {...(timezone ? { userTimezone: ctx.user?.timezone } : {})} + dayLinks + {...props} + /> + ); +} + /** Every chart-row card, in the order the cards are collected. */ export const CHART_TILES: readonly ChartTile[] = [ { @@ -118,24 +150,16 @@ export const CHART_TILES: readonly ChartTile[] = [ revealGated: true, visible: (ctx) => countOf(ctx.summaries?.WEIGHT) > 0, count: (ctx) => ctx.summaries?.WEIGHT?.count ?? 0, - render: (ctx) => ( - ctx.markChartReady("weight-chart")} - preloadedSeries={ctx.preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="weight" - types={["WEIGHT"]} - title={ctx.t("dashboard.weight")} - colors={["var(--chart-1)"]} - unit={ctx.unitDisplay.unitFor("WEIGHT")} - valueBands={ctx.weightDisplayBands} - compareBaseline={ctx.compareBaseline} - userTimezone={ctx.user?.timezone} - valueScale={ctx.unitDisplay.transformFor("WEIGHT").factor} - dayLinks - /> - ), + render: (ctx) => + metricChart(ctx, "weight-chart", { + chartKey: "weight", + types: ["WEIGHT"], + title: ctx.t("dashboard.weight"), + colors: ["var(--chart-1)"], + unit: ctx.unitDisplay.unitFor("WEIGHT"), + valueBands: ctx.weightDisplayBands, + valueScale: ctx.unitDisplay.transformFor("WEIGHT").factor, + }), }, { // BMI rides the weight widget, right behind its chart, and only for a @@ -146,23 +170,21 @@ export const CHART_TILES: readonly ChartTile[] = [ revealGated: true, visible: (ctx) => countOf(ctx.summaries?.WEIGHT) > 0 && Boolean(ctx.user?.heightCm), - render: (ctx) => ( - ctx.markChartReady("bmi-chart")} - preloadedSeries={ctx.preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="bmi" - types={["WEIGHT"]} - title={ctx.t("targets.bmi")} - colors={["var(--dracula-yellow)"]} - unit="kg/m²" - valueMode="bmi" - valueBands={BMI_BANDS} - compareBaseline={ctx.compareBaseline} - dayLinks - /> - ), + render: (ctx) => + metricChart( + ctx, + "bmi-chart", + { + chartKey: "bmi", + types: ["WEIGHT"], + title: ctx.t("targets.bmi"), + colors: ["var(--dracula-yellow)"], + unit: "kg/m²", + valueMode: "bmi", + valueBands: BMI_BANDS, + }, + { timezone: false }, + ), }, { id: "bp-chart", @@ -176,23 +198,15 @@ export const CHART_TILES: readonly ChartTile[] = [ ctx.summaries?.BLOOD_PRESSURE_SYS?.count ?? 0, ctx.summaries?.BLOOD_PRESSURE_DIA?.count ?? 0, ), - render: (ctx) => ( - ctx.markChartReady("bp-chart")} - preloadedSeries={ctx.preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="bp" - types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]} - title={ctx.t("dashboard.bloodPressure")} - colors={["var(--chart-3)", "var(--chart-4)"]} - unit="mmHg" - targetZones={ctx.bpTargetZones} - compareBaseline={ctx.compareBaseline} - userTimezone={ctx.user?.timezone} - dayLinks - /> - ), + render: (ctx) => + metricChart(ctx, "bp-chart", { + chartKey: "bp", + types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + title: ctx.t("dashboard.bloodPressure"), + colors: ["var(--chart-3)", "var(--chart-4)"], + unit: "mmHg", + targetZones: ctx.bpTargetZones, + }), }, { // The resting series against the resting band when there is one; @@ -205,27 +219,17 @@ export const CHART_TILES: readonly ChartTile[] = [ countOf(ctx.summaries?.PULSE) > 0 || countOf(ctx.summaries?.RESTING_HEART_RATE) > 0, count: (ctx) => ctx.summaries?.PULSE?.count ?? 0, - render: (ctx) => ( - ctx.markChartReady("pulse-chart")} - preloadedSeries={ctx.preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="pulse" - types={hasRestingHr(ctx) ? ["RESTING_HEART_RATE"] : ["PULSE"]} - title={ - hasRestingHr(ctx) - ? ctx.t(MEASUREMENT_TYPE_LABEL_KEYS.RESTING_HEART_RATE) - : ctx.t("dashboard.pulse") - } - colors={["var(--success)"]} - unit="bpm" - valueBands={hasRestingHr(ctx) ? ctx.pulseBands : undefined} - compareBaseline={ctx.compareBaseline} - userTimezone={ctx.user?.timezone} - dayLinks - /> - ), + render: (ctx) => + metricChart(ctx, "pulse-chart", { + chartKey: "pulse", + types: hasRestingHr(ctx) ? ["RESTING_HEART_RATE"] : ["PULSE"], + title: hasRestingHr(ctx) + ? ctx.t(MEASUREMENT_TYPE_LABEL_KEYS.RESTING_HEART_RATE) + : ctx.t("dashboard.pulse"), + colors: ["var(--success)"], + unit: "bpm", + valueBands: hasRestingHr(ctx) ? ctx.pulseBands : undefined, + }), }, { id: "bodyFat-chart", @@ -233,23 +237,15 @@ export const CHART_TILES: readonly ChartTile[] = [ revealGated: true, visible: (ctx) => countOf(ctx.summaries?.BODY_FAT) > 0, count: (ctx) => ctx.summaries?.BODY_FAT?.count ?? 0, - render: (ctx) => ( - ctx.markChartReady("bodyFat-chart")} - preloadedSeries={ctx.preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="bodyFat" - types={["BODY_FAT"]} - title={ctx.t("dashboard.bodyFat")} - colors={["var(--warning)"]} - unit="%" - valueBands={ctx.bodyFatBands} - compareBaseline={ctx.compareBaseline} - userTimezone={ctx.user?.timezone} - dayLinks - /> - ), + render: (ctx) => + metricChart(ctx, "bodyFat-chart", { + chartKey: "bodyFat", + types: ["BODY_FAT"], + title: ctx.t("dashboard.bodyFat"), + colors: ["var(--warning)"], + unit: "%", + valueBands: ctx.bodyFatBands, + }), }, { id: "mood-chart", @@ -274,20 +270,19 @@ export const CHART_TILES: readonly ChartTile[] = [ revealGated: true, visible: (ctx) => countOf(ctx.summaries?.SLEEP_DURATION) > 0, count: (ctx) => ctx.summaries?.SLEEP_DURATION?.count ?? 0, - render: (ctx) => ( - ctx.markChartReady("sleep-chart")} - chartKey="sleep" - types={["SLEEP_DURATION"]} - title={ctx.t("dashboard.sleep") ?? "Sleep"} - colors={["var(--chart-4)"]} - unit="h" - compareBaseline={ctx.compareBaseline} - userTimezone={ctx.user?.timezone} - dayLinks - /> - ), + render: (ctx) => + metricChart( + ctx, + "sleep-chart", + { + chartKey: "sleep", + types: ["SLEEP_DURATION"], + title: ctx.t("dashboard.sleep") ?? "Sleep", + colors: ["var(--chart-4)"], + unit: "h", + }, + { batched: false }, + ), }, { id: "steps-chart", @@ -295,21 +290,13 @@ export const CHART_TILES: readonly ChartTile[] = [ revealGated: true, visible: (ctx) => countOf(ctx.summaries?.ACTIVITY_STEPS) > 0, count: (ctx) => ctx.summaries?.ACTIVITY_STEPS?.count ?? 0, - render: (ctx) => ( - ctx.markChartReady("steps-chart")} - preloadedSeries={ctx.preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="steps" - types={["ACTIVITY_STEPS"]} - title={ctx.t("dashboard.steps") ?? "Steps"} - colors={["var(--success)"]} - compareBaseline={ctx.compareBaseline} - userTimezone={ctx.user?.timezone} - dayLinks - /> - ), + render: (ctx) => + metricChart(ctx, "steps-chart", { + chartKey: "steps", + types: ["ACTIVITY_STEPS"], + title: ctx.t("dashboard.steps") ?? "Steps", + colors: ["var(--success)"], + }), }, { // Adherence over `/api/medications/intake?scope=compliance&days=N`. diff --git a/src/components/dashboard/tiles/strip-tiles.tsx b/src/components/dashboard/tiles/strip-tiles.tsx index 6ee5dc19e..d45321c39 100644 --- a/src/components/dashboard/tiles/strip-tiles.tsx +++ b/src/components/dashboard/tiles/strip-tiles.tsx @@ -32,6 +32,7 @@ import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; import { isWindowSufficient } from "@/lib/analytics/window-confidence"; import { convertGlucose, glucoseFractionDigits } from "@/lib/glucose"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"; +import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; import type { DashboardTileContext } from "./tile-context"; @@ -80,12 +81,18 @@ function pulseSource(ctx: DashboardTileContext) { }; } +type Summary = NonNullable[string]; +type TrafficRange = Parameters[1]["range"]; + /** - * A tile for a plain metric: the headline, its 7- and 30-day means, the trend - * and the comparison delta, nothing else. `display` names the type whose - * metric / imperial transform applies; without it the values pass through. + * One metric tile: the headline, its 7- and 30-day means, the trend and the + * comparison delta. `type` names the series (direction and freshness follow + * it unless overridden); `summary` replaces the summary read off `type`. + * With `display` the values go through the metric / imperial transform, and + * with `range` the means are coloured against it (on the canonical values) + * and carry its hint (in `hintRange`, the display unit, when given). */ -function plainCard( +function metricCard( ctx: DashboardTileContext, id: string, props: { @@ -93,16 +100,30 @@ function plainCard( label: string; unit: string; icon: React.ComponentType<{ className?: string }>; + summary?: Summary | null; display?: boolean; fractionDigits?: number; + range?: TrafficRange; + hintRange?: TrafficRange; + direction?: TrendDirectionSentiment; staleType?: string | null; + valueAdornment?: ReactNode; }, ): ReactNode { - const s = ctx.summaries?.[props.type]; + const s = "summary" in props ? props.summary : ctx.summaries?.[props.type]; const value = (v: number | null | undefined) => props.display ? ctx.td(props.type, v) : (v ?? null); const delta = (v: number | null | undefined) => props.display ? ctx.tdd(props.type, v) : v; + const hint = + props.range === undefined + ? undefined + : getRangeHint( + props.unit, + { range: props.hintRange ?? props.range }, + ctx.t, + ctx.fmt.number, + ); return ( ); } +/** A tile that paints one card under its own id. */ +function one( + widget: string, + visible: StripTile["visible"], + node: (ctx: DashboardTileContext) => ReactNode, +): StripTile { + return { widget, visible, cards: (ctx) => [{ id: widget, node: node(ctx) }] }; +} + +/** A plain metric tile, shown when its series has a reading. */ +function metricTile( + widget: string, + type: string, + props: ( + ctx: DashboardTileContext, + ) => Omit[2], "type">, +): StripTile { + return one( + widget, + (ctx) => countOf(ctx.summaries?.[type]) > 0, + (ctx) => metricCard(ctx, widget, { type, ...props(ctx) }), + ); +} + /** * Every strip tile, in the order the cards are collected. Cards are then * sorted by their layout order (a stable sort), so for two widgets that share * an order this list decides which comes first. */ export const STRIP_TILES: readonly StripTile[] = [ - { - widget: "weight", - visible: (ctx) => countOf(ctx.summaries?.WEIGHT) > 0, - cards: (ctx) => { - const w = ctx.summaries?.WEIGHT; - const unit = ctx.unitDisplay.unitFor("WEIGHT"); - const hint = getRangeHint( - unit, - { range: ctx.weightHintRange }, - ctx.t, - ctx.fmt.number, - ); - return [ - { - id: "weight", - node: ( - - ), - }, - ]; - }, - }, + metricTile("weight", "WEIGHT", (ctx) => ({ + label: ctx.t("dashboard.weightShort"), + unit: ctx.unitDisplay.unitFor("WEIGHT"), + icon: Activity, + display: true, + range: ctx.weightRange, + hintRange: ctx.weightHintRange, + // Which way is progress depends on the person's own target, resolved + // on the server. + direction: ctx.weightTrend.direction, + })), { // One signal, two tiles: systolic and diastolic side by side at the size // of every other tile. The diastolic card's offset keeps it right behind @@ -183,360 +208,175 @@ export const STRIP_TILES: readonly StripTile[] = [ visible: (ctx) => countOf(ctx.summaries?.BLOOD_PRESSURE_SYS) > 0 || countOf(ctx.summaries?.BLOOD_PRESSURE_DIA) > 0, - cards: (ctx) => - [ - { - id: "bp-sys", + cards: (ctx) => [ + { + id: "bp-sys", + node: metricCard(ctx, "bp-sys", { type: "BLOOD_PRESSURE_SYS", label: ctx.t("dashboard.bloodPressureSysShort"), + unit: "mmHg", + icon: Heart, + fractionDigits: 0, range: ctx.bpSysRange, - orderOffset: 0, - }, - { - id: "bp-dia", + }), + }, + { + id: "bp-dia", + orderOffset: 0.001, + node: metricCard(ctx, "bp-dia", { type: "BLOOD_PRESSURE_DIA", label: ctx.t("dashboard.bloodPressureDiaShort"), + unit: "mmHg", + icon: Heart, + fractionDigits: 0, range: ctx.bpDiaRange, - orderOffset: 0.001, - }, - ].map(({ id, type, label, range, orderOffset }) => { - const s = ctx.summaries?.[type]; - return { - id, - orderOffset, - node: ( - - ), - }; - }), + }), + }, + ], }, - { - widget: "pulse", - visible: (ctx) => + one( + "pulse", + (ctx) => countOf(ctx.summaries?.PULSE) > 0 || countOf(ctx.summaries?.RESTING_HEART_RATE) > 0, - cards: (ctx) => { - const { hasRestingHr, summary: s, type } = pulseSource(ctx); - const range = hasRestingHr ? ctx.pulseDisplayRange : null; - return [ - { - id: "pulse", - node: ( - - ), - }, - ]; + (ctx) => { + const { hasRestingHr, summary, type } = pulseSource(ctx); + return metricCard(ctx, "pulse", { + type, + summary, + label: hasRestingHr + ? ctx.t(MEASUREMENT_TYPE_LABEL_KEYS.RESTING_HEART_RATE) + : ctx.t("dashboard.pulseShort"), + unit: "bpm", + icon: TrendingUp, + fractionDigits: 0, + range: hasRestingHr ? ctx.pulseDisplayRange : null, + }); }, - }, - { - widget: "bodyFat", - visible: (ctx) => countOf(ctx.summaries?.BODY_FAT) > 0, - cards: (ctx) => [ - { - id: "bodyFat", - node: plainCard(ctx, "bodyFat", { - type: "BODY_FAT", - label: ctx.t("dashboard.bodyFatShort"), - unit: "%", - icon: Percent, - }), - }, - ], - }, - { - widget: "mood", - visible: (ctx) => countOf(ctx.moodSummary) > 0, - cards: (ctx) => { - const s = ctx.moodSummary; - return [ - { - id: "mood", - node: ( - - ), - }, - ]; - }, - }, - { - widget: "sleep", - visible: (ctx) => countOf(ctx.summaries?.SLEEP_DURATION) > 0, - cards: (ctx) => { - // The summary carries per-night time asleep in MINUTES; the tile reads - // hours, so every value field converts while the staleness and count - // metadata stay as they are. + ), + metricTile("bodyFat", "BODY_FAT", (ctx) => ({ + label: ctx.t("dashboard.bodyFatShort"), + unit: "%", + icon: Percent, + })), + one( + "mood", + (ctx) => countOf(ctx.moodSummary) > 0, + (ctx) => + metricCard(ctx, "mood", { + type: "MOOD", + summary: ctx.moodSummary, + label: ctx.t("dashboard.moodShort"), + unit: "/ 5", + icon: Smile, + staleType: null, + }), + ), + one( + "sleep", + (ctx) => countOf(ctx.summaries?.SLEEP_DURATION) > 0, + (ctx) => { + // The summary carries per-night time asleep in MINUTES; the tile + // reads hours, so every value field converts while the staleness and + // count metadata stay as they are. const raw = ctx.summaries?.SLEEP_DURATION; - const s = raw ? toHoursSummary(raw) : undefined; const discrepancy = ctx.data?.sleepSourceDiscrepancy; - return [ - { - id: "sleep", - node: ( - - ) : null - } - /> - ), - }, - ]; + return metricCard(ctx, "sleep", { + type: "SLEEP_DURATION", + summary: raw ? toHoursSummary(raw) : undefined, + label: ctx.t("dashboard.sleepShort") ?? "Sleep", + unit: "h", + icon: Moon, + // A discreet "sources disagree" marker beside the headline when two + // writers reported clearly different totals for the latest night. + // Null on the ordinary path: no layout change. + valueAdornment: discrepancy ? ( + + ) : null, + }); }, - }, - { - // Steps are activity data tied to the workouts module; only an explicit - // `false` hides them (fail-open). - widget: "steps", - visible: (ctx) => + ), + // Steps are activity data tied to the workouts module; only an explicit + // `false` hides them (fail-open). + one( + "steps", + (ctx) => countOf(ctx.summaries?.ACTIVITY_STEPS) > 0 && ctx.user?.modules?.workouts !== false, - cards: (ctx) => [ - { - id: "steps", - node: plainCard(ctx, "steps", { - type: "ACTIVITY_STEPS", - label: ctx.t("dashboard.stepsShort") ?? "Steps", - unit: "", - icon: Footprints, - fractionDigits: 0, - }), - }, - ], - }, - { - widget: "vo2Max", - visible: (ctx) => countOf(ctx.summaries?.VO2_MAX) > 0, - cards: (ctx) => [ - { - id: "vo2Max", - node: plainCard(ctx, "vo2Max", { - type: "VO2_MAX", - label: ctx.t("dashboard.vo2MaxShort") ?? "VO₂ max", - unit: ctx.t("dashboard.vo2MaxUnit") ?? "mL/(kg·min)", - icon: Gauge, - }), - }, - ], - }, - { - // HRV is stored under two types (SDNN from Apple Health / Health Connect, - // nightly RMSSD from rings and straps); `pickHrvSummary` names the one - // shown, and the label, the direction and the freshness caption follow - // it. Each series has its OWN label key, never a base label with the - // measure appended: the label renders truncated on one line, and an - // appended marker is the first thing a longer locale drops. - widget: "hrv", - visible: (ctx) => countOf(ctx.hrv.summary) > 0, - cards: (ctx) => { - const { summary: s, type, labelKey } = ctx.hrv; - return [ - { - id: "hrv", - node: ( - - ), - }, - ]; - }, - }, - { - widget: "oxygenSaturation", - visible: (ctx) => countOf(ctx.summaries?.OXYGEN_SATURATION) > 0, - cards: (ctx) => [ - { - id: "oxygenSaturation", - node: plainCard(ctx, "oxygenSaturation", { - type: "OXYGEN_SATURATION", - label: ctx.t("measurements.typeOxygenSaturation"), - unit: "%", - icon: Droplets, - }), - }, - ], - }, - { - widget: "respiratoryRate", - visible: (ctx) => countOf(ctx.summaries?.RESPIRATORY_RATE) > 0, - cards: (ctx) => [ - { - id: "respiratoryRate", - node: plainCard(ctx, "respiratoryRate", { - type: "RESPIRATORY_RATE", - label: ctx.t("measurements.typeRespiratoryRate"), - unit: ctx.t("insights.units.respiratoryRate"), - icon: Wind, - }), - }, - ], - }, - { - widget: "wristTemperature", - visible: (ctx) => countOf(ctx.summaries?.WRIST_TEMPERATURE) > 0, - cards: (ctx) => [ - { - id: "wristTemperature", - node: plainCard(ctx, "wristTemperature", { - type: "WRIST_TEMPERATURE", - label: ctx.t("measurements.typeWristTemperature"), - unit: ctx.unitDisplay.unitFor("WRIST_TEMPERATURE"), - icon: Thermometer, - display: true, - }), - }, - ], - }, - { - widget: "muscleMass", - visible: (ctx) => countOf(ctx.summaries?.MUSCLE_MASS) > 0, - cards: (ctx) => [ - { - id: "muscleMass", - node: plainCard(ctx, "muscleMass", { - type: "MUSCLE_MASS", - label: ctx.t("measurements.typeMuscleMass"), - unit: ctx.unitDisplay.unitFor("MUSCLE_MASS"), - icon: Dumbbell, - display: true, - }), - }, - ], - }, - { - widget: "totalBodyWater", - visible: (ctx) => countOf(ctx.summaries?.TOTAL_BODY_WATER) > 0, - cards: (ctx) => [ - { - id: "totalBodyWater", - node: plainCard(ctx, "totalBodyWater", { - type: "TOTAL_BODY_WATER", - label: ctx.t("measurements.typeTotalBodyWater"), - unit: ctx.unitDisplay.unitFor("TOTAL_BODY_WATER"), - icon: Droplet, - display: true, - }), - }, - ], - }, - { - widget: "boneMass", - visible: (ctx) => countOf(ctx.summaries?.BONE_MASS) > 0, - cards: (ctx) => [ - { - id: "boneMass", - node: plainCard(ctx, "boneMass", { - type: "BONE_MASS", - label: ctx.t("measurements.typeBoneMass"), - unit: ctx.unitDisplay.unitFor("BONE_MASS"), - icon: Bone, - display: true, - }), - }, - ], - }, - { - // Fluid intake: a synthetic summary key derived server-side from the - // nutrient days. Neutral sentiment, since more water is not simply better. - widget: "waterIntake", - visible: (ctx) => countOf(ctx.summaries?.NUTRIENT_WATER) > 0, - cards: (ctx) => [ - { - id: "waterIntake", - node: plainCard(ctx, "waterIntake", { - type: "NUTRIENT_WATER", - label: ctx.t("nutrients.names.water"), - unit: "mL", - icon: GlassWater, - }), - }, - ], - }, + (ctx) => + metricCard(ctx, "steps", { + type: "ACTIVITY_STEPS", + label: ctx.t("dashboard.stepsShort") ?? "Steps", + unit: "", + icon: Footprints, + fractionDigits: 0, + }), + ), + metricTile("vo2Max", "VO2_MAX", (ctx) => ({ + label: ctx.t("dashboard.vo2MaxShort") ?? "VO₂ max", + unit: ctx.t("dashboard.vo2MaxUnit") ?? "mL/(kg·min)", + icon: Gauge, + })), + // HRV is stored under two types (SDNN from Apple Health / Health Connect, + // nightly RMSSD from rings and straps); `pickHrvSummary` names the one + // shown, and the label, the direction and the freshness caption follow it. + // Each series has its OWN label key, never a base label with the measure + // appended: the label renders truncated on one line, and an appended + // marker is the first thing a longer locale drops. + one( + "hrv", + (ctx) => countOf(ctx.hrv.summary) > 0, + (ctx) => + metricCard(ctx, "hrv", { + type: ctx.hrv.type, + summary: ctx.hrv.summary, + label: ctx.t(ctx.hrv.labelKey), + unit: "ms", + icon: HeartPulse, + fractionDigits: 0, + }), + ), + metricTile("oxygenSaturation", "OXYGEN_SATURATION", (ctx) => ({ + label: ctx.t("measurements.typeOxygenSaturation"), + unit: "%", + icon: Droplets, + })), + metricTile("respiratoryRate", "RESPIRATORY_RATE", (ctx) => ({ + label: ctx.t("measurements.typeRespiratoryRate"), + unit: ctx.t("insights.units.respiratoryRate"), + icon: Wind, + })), + metricTile("wristTemperature", "WRIST_TEMPERATURE", (ctx) => ({ + label: ctx.t("measurements.typeWristTemperature"), + unit: ctx.unitDisplay.unitFor("WRIST_TEMPERATURE"), + icon: Thermometer, + display: true, + })), + metricTile("muscleMass", "MUSCLE_MASS", (ctx) => ({ + label: ctx.t("measurements.typeMuscleMass"), + unit: ctx.unitDisplay.unitFor("MUSCLE_MASS"), + icon: Dumbbell, + display: true, + })), + metricTile("totalBodyWater", "TOTAL_BODY_WATER", (ctx) => ({ + label: ctx.t("measurements.typeTotalBodyWater"), + unit: ctx.unitDisplay.unitFor("TOTAL_BODY_WATER"), + icon: Droplet, + display: true, + })), + metricTile("boneMass", "BONE_MASS", (ctx) => ({ + label: ctx.t("measurements.typeBoneMass"), + unit: ctx.unitDisplay.unitFor("BONE_MASS"), + icon: Bone, + display: true, + })), + // Fluid intake: a synthetic summary key derived server-side from the + // nutrient days. Neutral sentiment, since more water is not simply better. + metricTile("waterIntake", "NUTRIENT_WATER", (ctx) => ({ + label: ctx.t("nutrients.names.water"), + unit: "mL", + icon: GlassWater, + })), { // Blood pressure in target. Hidden while every window reads zero, so a // literal "0 %" never rides the dashboard; the headline is the trailing