Files
HealthLog/src/lib/insights/stress-score.ts
T
Marc-André Bombeck 7c5ff89def feat(lint): refuse UTC day keys outside the time zone module
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.

The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.

Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:

- cycle calendar, insights, Coach snapshot and prediction refresh start
  their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
  early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
  zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
  projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening

49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
2026-10-01 09:59:57 +02:00

263 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 {
computeVitalsBaseline,
loadBaselineProfile,
} from "@/lib/insights/derived/baseline";
import { scoreDeviation } from "@/lib/insights/derived/readiness";
import type { BaselineProfile } from "@/lib/insights/derived/baseline";
import {
scoreDayKey,
scoreExternalId,
scoreMeasuredAt,
upsertScoreRow,
} from "@/lib/insights/score-row";
/** 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 Stress run scores — the PREVIOUS day relative to
* `now`. The cron fires in the small hours; scoring the just-ended day is what
* gives the intra-day SDNN set a full day of samples instead of a near-empty
* few hours. Delegates to the shared `scoreDayKey` so all three engines agree.
*/
export function stressDayKey(now: Date): string {
return scoreDayKey(now);
}
/** The full `externalId` for a given run's Stress score row. */
export function stressExternalId(now: Date): string {
return scoreExternalId(STRESS_SCORE_EXTERNAL_ID_PREFIX, now);
}
/** The canonical timestamp a stored Stress row carries (noon UTC, scored day). */
export function stressMeasuredAt(now: Date): Date {
return scoreMeasuredAt(now);
}
/**
* 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);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: UTC day window of the UTC-day score engines (scoreDayKey)
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. Thin
* alias over the shared `loadBaselineProfile` so callers / tests keep the
* name.
*/
export async function loadStressProfile(
prisma: PrismaClient,
userId: string,
): Promise<BaselineProfile> {
return loadBaselineProfile(prisma, userId);
}
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 };
}
await upsertScoreRow(prisma, {
userId,
type: "STRESS_SCORE",
externalIdPrefix: STRESS_SCORE_EXTERNAL_ID_PREFIX,
score,
now,
});
return { outcome: "stored", score, reason };
}