feat(insights): mount per-metric assessment on HealthKit sub-pages

Add an optional statusMetric prop to HealthKitMetricPage that mounts
InsightStatusCard beneath the chart, pointed at the generic
metric-status route. The card renders only on the data-bearing branch;
the insufficient-data branch keeps the existing empty-state note and
never fires an assessment fetch. Wire statusMetric on the 29
HealthKit metric sub-pages and on the sleep page, which closes the
assessment slot its deferral comment had left open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 083044201af7715740ec0ee2f14c7240c4999fbf)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 13:26:10 +02:00
1 parent 16b0c349f2
commit b1b2e22eeb
32 files changed
+232 -9

No files matched your search

@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { InsightStatusData } from "@/hooks/use-insight-status";
/**
* v1.8.7.1 — `<HealthKitMetricPage statusMetric=…>` mounts the generic
* per-metric assessment card beneath the chart when the metric has data,
* and falls back to the existing insufficient-data empty state otherwise.
*
* The page leans on several hooks (`useAuth`, `useInsightsAnalytics`,
* `useInsightMetricStatus`, …); the test mocks them so the data /
* empty branch is deterministic. The chart is `next/dynamic`-imported,
* so SSR renders its stub — enough to verify the assessment slot.
*/
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="healthkit-chart-stub" />;
Stub.displayName = "HealthChartStub";
return Stub;
},
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { timezone: "UTC", dateOfBirth: null, gender: null },
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
}));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => analyticsMock(),
}));
const metricStatusMock = vi.fn();
vi.mock("@/hooks/use-insight-status", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/hooks/use-insight-status")>();
return {
...actual,
useInsightMetricStatus: (...args: unknown[]) => metricStatusMock(...args),
};
});
import { HealthKitMetricPage } from "../healthkit-metric-page";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
const baseProps = {
measurementType: "HEART_RATE_VARIABILITY",
insightMetric: "HEART_RATE_VARIABILITY" as const,
chartKey: "hrv" as const,
i18nPrefix: "insights.hrv",
color: "#bd93f9",
unit: "ms",
emptyStateIcon: null,
};
const settledStatus: InsightStatusData = {
hasProvider: true,
text: "Your HRV has trended upward over the last month.",
cached: false,
updatedAt: null,
};
beforeEach(() => {
analyticsMock.mockReset();
metricStatusMock.mockReset();
});
describe("<HealthKitMetricPage> status mount", () => {
it("mounts the assessment card when statusMetric is set and the metric has data", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HEART_RATE_VARIABILITY: { count: 12 } } },
isEmpty: false,
});
metricStatusMock.mockReturnValue({
data: settledStatus,
isLoading: false,
});
const html = render(
<HealthKitMetricPage
{...baseProps}
statusMetric="HEART_RATE_VARIABILITY"
/>,
);
expect(html).toContain("Your HRV has trended upward");
// The card fetch is enabled (statusMetric present + data present).
expect(metricStatusMock).toHaveBeenCalledWith("HEART_RATE_VARIABILITY", true);
});
it("does not mount the assessment card when statusMetric is omitted", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HEART_RATE_VARIABILITY: { count: 12 } } },
isEmpty: false,
});
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(<HealthKitMetricPage {...baseProps} />);
// No assessment heading, and the generic fetch is disabled.
expect(html).not.toContain("Assessment");
expect(metricStatusMock).toHaveBeenCalledWith("", false);
});
it("renders the insufficient-data empty state (no card) when the metric has no data", () => {
analyticsMock.mockReturnValue({ data: null, isEmpty: true });
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(
<HealthKitMetricPage
{...baseProps}
statusMetric="HEART_RATE_VARIABILITY"
/>,
);
// Empty branch: no settled assessment text, and the status fetch is
// disabled so a source-less account never fires the round-trip.
expect(html).not.toContain("Your HRV has trended upward");
expect(metricStatusMock).toHaveBeenCalledWith("HEART_RATE_VARIABILITY", false);
});
});
@@ -3,7 +3,10 @@
import Link from "next/link";
import type { ComponentProps, ReactNode } from "react";
import { Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightMetricStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useTranslations } from "@/lib/i18n/context";
@@ -11,6 +14,7 @@ import type { InsightMetric } from "@/lib/insights/metric-availability";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
@@ -96,6 +100,17 @@ export interface HealthKitMetricPageProps {
* rather than a bespoke module. Omit it for metrics without a target.
*/
targetSummarySlug?: string;
/**
* v1.8.7.1 — when set, mounts `<InsightStatusCard>` beneath the chart,
* pointed at the generic per-metric assessment route
* (`/api/insights/metric-status?metric=<statusMetric>`). The value is
* the HealthKit metric identifier the route keys on — almost always the
* same string as `measurementType`. The card is only rendered on the
* data-bearing branch; the empty (insufficient-data) branch keeps the
* existing `<MetricEmptyState>` note and never fires an assessment
* fetch. Omit it for metrics that should not carry an assessment.
*/
statusMetric?: string;
}
export function HealthKitMetricPage({
@@ -113,12 +128,22 @@ export function HealthKitMetricPage({
valueScale,
explainerMetric,
targetSummarySlug,
statusMetric,
}: HealthKitMetricPageProps) {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric);
// v1.8.7.1 — generic per-metric assessment. The hook must run on every
// render (rules of hooks), so it is always called; `enabled` gates the
// actual fetch on a configured `statusMetric` AND the presence of data
// so an insufficient-data page never fires an assessment round-trip.
const { data: status, isLoading: isStatusLoading } = useInsightMetricStatus(
statusMetric ?? "",
Boolean(statusMetric) && !isEmpty,
);
const rawSummary = analytics?.summaries?.[measurementType] ?? null;
// The stat strip renders display-unit values. The summary holds stored
// values, so when the page renders a scaled unit (e.g. WALKING_SPEED
@@ -199,6 +224,18 @@ export function HealthKitMetricPage({
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
{statusMetric ? (
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Sparkles className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
) : null}
</SubPageShell>
);
}