diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index cc98de22e..c3ee82486 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -192,6 +192,7 @@ function BaselineTile({ value={v.center} unit={unit} icon={Icon} + series={v.series} framing={framing} directionSentiment={vitalSentiment(type)} provenance={ @@ -320,6 +321,7 @@ function HrvBalanceTile({ read, isLoading }: TileProps) { value={v.recentAvg} unit={getUnitForType("HEART_RATE_VARIABILITY")} icon={HeartPulse} + series={v.series} directionSentiment="up-good" framing={framing} precision={0} @@ -345,6 +347,7 @@ function BmiTile({ read, isLoading }: TileProps) { value={v.bmi} unit={getUnitForType("BODY_MASS_INDEX")} icon={Scale} + series={v.series} directionSentiment="neutral" framing={framing} precision={1} diff --git a/src/lib/insights/derived/__tests__/baseline.test.ts b/src/lib/insights/derived/__tests__/baseline.test.ts index 98f1c95c3..88f3b5d87 100644 --- a/src/lib/insights/derived/__tests__/baseline.test.ts +++ b/src/lib/insights/derived/__tests__/baseline.test.ts @@ -102,10 +102,41 @@ describe("computeVitalsBaseline — ok path (rollup tier)", () => { expect(result.provenance.source).toBe("DAY"); expect(result.coverage.historyDays).toBe(8); expect(result.confidence.score).toBeGreaterThan(0); + // The trailing per-day mean series backs the inline sparkline: one + // point per contributing day, oldest → newest (55..62). + expect(result.value.series).toEqual([ + 55, 56, 57, 58, 59, 60, 61, 62, + ]); } // rollup-covered path must not touch raw SQL expect(prisma.measurement.findMany).not.toHaveBeenCalled(); }); + + it("caps the sparkline series to the last 30 points on a long window", async () => { + vi.mocked(probeRollupCoverage).mockResolvedValue( + new Map([["RESTING_HEART_RATE", true]]), + ); + // 40 days of DAY rollups — the series must trail to the last 30, newest last. + const rows = Array.from({ length: 40 }, (_, i) => { + const d = new Date(Date.UTC(2026, 3, 1 + i)).toISOString().slice(0, 10); + return dayRow(d, 50 + i); + }); + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "DAY", + rows, + }); + + const result = await computeVitalsBaseline("u1", PROFILE, { + type: "RESTING_HEART_RATE", + now: NOW, + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.series).toHaveLength(30); + // The newest day's mean is the final series point (50 + 39 = 89). + expect(result.value.series.at(-1)).toBe(89); + } + }); }); describe("computeVitalsBaseline — coverage-miss live fallback", () => { diff --git a/src/lib/insights/derived/__tests__/bmi.test.ts b/src/lib/insights/derived/__tests__/bmi.test.ts index 1b5407786..a414ca667 100644 --- a/src/lib/insights/derived/__tests__/bmi.test.ts +++ b/src/lib/insights/derived/__tests__/bmi.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -const findFirst = vi.fn(); +const findMany = vi.fn(); vi.mock("@/lib/db", () => ({ - prisma: { measurement: { findFirst: (...a: unknown[]) => findFirst(...a) } }, + prisma: { measurement: { findMany: (...a: unknown[]) => findMany(...a) } }, })); import { computeBmi, classifyBmi } from "../bmi"; @@ -31,18 +31,18 @@ describe("computeBmi", () => { const r = await computeBmi("u1", { ageYears: 40, sex: "MALE", heightCm: null }, { now: NOW }); expect(r.status).toBe("insufficient"); if (r.status === "insufficient") expect(r.reason).toBe("no_height_on_profile"); - expect(findFirst).not.toHaveBeenCalled(); + expect(findMany).not.toHaveBeenCalled(); }); it("insufficient when height present but no recent weight", async () => { - findFirst.mockResolvedValueOnce(null); + findMany.mockResolvedValueOnce([]); const r = await computeBmi("u1", { ageYears: 40, sex: "MALE", heightCm: 180 }, { now: NOW }); expect(r.status).toBe("insufficient"); if (r.status === "insufficient") expect(r.reason).toBe("no_weight_in_window"); }); it("ok exact BMI from weight + height", async () => { - findFirst.mockResolvedValueOnce({ value: 80 }); + findMany.mockResolvedValueOnce([{ value: 80 }]); const r = await computeBmi("u1", { ageYears: 40, sex: "MALE", heightCm: 180 }, { now: NOW }); expect(r.status).toBe("ok"); if (r.status === "ok") { @@ -55,4 +55,23 @@ describe("computeBmi", () => { expect(r.confidence.score).toBeGreaterThan(0); } }); + + it("derives a trailing BMI series from the recent weights (oldest → newest)", async () => { + // Weights are read newest-first; the series trails oldest → newest. + findMany.mockResolvedValueOnce([ + { value: 80 }, + { value: 81 }, + { value: 82 }, + ]); + const r = await computeBmi( + "u1", + { ageYears: 40, sex: "MALE", heightCm: 180 }, + { now: NOW }, + ); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + // 82,81,80 kg / 1.8² → 25.3, 25.0, 24.7, reversed to oldest → newest. + expect(r.value.series).toEqual([25.3, 25, 24.7]); + } + }); }); diff --git a/src/lib/insights/derived/__tests__/hrv-balance.test.ts b/src/lib/insights/derived/__tests__/hrv-balance.test.ts index ddfde8842..1af07fbe7 100644 --- a/src/lib/insights/derived/__tests__/hrv-balance.test.ts +++ b/src/lib/insights/derived/__tests__/hrv-balance.test.ts @@ -76,6 +76,8 @@ describe("computeHrvBalance", () => { expect(r.value.baselineLow).toBe(40); expect(r.value.baselineHigh).toBe(80); expect(r.value.sampleDays).toBe(21); + // Sparkline series reuses the day-mean read (no extra query). + expect(r.value.series).toEqual([58, 62, 60]); } }); diff --git a/src/lib/insights/derived/__tests__/six-minute-walk.test.ts b/src/lib/insights/derived/__tests__/six-minute-walk.test.ts index a77305381..df3101955 100644 --- a/src/lib/insights/derived/__tests__/six-minute-walk.test.ts +++ b/src/lib/insights/derived/__tests__/six-minute-walk.test.ts @@ -70,6 +70,8 @@ describe("computeSixMinuteWalkBand", () => { expect(r.value.percentOfPredicted).toBe(100); expect(r.value.band).toBe("green"); expect(r.value.trendDelta).toBe(12); + // Sparkline series: readings oldest → newest (rows are read desc). + expect(r.value.series).toEqual([690, 700, 712]); } }); diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts index 438b67f9c..ffc105b4e 100644 --- a/src/lib/insights/derived/__tests__/wellness-scores.test.ts +++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts @@ -62,6 +62,8 @@ describe("computeWellnessScore", () => { // 72 - mean(60, 64) = 72 - 62 = 10 expect(v.trendDelta).toBe(10); expect(v.daysInWindow).toBe(3); + // Sparkline series: window rows oldest → newest (rows are read desc). + expect(v.series).toEqual([64, 60, 72]); } }); diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index aa46ba120..f2835e1a9 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -42,7 +42,11 @@ import { deriveCoverage, nowProvenanceTimestamp, } from "./coverage"; -import type { Derived, DerivedProvenanceSource } from "./types"; +import { + SPARKLINE_MAX_POINTS, + type Derived, + type DerivedProvenanceSource, +} from "./types"; /** k for the median ± k·MAD band — ≈3σ-equivalent for normal data. */ const DEFAULT_MAD_K = 3; @@ -67,6 +71,12 @@ export interface VitalsBaselineValue { sampleDays: number; /** k used for the band (echoed for transparency). */ k: number; + /** + * Trailing per-day mean series (oldest → newest), capped to the last + * `SPARKLINE_MAX_POINTS`. Drives the tile sparkline; reuses the rows the + * band is already computed from (no extra read). + */ + series: number[]; } /** Caller-supplied profile (read once per request, never re-fetched here). */ @@ -159,7 +169,7 @@ export function medianAbsoluteDeviation(values: number[]): number { export function buildBaselineBand( dayMeans: number[], k: number = DEFAULT_MAD_K, -): Omit | null { +): Omit | null { if (dayMeans.length === 0) return null; const center = median(dayMeans); const mad = medianAbsoluteDeviation(dayMeans); @@ -332,8 +342,14 @@ export async function computeVitalsBaseline( fullHistoryDays: windowDays, }); + // Trailing per-day mean series for the inline sparkline — the same DAY + // means the band is built from, capped to the recent window. + const series = points + .slice(-SPARKLINE_MAX_POINTS) + .map((p) => p.mean); + return buildOk({ - value: { type, ...band }, + value: { type, ...band, series }, coverage: cov, confidence, provenance: { inputs: [String(type)], source, windowDays, computedAt }, diff --git a/src/lib/insights/derived/bmi.ts b/src/lib/insights/derived/bmi.ts index 6c19e2cc0..0766f01e8 100644 --- a/src/lib/insights/derived/bmi.ts +++ b/src/lib/insights/derived/bmi.ts @@ -32,7 +32,7 @@ import { nowProvenanceTimestamp, } from "./coverage"; import type { BaselineProfile } from "./baseline"; -import type { Derived } from "./types"; +import { SPARKLINE_MAX_POINTS, type Derived } from "./types"; const WEIGHT_TYPE: MeasurementType = "WEIGHT"; /** A weight reading older than this no longer reflects current BMI. */ @@ -55,6 +55,12 @@ export interface BmiValue { weightKg: number; /** The height used (cm). */ heightCm: number; + /** + * Trailing BMI series (oldest → newest), capped to the last + * `SPARKLINE_MAX_POINTS`. Derived from the recent weight readings at the + * fixed profile height — a bounded read, not a per-day rollup. + */ + series: number[]; } /** WHO category + band for a BMI value. Pure. */ @@ -104,7 +110,9 @@ export async function computeBmi( } const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); - const latest = await prisma.measurement.findFirst({ + // Bounded read of the recent weights — the latest backs the BMI value, the + // trailing set backs the inline sparkline (a small cap, not a per-day walk). + const weights = await prisma.measurement.findMany({ where: { userId, type: WEIGHT_TYPE, @@ -112,8 +120,10 @@ export async function computeBmi( measuredAt: { gte: since }, }, orderBy: { measuredAt: "desc" }, + take: SPARKLINE_MAX_POINTS, select: { value: true }, }); + const latest = weights[0] ?? null; if (!latest) { // Height present but no recent weight → missing the weight input only. @@ -141,6 +151,12 @@ export async function computeBmi( const bmi = weightKg / (heightM * heightM); const { category, band } = classifyBmi(bmi); + // BMI series from the recent weights at the fixed height; rows are + // newest-first, the sparkline wants oldest → newest. + const series = weights + .map((w) => Math.round((w.value / (heightM * heightM)) * 10) / 10) + .reverse(); + const { coverage, confidence } = deriveCoverage({ requiredInputs: 2, presentInputs: 2, @@ -156,6 +172,7 @@ export async function computeBmi( band, weightKg, heightCm, + series, }, coverage, confidence, diff --git a/src/lib/insights/derived/hrv-balance.ts b/src/lib/insights/derived/hrv-balance.ts index abff1c479..5ffee9ce0 100644 --- a/src/lib/insights/derived/hrv-balance.ts +++ b/src/lib/insights/derived/hrv-balance.ts @@ -44,7 +44,7 @@ import { readDayMeanSeries, type BaselineProfile, } from "./baseline"; -import { isDerivedOk } from "./types"; +import { isDerivedOk, SPARKLINE_MAX_POINTS } from "./types"; import type { Derived } from "./types"; const HRV_TYPE: MeasurementType = "HEART_RATE_VARIABILITY"; @@ -69,6 +69,12 @@ export interface HrvBalanceValue { band: HrvBalanceBand; /** Distinct days that backed the baseline. */ sampleDays: number; + /** + * Trailing per-day SDNN mean series (oldest → newest), capped to the last + * `SPARKLINE_MAX_POINTS`. Reuses the day-mean read already done for the + * recent average — no extra query. + */ + series: number[]; } /** @@ -165,6 +171,9 @@ export async function computeHrvBalance( baselineHigh: baseline.value.high, band, sampleDays: baseline.value.sampleDays, + series: recent.points + .slice(-SPARKLINE_MAX_POINTS) + .map((p) => p.mean), }, coverage, confidence, diff --git a/src/lib/insights/derived/six-minute-walk.ts b/src/lib/insights/derived/six-minute-walk.ts index b8f023377..1ef7bdd75 100644 --- a/src/lib/insights/derived/six-minute-walk.ts +++ b/src/lib/insights/derived/six-minute-walk.ts @@ -38,7 +38,11 @@ import { } from "./coverage"; import { predictSixMinuteWalkDistance } from "./norms"; import type { BaselineProfile } from "./baseline"; -import type { Derived, DerivedProvenanceSource } from "./types"; +import { + SPARKLINE_MAX_POINTS, + type Derived, + type DerivedProvenanceSource, +} from "./types"; /** Band placement, same green/yellow/red vocabulary the tokens speak. */ export type SixMinuteWalkBand = "green" | "yellow" | "red"; @@ -73,6 +77,12 @@ export interface SixMinuteWalkValue { trendDelta: number | null; /** Distinct readings in the window. */ readingCount: number; + /** + * Trailing distance series (oldest → newest), capped to the last + * `SPARKLINE_MAX_POINTS`. Reuses the window rows already read — no extra + * query. + */ + series: number[]; } /** @@ -186,6 +196,11 @@ export async function computeSixMinuteWalkBand( band, trendDelta, readingCount: rows.length, + // rows are newest-first; the sparkline wants oldest → newest, capped. + series: rows + .slice(0, SPARKLINE_MAX_POINTS) + .map((r) => r.value) + .reverse(), }, coverage, confidence, diff --git a/src/lib/insights/derived/types.ts b/src/lib/insights/derived/types.ts index 905fffe83..5d4398baf 100644 --- a/src/lib/insights/derived/types.ts +++ b/src/lib/insights/derived/types.ts @@ -90,6 +90,14 @@ export interface DerivedInsufficient { reason: string; } +/** + * Cap for a trailing `series` carried on a `Derived` value — the inline + * sparkline a tile renders. The window readers already bound their reads; + * this caps the points handed to the chart so a dense window never ships a + * thousand-point array to the client. The tile only needs the recent shape. + */ +export const SPARKLINE_MAX_POINTS = 30; + export type Derived = DerivedOk | DerivedInsufficient; /** Narrowing type guard — `true` when the value computed successfully. */ diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts index 7aa7e460f..3dca29a72 100644 --- a/src/lib/insights/derived/wellness-scores.ts +++ b/src/lib/insights/derived/wellness-scores.ts @@ -16,7 +16,7 @@ import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; import { buildInsufficient, buildOk, nowProvenanceTimestamp } from "./coverage"; import type { BaselineProfile } from "./baseline"; -import type { Derived } from "./types"; +import { SPARKLINE_MAX_POINTS, type Derived } from "./types"; /** A 0–100 wellness score band. Higher is better for recovery; for stress a * higher score is worse, so the band direction flips (see `WELLNESS_DIR`). */ @@ -32,6 +32,12 @@ export interface WellnessScoreValue { daysInWindow: number; /** ISO timestamp of the latest score's `measuredAt`. */ asOf: string; + /** + * Trailing score series (oldest → newest), capped to the last + * `SPARKLINE_MAX_POINTS`. Reuses the window rows already read — no extra + * query. + */ + series: number[]; } /** The three persisted score types this engine serves. */ @@ -132,6 +138,11 @@ export async function computeWellnessScore( trendDelta, daysInWindow: rows.length, asOf: latest.measuredAt.toISOString(), + // rows are newest-first; the sparkline wants oldest → newest, capped. + series: rows + .slice(0, SPARKLINE_MAX_POINTS) + .map((r) => r.value) + .reverse(), }, coverage: { requiredInputs: 1, diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index a20996484..d0590e5f0 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1388,7 +1388,7 @@ const derivedMetricResponse = z .record(z.string(), z.unknown()) .nullable() .describe( - "Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k } for VITALS_BASELINE); null when 'insufficient'.", + "Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); null when 'insufficient'.", ), coverage: derivedCoverage, confidence: derivedConfidence