feat(insights): VO2 max chart-row card on Insights cardio surface

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 17:50:30 +02:00
1 parent a1ffa497f1
commit 0e51007974
10 files changed
+425

No files matched your search

+6
View File
@@ -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.",
+6
View File
@@ -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.",
+6
View File
@@ -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.",
+6
View File
@@ -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.",
+6
View File
@@ -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.",
+6
View File
@@ -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.",
+35
View File
@@ -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<string, DataSummary>;
}
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). */}
<Vo2MaxChartRow
summary={vo2Summary}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
</SubPageShell>
);
}
@@ -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 — `<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,
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("<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);
});
});
@@ -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 `<TrendsRow>` so the
// VO2 chart-row doesn't blow up the Insights bundle.
const ChartSkeleton = () => (
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
);
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 (
<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. */}
<div
data-slot="vo2-chart-row-stats"
className="bg-card border-border grid grid-cols-2 gap-3 rounded-xl border p-4 sm:grid-cols-4"
>
<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. */}
{hasData ? (
<HealthChart
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>
);
}
+4
View File
@@ -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];