mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
f1145c0e91
commit
03925ddece
21 files changed
+1151
-65
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -60,6 +60,7 @@ const openApiBase: Pick<
|
||||
{ name: "Measurements" },
|
||||
{ name: "Medications" },
|
||||
{ name: "Mood" },
|
||||
{ name: "Analytics" },
|
||||
{ name: "Insights" },
|
||||
{ name: "Dashboard" },
|
||||
{ name: "Notifications" },
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user