mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
a1ffa497f1
commit
0e51007974
10 files changed
+425
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
|
||||
Reference in new issue
Block a user