mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
`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.
263 lines
9.6 KiB
TypeScript
263 lines
9.6 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|