refactor(insights): point the pulse page to the cardio-fitness surface for VO2 max

VO2 max had two divergent surfaces — the dedicated /insights/cardio-fitness
page (full chart + plain-language assessment) and a legacy compact chart-row
on the pulse page. Replace the pulse-page row with a clear cross-link to the
dedicated page so there is one VO2 max surface, and remove the now-unused
chart-row component + its test. New cardioLink copy added across all six
locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 14:07:56 +02:00
1 parent 4847d2fc40
commit d2edaeb8d1
9 files changed
+64 -381

No files matched your search

+4 -1
View File
@@ -1680,7 +1680,10 @@
"statLatest": "Aktuell",
"statMin": "Min",
"statMax": "Max",
"empty": "Noch keine VO₂-Max-Daten — synchronisiere Apple Health, um diese Ansicht zu füllen."
"empty": "Noch keine VO₂-Max-Daten — synchronisiere Apple Health, um diese Ansicht zu füllen.",
"cardioLinkTitle": "Cardio-Fitness (VO₂ max)",
"cardioLinkBody": "VO₂ max ist das zentrale Maß für die aerobe Fitness. Das vollständige Diagramm und die Einschätzung findest du auf der eigenen Seite.",
"cardioLinkCta": "Cardio-Fitness öffnen"
},
"trendAnnotation": {
"emptyBp": "Mehr Daten nötig, um diesen Trend zu kommentieren.",
+4 -1
View File
@@ -1680,7 +1680,10 @@
"statLatest": "Latest",
"statMin": "Min",
"statMax": "Max",
"empty": "No VO₂ max data yet — sync Apple Health to populate this view."
"empty": "No VO₂ max data yet — sync Apple Health to populate this view.",
"cardioLinkTitle": "Cardio fitness (VO₂ max)",
"cardioLinkBody": "VO₂ max is the headline measure of aerobic fitness. The full chart and assessment live on its own page.",
"cardioLinkCta": "Open cardio fitness"
},
"trendAnnotation": {
"emptyBp": "Awaiting more data to annotate this trend.",
+4 -1
View File
@@ -1680,7 +1680,10 @@
"statLatest": "Actual",
"statMin": "Mín",
"statMax": "Máx",
"empty": "Aún no hay datos de VO₂ máx — sincroniza Apple Health para llenar esta vista."
"empty": "Aún no hay datos de VO₂ máx — sincroniza Apple Health para llenar esta vista.",
"cardioLinkTitle": "Forma cardiovascular (VO₂ máx)",
"cardioLinkBody": "El VO₂ máx es la medida principal de la forma aeróbica. El gráfico completo y la evaluación están en su propia página.",
"cardioLinkCta": "Abrir forma cardiovascular"
},
"trendAnnotation": {
"emptyBp": "Necesitamos más datos para comentar esta tendencia.",
+4 -1
View File
@@ -1680,7 +1680,10 @@
"statLatest": "Actuel",
"statMin": "Min",
"statMax": "Max",
"empty": "Pas encore de données VO₂ max — synchronise Apple Health pour remplir cette vue."
"empty": "Pas encore de données VO₂ max — synchronise Apple Health pour remplir cette vue.",
"cardioLinkTitle": "Condition cardio (VO₂ max)",
"cardioLinkBody": "Le VO₂ max est la mesure de référence de la condition aérobie. Le graphique complet et l’évaluation se trouvent sur leur propre page.",
"cardioLinkCta": "Ouvrir la condition cardio"
},
"trendAnnotation": {
"emptyBp": "Il faut plus de données pour commenter cette tendance.",
+4 -1
View File
@@ -1680,7 +1680,10 @@
"statLatest": "Attuale",
"statMin": "Min",
"statMax": "Max",
"empty": "Ancora nessun dato VO₂ max — sincronizza Apple Health per riempire questa vista."
"empty": "Ancora nessun dato VO₂ max — sincronizza Apple Health per riempire questa vista.",
"cardioLinkTitle": "Forma cardio (VO₂ max)",
"cardioLinkBody": "Il VO₂ max è la misura principale della forma aerobica. Il grafico completo e la valutazione sono nella loro pagina dedicata.",
"cardioLinkCta": "Apri forma cardio"
},
"trendAnnotation": {
"emptyBp": "Servono più dati per commentare questo trend.",
+4 -1
View File
@@ -1680,7 +1680,10 @@
"statLatest": "Aktualne",
"statMin": "Min",
"statMax": "Maks",
"empty": "Brak danych VO₂ max — zsynchronizuj Apple Health, aby uzupełnić ten widok."
"empty": "Brak danych VO₂ max — zsynchronizuj Apple Health, aby uzupełnić ten widok.",
"cardioLinkTitle": "Wydolność krążeniowa (VO₂ max)",
"cardioLinkBody": "VO₂ max to główna miara wydolności tlenowej. Pełny wykres i ocenę znajdziesz na osobnej stronie.",
"cardioLinkCta": "Otwórz wydolność krążeniową"
},
"trendAnnotation": {
"emptyBp": "Potrzeba więcej danych, aby skomentować ten trend.",
+40 -22
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { Heart } from "lucide-react";
import { ArrowRight, Gauge, Heart } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
@@ -17,7 +17,6 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation-
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
@@ -33,22 +32,21 @@ import {
* MeasurementType filter is `PULSE` (the same field used elsewhere
* in the codebase).
*
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state
* branch consume `useInsightsAnalytics()` + `<MetricEmptyState>`. The
* VO2 max chart-row still reads `data.summaries.VO2_MAX` directly so
* the hook exposes the unwrapped payload alongside `isEmpty`.
* Analytics fetch + empty-state branch consume `useInsightsAnalytics()`
* + `<MetricEmptyState>`. VO₂ max is a cardio-fitness metric, so the page
* links across to the dedicated `/insights/cardio-fitness` surface (which
* owns the full chart + assessment) rather than rendering a second view.
*/
export default function InsightsPulsPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
// 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, isEmpty } = useInsightsAnalytics("PULSE");
const vo2Summary = analytics?.summaries?.VO2_MAX ?? null;
// VO₂ max rides on the same `/api/analytics` bundle; we only need the
// sample count to decide whether to surface the cross-link to the
// dedicated cardio-fitness page.
const hasVo2 = (analytics?.summaries?.VO2_MAX?.count ?? 0) > 0;
const pulseSummary = analytics?.summaries?.PULSE ?? null;
// v1.12.7 — brushed-window stats shared between the pulse chart and the
@@ -152,17 +150,37 @@ export default function InsightsPulsPage() {
<MetricTargetSummary slug="pulse" />
{/* 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). */}
<Vo2MaxChartRow
summary={vo2Summary}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
{/* VO₂ max is a cardio-fitness metric (Apple's Health app surfaces it
under "Heart"), so the pulse page links across to its dedicated
`/insights/cardio-fitness` page — the single surface that carries
the full chart plus the plain-language assessment — rather than
duplicating a second, divergent VO₂ max view here. */}
{hasVo2 ? (
<Link
href="/insights/cardio-fitness"
data-slot="vo2-cardio-link"
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 flex items-center justify-between gap-3 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span className="flex items-start gap-3">
<Gauge
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
<span className="space-y-1">
<span className="block text-sm font-semibold">
{t("insights.vo2Max.cardioLinkTitle")}
</span>
<span className="text-muted-foreground block text-xs leading-snug">
{t("insights.vo2Max.cardioLinkBody")}
</span>
</span>
</span>
<span className="text-primary inline-flex shrink-0 items-center gap-1 text-xs font-medium">
{t("insights.vo2Max.cardioLinkCta")}
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</span>
</Link>
) : null}
{/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off
the overview onto its metric page). */}
@@ -1,129 +0,0 @@
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 — `<Vo2MaxChartRow>` 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 = () => <div data-slot="health-chart-stub">chart</div>;
Stub.displayName = "HealthChartStub";
return Stub;
},
}));
import { Vo2MaxChartRow } from "../vo2-max-chart-row";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const populatedSummary: DataSummary = {
count: 12,
latest: 42.3,
min: 38.1,
max: 45.7,
mean: 41.5,
median: 41.4,
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,
median: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
};
describe("<Vo2MaxChartRow>", () => {
it("renders the four stat cells regardless of data state", () => {
const html = render(<Vo2MaxChartRow summary={populatedSummary} />);
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(<Vo2MaxChartRow summary={populatedSummary} />);
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(<Vo2MaxChartRow summary={emptySummary} />);
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(<Vo2MaxChartRow summary={populatedSummary} />);
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(
<Vo2MaxChartRow
summary={populatedSummary}
compareBaseline="lastMonth"
/>,
);
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(
<Vo2MaxChartRow
summary={populatedSummary}
compareBaseline="lastYear"
/>,
);
// 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(<Vo2MaxChartRow summary={emptySummary} />, "de");
expect(html).toContain("Aktuell");
expect(html).toMatch(/Apple Health/i);
});
});
@@ -1,224 +0,0 @@
"use client";
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";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
/**
* v1.4.25 W16a — VO2 max chart-row card for `/insights/pulse`.
*
* 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.
*
* The HealthChart mounts behind the shared `<HealthChartDynamic>`
* boundary so the lazy-import + skeleton-loading state stays in
* lock-step with the other Insights chart consumers.
*/
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 (
<section
data-slot="vo2-chart-row"
aria-label={title}
className="space-y-3"
>
<div className="flex items-baseline justify-between gap-2">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Gauge className="text-muted-foreground h-4 w-4" aria-hidden="true" />
{title}
</h2>
<p className="text-muted-foreground text-xs">{unit}</p>
</div>
{/* Stat strip — latest / min / max / avg30 / Δ vs. prior period.
v1.4.27 MB7 / CF-38 — extend the 2-column phone grid all the
way to `<lg` so tablets keep the 2×2 layout (avoids
mid-viewport reflows on Pixel Fold) and lift each cell to a
`min-h-[64px]` floor so the chips paint at consistent
heights even when one stat has no trend arrow. */}
<div
data-slot="vo2-chart-row-stats"
className="bg-card border-border grid grid-cols-2 gap-3 rounded-xl border p-4 lg:grid-cols-4 [&>div]:min-h-[64px]"
>
<div data-slot="vo2-stat" data-stat="latest" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("insights.vo2Max.statLatest") ?? "Latest"}
</p>
<p className="flex items-baseline gap-1 text-xl font-semibold tabular-nums">
<span>{formatValue(latest)}</span>
{slope30 ? (
<TrendIcon
className={cn("h-4 w-4 shrink-0", trendColor)}
aria-hidden="true"
/>
) : null}
</p>
</div>
<div data-slot="vo2-stat" data-stat="min" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("insights.vo2Max.statMin") ?? "Min"}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatValue(min)}
</p>
</div>
<div data-slot="vo2-stat" data-stat="max" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("insights.vo2Max.statMax") ?? "Max"}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatValue(max)}
</p>
</div>
<div data-slot="vo2-stat" data-stat="avg30" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("charts.avg30dShort") ?? "30d"}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatValue(avg30)}
</p>
{compareDelta !== null ? (
<p
data-slot="vo2-compare-delta"
data-compare-baseline={compareBaseline}
className={cn(
"text-xs font-medium tabular-nums",
compareColor,
)}
>
{`Δ ${formatDelta(compareDelta)} ${t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}`}
</p>
) : null}
</div>
</div>
{/* 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.
The chart mounts as soon as there is at least one reading. VO₂ max
arrives from Apple Health at most every few days, so the series is
inherently sparse; the line carries a visible dot per point
(`dot r=3`), so a one- or two-sample series still reads as a real
chart. A zero-reading account keeps the short onboarding note. */}
{hasData ? (
<HealthChartDynamic
chartKey="vo2Max"
types={["VO2_MAX"]}
title={title}
colors={["#50fa7b"]}
unit={unit}
compareBaseline={compareBaseline}
userTimezone={userTimezone}
/>
) : (
<p
data-slot="vo2-chart-row-empty"
className="text-muted-foreground bg-muted/30 rounded-md border border-dashed p-6 text-center text-sm italic"
>
{t("insights.vo2Max.empty") ??
"No VO₂ max data yet — sync Apple Health to populate this view."}
</p>
)}
</section>
);
}