feat(insights): time-range pills with period-over-period deltas on metric pages

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=<MeasurementType>&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) <noreply@anthropic.com>
(cherry picked from commit eac87a9f8b144f4d8e7a8b950528a19d5dcf8726)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 16:45:14 +02:00
1 parent f1145c0e91
commit 03925ddece
21 files changed
+1151 -65

No files matched your search

+182
View File
@@ -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:
+16
View File
@@ -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",
+16
View File
@@ -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",
+16
View File
@@ -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",
+16
View File
@@ -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",
+16
View File
@@ -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",
+16
View File
@@ -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",
+72
View File
@@ -0,0 +1,72 @@
/**
* v1.9.0 — single-metric period-over-period range read.
*
* `GET /api/analytics/range?type=<MeasurementType>&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);
});
+11 -64
View File
@@ -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;
@@ -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();
@@ -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`. */}
<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>
<HealthChartDynamic
chartKey={chartKey}
types={[measurementType]}
@@ -0,0 +1,86 @@
"use client";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
getTrendSentiment,
sentimentColorClass,
type TrendDirectionSentiment,
} from "@/lib/insights/trend-sentiment";
import type { AnalyticsRangeData } from "@/hooks/use-analytics-range";
import type { AnalyticsRange } from "@/lib/analytics/range-delta";
/**
* v1.9.0 — period-over-period delta caption beside the time-range pills.
*
* Renders "+3 % vs prior 30d" with the metric-aware sentiment colour the
* dashboard tiles use (up-good vs up-bad). When the prior window has no data
* it shows a neutral "no prior-period data" line instead of a misleading 0 %
* (the `deltaPct` is null in that case). For `target-band` metrics the
* sentiment is neutral, so the delta still shows magnitude + direction but
* carries no good/bad colour — matching Oura's deviation framing for metrics
* with no single good direction.
*/
export function MetricRangeDelta({
data,
range,
directionSentiment,
isLoading,
}: {
data: AnalyticsRangeData | undefined;
range: AnalyticsRange;
directionSentiment: TrendDirectionSentiment;
isLoading: boolean;
}) {
const { t } = useTranslations();
if (isLoading) {
return (
<span
data-slot="metric-range-delta-pending"
className="bg-muted/60 inline-block h-3.5 w-28 animate-pulse rounded motion-reduce:animate-none"
aria-hidden="true"
/>
);
}
const priorLabel = t(`insights.range.priorLabel.${range}`);
// No comparison available — prior window empty, or current window empty.
if (!data || data.delta === null || data.deltaPct === null) {
return (
<span
data-slot="metric-range-delta-empty"
className="text-muted-foreground text-xs"
>
{t("insights.range.noPriorData")}
</span>
);
}
const direction = getTrendSentiment(data.delta, directionSentiment);
const colorClass = sentimentColorClass(direction);
const isUp = data.delta > 0;
const pct = Math.round(Math.abs(data.deltaPct) * 100);
const sign = isUp ? "+" : "−";
return (
<span
data-slot="metric-range-delta"
data-direction={direction}
className={cn("inline-flex items-center gap-1 text-xs", colorClass)}
>
{isUp ? (
<ArrowUp className="h-3 w-3" aria-hidden="true" />
) : (
<ArrowDown className="h-3 w-3" aria-hidden="true" />
)}
<span>
{sign}
{pct}% {priorLabel}
</span>
</span>
);
}
@@ -0,0 +1,64 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
ANALYTICS_RANGES,
type AnalyticsRange,
} from "@/lib/analytics/range-delta";
/**
* v1.9.0 — `7d / 30d / 90d / 1y` segmented control for the Insights metric
* pages. The interaction model Apple Health / Oura users already know (a
* range selector across the top of a metric chart). Built as an accessible
* `radiogroup` of pills over the existing zinc/shadcn primitives rather than
* pulling a new Radix dependency — the project's tab strip uses the same
* custom-pill approach. The selected range drives the period-over-period
* delta caption and (where wired) the chart window.
*/
export function TimeRangePills({
value,
onChange,
className,
}: {
value: AnalyticsRange;
onChange: (range: AnalyticsRange) => void;
className?: string;
}) {
const { t } = useTranslations();
return (
<div
role="radiogroup"
aria-label={t("insights.range.label")}
data-slot="time-range-pills"
className={cn(
"bg-muted/50 inline-flex items-center gap-0.5 rounded-lg p-0.5",
className,
)}
>
{ANALYTICS_RANGES.map((range) => {
const selected = range === value;
return (
<button
key={range}
type="button"
role="radio"
aria-checked={selected}
data-range={range}
data-selected={selected}
onClick={() => onChange(range)}
className={cn(
"focus-visible:ring-ring rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
selected
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(`insights.range.option.${range}`)}
</button>
);
})}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import type { AnalyticsRange } from "@/lib/analytics/range-delta";
/**
* v1.9.0 — client reader for the single-metric period-over-period range
* read (`GET /api/analytics/range`). One cache slot per `(type, range)` via
* the centralised query-key factory, so switching the time-range pills is a
* cheap cache hit after first fetch and never collides with the shared
* `["analytics", "summaries"]` slot.
*/
export interface AnalyticsWindowAggregate {
count: number;
min: number | null;
max: number | null;
mean: number | null;
sum: number | null;
}
export interface AnalyticsRangeData {
range: AnalyticsRange;
windowDays: number;
granularity: string;
current: AnalyticsWindowAggregate;
previous: AnalyticsWindowAggregate;
delta: number | null;
deltaPct: number | null;
}
export function useAnalyticsRange(
type: string,
range: AnalyticsRange,
enabled = true,
) {
const { isAuthenticated } = useAuth();
return useQuery({
queryKey: queryKeys.analyticsRange(type, range),
queryFn: async (): Promise<AnalyticsRangeData> => {
const res = await fetch(
`/api/analytics/range?type=${encodeURIComponent(
type,
)}&range=${encodeURIComponent(range)}`,
);
if (!res.ok) throw new Error("Failed to load range");
const json = (await res.json()) as { data: AnalyticsRangeData };
return json.data;
},
enabled: isAuthenticated && enabled && type.length > 0,
staleTime: 60 * 1000,
});
}
+55
View File
@@ -1,5 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
@@ -7,6 +9,59 @@ import {
resolveDashboardLayout,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import {
ANALYTICS_RANGES,
type AnalyticsRange,
} from "@/lib/analytics/range-delta";
const RANGE_STORAGE_KEY = "healthlog.insights.range";
const DEFAULT_RANGE: AnalyticsRange = "30d";
function isAnalyticsRange(value: string | null): value is AnalyticsRange {
return (
value !== null && (ANALYTICS_RANGES as readonly string[]).includes(value)
);
}
/**
* Read the persisted range once, lazily. Guarded by `typeof window` so the
* server render (where `localStorage` is absent) returns the default rather
* than throwing; the first client render then hydrates with the stored value
* via the lazy `useState` initializer — no `setState`-in-effect needed.
*/
function readStoredRange(): AnalyticsRange {
if (typeof window === "undefined") return DEFAULT_RANGE;
try {
const stored = window.localStorage.getItem(RANGE_STORAGE_KEY);
return isAnalyticsRange(stored) ? stored : DEFAULT_RANGE;
} catch {
return DEFAULT_RANGE;
}
}
/**
* v1.9.0 — persisted time-range choice for the Insights metric pages.
*
* The choice sticks across metrics (a user who picks `90d` on weight sees
* `90d` on HRV) via `localStorage` — the same lightweight client-pref seam
* the comparison toggle conceptually lives alongside, without a server
* round-trip or a dashboard-layout schema change. Defaults to `30d` so the
* existing fixed-window behaviour is the default and nothing regresses.
*/
export function useInsightsRangePref() {
const [range, setRangeState] = useState<AnalyticsRange>(readStoredRange);
const setRange = useCallback((next: AnalyticsRange) => {
setRangeState(next);
try {
window.localStorage.setItem(RANGE_STORAGE_KEY, next);
} catch {
// Best-effort persistence; the in-memory state still drives the UI.
}
}, []);
return { range, setRange } as const;
}
/**
* v1.4.25 W4 — shared reader for the dashboard-layout payload across
@@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import {
composeDelta,
rangeWindowDays,
sliceWindowDelta,
type WindowAggregate,
} from "@/lib/analytics/range-delta";
import type { RollupBucketRow } from "@/lib/rollups/measurement-read-wmy";
/**
* v1.9.0 — the period-over-period range delta is two reads composed into one
* caption. The composition (slice the buckets into the current vs previous
* halves, aggregate each, derive the delta) is the load-bearing logic and is
* pure over the bucket rows — pinned here without a DB.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
function bucket(daysAgo: number, count: number, mean: number): RollupBucketRow {
return {
bucketStart: new Date(Date.now() - daysAgo * DAY_MS),
count,
mean,
sd: null,
slope: null,
r2: null,
sumValue: count * mean,
minValue: mean,
maxValue: mean,
};
}
describe("rangeWindowDays", () => {
it("maps each range onto its trailing-window length", () => {
expect(rangeWindowDays("7d")).toBe(7);
expect(rangeWindowDays("30d")).toBe(30);
expect(rangeWindowDays("90d")).toBe(90);
expect(rangeWindowDays("1y")).toBe(365);
});
});
describe("sliceWindowDelta", () => {
it("splits buckets into the current and previous halves by bucketStart", () => {
const now = Date.now();
// 30d window: current = [now-30, now), previous = [now-60, now-30).
const rows = [
bucket(5, 2, 80), // current
bucket(20, 3, 82), // current
bucket(40, 2, 70), // previous
bucket(55, 2, 74), // previous
];
const { current, previous } = sliceWindowDelta(rows, 30, now);
expect(current.count).toBe(5);
expect(previous.count).toBe(4);
// Count-weighted means compose linearly across buckets.
expect(current.mean).toBeCloseTo((2 * 80 + 3 * 82) / 5, 6);
expect(previous.mean).toBeCloseTo((2 * 70 + 2 * 74) / 4, 6);
});
it("excludes buckets outside the 2N span", () => {
const now = Date.now();
const rows = [
bucket(5, 1, 90), // current
bucket(40, 1, 80), // previous
bucket(200, 1, 50), // outside both halves
];
const { current, previous } = sliceWindowDelta(rows, 30, now);
expect(current.count).toBe(1);
expect(previous.count).toBe(1);
});
it("yields empty aggregates when no bucket falls in a half", () => {
const now = Date.now();
const rows = [bucket(5, 4, 100)]; // only current half populated
const { current, previous } = sliceWindowDelta(rows, 30, now);
expect(current.count).toBe(4);
expect(previous.count).toBe(0);
expect(previous.mean).toBeNull();
});
});
describe("composeDelta", () => {
const win = (count: number, mean: number | null): WindowAggregate => ({
count,
min: mean,
max: mean,
mean,
sum: mean === null ? null : count * mean,
});
it("computes the signed delta and percentage", () => {
const { delta, deltaPct } = composeDelta(win(5, 103), win(5, 100));
expect(delta).toBeCloseTo(3, 6);
expect(deltaPct).toBeCloseTo(0.03, 6);
});
it("returns null delta when either window is empty", () => {
expect(composeDelta(win(0, null), win(5, 100))).toEqual({
delta: null,
deltaPct: null,
});
expect(composeDelta(win(5, 100), win(0, null))).toEqual({
delta: null,
deltaPct: null,
});
});
it("returns null deltaPct (not Infinity) when the prior mean is zero", () => {
const { delta, deltaPct } = composeDelta(win(5, 4), win(3, 0));
expect(delta).toBeCloseTo(4, 6);
expect(deltaPct).toBeNull();
});
it("captures a negative move", () => {
const { delta, deltaPct } = composeDelta(win(5, 90), win(5, 100));
expect(delta).toBeCloseTo(-10, 6);
expect(deltaPct).toBeCloseTo(-0.1, 6);
});
});
+180
View File
@@ -0,0 +1,180 @@
/**
* v1.9.0 — period-over-period range deltas for a single metric.
*
* Background
* ----------
* The Insights metric pages render fixed windows (`avg7`, `avg30`,
* `slope7/30/90`). A user-selectable time-range with a "vs prior period"
* delta needs two reads of the same metric: the current window and the
* previous comparable window, composed into a delta. This is exactly the
* two-window pattern `computeAvg30LastYearForType` already uses for the
* year-ago baseline (`summaries-slice.ts`) — current = the trailing window,
* previous = the equally-sized window immediately before it.
*
* Single metric, two reads, no fan-out
* ------------------------------------
* The route this backs is single-metric (the metric page is single-metric),
* so the cost is one `readBestGranularityRollups` call covering the trailing
* `2N` days, sliced into the current and previous halves. No per-type fan-out,
* no Prisma-pool burst — the opposite of the deleted 15-way live walk.
*
* Compositional contract
* ----------------------
* `count / min / max / mean / sum` are linearly composable across buckets
* (the rollup tier's compositional contract — `measurement-read-wmy.ts`), so
* a window aggregate built from WEEK / MONTH buckets equals the per-row
* aggregate over the same span. SD / slope / r² are intentionally NOT part of
* the delta — they do not compose, matching the rest of the WMY reader tier.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import {
aggregateWmyBuckets,
readBestGranularityRollups,
type RollupBucketRow,
} from "@/lib/rollups/measurement-read-wmy";
const DAY_MS = 24 * 60 * 60 * 1000;
/** The user-selectable ranges. */
export const ANALYTICS_RANGES = ["7d", "30d", "90d", "1y"] as const;
export type AnalyticsRange = (typeof ANALYTICS_RANGES)[number];
/** Trailing-window length, in days, for each range. */
const RANGE_WINDOW_DAYS: Record<AnalyticsRange, number> = {
"7d": 7,
"30d": 30,
"90d": 90,
"1y": 365,
};
export function rangeWindowDays(range: AnalyticsRange): number {
return RANGE_WINDOW_DAYS[range];
}
/** A composed window aggregate. `mean`/etc. are null when the window is empty. */
export interface WindowAggregate {
count: number;
min: number | null;
max: number | null;
mean: number | null;
sum: number | null;
}
export interface RangeDeltaResult {
range: AnalyticsRange;
windowDays: number;
/** The granularity the underlying rollup read resolved against. */
granularity: string;
current: WindowAggregate;
previous: WindowAggregate;
/**
* `current.mean - previous.mean`, or null when either window has no mean
* (no data in the current or prior window) — never a misleading 0.
*/
delta: number | null;
/**
* `delta / previous.mean` as a fraction (0.03 = +3 %), or null when the
* prior window has no mean / a zero mean (no divide-by-zero, no misleading
* 0 %). The client renders "no prior-period data" in that case.
*/
deltaPct: number | null;
}
/**
* Slice the resolved buckets into the current half (`[now-N, now)`) and the
* previous half (`[now-2N, now-N)`) and compose each. Pure over the bucket
* rows + the window boundaries so the composition is unit-testable without a
* DB. `bucketStart` is the slice key — the same conservative overlap filter
* `computeAvg30LastYearForType` uses (a bucket counts toward the half its
* `bucketStart` falls into).
*/
export function sliceWindowDelta(
rows: RollupBucketRow[],
windowDays: number,
now: number,
): { current: WindowAggregate; previous: WindowAggregate } {
const currentStart = now - windowDays * DAY_MS;
const previousStart = now - 2 * windowDays * DAY_MS;
const currentRows: RollupBucketRow[] = [];
const previousRows: RollupBucketRow[] = [];
for (const row of rows) {
const t = row.bucketStart.getTime();
if (t >= currentStart && t < now) {
currentRows.push(row);
} else if (t >= previousStart && t < currentStart) {
previousRows.push(row);
}
}
return {
current: aggregateWmyBuckets(currentRows),
previous: aggregateWmyBuckets(previousRows),
};
}
/**
* Compose the period-over-period delta from the two window aggregates.
* Pure — pinned by unit test. Guards both the missing-data case (either
* window empty → delta null) and the divide-by-zero case (prior mean zero or
* null → deltaPct null) so the caption never paints a misleading 0 %.
*/
export function composeDelta(
current: WindowAggregate,
previous: WindowAggregate,
): { delta: number | null; deltaPct: number | null } {
if (current.mean === null || previous.mean === null) {
return { delta: null, deltaPct: null };
}
const delta = current.mean - previous.mean;
const deltaPct = previous.mean !== 0 ? delta / previous.mean : null;
return { delta, deltaPct };
}
/**
* Read the current vs previous window for one `(userId, type)` and compose
* the delta. Reads a single `2N`-day window through the granularity router so
* both halves resolve at the same granularity (the comparison stays
* apples-to-apples). Returns a zeroed result on a coverage miss — the route
* still answers 200 with empty windows so the UI shows "no prior-period
* data" rather than erroring.
*/
export async function computeRangeDelta(
userId: string,
type: MeasurementType,
range: AnalyticsRange,
now: number = Date.now(),
): Promise<RangeDeltaResult> {
const windowDays = rangeWindowDays(range);
// Read the full 2N span in one go so the current and previous halves share
// a granularity; the router picks the coarsest tier that resolves 2N.
const resolved = await readBestGranularityRollups(userId, type, windowDays * 2);
if (!resolved) {
const empty: WindowAggregate = {
count: 0,
min: null,
max: null,
mean: null,
sum: null,
};
return {
range,
windowDays,
granularity: "none",
current: empty,
previous: empty,
delta: null,
deltaPct: null,
};
}
const { current, previous } = sliceWindowDelta(resolved.rows, windowDays, now);
const { delta, deltaPct } = composeDelta(current, previous);
return {
range,
windowDays,
granularity: resolved.granularity,
current,
previous,
delta,
deltaPct,
};
}
+66
View File
@@ -0,0 +1,66 @@
/**
* v1.9.0 — shared metric-aware trend sentiment helpers.
*
* Extracted from `trend-card.tsx` so the range-delta caption (the time-range
* pills feature) paints the exact same colour for the same signal as the
* dashboard tiles. The mapping is the v1.5 phase-5 audit's: an upward move is
* good for some metrics (mood, HRV, steps), bad for others (resting HR,
* weight, body fat), and value-judgement-free for the rest.
*/
/**
* Maps a metric's "up means" direction to colour sentiment.
*
* - `up-good` — higher value is better (mood, HRV, steps). ↑ green, ↓ orange.
* - `up-bad` — higher value is worse (resting HR, weight, body fat).
* ↑ orange, ↓ green.
* - `neutral` — direction carries no value judgement (the change renders
* muted).
*/
export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral";
/**
* The resolved sentiment of a signed change under a metric's direction.
*
* - `positive` — the change moves the metric toward its goal. Renders green.
* - `negative` — the change moves away from the goal. Renders orange.
* - `neutral` — the metric's direction is neutral, or the change is below
* the noise floor (|change| < 0.05). Renders muted.
*/
export type TrendSentimentDirection = "positive" | "negative" | "neutral";
export 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";
}
export function sentimentColorClass(
direction: TrendSentimentDirection,
): string {
if (direction === "positive") return "text-dracula-green";
if (direction === "negative") return "text-dracula-orange";
return "text-muted-foreground";
}
/**
* v1.9.0 — map a `MetricDirection` (the metric-status registry's
* `higher-better` / `lower-better` / `target-band`) onto a
* `TrendDirectionSentiment` for the range-delta caption. `target-band`
* metrics (body temperature, respiratory rate, …) have no single good
* direction, so a period-over-period delta carries no sentiment colour —
* `neutral`, matching Oura's deviation framing for baseline metrics.
*/
export function sentimentFromMetricDirection(
direction: "higher-better" | "lower-better" | "target-band",
): TrendDirectionSentiment {
if (direction === "higher-better") return "up-good";
if (direction === "lower-better") return "up-bad";
return "neutral";
}
+1
View File
@@ -60,6 +60,7 @@ const openApiBase: Pick<
{ name: "Measurements" },
{ name: "Medications" },
{ name: "Mood" },
{ name: "Analytics" },
{ name: "Insights" },
{ name: "Dashboard" },
{ name: "Notifications" },
+96
View File
@@ -47,6 +47,7 @@ import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-pr
import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
/**
* Common envelopes — every HealthLog API response wraps payload in
@@ -1156,6 +1157,76 @@ const metricStatusResponse = z
"Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.",
});
const analyticsRangeQuery = z
.object({
type: measurementTypeEnum.describe(
"The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s.",
),
range: z
.enum(ANALYTICS_RANGES)
.describe(
"Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span immediately before it.",
),
})
.meta({ id: "AnalyticsRangeQuery" });
const analyticsWindowAggregate = z
.object({
count: z.number().int().describe("Reading count composed across buckets."),
min: z.number().nullable().describe("Window minimum; null when empty."),
max: z.number().nullable().describe("Window maximum; null when empty."),
mean: z
.number()
.nullable()
.describe("Count-weighted mean across buckets; null when empty."),
sum: z
.number()
.nullable()
.describe(
"Cumulative total for cumulative metrics (steps, energy, distance); null when no bucket carries a sum.",
),
})
.meta({ id: "AnalyticsWindowAggregate" });
const analyticsRangeResponse = z
.object({
range: z
.enum(ANALYTICS_RANGES)
.describe("The range that was read (echoes the request)."),
windowDays: z
.number()
.int()
.describe("Trailing-window length in days for the chosen range."),
granularity: z
.string()
.describe(
"Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage miss).",
),
current: analyticsWindowAggregate.describe(
"Aggregate over the current window `[now-N, now)`.",
),
previous: analyticsWindowAggregate.describe(
"Aggregate over the previous comparable window `[now-2N, now-N)`.",
),
delta: z
.number()
.nullable()
.describe(
"`current.mean - previous.mean`; null when either window has no data (never a misleading 0).",
),
deltaPct: z
.number()
.nullable()
.describe(
"`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.",
),
})
.meta({
id: "AnalyticsRangeResponse",
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).",
});
const insightsPregenerateRequest = z
.object({})
.meta({
@@ -2461,6 +2532,31 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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.",
requestParams: {
query: analyticsRangeQuery,
},
responses: {
"200": {
description: "Current + previous window aggregates and the delta.",
content: {
"application/json": {
schema: dataEnvelope(
analyticsRangeResponse,
"AnalyticsRangeResponseEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/insights/metric-status": {
get: {
tags: ["Insights"],
+10
View File
@@ -52,6 +52,16 @@ export const queryKeys = {
*/
analytics: (slice?: "summaries") =>
(slice ? (["analytics", slice] as const) : (["analytics"] as const)),
/**
* v1.9.0 — single-metric period-over-period range read
* (`GET /api/analytics/range`). A dedicated cache slot per `(type, range)`
* so switching the time-range pills is a cheap cache hit after the first
* fetch and never collides with the shared `["analytics", "summaries"]`
* slot the dashboard tile-strip subscribes to. `["analytics"]` is a prefix
* so a blanket `queryKeys.analytics()` invalidation still reaches it.
*/
analyticsRange: (type: string, range: string) =>
["analytics", "range", type, range] as const,
moodAnalytics: () => ["mood-analytics"] as const,
/**
* v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution,