diff --git a/src/lib/insights/__tests__/stress-score.test.ts b/src/lib/insights/__tests__/stress-score.test.ts new file mode 100644 index 000000000..8d8ce2e8b --- /dev/null +++ b/src/lib/insights/__tests__/stress-score.test.ts @@ -0,0 +1,158 @@ +/** + * v1.10.0 — computed scores (WX-E). Stress-score engine + persistence. + * + * The Stress score is an HONEST HRV-derived proxy (Apple Watch has no EDA + * sensor). These tests pin: + * - the proxy math (HRV suppression below baseline → higher stress), + * - the per-day idempotency-key + canonical-timestamp helpers, + * - the insufficient-data gates (too few intra-day samples; no baseline), + * - the "store on ok, write nothing on gate" persistence + idempotency. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const computeVitalsBaselineMock = vi.fn(); +vi.mock("@/lib/insights/derived/baseline", () => ({ + computeVitalsBaseline: (...args: unknown[]) => + computeVitalsBaselineMock(...args), +})); + +import { + stressDayKey, + stressExternalId, + stressMeasuredAt, + stressProxyFromSdnn, + persistStressScore, + STRESS_SCORE_EXTERNAL_ID_PREFIX, + STRESS_MIN_INTRADAY_SAMPLES, +} from "../stress-score"; + +const NOW = new Date("2026-06-02T08:30:00Z"); + +function okBaseline(center: number, spread: number) { + return { + status: "ok" as const, + value: { center, low: center - spread, high: center + spread, spread }, + }; +} + +function makePrisma(sdnnValues: number[]) { + const sdnnRows = sdnnValues.map((value, i) => ({ value, id: `s${i}` })); + const findMany = vi.fn().mockResolvedValue(sdnnRows); + const upsert = vi.fn().mockResolvedValue({}); + const findUnique = vi + .fn() + .mockResolvedValue({ dateOfBirth: null, gender: "MALE", heightCm: 180 }); + return { + prisma: { + measurement: { findMany, upsert }, + user: { findUnique }, + } as unknown as Parameters[0], + findMany, + upsert, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("stress-score helpers", () => { + it("keys the day + externalId by the UTC calendar day", () => { + expect(stressDayKey(NOW)).toBe("2026-06-02"); + expect(stressExternalId(NOW)).toBe( + `${STRESS_SCORE_EXTERNAL_ID_PREFIX}2026-06-02`, + ); + }); + + it("anchors the canonical timestamp at noon UTC on the scored day", () => { + expect(stressMeasuredAt(NOW).toISOString()).toBe( + "2026-06-02T12:00:00.000Z", + ); + }); +}); + +describe("stressProxyFromSdnn — HRV suppression → higher stress", () => { + it("scores 0 when SDNN is at or above baseline (no inferred stress)", () => { + expect(stressProxyFromSdnn(60, 60, 10)).toBe(0); + expect(stressProxyFromSdnn(80, 60, 10)).toBe(0); + }); + + it("rises as SDNN drops below baseline", () => { + // One spread below baseline → ~50 stress (recovery scorer gives ~50). + expect(stressProxyFromSdnn(50, 60, 10)).toBeGreaterThanOrEqual(45); + expect(stressProxyFromSdnn(50, 60, 10)).toBeLessThanOrEqual(55); + // Two spreads below → ~100 stress. + expect(stressProxyFromSdnn(40, 60, 10)).toBeGreaterThanOrEqual(95); + }); + + it("is monotone — lower SDNN never yields lower stress", () => { + const a = stressProxyFromSdnn(55, 60, 10); + const b = stressProxyFromSdnn(45, 60, 10); + expect(b).toBeGreaterThanOrEqual(a); + }); +}); + +describe("persistStressScore", () => { + it("stores a COMPUTED STRESS_SCORE row when inputs are sufficient", async () => { + const { prisma, upsert } = makePrisma([40, 42, 38, 41]); // mean 40.25 + computeVitalsBaselineMock.mockResolvedValue(okBaseline(60, 10)); + + const result = await persistStressScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("stored"); + expect(result.score).toBeGreaterThan(0); + expect(upsert).toHaveBeenCalledTimes(1); + const arg = upsert.mock.calls[0][0]; + expect(arg.where.userId_type_source_externalId).toEqual({ + userId: "user-1", + type: "STRESS_SCORE", + source: "COMPUTED", + externalId: "stress:2026-06-02", + }); + expect(arg.create).toMatchObject({ + type: "STRESS_SCORE", + source: "COMPUTED", + unit: "score", + externalId: "stress:2026-06-02", + }); + }); + + it("writes NOTHING when there are too few intra-day samples", async () => { + const { prisma, upsert } = makePrisma( + Array(STRESS_MIN_INTRADAY_SAMPLES - 1).fill(50), + ); + computeVitalsBaselineMock.mockResolvedValue(okBaseline(60, 10)); + + const result = await persistStressScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("insufficient"); + expect(result.reason).toBe("insufficient_intraday_samples"); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("writes NOTHING when the SDNN baseline is not usable", async () => { + const { prisma, upsert } = makePrisma([40, 42, 38, 41]); + computeVitalsBaselineMock.mockResolvedValue({ status: "insufficient" }); + + const result = await persistStressScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("insufficient"); + expect(result.reason).toBe("insufficient_baseline"); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("is idempotent per user per day — re-runs upsert the same key", async () => { + const { prisma, upsert } = makePrisma([40, 42, 38, 41]); + computeVitalsBaselineMock.mockResolvedValue(okBaseline(60, 10)); + + await persistStressScore(prisma, "user-1", NOW); + await persistStressScore(prisma, "user-1", NOW); + + expect(upsert).toHaveBeenCalledTimes(2); + const firstKey = + upsert.mock.calls[0][0].where.userId_type_source_externalId; + const secondKey = + upsert.mock.calls[1][0].where.userId_type_source_externalId; + expect(secondKey).toEqual(firstKey); + }); +}); diff --git a/src/lib/insights/stress-score.ts b/src/lib/insights/stress-score.ts new file mode 100644 index 000000000..46eecc18d --- /dev/null +++ b/src/lib/insights/stress-score.ts @@ -0,0 +1,283 @@ +/** + * v1.10.0 — computed scores (WX-E). The Stress score ENGINE + persistence. + * + * HONEST FRAMING (load-bearing — read before changing the label or copy): + * Apple Watch has NO electrodermal-activity (EDA / galvanic) sensor. There + * is no continuous stress measurement in HealthKit. What HealthKit DOES + * give is discrete heart-rate-variability (SDNN) samples through the day. + * This engine derives a defensible *proxy* from the intra-day SDNN signal — + * autonomic-balance research treats a SUPPRESSED, less-variable HRV through + * the day as a marker of higher sympathetic ("stress") load. It is NOT a + * direct stress measurement and the provenance/label must always say + * "HRV-derived proxy", never imply a galvanic / EDA reading. + * + * The proxy (0–100, higher = more inferred stress): + * - Read the day's intra-day `HEART_RATE_VARIABILITY` (SDNN, ms) samples + * (the dense intra-day retention tier keeps these raw within its + * window — see `dense-intraday-retention.ts`). + * - Take the day's MEAN SDNN. + * - Compare it to the user's personal SDNN baseline (the robust + * median/MAD band from the shared vitals-baseline engine over a 7-day + * reference window). HRV is "higher-better" for recovery; a day mean + * BELOW baseline = HRV suppression = higher inferred stress. + * - Map the suppression deviation to 0–100 via the same MAD-scaled + * deviation scorer the readiness blend uses (one spread below baseline + * → ~50, two spreads → ~100), then INVERT so suppression raises stress. + * + * Honest confidence: the score is only stored when the day has at least + * `STRESS_MIN_INTRADAY_SAMPLES` SDNN samples AND the SDNN baseline is + * usable. Below that the engine returns `insufficient` and NO row is + * written — the series never carries a headline from a single spot reading + * or an unestablished baseline. + * + * Standard / framing: HRV (SDNN) suppression + reduced variability as a + * sympathetic-load marker — Kim et al. 2018, "Stress and Heart Rate + * Variability: A Meta-Analysis", Psychiatry Investigation 15(3):235–245; + * Thayer et al. 2012, Neuroscience & Biobehavioral Reviews 36(2):747–756. + * The score is descriptive — a daily wellness proxy, NOT a clinical + * assessment, and it is excluded from the doctor PDF. + * + * Row shape (identical posture to the Recovery score): + * - `type = STRESS_SCORE` + * - `source = COMPUTED` (server-owned; a client can never POST it) + * - `unit = "score"` + * - `value = 0..100` (plausibility-pinned to {min:0, max:100}) + * - `externalId = stress:YYYY-MM-DD` (the per-day idempotency key) + * + * Server-only — runs from the nightly pg-boss job in + * `src/lib/jobs/stress-score.ts`. + */ +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { computeVitalsBaseline } from "@/lib/insights/derived/baseline"; +import { scoreDeviation } from "@/lib/insights/derived/readiness"; +import type { BaselineProfile } from "@/lib/insights/derived/baseline"; + +/** The per-day idempotency-key prefix for a stored Stress score row. */ +export const STRESS_SCORE_EXTERNAL_ID_PREFIX = "stress:"; + +/** SDNN reference window (days) for the personal baseline. */ +export const STRESS_BASELINE_WINDOW_DAYS = 7; + +/** + * Minimum intra-day SDNN samples in the scored day before a headline is + * produced. A single spot SDNN reading is not an intra-day shape; below + * this floor the engine gates and writes nothing. + */ +export const STRESS_MIN_INTRADAY_SAMPLES = 3; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** The UTC calendar day a `now` falls in, as `YYYY-MM-DD`. */ +export function stressDayKey(now: Date): string { + return now.toISOString().slice(0, 10); +} + +/** The full `externalId` for a given day's Stress score row. */ +export function stressExternalId(now: Date): string { + return `${STRESS_SCORE_EXTERNAL_ID_PREFIX}${stressDayKey(now)}`; +} + +/** + * The canonical timestamp a stored Stress row carries: noon UTC on the + * scored day — same day-keying convention as the Recovery score so the + * series lines up with the buckets it summarises. + */ +export function stressMeasuredAt(now: Date): Date { + return new Date(`${stressDayKey(now)}T12:00:00.000Z`); +} + +/** + * Map a day-mean SDNN below the personal baseline to a 0–100 stress proxy. + * Reuses the readiness deviation scorer (HRV is "higher-better" for + * recovery, so a suppression scores LOW there) and INVERTS it so HRV + * suppression raises the stress proxy. On-or-above baseline → 0 (no + * inferred stress); one spread below → ~50; two spreads below → ~100. + * + * Pure — exported for unit testing without Prisma. + */ +export function stressProxyFromSdnn( + dayMeanSdnn: number, + baselineCenter: number, + baselineSpread: number, +): number { + // `scoreDeviation(... "higher-better")` returns 100 at/above baseline and + // falls toward 0 as SDNN drops; invert so suppression → high stress. + const recoveryComponent = scoreDeviation( + dayMeanSdnn, + baselineCenter, + baselineSpread, + "higher-better", + ); + return Math.max(0, Math.min(100, Math.round(100 - recoveryComponent))); +} + +/** + * Read the scored day's intra-day SDNN samples (raw `HEART_RATE_VARIABILITY` + * rows on the scored UTC day). The dense intra-day retention tier keeps + * these raw within its window, so for a recently-synced day the engine sees + * the full intra-day set. Returns the per-sample values for the day. + */ +async function readIntradaySdnn( + prisma: PrismaClient, + userId: string, + now: Date, +): Promise { + const dayKey = stressDayKey(now); + const dayStart = new Date(`${dayKey}T00:00:00.000Z`); + const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: "HEART_RATE_VARIABILITY" as MeasurementType, + deletedAt: null, + measuredAt: { gte: dayStart, lt: dayEnd }, + // Only count raw per-sample rows toward the intra-day floor — a single + // already-folded `stats:` daily-mean row is one point, not a shape. + NOT: { externalId: { startsWith: "stats:" } }, + }, + select: { value: true }, + take: 2000, + }); + return rows.map((r) => r.value); +} + +export interface StressComputeResult { + /** The 0..100 score to persist, or null when the inputs gate. */ + score: number | null; + /** Why a null score gated (exposed for the job log + tests). */ + reason: + | "ok" + | "insufficient_intraday_samples" + | "insufficient_baseline"; + /** Number of intra-day SDNN samples found for the scored day. */ + intradaySamples: number; +} + +/** + * Compute the Stress score for one user as of `now`. Pure of persistence — + * the job calls this, then writes the row. Reuses the shared vitals-baseline + * engine (the same SDNN baseline the readiness blend reads) so the two + * surfaces can never drift. + */ +export async function computeStressScore( + prisma: PrismaClient, + userId: string, + profile: BaselineProfile, + now: Date, +): Promise { + const sdnn = await readIntradaySdnn(prisma, userId, now); + if (sdnn.length < STRESS_MIN_INTRADAY_SAMPLES) { + return { + score: null, + reason: "insufficient_intraday_samples", + intradaySamples: sdnn.length, + }; + } + + const baseline = await computeVitalsBaseline(userId, profile, { + type: "HEART_RATE_VARIABILITY", + windowDays: STRESS_BASELINE_WINDOW_DAYS, + now, + }); + if (baseline.status !== "ok") { + return { + score: null, + reason: "insufficient_baseline", + intradaySamples: sdnn.length, + }; + } + + const dayMean = sdnn.reduce((s, v) => s + v, 0) / sdnn.length; + const score = stressProxyFromSdnn( + dayMean, + baseline.value.center, + baseline.value.spread, + ); + return { score, reason: "ok", intradaySamples: sdnn.length }; +} + +/** + * Build the `BaselineProfile` the SDNN baseline needs from the user row — + * the same shape the live `/api/insights/derived` route builds. + */ +export async function loadStressProfile( + prisma: PrismaClient, + userId: string, +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { dateOfBirth: true, gender: true, heightCm: true }, + }); + const sex = + user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; + return { + ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), + sex, + heightCm: user?.heightCm ?? null, + }; +} + +export interface PersistStressResult { + /** "stored" when a row was upserted; "insufficient" when gated. */ + outcome: "stored" | "insufficient"; + /** The persisted score, or null when insufficient. */ + score: number | null; + /** The gate reason (mirrors the compute result). */ + reason: StressComputeResult["reason"]; +} + +/** + * Compute + persist one user's Stress score for the `now` day. Upserts on + * the `(userId, type, source, externalId)` key so a re-run overwrites the + * day's row in place rather than duplicating it. Writes NOTHING when the + * inputs gate — no phantom proxy from a single reading or an unestablished + * baseline. The baseline engine reads `@/lib/db` directly; the nightly job + * passes that same shared client so both reads hit one connection. + */ +export async function persistStressScore( + prisma: PrismaClient, + userId: string, + now: Date, +): Promise { + const profile = await loadStressProfile(prisma, userId); + const { score, reason } = await computeStressScore( + prisma, + userId, + profile, + now, + ); + + if (score === null) { + return { outcome: "insufficient", score: null, reason }; + } + + const externalId = stressExternalId(now); + const measuredAt = stressMeasuredAt(now); + + await prisma.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId, + type: "STRESS_SCORE", + source: "COMPUTED", + externalId, + }, + }, + create: { + userId, + type: "STRESS_SCORE", + source: "COMPUTED", + value: score, + unit: "score", + measuredAt, + externalId, + }, + update: { + value: score, + measuredAt, + }, + }); + + return { outcome: "stored", score, reason }; +} diff --git a/src/lib/jobs/stress-score.ts b/src/lib/jobs/stress-score.ts new file mode 100644 index 000000000..9f7accffb --- /dev/null +++ b/src/lib/jobs/stress-score.ts @@ -0,0 +1,109 @@ +/** + * v1.10.0 — computed scores (WX-E). Nightly Stress-score computation + store. + * + * Once a night this cron computes each eligible user's Stress score from + * their intra-day `HEART_RATE_VARIABILITY` (SDNN) samples and persists it as + * a `COMPUTED STRESS_SCORE` Measurement row (see + * `src/lib/insights/stress-score.ts`). The score is an HONEST HRV-derived + * proxy — Apple Watch has no EDA sensor; it is NEVER a direct stress + * measurement. The score is never computed on a page visit. + * + * Idempotent per user per day: the per-user persist upserts on + * `(userId, type, source, externalId)` with `externalId = stress:YYYY-MM-DD`, + * so a re-fired cron tick (or a manual re-run) overwrites the day's row in + * place. A user without enough intra-day SDNN samples or a usable baseline + * gets NO row that night — the series stays honest. + * + * The queue name MUST be registered in `allQueues` in + * `src/lib/jobs/reminder-worker.ts` so pg-boss provisions it at boot; an + * unregistered queue silently never drains. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { persistStressScore } from "@/lib/insights/stress-score"; + +export const STRESS_SCORE_QUEUE = "stress-score-compute"; + +/** + * Daily at 04:50 Europe/Berlin — inside the existing maintenance window, + * after the dense intra-day retention drain (03:50) and the recovery-score + * pass (04:45) so the HRV inputs it reads are settled. + */ +export const STRESS_SCORE_CRON = "50 4 * * *"; + +/** Per-run user cap — same starvation guard the recovery-score pass uses. */ +export const STRESS_SCORE_BATCH_CAP = 500; + +/** Trailing window (days) a user must have a recent HRV row in to be a candidate. */ +export const STRESS_SCORE_RECENCY_DAYS = 2; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export interface StressScoreRunResult { + considered: number; + stored: number; + insufficient: number; + errored: number; +} + +/** + * Discover users worth scoring tonight: anyone with at least one LIVE + * `HEART_RATE_VARIABILITY` row in the recency window. A user with no recent + * HRV cannot produce a Stress proxy, so skipping them keeps the pass cheap. + * Bounded by `cap`. + */ +export async function findStressScoreCandidates( + prisma: PrismaClient, + now: Date, + cap: number, +): Promise { + const since = new Date(now.getTime() - STRESS_SCORE_RECENCY_DAYS * MS_PER_DAY); + const rows = await prisma.measurement.findMany({ + where: { + type: "HEART_RATE_VARIABILITY", + deletedAt: null, + measuredAt: { gte: since }, + }, + select: { userId: true }, + distinct: ["userId"], + take: cap, + }); + return rows.map((r) => r.userId); +} + +/** + * Run one Stress-score pass. Pure of pg-boss so the unit test drives it + * directly. Each user is scored independently; a single user's error is + * recorded and the pass continues. + */ +export async function runStressScore( + prisma: PrismaClient, + options: { now?: Date; cap?: number } = {}, +): Promise { + const now = options.now ?? new Date(); + const cap = options.cap ?? STRESS_SCORE_BATCH_CAP; + + const userIds = await findStressScoreCandidates(prisma, now, cap); + + let stored = 0; + let insufficient = 0; + let errored = 0; + for (const userId of userIds) { + try { + const result = await persistStressScore(prisma, userId, now); + if (result.outcome === "stored") stored += 1; + else insufficient += 1; + } catch { + errored += 1; + } + } + + annotate({ + action: { + name: "insights.stress.compute", + details: { considered: userIds.length, stored, insufficient, errored }, + }, + }); + + return { considered: userIds.length, stored, insufficient, errored }; +}