mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
4847d2fc40
commit
d2edaeb8d1
9 files changed
+64
-381
No files matched your search
+4
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user