diff --git a/src/app/insights/active-energy/page.tsx b/src/app/insights/active-energy/page.tsx
index 1fcf9dc01..de9a57fd9 100644
--- a/src/app/insights/active-energy/page.tsx
+++ b/src/app/insights/active-energy/page.tsx
@@ -17,6 +17,7 @@ export default function InsightsActiveEnergyPage() {
return (
{/*
- v1.4.28 (BK-UI-StatusSchlaf) — the six sibling sub-pages all
- mount `` underneath the chart so the user
- sees a written per-section assessment. Sleep has no
- `/api/insights/sleep-status` route yet (the v1.4.23 schema
- landed the data but the assessment-generation pass was deferred
- to the v1.5 iOS sprint where the Apple-Health sleep snapshot
- will inform the prompt). No per-section assessment yet; the
- structural slot waits for v1.5 where the route + hook key will
- wire in.
+ v1.8.7.1 — the per-section sleep assessment lands via the generic
+ metric-status route (`?metric=SLEEP_DURATION`), closing the slot
+ the v1.4.28 deferral left open. The card consumes the same
+ `InsightStatusData` envelope every sibling sub-page reads.
*/}
+ }
+ text={status?.text ?? null}
+ hasProvider={status?.hasProvider ?? false}
+ cached={status?.cached ?? false}
+ updatedAt={status?.updatedAt ?? null}
+ loading={isStatusLoading}
+ preparing={status?.preparing ?? false}
+ />
);
}
diff --git a/src/app/insights/step-length/page.tsx b/src/app/insights/step-length/page.tsx
index 31ac8be07..56c3a62d0 100644
--- a/src/app/insights/step-length/page.tsx
+++ b/src/app/insights/step-length/page.tsx
@@ -16,6 +16,7 @@ export default function InsightsSchrittlaengePage() {
return (
` 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 = () => ;
+ 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();
+ 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(
+
+ {node}
+ ,
+ );
+}
+
+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(" 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(
+ ,
+ );
+
+ 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();
+
+ // 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(
+ ,
+ );
+
+ // 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);
+ });
+});
diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx
index c4a7a2f90..929b9b17e 100644
--- a/src/components/insights/healthkit-metric-page.tsx
+++ b/src/components/insights/healthkit-metric-page.tsx
@@ -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 `` beneath the chart,
+ * pointed at the generic per-metric assessment route
+ * (`/api/insights/metric-status?metric=`). 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 `` 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 ? (
) : null}
+ {statusMetric ? (
+ }
+ text={status?.text ?? null}
+ hasProvider={status?.hasProvider ?? false}
+ cached={status?.cached ?? false}
+ updatedAt={status?.updatedAt ?? null}
+ loading={isStatusLoading}
+ preparing={status?.preparing ?? false}
+ />
+ ) : null}
);
}