mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user