mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user