From b1b2e22eebdce2e998dd884cb713a3e2478f3919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:17:12 +0200 Subject: [PATCH] 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) (cherry picked from commit 083044201af7715740ec0ee2f14c7240c4999fbf) --- src/app/insights/active-energy/page.tsx | 1 + src/app/insights/audio-events/page.tsx | 1 + src/app/insights/blood-glucose/page.tsx | 1 + src/app/insights/body-temperature/page.tsx | 1 + src/app/insights/body-water/page.tsx | 1 + src/app/insights/bone-mass/page.tsx | 1 + src/app/insights/daylight/page.tsx | 1 + src/app/insights/double-support-time/page.tsx | 1 + src/app/insights/environmental-audio/page.tsx | 1 + src/app/insights/fat-free-mass/page.tsx | 1 + src/app/insights/fat-mass/page.tsx | 1 + src/app/insights/flights-climbed/page.tsx | 1 + src/app/insights/headphone-audio/page.tsx | 1 + src/app/insights/hrv/page.tsx | 1 + src/app/insights/lean-body-mass/page.tsx | 1 + src/app/insights/muscle-mass/page.tsx | 1 + src/app/insights/oxygen/page.tsx | 1 + src/app/insights/pulse-wave-velocity/page.tsx | 1 + src/app/insights/respiratory-rate/page.tsx | 1 + src/app/insights/resting-pulse/page.tsx | 1 + src/app/insights/skin-temperature/page.tsx | 1 + src/app/insights/sleep/page.tsx | 35 +++-- src/app/insights/step-length/page.tsx | 1 + src/app/insights/vascular-age/page.tsx | 1 + src/app/insights/visceral-fat/page.tsx | 1 + src/app/insights/walking-asymmetry/page.tsx | 1 + src/app/insights/walking-distance/page.tsx | 1 + src/app/insights/walking-heart-rate/page.tsx | 1 + src/app/insights/walking-speed/page.tsx | 1 + src/app/insights/walking-steadiness/page.tsx | 1 + .../healthkit-metric-page-status.test.tsx | 140 ++++++++++++++++++ .../insights/healthkit-metric-page.tsx | 37 +++++ 32 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 src/components/insights/__tests__/healthkit-metric-page-status.test.tsx 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} ); }