feat(insights): populate the trailing sparkline series on derived metrics

The Derived<T> tile (SparklineDeltaTile) renders a trailing series, but the
engines never filled it, so the inline sparkline was always empty. Each engine
now carries a capped trailing series sourced from the rows it already reads:

- baseline-band metrics (incl. wrist-temperature / stair ascent+descent) ship
  the per-day mean series the band is built from;
- HRV balance reuses the day-mean read it already does for the recent average;
- the wellness scores (recovery / stress / strain) ship their window scores;
- the six-minute-walk band ships its reading series;
- BMI reads a bounded recent-weight set and derives a BMI series at the fixed
  profile height.

All series cap at the last 30 points. The vitals dashboard wires the series
into the baseline, HRV and BMI tiles; the tile already renders >= 2 points and
collapses the sparkline row otherwise.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 19:48:48 +02:00
1 parent e2c7d08bad
commit 99e089ff4c
13 files changed
+149 -14

No files matched your search

@@ -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}
@@ -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", () => {
+24 -5
View File
@@ -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]);
}
});
});
@@ -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]);
}
});
@@ -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]);
}
});
@@ -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]);
}
});
+19 -3
View File
@@ -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<VitalsBaselineValue, "type"> | null {
): Omit<VitalsBaselineValue, "type" | "series"> | 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<VitalsBaselineValue>({
value: { type, ...band },
value: { type, ...band, series },
coverage: cov,
confidence,
provenance: { inputs: [String(type)], source, windowDays, computedAt },
+19 -2
View File
@@ -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,
+10 -1
View File
@@ -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,
+16 -1
View File
@@ -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,
+8
View File
@@ -90,6 +90,14 @@ export interface DerivedInsufficient {
reason: string;
}
/**
* Cap for a trailing `series` carried on a `Derived<T>` 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<T> = DerivedOk<T> | DerivedInsufficient;
/** Narrowing type guard — `true` when the value computed successfully. */
+12 -1
View File
@@ -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,
+1 -1
View File
@@ -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