From ad2f0389633361b6e31851ae234666943d0ba8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:48:35 +0200 Subject: [PATCH] feat(insights): tighten the stat strip and add a side-by-side multi-series mode Drop the strip card to a denser py-3/gap-2 rhythm with 44 px stat cells and text-base values so the numbers-first header gives away less vertical space on every metric subpage while staying readable. Extract the per-series block (header + four stats + brushed-window pill) into a reusable piece and add a `series` prop that renders multiple series as columns in ONE card (two on desktop, stacking on narrow mobile). Single-series callers keep their contract unchanged; the brushed-window stats stay per-series. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/metric-stat-strip.test.tsx | 87 ++++++ src/components/insights/metric-stat-strip.tsx | 259 ++++++++++++------ 2 files changed, 266 insertions(+), 80 deletions(-) diff --git a/src/components/insights/__tests__/metric-stat-strip.test.tsx b/src/components/insights/__tests__/metric-stat-strip.test.tsx index 24ce1db02..4cbddc0eb 100644 --- a/src/components/insights/__tests__/metric-stat-strip.test.tsx +++ b/src/components/insights/__tests__/metric-stat-strip.test.tsx @@ -78,4 +78,91 @@ describe("", () => { expect(html).toContain('data-stat="median"'); expect(html).toContain('data-stat="mean"'); }); + + describe("multi-series mode (blood pressure — one card, two columns)", () => { + const sys: DataSummary = { ...populated, min: 118, max: 142, median: 128 }; + const dia: DataSummary = { ...populated, min: 72, max: 91, median: 80 }; + + it("renders ONE card with both series as side-by-side columns", () => { + const html = render( + , + ); + // Exactly one strip card, flagged multi-series. + expect(html.match(/data-slot="metric-stat-strip"/g)?.length).toBe(1); + expect(html).toContain('data-multi-series="true"'); + // Both series blocks present with their own data-series tokens. + expect(html).toContain('data-series="sys"'); + expect(html).toContain('data-series="dia"'); + // Both headers + both values render in the single card. + expect(html).toContain("Sys"); + expect(html).toContain("Dia"); + expect(html).toContain("118"); + expect(html).toContain("72"); + }); + + it("still paints the populated half when one series has no data", () => { + const html = render( + , + ); + // The populated half paints; the empty half self-gates to nothing + // (no wrapper, no cells) so the card shows just the one column. + expect(html).toContain('data-series="sys"'); + expect(html).not.toContain('data-series="dia"'); + expect(html).not.toContain("72"); + }); + + it("renders nothing when every series is empty", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); + + it("pins the 'selected range' pill on a brushed series", () => { + const html = render( + , + ); + // The brushed sys block flags windowed + carries the pill; dia does not. + expect(html).toContain('data-series="sys" data-windowed="true"'); + expect(html).toContain('data-slot="metric-stat-window-badge"'); + // Windowed numbers win over the full-range summary for sys. + expect(html).toContain("120"); + }); + }); }); diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index 430c7987e..f3a4318eb 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -4,7 +4,7 @@ import type { ComponentType } from "react"; import { Sigma } from "lucide-react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import type { DataSummary } from "@/lib/analytics/trends"; import type { MetricWindowStats } from "@/lib/charts/window-stats"; @@ -27,18 +27,36 @@ import type { MetricWindowStats } from "@/lib/charts/window-stats"; * The thin HealthKit pages (`HealthkitMetricPage`) mount this too so they * reach parity with the bespoke pages — a rich numeric header even where * there is no target band or AI assessment. + * + * v1.12.7 follow-up — two render modes: + * - single-series (default): one Card, one `` + 4-stat grid. + * - multi-series (`series=[…]`): ONE Card with the series side by side + * (two columns on desktop, stacking on narrow mobile). Blood pressure + * uses this so systolic + diastolic share a single card rather than + * stacking two. Each column keeps its own header, its own four stats, + * and its own brushed-window behaviour. + * + * The card chrome is tightened from the earlier `py-5 / gap-3 / min-h-56px` + * to a denser rhythm so the numbers-first header gives away less vertical + * space on every subpage without dropping below a readable value size or + * the AA-contrast micro-labels. */ -interface MetricStatStripProps { +/** + * One series rendered inside the strip — its header (label + icon), the + * full-range `summary`, and the optional brushed-window stats. Single-series + * callers pass these props at the top level; multi-series callers pass an + * array of these via `series`. + */ +export interface MetricStatSeries { /** - * Analytics summary for the page's `MeasurementType`. Null is allowed - * so the parent doesn't have to gate on the network read finishing — - * the strip simply doesn't paint until the data lands. A zero-count - * summary (brand-new metric) also renders nothing; the page's - * empty-state owns that surface. + * Analytics summary for the series' `MeasurementType`. Null is allowed + * so the parent doesn't have to gate on the network read finishing — the + * series simply doesn't paint until the data lands. A zero-count summary + * (brand-new metric) also renders nothing. */ summary: DataSummary | null; - /** Unit suffix rendered next to each value (e.g. `bpm`, `kg`). */ + /** Unit suffix rendered next to each value (e.g. `bpm`, `kg`, `mmHg`). */ unit: string; /** * Decimal precision for the formatted values. Defaults to 1 — enough @@ -47,58 +65,81 @@ interface MetricStatStripProps { */ fractionDigits?: number; /** - * v1.12.4 — series caption rendered above the grid. Single-series metrics - * pass the metric name (e.g. "Gewicht"); blood pressure stacks two strips - * (systolic / diastolic) and labels each so the unified strip covers both - * halves rather than silently dropping one. Also feeds the `aria-label` - * for the section. - * - * v1.12.6 — the caption is now rendered through the canonical - * `` (white heading + white icon, matching the Einschätzung - * card) rather than a small muted uppercase line, so every series block - * leads with the same header language as the rest of the surface. + * Series caption rendered through the canonical `` (white + * heading + white icon). Single-series metrics pass the metric name; + * blood pressure labels each half (systolic / diastolic). */ seriesLabel?: string; /** - * v1.12.6 — leading glyph for the series ``. Pass the metric's - * icon component (e.g. `Scale`, `Heart`); blood pressure passes a - * directional glyph per series. Defaults to a generic stats glyph so a - * caller that supplies a `seriesLabel` without an icon still renders a - * complete header. + * Leading glyph for the series ``. Defaults to a generic + * stats glyph so a caller that supplies a `seriesLabel` without an icon + * still renders a complete header. */ icon?: ComponentType<{ className?: string }>; /** * v1.12.7 — chart-reactive metric statistics. When the user brushes a * window in the metric's chart, the sub-page lifts the chart's per-series * Min / Max / Median / Mean for that window and threads it here. When - * present (non-null), the strip renders these windowed numbers in place of - * the full-range `summary` and swaps the header copy to "selected range" so - * the user reads the stats as describing their selection. Null (no - * selection) falls back to the cheap precomputed `summary` — the default, - * unchanged. + * present (non-null) it renders in place of the full-range `summary` and + * the header pins a "selected range" pill. Null falls back to the + * precomputed `summary`. */ windowStats?: MetricWindowStats | null; + /** + * Stable suffix for the per-cell `data-slot`s. Multi-series mode passes a + * distinct token per column (e.g. the unit or label slug) so the two + * columns' cells don't collide on `[data-stat="min"]` in e2e. Optional; + * single-series mode omits it. + */ + dataKey?: string; } -export function MetricStatStrip({ +type MetricStatStripProps = Partial & { + /** + * v1.12.7 — multi-series mode. When supplied, the strip renders ONE Card + * with each entry as a side-by-side column (two columns on desktop, + * stacking on narrow mobile). The top-level `summary` / `unit` / + * `seriesLabel` / … props are ignored. `aria-label` then describes the + * group; pass `groupLabel` for the accessible name. + * + * Single-series callers MUST still pass `summary` + `unit` at the top + * level (the contract the ~30 HealthKit pages + the weight/pulse/… pages + * rely on); they are optional in the type only so multi-series callers can + * omit them, and the single-series branch self-gates on a missing + * `summary` by rendering nothing. + */ + series?: MetricStatSeries[]; + /** + * Accessible name for the multi-series group (e.g. "Blood pressure"). + * Single-series mode derives the label from `seriesLabel` instead. + */ + groupLabel?: string; +}; + +/** + * Render one series' four stats. Self-gates: returns null while the + * summary is in flight or for a zero-count metric. The brushed-window + * source wins over the full-range summary when a selection is active. + */ +function SeriesBlock({ summary, unit, fractionDigits = 1, seriesLabel, icon, windowStats, -}: MetricStatStripProps) { + dataKey, +}: MetricStatSeries) { const { t } = useTranslations(); const fmt = useFormatters(); // Nothing to show until the summary lands or for a metric with no - // readings — the page's empty-state covers the zero-data case. The gate - // rides the full-range summary even when a window is active, so a brush - // never resurrects the strip on a metric that has no data at all. + // readings. The gate rides the full-range summary even when a window is + // active, so a brush never resurrects a series that has no data at all. if (!summary || summary.count <= 0) return null; - // v1.12.7 — a window selection with at least one reading wins over the - // full-range summary; otherwise the strip reads the precomputed numbers. + // A window selection with at least one reading wins over the full-range + // summary; otherwise the block reads the precomputed numbers. const windowed = windowStats != null && windowStats.count > 0; const source = windowed ? windowStats : summary; @@ -121,61 +162,119 @@ export function MetricStatStrip({ ]; return ( - // Card-wrapped so the header-to-body offset matches the `` tiles on the same subpage spine (assessment, mood, medication) - // rather than the old bare-div `space-y-3`. The grid keeps its own - // data-slots; the section semantics ride on `role` + `aria-label`. +
+ {seriesLabel ? ( + + {t("insights.subPage.stats.selectedRange")} + + ) : undefined + } + /> + ) : null} +
+ {cells.map((cell) => ( +
+

+ {cell.label} +

+

+ {format(cell.value)} +

+
+ ))} +
+
+ ); +} + +export function MetricStatStrip({ + summary, + unit, + fractionDigits, + seriesLabel, + icon, + windowStats, + dataKey, + series, + groupLabel, +}: MetricStatStripProps) { + const { t } = useTranslations(); + + // v1.12.7 — multi-series mode: ONE Card, columns side by side. The card + // self-gates to nothing only when EVERY series is empty so a one-sided + // metric (e.g. only systolic logged) still paints its half. + if (series && series.length > 0) { + const anyData = series.some((s) => s.summary && s.summary.count > 0); + if (!anyData) return null; + return ( + + +
+ {series.map((s, i) => ( + + ))} +
+
+
+ ); + } + + // Single-series mode (default, unchanged contract). Gate on the one + // series' summary so a null / zero-count read paints nothing. + if (!summary || summary.count <= 0) return null; + + return ( + // Card-wrapped so the header-to-body offset matches the sibling tiles + // on the same subpage spine (assessment, mood, medication). The series + // header now lives inside `SeriesBlock`, so the Card carries body chrome + // only. 0 ? "true" : undefined + } role="group" aria-label={ seriesLabel ? `${t("insights.subPage.stats.label")} — ${seriesLabel}` : t("insights.subPage.stats.label") } - className="gap-2 py-4 md:gap-3 md:py-5" + className="gap-2 py-3 md:py-4" > - {seriesLabel ? ( - - - {t("insights.subPage.stats.selectedRange")} - - ) : undefined - } - /> - - ) : null} -
- {cells.map((cell) => ( -
-

- {cell.label} -

-

- {format(cell.value)} -

-
- ))} -
+
);