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

@@ -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>
);
}