From 03925ddece00b220087adc56313d4f1b54833429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:43:41 +0200 Subject: [PATCH] feat(insights): time-range pills with period-over-period deltas on metric pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 7d / 30d / 90d / 1y segmented control to every HealthKit metric page, between the stat strip and the chart, with a sentiment-coloured "vs prior period" delta caption beside it. New route GET /api/analytics/range?type=&range=<7d|30d|90d|1y> returns the current-window aggregate, the previous comparable window, and the composed delta. Single metric type by construction, so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — no per-type fan-out, no Prisma-pool burst. Reuses the WMY readers (readBestGranularityRollups + aggregateWmyBuckets) and the two-window pattern the year-ago baseline already uses; count/min/max/mean/sum compose across buckets, SD/slope/r2 stay out (not composable). The cached summaries slice is untouched so the hottest dashboard cache key never fragments. The delta caption reuses the dashboard's metric-aware up-good/up-bad colour logic — extracted from trend-card into a shared trend-sentiment module — with the metric's good direction sourced from the metric-status registry (target-band metrics render neutral, matching deviation framing). When the prior window has no data the caption shows "no prior-period data" rather than a misleading 0%. The range choice persists across metrics via a localStorage seam and defaults to 30d so nothing regresses. Cache keyed per (type, range) through the query-key factory. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit eac87a9f8b144f4d8e7a8b950528a19d5dcf8726) --- docs/api/openapi.yaml | 182 ++++++++++++++++++ messages/de.json | 16 ++ messages/en.json | 16 ++ messages/es.json | 16 ++ messages/fr.json | 16 ++ messages/it.json | 16 ++ messages/pl.json | 16 ++ src/app/api/analytics/range/route.ts | 72 +++++++ src/components/charts/trend-card.tsx | 75 ++------ .../healthkit-metric-page-status.test.tsx | 7 + .../insights/healthkit-metric-page.tsx | 50 ++++- .../insights/metric-range-delta.tsx | 86 +++++++++ src/components/insights/time-range-pills.tsx | 64 ++++++ src/hooks/use-analytics-range.ts | 56 ++++++ src/hooks/use-insights-layout-prefs.ts | 55 ++++++ .../analytics/__tests__/range-delta.test.ts | 120 ++++++++++++ src/lib/analytics/range-delta.ts | 180 +++++++++++++++++ src/lib/insights/trend-sentiment.ts | 66 +++++++ src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes.ts | 96 +++++++++ src/lib/query-keys.ts | 10 + 21 files changed, 1151 insertions(+), 65 deletions(-) create mode 100644 src/app/api/analytics/range/route.ts create mode 100644 src/components/insights/metric-range-delta.tsx create mode 100644 src/components/insights/time-range-pills.tsx create mode 100644 src/hooks/use-analytics-range.ts create mode 100644 src/lib/analytics/__tests__/range-delta.test.ts create mode 100644 src/lib/analytics/range-delta.ts create mode 100644 src/lib/insights/trend-sentiment.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2e4cf533e..6395d7cab 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -30,6 +30,7 @@ tags: - name: Measurements - name: Medications - name: Mood + - name: Analytics - name: Insights - name: Dashboard - name: Notifications @@ -1954,6 +1955,86 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/analytics/range: + get: + tags: + - Analytics + summary: Single-metric period-over-period range delta + description: v1.9.0 — returns the current-window aggregate, the previous comparable window, and the composed delta for + ONE metric type over a `7d` / `30d` / `90d` / `1y` range. Single-type by construction (the metric page is + single-metric), so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — + no per-type fan-out. Additive route; the `/api/analytics` envelope is unchanged. Auth via cookie or Bearer. + parameters: + - in: query + name: type + schema: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." + required: true + description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." + - in: query + name: range + schema: + type: string + enum: + - 7d + - 30d + - 90d + - 1y + description: "Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span + immediately before it." + required: true + description: "Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span + immediately before it." + responses: + "200": + description: Current + previous window aggregates and the delta. + content: + application/json: + schema: + $ref: "#/components/schemas/AnalyticsRangeResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 /api/insights/metric-status: get: tags: @@ -6334,6 +6415,107 @@ components: additionalProperties: false description: Acknowledgement that a full assessment warm was enqueued for the calling user. The generation runs out of band; the text lands in the read-only status routes. + AnalyticsRangeResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/AnalyticsRangeResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AnalyticsRangeResponse: + type: object + properties: + range: + type: string + enum: + - 7d + - 30d + - 90d + - 1y + description: The range that was read (echoes the request). + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Trailing-window length in days for the chosen range. + granularity: + type: string + description: Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage + miss). + current: + description: Aggregate over the current window `[now-N, now)`. + $ref: "#/components/schemas/AnalyticsWindowAggregate" + previous: + description: Aggregate over the previous comparable window `[now-2N, now-N)`. + $ref: "#/components/schemas/AnalyticsWindowAggregate" + delta: + anyOf: + - type: number + - type: "null" + description: "`current.mean - previous.mean`; null when either window has no data (never a misleading 0)." + deltaPct: + anyOf: + - type: number + - type: "null" + description: "`delta / previous.mean` as a fraction (0.03 = +3 %); null when the prior window has no / zero mean (no + divide-by-zero). The client shows 'no prior-period data' in that case." + required: + - range + - windowDays + - granularity + - current + - previous + - delta + - deltaPct + additionalProperties: false + description: Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY + rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across + buckets; SD/slope/r² are intentionally excluded (not composable). + AnalyticsWindowAggregate: + type: object + properties: + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Reading count composed across buckets. + min: + anyOf: + - type: number + - type: "null" + description: Window minimum; null when empty. + max: + anyOf: + - type: number + - type: "null" + description: Window maximum; null when empty. + mean: + anyOf: + - type: number + - type: "null" + description: Count-weighted mean across buckets; null when empty. + sum: + anyOf: + - type: number + - type: "null" + description: Cumulative total for cumulative metrics (steps, energy, distance); null when no bucket carries a sum. + required: + - count + - min + - max + - mean + - sum + additionalProperties: false MetricStatusResponseEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index b8f1a6c9f..0b4fce63a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1601,6 +1601,22 @@ "confidenceLow": "Geringe Konfidenz", "pendingLabel": "Trend-Kommentar wird generiert" }, + "range": { + "label": "Zeitraum", + "noPriorData": "keine Daten im Vorzeitraum", + "option": { + "7d": "7T", + "30d": "30T", + "90d": "90T", + "1y": "1J" + }, + "priorLabel": { + "7d": "ggü. vorherigen 7T", + "30d": "ggü. vorherigen 30T", + "90d": "ggü. vorherigen 90T", + "1y": "ggü. Vorjahr" + } + }, "correlationRow": { "title": "Zusammenhänge", "subtitle": "Vorab definierte Hypothesen, gegated auf n ≥ 14 und p < 0,05", diff --git a/messages/en.json b/messages/en.json index ca23e5853..2d8dd0c2f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1601,6 +1601,22 @@ "confidenceLow": "Low confidence", "pendingLabel": "Generating trend commentary" }, + "range": { + "label": "Time range", + "noPriorData": "no prior-period data", + "option": { + "7d": "7d", + "30d": "30d", + "90d": "90d", + "1y": "1y" + }, + "priorLabel": { + "7d": "vs prior 7d", + "30d": "vs prior 30d", + "90d": "vs prior 90d", + "1y": "vs prior year" + } + }, "correlationRow": { "title": "Relationships", "subtitle": "Pre-defined hypotheses, gated on n ≥ 14 and p < 0.05", diff --git a/messages/es.json b/messages/es.json index b34fa799b..81e7c7f83 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1601,6 +1601,22 @@ "confidenceLow": "Confianza baja", "pendingLabel": "Generando comentario de tendencia" }, + "range": { + "label": "Período", + "noPriorData": "sin datos del período anterior", + "option": { + "7d": "7d", + "30d": "30d", + "90d": "90d", + "1y": "1a" + }, + "priorLabel": { + "7d": "vs 7d anteriores", + "30d": "vs 30d anteriores", + "90d": "vs 90d anteriores", + "1y": "vs año anterior" + } + }, "correlationRow": { "title": "Correlaciones", "subtitle": "Hipótesis predefinidas, con umbral n ≥ 14 y p < 0,05", diff --git a/messages/fr.json b/messages/fr.json index cf4ceb691..69ba52e45 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1601,6 +1601,22 @@ "confidenceLow": "Confiance faible", "pendingLabel": "Commentaire de tendance en cours de génération" }, + "range": { + "label": "Période", + "noPriorData": "aucune donnée sur la période précédente", + "option": { + "7d": "7j", + "30d": "30j", + "90d": "90j", + "1y": "1an" + }, + "priorLabel": { + "7d": "vs 7j précédents", + "30d": "vs 30j précédents", + "90d": "vs 90j précédents", + "1y": "vs année précédente" + } + }, "correlationRow": { "title": "Corrélations", "subtitle": "Hypothèses prédéfinies, validées si n ≥ 14 et p < 0,05", diff --git a/messages/it.json b/messages/it.json index 175434875..360007928 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1601,6 +1601,22 @@ "confidenceLow": "Confidenza bassa", "pendingLabel": "Commento di trend in generazione" }, + "range": { + "label": "Periodo", + "noPriorData": "nessun dato nel periodo precedente", + "option": { + "7d": "7g", + "30d": "30g", + "90d": "90g", + "1y": "1a" + }, + "priorLabel": { + "7d": "vs 7g precedenti", + "30d": "vs 30g precedenti", + "90d": "vs 90g precedenti", + "1y": "vs anno precedente" + } + }, "correlationRow": { "title": "Correlazioni", "subtitle": "Ipotesi predefinite, vincolate a n ≥ 14 e p < 0,05", diff --git a/messages/pl.json b/messages/pl.json index 22b4b7bc5..2e37e134e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1601,6 +1601,22 @@ "confidenceLow": "Niska pewność", "pendingLabel": "Komentarz trendu jest generowany" }, + "range": { + "label": "Okres", + "noPriorData": "brak danych z poprzedniego okresu", + "option": { + "7d": "7d", + "30d": "30d", + "90d": "90d", + "1y": "1r" + }, + "priorLabel": { + "7d": "vs poprzednie 7d", + "30d": "vs poprzednie 30d", + "90d": "vs poprzednie 90d", + "1y": "vs poprzedni rok" + } + }, "correlationRow": { "title": "Korelacje", "subtitle": "Hipotezy zdefiniowane wcześniej, z progiem n ≥ 14 i p < 0,05", diff --git a/src/app/api/analytics/range/route.ts b/src/app/api/analytics/range/route.ts new file mode 100644 index 000000000..bebb0a292 --- /dev/null +++ b/src/app/api/analytics/range/route.ts @@ -0,0 +1,72 @@ +/** + * v1.9.0 — single-metric period-over-period range read. + * + * `GET /api/analytics/range?type=&range=7d|30d|90d|1y` + * returns the current-window aggregate, the previous comparable window, and + * the composed delta. Single metric type by construction — the Insights + * metric page is single-metric — so the read is one + * `readBestGranularityRollups` covering the trailing `2N` days, sliced into + * the two halves. No per-type fan-out, no Prisma-pool burst (the opposite of + * the deleted 15-way live walk). + * + * The cached `/api/analytics?slice=summaries` slice is deliberately left + * untouched: adding a range dimension to its single shared cache key would + * fragment the hottest cache on the dashboard. This dedicated route carries + * its own per-(type, range) cache slot on the client (`queryKeys.analyticsRange`). + * + * iOS-safe: additive new route, no change to the `/api/analytics` envelope. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { + ANALYTICS_RANGES, + computeRangeDelta, + type AnalyticsRange, +} from "@/lib/analytics/range-delta"; +import type { MeasurementType } from "@/generated/prisma/client"; + +export const dynamic = "force-dynamic"; + +const rangeQuerySchema = z.object({ + type: measurementTypeEnum, + range: z.enum(ANALYTICS_RANGES), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const parsed = rangeQuerySchema.safeParse({ + type: request.nextUrl.searchParams.get("type"), + range: request.nextUrl.searchParams.get("range"), + }); + if (!parsed.success) { + annotate({ + action: { name: "analytics.range.invalid-params" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const type = parsed.data.type as MeasurementType; + const range = parsed.data.range as AnalyticsRange; + + const result = await computeRangeDelta(user.id, type, range); + + annotate({ + action: { name: "analytics.range.read" }, + meta: { + type, + range, + granularity: result.granularity, + current_count: result.current.count, + previous_count: result.previous.count, + }, + }); + + return apiSuccess(result); +}); diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index cfc73b02a..8addfbfb6 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -11,6 +11,17 @@ import { } from "@/components/ui/tooltip"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import type { ComparisonBaseline } from "@/lib/dashboard-layout"; +import { + getTrendSentiment, + sentimentColorClass, + type TrendDirectionSentiment, + type TrendSentimentDirection, +} from "@/lib/insights/trend-sentiment"; + +// v1.9.0 — the metric-aware sentiment mapping moved to a shared module so the +// range-delta caption paints the same colour for the same signal. Re-exported +// here so existing `@/components/charts/trend-card` importers keep the type. +export type { TrendDirectionSentiment, TrendSentimentDirection }; interface SecondaryMetric { /** Sub-value latest reading (e.g. diastolic when latest is systolic). */ @@ -19,70 +30,6 @@ interface SecondaryMetric { avg30: number | null; } -/** - * Maps a metric's "up means" direction to colour sentiment for the small - * trend arrow on each tile. v1.4.6 P4 stripped the colour entirely after - * the original up=red / down=green mapping was wrong for half the metrics - * (mood up = good, BP up = bad, pulse up = neutral). v1.5 phase-5 restores - * the colour but per-metric: - * - * - `up-good` — higher value is better (mood, sleep hours, steps). - * ↑ green, ↓ orange. - * - `up-bad` — higher value is worse (BP, weight, body fat). - * ↑ orange, ↓ green. - * - `neutral` — direction doesn't carry a value judgement (pulse, - * "BP in target %" — those have their own range - * colouring on the avg7/avg30 numbers already). - * - * Strictly affects the ↑/↓/→ arrow next to the latest reading. Chart - * lines, axes, and the avg7/avg30 colour classes are untouched. - */ -export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral"; - -/** - * v1.4.33 F5 — single sentiment helper so the headline arrow, the 7-day - * delta value, and the comparison-overlay caption all paint the same - * colour for the same signal. Before this helper each consumer ran its - * own branch — the arrow read `slope30.direction`, the delta value read - * `trend7Delta > 0`, and the comparison caption read `compareDelta > 0`. - * When the 30-day regression and the 7-day delta disagreed (weight down - * over a month, up over a week) the tile painted a green arrow next to - * an orange value, which read as "two metrics in one tile". - * - * `change` is a signed magnitude of the movement under inspection (the - * 7-day delta in units, or the slope-projected-over-7-days for tiles - * with only `slope30`). `sentiment` is the metric's improvement - * direction. - * - * - `'positive'` — the change moves the metric toward its goal - * (e.g. weight on `up-bad` going down, mood on `up-good` going up). - * Renders green. - * - `'negative'` — the change moves away from the goal. Renders orange. - * - `'neutral'` — either the sentiment is neutral (pulse, BP-in-target), - * or the change is below the noise floor (|change| < 0.05). Renders - * muted. - */ -export type TrendSentimentDirection = "positive" | "negative" | "neutral"; - -function getTrendSentiment( - change: number | null | undefined, - sentiment: TrendDirectionSentiment, -): TrendSentimentDirection { - if (change == null || Math.abs(change) < 0.05) return "neutral"; - if (sentiment === "neutral") return "neutral"; - const isUp = change > 0; - const isGood = - (sentiment === "up-good" && isUp) || - (sentiment === "up-bad" && !isUp); - return isGood ? "positive" : "negative"; -} - -function sentimentColorClass(direction: TrendSentimentDirection): string { - if (direction === "positive") return "text-dracula-green"; - if (direction === "negative") return "text-dracula-orange"; - return "text-muted-foreground"; -} - interface TrendCardProps { label: string; latest: number | null; 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 d03212622..5a398339f 100644 --- a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx +++ b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx @@ -33,6 +33,13 @@ vi.mock("@/hooks/use-auth", () => ({ vi.mock("@/hooks/use-insights-layout-prefs", () => ({ useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }), + useInsightsRangePref: () => ({ range: "30d", setRange: () => {} }), +})); + +// v1.9.0 — the page reads the period-over-period range delta; mock the hook +// so the test stays deterministic and never touches `fetch`. +vi.mock("@/hooks/use-analytics-range", () => ({ + useAnalyticsRange: () => ({ data: undefined, isLoading: false }), })); const analyticsMock = vi.fn(); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index dcbe22f2f..2fa7b6ff8 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -7,11 +7,21 @@ import { Sparkles } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; -import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; +import { + useInsightsLayoutPrefs, + useInsightsRangePref, +} from "@/hooks/use-insights-layout-prefs"; +import { useAnalyticsRange } from "@/hooks/use-analytics-range"; 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"; @@ -19,6 +29,8 @@ 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 { SubPageShell } from "@/components/insights/sub-page-shell"; /** @@ -139,9 +151,30 @@ 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 @@ -207,6 +240,21 @@ export function HealthKitMetricPage({ coachLaunch showAllValuesType={measurementType} > + {/* 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`. */} +
+ + +