mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user