mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
@@ -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