From a812a902dd9e691a40e86a9c582078d9d20ec92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:14:47 +0200 Subject: [PATCH] refactor(dashboard): build tiles and charts from shared card builders Most strip tiles differ only in their series, label, unit and icon, and most chart cards share the reveal report, the batched slice, the time zone and the day doors. One metric-card and one chart builder carry the shared props, so each registry entry states only what is its own. The dashboard route's client code shrinks by 0.2 KB gz; rendering is unchanged. The tile-destination guard now builds every strip tile from the registry instead of reading ids out of the source. --- .../display-transform-rounding-guard.test.ts | 5 +- .../__tests__/tile-destinations.test.ts | 47 +- .../dashboard/tiles/chart-tiles.tsx | 225 +++---- .../dashboard/tiles/strip-tiles.tsx | 618 +++++++----------- 4 files changed, 369 insertions(+), 526 deletions(-) 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