diff --git a/src/app/insights/active-energy/page.tsx b/src/app/insights/active-energy/page.tsx index de9a57fd9..b5b126fe8 100644 --- a/src/app/insights/active-energy/page.tsx +++ b/src/app/insights/active-energy/page.tsx @@ -17,7 +17,7 @@ export default function InsightsActiveEnergyPage() { return ( ` + owns the hook + card; only the icon differs from the HealthKit + scaffold (Moon vs Sparkles). The card mounts only on this + data-bearing branch, so a source-less account never fetches. */} - } - text={status?.text ?? null} - hasProvider={status?.hasProvider ?? false} - cached={status?.cached ?? false} - updatedAt={status?.updatedAt ?? null} - loading={isStatusLoading} - preparing={status?.preparing ?? false} + enabled={!isEmpty} /> ); diff --git a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx index 9a1b9b5a2..d03212622 100644 --- a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx +++ b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx @@ -50,6 +50,8 @@ vi.mock("@/hooks/use-insight-status", async (importOriginal) => { }); import { HealthKitMetricPage } from "../healthkit-metric-page"; +import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; +import InsightsActiveEnergyPage from "@/app/insights/active-energy/page"; function render(node: React.ReactNode, locale: "en" | "de" = "en") { const queryClient = new QueryClient({ @@ -116,9 +118,10 @@ describe(" status mount", () => { const html = render(); - // No assessment heading, and the generic fetch is disabled. + // No assessment heading, and the card (which owns the hook) never + // mounts, so the generic fetch is never wired. expect(html).not.toContain("Assessment"); - expect(metricStatusMock).toHaveBeenCalledWith("", false); + expect(metricStatusMock).not.toHaveBeenCalled(); }); it("renders the insufficient-data empty state (no card) when the metric has no data", () => { @@ -132,9 +135,30 @@ describe(" status mount", () => { />, ); - // Empty branch: no settled assessment text, and the status fetch is - // disabled so a source-less account never fires the round-trip. + // Empty branch: the page short-circuits to the empty state before the + // card JSX, so no settled assessment text and the card (and its fetch) + // never mounts — 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); + expect(metricStatusMock).not.toHaveBeenCalled(); + }); + + it("wires the active-energy page to an accepted registry id (not the MeasurementType remap)", () => { + // v1.8.7.1 (Design H1) — the active-energy page formerly passed + // `ACTIVE_ENERGY_BURNED` (the DB MeasurementType), which the route's + // closed enum rejects with 422; the registry id is `ACTIVE_ENERGY`. + // Render the real page module and assert the hook receives a metric id + // the route accepts. + analyticsMock.mockReturnValue({ + data: { summaries: { ACTIVE_ENERGY_BURNED: { count: 30 } } }, + isEmpty: false, + }); + metricStatusMock.mockReturnValue({ data: undefined, isLoading: false }); + + render(); + + expect(metricStatusMock).toHaveBeenCalled(); + const passedMetric = metricStatusMock.mock.calls[0][0] as string; + expect(passedMetric).toBe("ACTIVE_ENERGY"); + expect(METRIC_STATUS_IDS).toContain(passedMetric); }); }); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 929b9b17e..dcbe22f2f 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -6,15 +6,15 @@ 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"; import type { InsightMetric } from "@/lib/insights/metric-availability"; +import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; 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 { MetricStatusCard } from "@/components/insights/metric-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"; @@ -109,8 +109,14 @@ export interface HealthKitMetricPageProps { * 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. + * + * Typed to the closed `MetricStatusMetricId` union (the registry-id + * vocabulary the route's Zod enum accepts) rather than a bare string, + * so a page that passes a MeasurementType remap (e.g. + * `ACTIVE_ENERGY_BURNED` instead of the `ACTIVE_ENERGY` registry id) + * is a compile error, not a silent 422. */ - statusMetric?: string; + statusMetric?: MetricStatusMetricId; } export function HealthKitMetricPage({ @@ -136,14 +142,6 @@ export function HealthKitMetricPage({ 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 @@ -225,15 +223,10 @@ export function HealthKitMetricPage({ ) : null} {statusMetric ? ( - } - text={status?.text ?? null} - hasProvider={status?.hasProvider ?? false} - cached={status?.cached ?? false} - updatedAt={status?.updatedAt ?? null} - loading={isStatusLoading} - preparing={status?.preparing ?? false} + enabled={!isEmpty} /> ) : null} diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx new file mode 100644 index 000000000..4634d6ba6 --- /dev/null +++ b/src/components/insights/metric-status-card.tsx @@ -0,0 +1,52 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { useInsightMetricStatus } from "@/hooks/use-insight-status"; +import { useTranslations } from "@/lib/i18n/context"; +import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; +import { InsightStatusCard } from "@/components/insights/insight-status-card"; + +interface MetricStatusCardProps { + /** The registry metric id the generic assessment route keys on. */ + metric: MetricStatusMetricId; + /** The leading glyph (Sparkles for the generic scaffold, Moon for sleep). */ + icon: ReactNode; + /** + * Gate the fetch on the page having data, so an insufficient-data page + * never fires an assessment round-trip. The hook still runs on every + * render (rules of hooks); `enabled` decides whether it talks to the + * network. + */ + enabled: boolean; +} + +/** + * v1.8.7.1 — shared mount for the generic per-metric assessment card. + * + * Owns the `useInsightMetricStatus` hook + the eight-prop `` + * wiring (`status?.x ?? default`) so the HealthKit metric scaffold and the + * bespoke sleep page consume one seam instead of repeating the block. Only + * the icon differs between the two sites; everything else is identical. + */ +export function MetricStatusCard({ + metric, + icon, + enabled, +}: MetricStatusCardProps) { + const { t } = useTranslations(); + const { data: status, isLoading } = useInsightMetricStatus(metric, enabled); + + return ( + + ); +} diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index 0ac0bc3ef..710aba305 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import { queryKeys } from "@/lib/query-keys"; /** @@ -183,7 +184,15 @@ export function useInsightStatus(metric: InsightStatusMetric) { * no data (the page renders the insufficient-data empty state instead of * the card), so a brand-new account never fires an assessment round-trip. */ -export function useInsightMetricStatus(metric: string, enabled = true) { +export function useInsightMetricStatus( + // The closed registry-id union (the route's Zod enum vocabulary). `""` + // is the disabled-fetch placeholder the `HealthKitMetricPage` passes when + // no `statusMetric` is wired (the hook must still run per the rules of + // hooks); `enabled` is false on that branch so the empty id never reaches + // a round-trip. A typo'd metric id is therefore a compile error. + metric: MetricStatusMetricId | "", + enabled = true, +) { const { isAuthenticated } = useAuth(); const { locale } = useTranslations();