refactor(insights): extract shared MetricRangeControls from HealthKit page

Lift the time-range pills + period-over-period delta block out of
HealthKitMetricPage into a standalone MetricRangeControls component that
owns the range pref, the range read, and the direction-sentiment lookup.
The HealthKit scaffold now consumes the shared piece so the bespoke metric
pages can render the identical control.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 08:56:20 +02:00
1 parent 5b5ab5795c
commit 73255f817d
3 files changed
+131 -47

No files matched your search

@@ -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 — `<MetricRangeControls>` is the shared range-controls block lifted
* out of `<HealthKitMetricPage>` 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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
beforeEach(() => {
rangeMock.mockReset();
rangeMock.mockReturnValue({ data: undefined, isLoading: false });
});
describe("<MetricRangeControls>", () => {
it("renders the time-range pills and the delta slot", () => {
const html = render(<MetricRangeControls measurementType="WEIGHT" />);
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(<MetricRangeControls measurementType="PULSE" enabled={false} />);
expect(rangeMock).toHaveBeenCalledWith("PULSE", "30d", false);
});
it("defaults the range read to enabled", () => {
render(<MetricRangeControls measurementType="SLEEP_DURATION" />);
expect(rangeMock).toHaveBeenCalledWith("SLEEP_DURATION", "30d", true);
});
});
@@ -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`. */}
<div
data-slot="metric-range-controls"
className="flex flex-wrap items-center justify-between gap-2"
>
<TimeRangePills value={range} onChange={setRange} />
<MetricRangeDelta
data={rangeData}
range={range}
directionSentiment={directionSentiment}
isLoading={isRangeLoading}
/>
</div>
via `useInsightsRangePref`.
v1.10.2 — extracted into the shared `<MetricRangeControls>` so the
bespoke metric sub-pages render the identical control. */}
<MetricRangeControls measurementType={measurementType} enabled={!isEmpty} />
<HealthChartDynamic
chartKey={chartKey}
types={[measurementType]}
@@ -0,0 +1,73 @@
"use client";
import { useInsightsRangePref } from "@/hooks/use-insights-layout-prefs";
import { useAnalyticsRange } from "@/hooks/use-analytics-range";
import {
getMetricStatusMeta,
metricIdForMeasurementType,
} from "@/lib/insights/metric-status-registry";
import { sentimentFromMetricDirection } from "@/lib/insights/trend-sentiment";
import type { MeasurementType } from "@/generated/prisma/client";
import { TimeRangePills } from "@/components/insights/time-range-pills";
import { MetricRangeDelta } from "@/components/insights/metric-range-delta";
/**
* v1.10.2 — shared range-controls block (time-range pills +
* period-over-period delta) lifted out of `<HealthKitMetricPage>` 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 (
<div
data-slot="metric-range-controls"
className="flex flex-wrap items-center justify-between gap-2"
>
<TimeRangePills value={range} onChange={setRange} />
<MetricRangeDelta
data={rangeData}
range={range}
directionSentiment={directionSentiment}
isLoading={isRangeLoading}
/>
</div>
);
}