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

+1
View File
@@ -17,6 +17,7 @@ export default function InsightsActiveEnergyPage() {
return (
<HealthKitMetricPage
measurementType="ACTIVE_ENERGY_BURNED"
statusMetric="ACTIVE_ENERGY_BURNED"
insightMetric="ACTIVE_ENERGY_BURNED"
chartKey="activeEnergy"
i18nPrefix="insights.activeEnergy"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsLaermereignissePage() {
return (
<HealthKitMetricPage
measurementType="AUDIO_EXPOSURE_EVENT"
statusMetric="AUDIO_EXPOSURE_EVENT"
insightMetric="AUDIO_EXPOSURE_EVENT"
chartKey="audioExposureEvent"
i18nPrefix="insights.audioExposureEvent"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsBlutzuckerPage() {
return (
<HealthKitMetricPage
measurementType="BLOOD_GLUCOSE"
statusMetric="BLOOD_GLUCOSE"
insightMetric="BLOOD_GLUCOSE"
chartKey="bloodGlucose"
i18nPrefix="insights.bloodGlucose"
@@ -16,6 +16,7 @@ export default function InsightsBodyTemperaturePage() {
return (
<HealthKitMetricPage
measurementType="BODY_TEMPERATURE"
statusMetric="BODY_TEMPERATURE"
insightMetric="BODY_TEMPERATURE"
chartKey="bodyTemperature"
i18nPrefix="insights.bodyTemperature"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsKoerperwasserPage() {
return (
<HealthKitMetricPage
measurementType="TOTAL_BODY_WATER"
statusMetric="TOTAL_BODY_WATER"
insightMetric="TOTAL_BODY_WATER"
chartKey="totalBodyWater"
i18nPrefix="insights.totalBodyWater"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsKnochenmassePage() {
return (
<HealthKitMetricPage
measurementType="BONE_MASS"
statusMetric="BONE_MASS"
insightMetric="BONE_MASS"
chartKey="boneMass"
i18nPrefix="insights.boneMass"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsTageslichtPage() {
return (
<HealthKitMetricPage
measurementType="TIME_IN_DAYLIGHT"
statusMetric="TIME_IN_DAYLIGHT"
insightMetric="TIME_IN_DAYLIGHT"
chartKey="timeInDaylight"
i18nPrefix="insights.timeInDaylight"
@@ -16,6 +16,7 @@ export default function InsightsDoppelstandphasePage() {
return (
<HealthKitMetricPage
measurementType="WALKING_DOUBLE_SUPPORT"
statusMetric="WALKING_DOUBLE_SUPPORT"
insightMetric="WALKING_DOUBLE_SUPPORT"
chartKey="walkingDoubleSupport"
i18nPrefix="insights.walkingDoubleSupport"
@@ -16,6 +16,7 @@ export default function InsightsLaermbelastungPage() {
return (
<HealthKitMetricPage
measurementType="AUDIO_EXPOSURE_ENV"
statusMetric="AUDIO_EXPOSURE_ENV"
insightMetric="AUDIO_EXPOSURE_ENV"
chartKey="audioExposureEnv"
i18nPrefix="insights.audioExposureEnv"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsFettfreieMassePage() {
return (
<HealthKitMetricPage
measurementType="FAT_FREE_MASS"
statusMetric="FAT_FREE_MASS"
insightMetric="FAT_FREE_MASS"
chartKey="fatFreeMass"
i18nPrefix="insights.fatFreeMass"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsFettmassePage() {
return (
<HealthKitMetricPage
measurementType="FAT_MASS"
statusMetric="FAT_MASS"
insightMetric="FAT_MASS"
chartKey="fatMass"
i18nPrefix="insights.fatMass"
@@ -16,6 +16,7 @@ export default function InsightsStockwerkePage() {
return (
<HealthKitMetricPage
measurementType="FLIGHTS_CLIMBED"
statusMetric="FLIGHTS_CLIMBED"
insightMetric="FLIGHTS_CLIMBED"
chartKey="flightsClimbed"
i18nPrefix="insights.flightsClimbed"
@@ -16,6 +16,7 @@ export default function InsightsKopfhoererpegelPage() {
return (
<HealthKitMetricPage
measurementType="AUDIO_EXPOSURE_HEADPHONE"
statusMetric="AUDIO_EXPOSURE_HEADPHONE"
insightMetric="AUDIO_EXPOSURE_HEADPHONE"
chartKey="audioExposureHeadphone"
i18nPrefix="insights.audioExposureHeadphone"
+1
View File
@@ -17,6 +17,7 @@ export default function InsightsHrvPage() {
return (
<HealthKitMetricPage
measurementType="HEART_RATE_VARIABILITY"
statusMetric="HEART_RATE_VARIABILITY"
insightMetric="HEART_RATE_VARIABILITY"
chartKey="hrv"
i18nPrefix="insights.hrv"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsMagermassePage() {
return (
<HealthKitMetricPage
measurementType="LEAN_BODY_MASS"
statusMetric="LEAN_BODY_MASS"
insightMetric="LEAN_BODY_MASS"
chartKey="leanBodyMass"
i18nPrefix="insights.leanBodyMass"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsMuskelmassePage() {
return (
<HealthKitMetricPage
measurementType="MUSCLE_MASS"
statusMetric="MUSCLE_MASS"
insightMetric="MUSCLE_MASS"
chartKey="muscleMass"
i18nPrefix="insights.muscleMass"
+1
View File
@@ -18,6 +18,7 @@ export default function InsightsOxygenSaturationPage() {
return (
<HealthKitMetricPage
measurementType="OXYGEN_SATURATION"
statusMetric="OXYGEN_SATURATION"
insightMetric="OXYGEN_SATURATION"
chartKey="oxygenSaturation"
i18nPrefix="insights.oxygenSaturation"
@@ -16,6 +16,7 @@ export default function InsightsPulswellengeschwindigkeitPage() {
return (
<HealthKitMetricPage
measurementType="PULSE_WAVE_VELOCITY"
statusMetric="PULSE_WAVE_VELOCITY"
insightMetric="PULSE_WAVE_VELOCITY"
chartKey="pulseWaveVelocity"
i18nPrefix="insights.pulseWaveVelocity"
@@ -16,6 +16,7 @@ export default function InsightsAtemfrequenzPage() {
return (
<HealthKitMetricPage
measurementType="RESPIRATORY_RATE"
statusMetric="RESPIRATORY_RATE"
insightMetric="RESPIRATORY_RATE"
chartKey="respiratoryRate"
i18nPrefix="insights.respiratoryRate"
+1
View File
@@ -17,6 +17,7 @@ export default function InsightsRestingHrPage() {
return (
<HealthKitMetricPage
measurementType="RESTING_HEART_RATE"
statusMetric="RESTING_HEART_RATE"
insightMetric="RESTING_HEART_RATE"
chartKey="restingHr"
i18nPrefix="insights.restingHr"
@@ -16,6 +16,7 @@ export default function InsightsHauttemperaturPage() {
return (
<HealthKitMetricPage
measurementType="SKIN_TEMPERATURE"
statusMetric="SKIN_TEMPERATURE"
insightMetric="SKIN_TEMPERATURE"
chartKey="skinTemperature"
i18nPrefix="insights.skinTemperature"
+26 -9
View File
@@ -3,9 +3,11 @@
import Link from "next/link";
import { Moon } from "lucide-react";
import { useInsightMetricStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
@@ -32,6 +34,16 @@ export default function InsightsSchlafPage() {
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
// v1.8.7.1 — the per-section sleep assessment now rides the generic
// metric-status route, keyed by `SLEEP_DURATION`. The hook runs on every
// render but only fetches once the page has data (the empty branch below
// short-circuits before the card mounts), so a source-less account never
// fires an assessment round-trip.
const { data: status, isLoading: isStatusLoading } = useInsightMetricStatus(
"SLEEP_DURATION",
!isEmpty,
);
if (isEmpty) {
return (
<SubPageShell
@@ -68,16 +80,21 @@ export default function InsightsSchlafPage() {
<MetricTargetSummary slug="sleep" />
{/*
v1.4.28 (BK-UI-StatusSchlaf) — the six sibling sub-pages all
mount `<InsightStatusCard>` 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.
*/}
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Moon 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}
/>
</SubPageShell>
);
}
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsSchrittlaengePage() {
return (
<HealthKitMetricPage
measurementType="WALKING_STEP_LENGTH"
statusMetric="WALKING_STEP_LENGTH"
insightMetric="WALKING_STEP_LENGTH"
chartKey="walkingStepLength"
i18nPrefix="insights.walkingStepLength"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsGefaessalterPage() {
return (
<HealthKitMetricPage
measurementType="VASCULAR_AGE"
statusMetric="VASCULAR_AGE"
insightMetric="VASCULAR_AGE"
chartKey="vascularAge"
i18nPrefix="insights.vascularAge"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsViszeralfettPage() {
return (
<HealthKitMetricPage
measurementType="VISCERAL_FAT"
statusMetric="VISCERAL_FAT"
insightMetric="VISCERAL_FAT"
chartKey="visceralFat"
i18nPrefix="insights.visceralFat"
@@ -16,6 +16,7 @@ export default function InsightsGangasymmetriePage() {
return (
<HealthKitMetricPage
measurementType="WALKING_ASYMMETRY"
statusMetric="WALKING_ASYMMETRY"
insightMetric="WALKING_ASYMMETRY"
chartKey="walkingAsymmetry"
i18nPrefix="insights.walkingAsymmetry"
@@ -16,6 +16,7 @@ export default function InsightsGehstreckePage() {
return (
<HealthKitMetricPage
measurementType="WALKING_RUNNING_DISTANCE"
statusMetric="WALKING_RUNNING_DISTANCE"
insightMetric="WALKING_RUNNING_DISTANCE"
chartKey="walkingRunningDistance"
i18nPrefix="insights.walkingRunningDistance"
@@ -16,6 +16,7 @@ export default function InsightsGehpulsPage() {
return (
<HealthKitMetricPage
measurementType="WALKING_HEART_RATE_AVERAGE"
statusMetric="WALKING_HEART_RATE_AVERAGE"
insightMetric="WALKING_HEART_RATE_AVERAGE"
chartKey="walkingHeartRateAverage"
i18nPrefix="insights.walkingHeartRateAverage"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsGehgeschwindigkeitPage() {
return (
<HealthKitMetricPage
measurementType="WALKING_SPEED"
statusMetric="WALKING_SPEED"
insightMetric="WALKING_SPEED"
chartKey="walkingSpeed"
i18nPrefix="insights.walkingSpeed"
@@ -16,6 +16,7 @@ export default function InsightsGangstabilitaetPage() {
return (
<HealthKitMetricPage
measurementType="WALKING_STEADINESS"
statusMetric="WALKING_STEADINESS"
insightMetric="WALKING_STEADINESS"
chartKey="walkingSteadiness"
i18nPrefix="insights.walkingSteadiness"
@@ -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>
);
}