diff --git a/src/components/insights/__tests__/metric-range-controls.test.tsx b/src/components/insights/__tests__/metric-range-controls.test.tsx new file mode 100644 index 000000000..dfd58a797 --- /dev/null +++ b/src/components/insights/__tests__/metric-range-controls.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.10.2 — `` is the shared range-controls block lifted + * out of `` so the bespoke metric sub-pages render the + * identical time-range pills + period-over-period delta. The component owns + * the range pref + range read + direction-sentiment lookup; the test mocks + * those hooks so the render stays deterministic and never touches `fetch`. + */ + +const rangeMock = vi.fn(); +vi.mock("@/hooks/use-insights-layout-prefs", () => ({ + useInsightsRangePref: () => ({ range: "30d", setRange: () => {} }), +})); + +vi.mock("@/hooks/use-analytics-range", () => ({ + useAnalyticsRange: (...args: unknown[]) => rangeMock(...args), +})); + +import { MetricRangeControls } from "../metric-range-controls"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +beforeEach(() => { + rangeMock.mockReset(); + rangeMock.mockReturnValue({ data: undefined, isLoading: false }); +}); + +describe("", () => { + it("renders the time-range pills and the delta slot", () => { + const html = render(); + expect(html).toContain('data-slot="metric-range-controls"'); + expect(html).toContain('data-slot="time-range-pills"'); + }); + + it("passes the measurement type and enabled flag through to the range read", () => { + render(); + expect(rangeMock).toHaveBeenCalledWith("PULSE", "30d", false); + }); + + it("defaults the range read to enabled", () => { + render(); + expect(rangeMock).toHaveBeenCalledWith("SLEEP_DURATION", "30d", true); + }); +}); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 2fa7b6ff8..019390999 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -7,21 +7,11 @@ import { Sparkles } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; -import { - useInsightsLayoutPrefs, - useInsightsRangePref, -} from "@/hooks/use-insights-layout-prefs"; -import { useAnalyticsRange } from "@/hooks/use-analytics-range"; +import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { useTranslations } from "@/lib/i18n/context"; import type { InsightMetric } from "@/lib/insights/metric-availability"; -import { - getMetricStatusMeta, - metricIdForMeasurementType, -} from "@/lib/insights/metric-status-registry"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; -import { sentimentFromMetricDirection } from "@/lib/insights/trend-sentiment"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; -import type { MeasurementType } from "@/generated/prisma/client"; import { Button } from "@/components/ui/button"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { MetricStatusCard } from "@/components/insights/metric-status-card"; @@ -29,8 +19,7 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; -import { TimeRangePills } from "@/components/insights/time-range-pills"; -import { MetricRangeDelta } from "@/components/insights/metric-range-delta"; +import { MetricRangeControls } from "@/components/insights/metric-range-controls"; import { SubPageShell } from "@/components/insights/sub-page-shell"; /** @@ -151,30 +140,9 @@ export function HealthKitMetricPage({ const { user, isAuthenticated } = useAuth(); const { t } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); - const { range, setRange } = useInsightsRangePref(); const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric); - // v1.9.0 — period-over-period range read. Single-metric, additive route; - // gated off the empty-data branch so a brand-new metric never fires it. - const { data: rangeData, isLoading: isRangeLoading } = useAnalyticsRange( - measurementType, - range, - !isEmpty, - ); - - // The delta's sentiment colour follows the metric's "good direction" from - // the metric-status registry (higher-better → up-good, lower-better → - // up-bad, target-band → neutral). Every HealthKit metric page is backed by - // a registry entry; a metric absent from the registry falls back to neutral. - const registryMeta = (() => { - const id = metricIdForMeasurementType(measurementType as MeasurementType); - return id ? getMetricStatusMeta(id) : null; - })(); - const directionSentiment = registryMeta - ? sentimentFromMetricDirection(registryMeta.direction) - : "neutral"; - 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 @@ -242,19 +210,10 @@ export function HealthKitMetricPage({ > {/* v1.9.0 — time-range pills + period-over-period delta, between the stat strip and the chart. The selected range persists across metrics - via `useInsightsRangePref`. */} -
- - -
+ via `useInsightsRangePref`. + v1.10.2 — extracted into the shared `` so the + bespoke metric sub-pages render the identical control. */} + ` so the + * bespoke metric sub-pages (`weight`, `blood-pressure`, `pulse`, `mood`, + * `sleep`, `bmi`, …) render the identical control. + * + * Before this, the 37 HealthKit pages carried the selector and the 8 + * hand-rolled pages carried nothing, so navigating from an HK metric to + * `weight` made the control vanish. This component is the single source the + * HK scaffold and the bespoke pages both consume, wired to the same + * persisted range pref (`useInsightsRangePref`) so the choice sticks across + * every metric. + * + * The delta's sentiment colour follows the metric's "good direction" from + * the metric-status registry (higher-better → up-good, lower-better → + * up-bad, target-band → neutral); a metric absent from the registry falls + * back to neutral. + */ +export function MetricRangeControls({ + measurementType, + enabled = true, +}: { + /** The MeasurementType the period-over-period read is keyed on. */ + measurementType: string; + /** + * Gate the range read. Pages pass `!isEmpty` so a brand-new metric with + * no observations never fires the round-trip. Defaults to `true`. + */ + enabled?: boolean; +}) { + const { range, setRange } = useInsightsRangePref(); + const { data: rangeData, isLoading: isRangeLoading } = useAnalyticsRange( + measurementType, + range, + enabled, + ); + + const registryMeta = (() => { + const id = metricIdForMeasurementType(measurementType as MeasurementType); + return id ? getMetricStatusMeta(id) : null; + })(); + const directionSentiment = registryMeta + ? sentimentFromMetricDirection(registryMeta.direction) + : "neutral"; + + return ( +
+ + +
+ ); +}