diff --git a/src/lib/insights/derived/__tests__/hrv-balance.test.ts b/src/lib/insights/derived/__tests__/hrv-balance.test.ts index b293eb19a..ddfde8842 100644 --- a/src/lib/insights/derived/__tests__/hrv-balance.test.ts +++ b/src/lib/insights/derived/__tests__/hrv-balance.test.ts @@ -1,8 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const computeVitalsBaseline = vi.fn(); +const readDayMeanSeries = vi.fn(); vi.mock("../baseline", () => ({ computeVitalsBaseline: (...a: unknown[]) => computeVitalsBaseline(...a), + readDayMeanSeries: (...a: unknown[]) => readDayMeanSeries(...a), +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), })); import { computeHrvBalance, placeHrvBalance } from "../hrv-balance"; @@ -10,7 +15,20 @@ import { computeHrvBalance, placeHrvBalance } from "../hrv-balance"; const NOW = new Date("2026-06-02T07:00:00Z"); const PROFILE = { ageYears: 40, sex: "MALE" as const }; -beforeEach(() => vi.clearAllMocks()); +/** Build a `readDayMeanSeries` return from a list of recent DAY means. */ +function recentSeries(means: number[]) { + return { + points: means.map((mean, i) => ({ day: `2026-05-${20 + i}`, mean })), + source: "DAY" as const, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + // Default: the recent trend sits on the band center so unrelated tests stay + // "balanced" unless they override the series. + readDayMeanSeries.mockResolvedValue(recentSeries([60, 60, 60])); +}); describe("placeHrvBalance", () => { it("low below the band floor", () => { @@ -40,7 +58,7 @@ describe("computeHrvBalance", () => { } }); - it("builds a balanced band off the baseline center", async () => { + it("builds a balanced band when the recent trend sits inside the band", async () => { computeVitalsBaseline.mockResolvedValueOnce({ status: "ok", value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 }, @@ -48,14 +66,74 @@ describe("computeHrvBalance", () => { confidence: { score: 70, band: "medium" }, provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" }, }); + // Recent trend right around the center → balanced. + readDayMeanSeries.mockResolvedValueOnce(recentSeries([58, 62, 60])); const r = await computeHrvBalance("u1", PROFILE, { now: NOW }); expect(r.status).toBe("ok"); if (r.status === "ok") { expect(r.value.band).toBe("balanced"); - expect(r.value.recentAvg).toBe(60); + expect(r.value.recentAvg).toBeCloseTo(60, 5); expect(r.value.baselineLow).toBe(40); expect(r.value.baselineHigh).toBe(80); expect(r.value.sampleDays).toBe(21); } }); + + it("flags 'low' when the RECENT trend is suppressed below the band, not the band center", async () => { + // The band center sits inside [low,high] by construction; the previous + // implementation read it directly so the recentAvg could never go low. + // Drive a suppressed recent 7-day trend and assert it places "low". + computeVitalsBaseline.mockResolvedValueOnce({ + status: "ok", + value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 21, missing: [] }, + confidence: { score: 70, band: "medium" }, + provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" }, + }); + readDayMeanSeries.mockResolvedValueOnce(recentSeries([30, 28, 32])); + const r = await computeHrvBalance("u1", PROFILE, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.band).toBe("low"); + expect(r.value.recentAvg).toBeCloseTo(30, 5); + } + }); + + it("flags 'unbalanced' when the recent trend runs above the band high edge", async () => { + computeVitalsBaseline.mockResolvedValueOnce({ + status: "ok", + value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 21, missing: [] }, + confidence: { score: 70, band: "medium" }, + provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" }, + }); + readDayMeanSeries.mockResolvedValueOnce(recentSeries([95, 100, 98])); + const r = await computeHrvBalance("u1", PROFILE, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.band).toBe("unbalanced"); + expect(r.value.recentAvg).toBeGreaterThan(80); + } + }); + + it("uses only the last ≤ 7 DAY means for the recent trend", async () => { + computeVitalsBaseline.mockResolvedValueOnce({ + status: "ok", + value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 21, missing: [] }, + confidence: { score: 70, band: "medium" }, + provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" }, + }); + // 10 days: the first three (high) must NOT pull the recent mean up — only + // the last 7 (all ~30) count, so the trend is suppressed → "low". + readDayMeanSeries.mockResolvedValueOnce( + recentSeries([90, 90, 90, 30, 30, 30, 30, 30, 30, 30]), + ); + const r = await computeHrvBalance("u1", PROFILE, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.recentAvg).toBeCloseTo(30, 5); + expect(r.value.band).toBe("low"); + } + }); }); diff --git a/src/lib/insights/derived/hrv-balance.ts b/src/lib/insights/derived/hrv-balance.ts index 8cb67fc9d..abff1c479 100644 --- a/src/lib/insights/derived/hrv-balance.ts +++ b/src/lib/insights/derived/hrv-balance.ts @@ -29,19 +29,29 @@ * placement helper is exported pure for the unit tests. */ import type { MeasurementType } from "@/generated/prisma/client"; -import type { RollupCoverageMap } from "@/lib/rollups/measurement-coverage"; +import { + probeRollupCoverage, + type RollupCoverageMap, +} from "@/lib/rollups/measurement-coverage"; import { buildInsufficient, buildOk, deriveCoverage, nowProvenanceTimestamp, } from "./coverage"; -import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; +import { + computeVitalsBaseline, + readDayMeanSeries, + type BaselineProfile, +} from "./baseline"; import { isDerivedOk } from "./types"; import type { Derived } from "./types"; const HRV_TYPE: MeasurementType = "HEART_RATE_VARIABILITY"; +/** The recent trend rides the last ≤ this many DAY means. */ +const RECENT_TREND_DAYS = 7; + /** Personal-trend balance placement. */ export type HrvBalanceBand = "balanced" | "unbalanced" | "low"; @@ -89,12 +99,16 @@ export async function computeHrvBalance( ): Promise> { const now = opts?.now ?? new Date(); const computedAt = nowProvenanceTimestamp(now); + // One coverage probe shared between the baseline read below and the + // recent-trend read (the pool-contention mitigation; the route passes a + // pre-probed map). + const coverageMap = opts?.coverage ?? (await probeRollupCoverage(userId)); const baseline = await computeVitalsBaseline(userId, profile, { type: HRV_TYPE, windowDays: opts?.windowDays, now, - coverage: opts?.coverage, + coverage: coverageMap, }); // Below the band floor (or no data) — surface the baseline engine's gated @@ -107,11 +121,28 @@ export async function computeHrvBalance( }); } - // The baseline already computed the robust center+band over DAY means; the - // recent average rides the same center for a stable, sparse-cadence-safe - // placement (per-day SDNN is noisy; the median center is the honest - // "recent typical"). - const recentAvg = baseline.value.center; + // The recent average is the mean of the last ≤ 7 DAY means — the RECENT + // TREND, placed against the longer-window personal band. Reading the band + // center here (the previous behaviour) made `recentAvg` identical to the + // band midpoint by construction, so it could never fall outside the band: + // the "low" / "unbalanced" arms were dead and the card always read + // "balanced". The recent-trend mean can drift below the band's low edge + // (suppressed HRV) or above its high edge, which is the whole point of a + // trend-vs-baseline placement. Falls back to the band center only when the + // recent series is somehow empty (the baseline being `ok` already implies + // ≥ 7 backing days, so this is defensive). + const recent = await readDayMeanSeries( + userId, + HRV_TYPE, + baseline.provenance.windowDays, + now, + coverageMap, + ); + const recentPoints = recent.points.slice(-RECENT_TREND_DAYS); + const recentAvg = + recentPoints.length > 0 + ? recentPoints.reduce((s, p) => s + p.mean, 0) / recentPoints.length + : baseline.value.center; const band = placeHrvBalance( recentAvg, baseline.value.low,