From 0e51007974daa4d068c5c221ce79244746393f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 17:50:30 +0200 Subject: [PATCH] feat(insights): VO2 max chart-row card on Insights cardio surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W8d shipped the opt-in VO2 max trend tile on the dashboard; this is the deeper Insights surface. Renders one HealthChart for the trend plus a compact stat strip (latest / min / max / 30-day average with the comparison delta vs. the prior period) on /insights/puls because VO2 max sits in Apple Health's Heart category — cardio fitness, not its own root concept. Implementation notes: - vo2Max joins CHART_OVERLAY_KEYS so the per-chart cog persists its overlay-prefs independently from the pulse chart sitting directly above it on the same page. - The card stays mounted at zero samples so a brand-new account sees the "no data yet" hint rather than a missing surface. - Compare-delta caption only renders when the user has a comparison baseline active AND the prior-period bucket has data; otherwise the slot is suppressed. - HealthChart is dynamic-imported with the same skeleton placeholder the trends row uses so Recharts stays out of the Insights bundle. Translations land in all six locales; FR / ES / IT / PL carry English placeholders matching the v1.4.25 convention. RTL test exercises the stat-strip slots, the empty-state branch, the compare-delta gates, and the German label rendering. --- messages/de.json | 6 + messages/en.json | 6 + messages/es.json | 6 + messages/fr.json | 6 + messages/it.json | 6 + messages/pl.json | 6 + src/app/insights/puls/page.tsx | 35 +++ .../__tests__/vo2-max-chart-row.test.tsx | 127 ++++++++++ src/components/insights/vo2-max-chart-row.tsx | 223 ++++++++++++++++++ src/lib/dashboard-layout.ts | 4 + 10 files changed, 425 insertions(+) create mode 100644 src/components/insights/__tests__/vo2-max-chart-row.test.tsx create mode 100644 src/components/insights/vo2-max-chart-row.tsx diff --git a/messages/de.json b/messages/de.json index e76f04971..d6bb0e524 100644 --- a/messages/de.json +++ b/messages/de.json @@ -942,6 +942,12 @@ "title": "Trends", "subtitle": "Die letzten 30 Tage auf einen Blick" }, + "vo2Max": { + "statLatest": "Aktuell", + "statMin": "Min", + "statMax": "Max", + "empty": "Noch keine VO₂-Max-Daten — synchronisiere Apple Health, um diese Ansicht zu füllen." + }, "trendAnnotation": { "emptyBp": "Mehr Daten nötig, um diesen Trend zu kommentieren.", "emptyWeight": "Mehr Daten nötig, um diesen Trend zu kommentieren.", diff --git a/messages/en.json b/messages/en.json index fcc96abec..ec6137aa1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -942,6 +942,12 @@ "title": "Trends", "subtitle": "Last 30 days at a glance" }, + "vo2Max": { + "statLatest": "Latest", + "statMin": "Min", + "statMax": "Max", + "empty": "No VO₂ max data yet — sync Apple Health to populate this view." + }, "trendAnnotation": { "emptyBp": "Awaiting more data to annotate this trend.", "emptyWeight": "Awaiting more data to annotate this trend.", diff --git a/messages/es.json b/messages/es.json index f762ac8a5..d050cf0df 100644 --- a/messages/es.json +++ b/messages/es.json @@ -942,6 +942,12 @@ "title": "Tendencias", "subtitle": "Last 30 days at a glance" }, + "vo2Max": { + "statLatest": "Latest", + "statMin": "Min", + "statMax": "Max", + "empty": "No VO₂ max data yet — sync Apple Health to populate this view." + }, "trendAnnotation": { "emptyBp": "Awaiting more data to annotate this trend.", "emptyWeight": "Awaiting more data to annotate this trend.", diff --git a/messages/fr.json b/messages/fr.json index 0dd6c6d92..4ce21ae47 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -942,6 +942,12 @@ "title": "Tendances", "subtitle": "Last 30 days at a glance" }, + "vo2Max": { + "statLatest": "Latest", + "statMin": "Min", + "statMax": "Max", + "empty": "No VO₂ max data yet — sync Apple Health to populate this view." + }, "trendAnnotation": { "emptyBp": "Awaiting more data to annotate this trend.", "emptyWeight": "Awaiting more data to annotate this trend.", diff --git a/messages/it.json b/messages/it.json index dea12620b..e5b8386b2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -942,6 +942,12 @@ "title": "Tendenze", "subtitle": "Last 30 days at a glance" }, + "vo2Max": { + "statLatest": "Latest", + "statMin": "Min", + "statMax": "Max", + "empty": "No VO₂ max data yet — sync Apple Health to populate this view." + }, "trendAnnotation": { "emptyBp": "Awaiting more data to annotate this trend.", "emptyWeight": "Awaiting more data to annotate this trend.", diff --git a/messages/pl.json b/messages/pl.json index 94682d681..5b092baca 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -942,6 +942,12 @@ "title": "Trendy", "subtitle": "Last 30 days at a glance" }, + "vo2Max": { + "statLatest": "Latest", + "statMin": "Min", + "statMax": "Max", + "empty": "No VO₂ max data yet — sync Apple Health to populate this view." + }, "trendAnnotation": { "emptyBp": "Awaiting more data to annotate this trend.", "emptyWeight": "Awaiting more data to annotate this trend.", diff --git a/src/app/insights/puls/page.tsx b/src/app/insights/puls/page.tsx index fa8389035..5cc456f9a 100644 --- a/src/app/insights/puls/page.tsx +++ b/src/app/insights/puls/page.tsx @@ -9,6 +9,8 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row"; +import type { DataSummary } from "@/lib/analytics/trends"; import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, @@ -39,6 +41,10 @@ interface PulseStatusData { updatedAt: string | null; } +interface AnalyticsData { + summaries: Record; +} + export default function InsightsPulsPage() { const { isAuthenticated, user } = useAuth(); const { t, locale } = useTranslations(); @@ -56,6 +62,23 @@ export default function InsightsPulsPage() { staleTime: 60 * 1000, }); + // v1.4.25 W16a — VO2 max chart-row consumes the same `/api/analytics` + // bundle the mother page reads. Sharing the cache key keeps the + // payload single-fetch on tab navigation (React-Query unwraps from + // the same key). + const { data: analytics } = useQuery({ + queryKey: ["analytics"], + queryFn: async () => { + const res = await fetch("/api/analytics"); + if (!res.ok) throw new Error("Failed"); + const json = await res.json(); + return json.data as AnalyticsData; + }, + enabled: isAuthenticated, + staleTime: 60 * 1000, + }); + const vo2Summary = analytics?.summaries?.VO2_MAX ?? null; + const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null); const pulseTarget = getPersonalizedPulseTarget( pulseAge, @@ -109,6 +132,18 @@ export default function InsightsPulsPage() { updatedAt={status?.updatedAt ?? null} loading={isStatusLoading} /> + + {/* v1.4.25 W16a — VO2 max sits on the cardio sub-page because it + is a cardio-fitness metric (Apple's Health app surfaces it + under "Heart"). The chart-row stays mounted even at zero + samples so a brand-new account sees the "no data yet" hint + rather than a missing surface — same pattern the dashboard + tile uses (opt-in via Settings → Dashboard). */} + ); } diff --git a/src/components/insights/__tests__/vo2-max-chart-row.test.tsx b/src/components/insights/__tests__/vo2-max-chart-row.test.tsx new file mode 100644 index 000000000..778bb20aa --- /dev/null +++ b/src/components/insights/__tests__/vo2-max-chart-row.test.tsx @@ -0,0 +1,127 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { DataSummary } from "@/lib/analytics/trends"; + +/** + * v1.4.25 W16a — `` SSR coverage. + * + * Acceptance: + * 1. Stat strip renders four data-slot cells: latest, min, max, avg30. + * 2. When `summary.count === 0`, the chart is replaced by the empty-state + * hint; the stat strip remains so a brand-new account still gets a + * consistent shell. + * 3. With a populated summary, the dynamic chart stub mounts (we mock + * `next/dynamic` so the Recharts dependency doesn't pull in DOM + * APIs unavailable in jsdom-less SSR). + * 4. Compare-delta caption only renders when `compareBaseline` is set + * AND the summary carries `avg30LastMonth` / `avg30LastYear` data. + */ + +vi.mock("next/dynamic", () => ({ + default: () => { + const Stub = () =>
chart
; + Stub.displayName = "HealthChartStub"; + return Stub; + }, +})); + +import { Vo2MaxChartRow } from "../vo2-max-chart-row"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const populatedSummary: DataSummary = { + count: 12, + latest: 42.3, + min: 38.1, + max: 45.7, + mean: 41.5, + avg7: 42.0, + avg30: 41.8, + slope7: null, + slope30: { direction: "up", slope: 0.05, confidence: 0.42 }, + slope90: null, + anomalyCount: 0, + avg30LastMonth: 40.4, + avg30LastYear: null, +}; + +const emptySummary: DataSummary = { + count: 0, + latest: null, + min: null, + max: null, + mean: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + avg30LastMonth: null, + avg30LastYear: null, +}; + +describe("", () => { + it("renders the four stat cells regardless of data state", () => { + const html = render(); + expect(html).toContain('data-stat="latest"'); + expect(html).toContain('data-stat="min"'); + expect(html).toContain('data-stat="max"'); + expect(html).toContain('data-stat="avg30"'); + }); + + it("mounts the chart stub when the summary carries data", () => { + const html = render(); + expect(html).toContain('data-slot="health-chart-stub"'); + // The empty-state hint must NOT appear when the chart is mounted. + expect(html).not.toContain('data-slot="vo2-chart-row-empty"'); + }); + + it("falls back to the empty-state hint when count is zero", () => { + const html = render(); + expect(html).toContain('data-slot="vo2-chart-row-empty"'); + expect(html).not.toContain('data-slot="health-chart-stub"'); + // Stat strip stays mounted with em-dashes for the empty values. + expect(html).toContain('data-stat="latest"'); + }); + + it("renders no compare-delta caption when comparison is off", () => { + const html = render(); + expect(html).not.toContain('data-slot="vo2-compare-delta"'); + }); + + it("renders a compare-delta caption when comparison is on and data exists", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="vo2-compare-delta"'); + expect(html).toContain('data-compare-baseline="lastMonth"'); + }); + + it("suppresses the compare-delta caption when the prior-period bucket is empty", () => { + const html = render( + , + ); + // avg30LastYear is null in the fixture, so the delta caption stays + // suppressed even though comparison is enabled. + expect(html).not.toContain('data-slot="vo2-compare-delta"'); + }); + + it("renders the German labels under the de locale", () => { + const html = render(, "de"); + expect(html).toContain("Aktuell"); + expect(html).toMatch(/Apple Health/i); + }); +}); diff --git a/src/components/insights/vo2-max-chart-row.tsx b/src/components/insights/vo2-max-chart-row.tsx new file mode 100644 index 000000000..27bce6f0f --- /dev/null +++ b/src/components/insights/vo2-max-chart-row.tsx @@ -0,0 +1,223 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { ArrowDown, ArrowRight, ArrowUp, Gauge, Minus } from "lucide-react"; + +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import type { ComparisonBaseline } from "@/lib/dashboard-layout"; +import type { DataSummary } from "@/lib/analytics/trends"; +import { cn } from "@/lib/utils"; + +/** + * v1.4.25 W16a — VO2 max chart-row card for `/insights/puls`. + * + * The dashboard already carries an opt-in VO2 max trend tile (W8d.5); + * this is the deeper Insights surface. Renders one HealthChart for the + * trend plus a compact stat strip: + * + * - latest value with the personalised target unit + * - min / max across the underlying samples + * - 30-day average and the comparison delta vs. the prior period + * + * The card stays mounted even when `summary` reports zero samples — a + * brand-new account gets the "no data yet" hint rather than a missing + * surface, matching the dashboard tile's opt-in pattern. + */ + +// Mirror the Recharts defer-load + skeleton used by `` so the +// VO2 chart-row doesn't blow up the Insights bundle. +const ChartSkeleton = () => ( +
+); + +const HealthChart = dynamic( + () => + import("@/components/charts/health-chart").then((mod) => ({ + default: mod.HealthChart, + })), + { ssr: false, loading: ChartSkeleton }, +); + +interface Vo2MaxChartRowProps { + /** + * Analytics summary for `MeasurementType=VO2_MAX`. Null is allowed so + * the parent doesn't have to gate on the network read finishing — the + * row falls back to the empty-state hint until the data lands. + */ + summary: DataSummary | null; + /** + * Comparison baseline propagated from the user's Insights layout pref + * so the chart's reference line + the in-card delta caption line up. + */ + compareBaseline?: ComparisonBaseline; + /** User's IANA timezone string — propagates to HealthChart formatting. */ + userTimezone?: string; +} + +export function Vo2MaxChartRow({ + summary, + compareBaseline = "none", + userTimezone, +}: Vo2MaxChartRowProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + const latest = summary?.latest ?? null; + const min = summary?.min ?? null; + const max = summary?.max ?? null; + const avg30 = summary?.avg30 ?? null; + const slope30 = summary?.slope30 ?? null; + const count = summary?.count ?? 0; + + // 30-day delta. Mirrors the dashboard's `tileCompareDelta` rule: + // suppressed when comparison is off OR either side has no data. + const compareDelta = ((): number | null => { + if (!summary || compareBaseline === "none") return null; + const current = summary.avg30 ?? null; + const prior = + compareBaseline === "lastMonth" + ? (summary.avg30LastMonth ?? null) + : (summary.avg30LastYear ?? null); + if (current === null || prior === null) return null; + return Math.round((current - prior) * 100) / 100; + })(); + + // Higher VO2 max is better → up-good sentiment (mirrors the dashboard + // tile). Determines arrow + delta colour. + const TrendIcon = + slope30?.direction === "up" + ? ArrowUp + : slope30?.direction === "down" + ? ArrowDown + : slope30 + ? ArrowRight + : Minus; + const trendColor = ((): string => { + if (!slope30 || slope30.direction === "stable") + return "text-muted-foreground"; + return slope30.direction === "up" + ? "text-dracula-green" + : "text-dracula-orange"; + })(); + + const formatValue = (value: number | null): string => + value === null ? "—" : fmt.number(value, 1); + + const formatDelta = (value: number): string => { + if (Math.abs(value) < 0.05) return "±0"; + const sign = value > 0 ? "+" : "−"; + return `${sign}${fmt.number(Math.abs(value), 1)}`; + }; + + const unit = t("dashboard.vo2MaxUnit") ?? "mL/(kg·min)"; + const title = t("dashboard.vo2Max") ?? "VO₂ max"; + const hasData = count > 0; + + // Color the comparison delta with the same up-good sentiment rules. + const compareColor = ((): string => { + if (compareDelta === null || Math.abs(compareDelta) < 0.05) { + return "text-muted-foreground"; + } + return compareDelta > 0 ? "text-dracula-green" : "text-dracula-orange"; + })(); + + return ( +
+
+

+

+

{unit}

+
+ + {/* Stat strip — latest / min / max / avg30 / Δ vs. prior period. */} +
+
+

+ {t("insights.vo2Max.statLatest") ?? "Latest"} +

+

+ {formatValue(latest)} + {slope30 ? ( +

+
+
+

+ {t("insights.vo2Max.statMin") ?? "Min"} +

+

+ {formatValue(min)} +

+
+
+

+ {t("insights.vo2Max.statMax") ?? "Max"} +

+

+ {formatValue(max)} +

+
+
+

+ {t("charts.avg30dShort") ?? "30d"} +

+

+ {formatValue(avg30)} +

+ {compareDelta !== null ? ( +

+ {`Δ ${formatDelta(compareDelta)} ${t( + compareBaseline === "lastMonth" + ? "comparison.captionLastMonth" + : "comparison.captionLastYear", + )}`} +

+ ) : null} +
+
+ + {/* Trend chart. The chartKey isolates per-chart overlay prefs from + the dashboard VO2 tile and from the pulse chart sitting above + it on the same page. */} + {hasData ? ( + + ) : ( +

+ {t("insights.vo2Max.empty") ?? + "No VO₂ max data yet — sync Apple Health to populate this view."} +

+ )} +
+ ); +} diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index e4670daf3..583505644 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -125,6 +125,10 @@ export const CHART_OVERLAY_KEYS = [ "medications", "sleep", "steps", + // v1.4.25 W16a — VO2 max chart-row on /insights/puls. Independent + // overlay-prefs slot so the cardio sub-page's VO2 chart-cog state + // doesn't bleed into the pulse chart sitting directly above it. + "vo2Max", ] as const; export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];