diff --git a/messages/de.json b/messages/de.json index f2b5eb18d..b8e95477c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index afd44dcf2..82405daa4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 64787b5a4..5cff9817f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index afd77ad1f..e855a5951 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index f5b7b8531..328707c98 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index 61f3e396f..8727a1bbf 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index c74fae7ad..89df6a77a 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -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()` + ``. 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()` + * + ``. 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() { - {/* 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). */} - + {/* 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 ? ( + + + + + {t("insights.vo2Max.cardioLinkCta")} + + + ) : null} {/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off the overview onto its metric page). */} diff --git a/src/components/insights/__tests__/vo2-max-chart-row.test.tsx b/src/components/insights/__tests__/vo2-max-chart-row.test.tsx deleted file mode 100644 index 7c486ebb2..000000000 --- a/src/components/insights/__tests__/vo2-max-chart-row.test.tsx +++ /dev/null @@ -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 — `` 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, - 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("", () => { - 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 deleted file mode 100644 index 2272cac32..000000000 --- a/src/components/insights/vo2-max-chart-row.tsx +++ /dev/null @@ -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 `` - * 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 ( -
-
-

-

-

{unit}

-
- - {/* 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 ` -
-

- {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. - - 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 ? ( - - ) : ( -

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

- )} -
- ); -}