feat(insights): compute and store a nightly stress score from intra-day HRV

Apple Watch has no electrodermal sensor, so there is no direct stress
measurement to read — what HealthKit does give is discrete SDNN samples
through the day. Derive an honest HRV-through-day PROXY: a suppressed,
below-baseline intra-day SDNN is a recognised marker of higher sympathetic
load. The provenance and label say "HRV-derived proxy" and never imply a
galvanic/EDA reading.

The engine reuses the shared vitals-baseline math (the same SDNN baseline
the wellness index reads), maps the day's below-baseline suppression to a
0-100 score, and stores it as a COMPUTED STRESS_SCORE row — server-owned, a
client can never POST it. It gates and writes nothing below the minimum
intra-day sample floor or without a usable baseline.

Standard: Kim et al. 2018 (HRV and stress meta-analysis); Thayer et al. 2012
(neurovisceral integration). Idempotent per user per day via the nightly job.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 22:49:43 +02:00
1 parent ce2b745cc3
commit 57d9cc1674
3 files changed
+550

No files matched your search

@@ -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<typeof persistStressScore>[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);
});
});
+283
View File
@@ -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<number[]> {
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<StressComputeResult> {
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<BaselineProfile> {
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<PersistStressResult> {
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 };
}
+109
View File
@@ -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<string[]> {
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<StressScoreRunResult> {
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 };
}