From 6935feda15bb9921bd321fcd4e3fdd463e6c05a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:48:14 +0200 Subject: [PATCH] feat(insights): compute and store a nightly strain score from workout load Derive a daily cardio-load proxy from the per-workout heart-rate series plus active energy. Each workout's HR series scores via Banister's gender-weighted exponential TRIMP over the fractional heart-rate reserve (Tanaka age-predicted HRmax, the user's resting HR); the day's TRIMP totals map to a 0-100 saturating scale. Days with no usable HR series but a non-trivial active-energy total fall back to an energy-only proxy, with the path recorded in the result. Stored as a COMPUTED STRAIN_SCORE row, idempotent per user per day, gated when neither a usable HR series nor active energy is present. Standard: Banister 1991; Morton, Fitz-Clarke & Banister 1990 (TRIMP); Tanaka, Monahan & Seals 2001 (HRmax). --- .../insights/__tests__/strain-score.test.ts | 258 +++++++++++++ src/lib/insights/strain-score.ts | 361 ++++++++++++++++++ src/lib/jobs/strain-score.ts | 119 ++++++ 3 files changed, 738 insertions(+) create mode 100644 src/lib/insights/__tests__/strain-score.test.ts create mode 100644 src/lib/insights/strain-score.ts create mode 100644 src/lib/jobs/strain-score.ts diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts new file mode 100644 index 000000000..1b26a91f5 --- /dev/null +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -0,0 +1,258 @@ +/** + * v1.10.0 — computed scores (WX-E). Strain-score engine + persistence. + * + * The Strain score is a Banister-TRIMP cardio-load proxy from the + * per-workout HR series + active energy. These tests pin: + * - the Banister gender-weighted exponential TRIMP math, + * - the saturating 0–100 map, + * - the TRIMP path + the active-energy fallback, + * - the per-day idempotency key + insufficient-data gate. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { + strainDayKey, + strainExternalId, + strainMeasuredAt, + tanakaHrMax, + banisterTrimp, + saturateToScore, + persistStrainScore, + STRAIN_SCORE_EXTERNAL_ID_PREFIX, +} from "../strain-score"; + +const NOW = new Date("2026-06-02T08:30:00Z"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("strain-score helpers", () => { + it("keys the day + externalId by the UTC calendar day", () => { + expect(strainDayKey(NOW)).toBe("2026-06-02"); + expect(strainExternalId(NOW)).toBe( + `${STRAIN_SCORE_EXTERNAL_ID_PREFIX}2026-06-02`, + ); + }); + + it("anchors the canonical timestamp at noon UTC", () => { + expect(strainMeasuredAt(NOW).toISOString()).toBe( + "2026-06-02T12:00:00.000Z", + ); + }); + + it("uses the Tanaka HRmax formula", () => { + expect(tanakaHrMax(40)).toBeCloseTo(208 - 0.7 * 40, 6); // 180 + }); +}); + +describe("banisterTrimp", () => { + const hrRest = 50; + const hrMax = 190; + const sex = "MALE" as const; + + it("returns 0 for a series with fewer than two usable HR samples", () => { + expect(banisterTrimp([{ t: NOW.toISOString(), hr: 150 }], hrRest, hrMax, sex)).toBe( + 0, + ); + }); + + it("accumulates a positive TRIMP for a sustained elevated series", () => { + const samples = [ + { t: "2026-06-02T08:00:00.000Z", hr: 150 }, + { t: "2026-06-02T08:30:00.000Z", hr: 150 }, + { t: "2026-06-02T09:00:00.000Z", hr: 150 }, + ]; + const trimp = banisterTrimp(samples, hrRest, hrMax, sex); + expect(trimp).toBeGreaterThan(0); + }); + + it("scores a harder hour above an easier hour", () => { + const easy = [ + { t: "2026-06-02T08:00:00.000Z", hr: 100 }, + { t: "2026-06-02T09:00:00.000Z", hr: 100 }, + ]; + const hard = [ + { t: "2026-06-02T08:00:00.000Z", hr: 170 }, + { t: "2026-06-02T09:00:00.000Z", hr: 170 }, + ]; + expect(banisterTrimp(hard, hrRest, hrMax, sex)).toBeGreaterThan( + banisterTrimp(easy, hrRest, hrMax, sex), + ); + }); + + it("ignores samples below resting HR (no negative impulse)", () => { + const below = [ + { t: "2026-06-02T08:00:00.000Z", hr: 45 }, + { t: "2026-06-02T09:00:00.000Z", hr: 45 }, + ]; + expect(banisterTrimp(below, hrRest, hrMax, sex)).toBe(0); + }); + + it("returns 0 when HRmax <= HRrest (degenerate profile)", () => { + const samples = [ + { t: "2026-06-02T08:00:00.000Z", hr: 100 }, + { t: "2026-06-02T09:00:00.000Z", hr: 100 }, + ]; + expect(banisterTrimp(samples, 190, 190, sex)).toBe(0); + }); +}); + +describe("saturateToScore", () => { + it("is 0 at zero load and rises monotonically toward 100", () => { + expect(saturateToScore(0, 150)).toBe(0); + const a = saturateToScore(50, 150); + const b = saturateToScore(150, 150); + const c = saturateToScore(600, 150); + expect(b).toBeGreaterThan(a); + expect(c).toBeGreaterThan(b); + expect(c).toBeLessThanOrEqual(100); + }); +}); + +function makePrisma(opts: { + energy?: number[]; + workouts?: Array<{ id: string; samples: { samples: unknown } | null }>; + rhr?: number | null; + gender?: string; + dateOfBirth?: Date | null; +}) { + const energyRows = (opts.energy ?? []).map((value, i) => ({ + value, + id: `e${i}`, + })); + const measurementFindMany = vi.fn().mockResolvedValue(energyRows); + const measurementFindFirst = vi + .fn() + .mockResolvedValue(opts.rhr == null ? null : { value: opts.rhr }); + const workoutFindMany = vi.fn().mockResolvedValue(opts.workouts ?? []); + const upsert = vi.fn().mockResolvedValue({}); + const findUnique = vi.fn().mockResolvedValue({ + dateOfBirth: + "dateOfBirth" in opts + ? opts.dateOfBirth + : new Date("1986-01-01T00:00:00Z"), + gender: opts.gender ?? "MALE", + }); + return { + prisma: { + measurement: { + findMany: measurementFindMany, + findFirst: measurementFindFirst, + upsert, + }, + workout: { findMany: workoutFindMany }, + user: { findUnique }, + } as unknown as Parameters[0], + upsert, + }; +} + +describe("persistStrainScore", () => { + it("stores a COMPUTED STRAIN_SCORE row via the TRIMP path", async () => { + const { prisma, upsert } = makePrisma({ + rhr: 50, + workouts: [ + { + id: "w1", + samples: { + samples: [ + { t: "2026-06-02T08:00:00.000Z", hr: 150 }, + { t: "2026-06-02T08:30:00.000Z", hr: 155 }, + { t: "2026-06-02T09:00:00.000Z", hr: 150 }, + ], + }, + }, + ], + }); + + const result = await persistStrainScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("stored"); + expect(result.reason).toBe("trimp"); + expect(result.score).toBeGreaterThan(0); + const arg = upsert.mock.calls[0][0]; + expect(arg.where.userId_type_source_externalId).toEqual({ + userId: "user-1", + type: "STRAIN_SCORE", + source: "COMPUTED", + externalId: "strain:2026-06-02", + }); + expect(arg.create).toMatchObject({ + type: "STRAIN_SCORE", + source: "COMPUTED", + unit: "score", + }); + }); + + it("falls back to the active-energy proxy when no HR series is usable", async () => { + const { prisma, upsert } = makePrisma({ + rhr: 50, + energy: [300, 250], + workouts: [], // no workouts → no TRIMP + }); + + const result = await persistStrainScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("stored"); + expect(result.reason).toBe("active_energy_fallback"); + expect(result.score).toBeGreaterThan(0); + expect(upsert).toHaveBeenCalledTimes(1); + }); + + it("gates when there is a workout but the profile yields no HRmax", async () => { + const { prisma, upsert } = makePrisma({ + rhr: 50, + dateOfBirth: null, // no age → no Tanaka HRmax + workouts: [ + { + id: "w1", + samples: { + samples: [ + { t: "2026-06-02T08:00:00.000Z", hr: 150 }, + { t: "2026-06-02T09:00:00.000Z", hr: 150 }, + ], + }, + }, + ], + energy: [], // no active-energy fallback either + }); + + const result = await persistStrainScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("insufficient"); + expect(result.reason).toBe("insufficient_profile"); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("writes NOTHING when there is no usable input at all", async () => { + const { prisma, upsert } = makePrisma({ + rhr: 50, + workouts: [], + energy: [], + }); + + const result = await persistStrainScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("insufficient"); + expect(result.reason).toBe("insufficient_inputs"); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("is idempotent per user per day — re-runs upsert the same key", async () => { + const { prisma, upsert } = makePrisma({ + rhr: 50, + energy: [400], + }); + + await persistStrainScore(prisma, "user-1", NOW); + await persistStrainScore(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/strain-score.ts b/src/lib/insights/strain-score.ts new file mode 100644 index 000000000..f572a2540 --- /dev/null +++ b/src/lib/insights/strain-score.ts @@ -0,0 +1,361 @@ +/** + * v1.10.0 — computed scores (WX-E). The Strain score ENGINE + persistence. + * + * A daily cardio-load proxy (0–100, higher = more training strain) built + * from the per-workout heart-rate series (`WorkoutSamples`, WX-D) plus + * active-energy burned (`ACTIVE_ENERGY_BURNED`). + * + * CARDIO-LOAD MODEL — Banister's TRIMP (Training Impulse). + * For each workout's HR series we compute the gender-weighted exponential + * TRIMP: + * + * TRIMP = Σ (Δt_min · HRr · 0.64 · e^(1.92 · HRr)) (men) + * TRIMP = Σ (Δt_min · HRr · 0.86 · e^(1.67 · HRr)) (women) + * + * where HRr = (HR − HRrest) / (HRmax − HRrest) is the fractional + * heart-rate reserve per sample, Δt_min the minutes that sample + * represents, HRmax the age-predicted maximum (Tanaka 2001: + * 208 − 0.7·age), and HRrest the user's resting heart rate. + * + * Standard: Banister EW, "Modeling Elite Athletic Performance" (1991); + * the gender-weighted exponential form is from Morton, Fitz-Clarke & + * Banister 1990, J. Appl. Physiol. 69(3):1171–1177. Tanaka HRmax: + * Tanaka, Monahan & Seals 2001, J. Am. Coll. Cardiol. 37(1):153–156. + * + * The per-workout TRIMP values are summed across the scored day to a + * day-total TRIMP, then mapped to the 0–100 scale by a saturating curve + * anchored at a reference daily load (`STRAIN_TRIMP_REFERENCE` ≈ a hard + * hour). Days with NO usable HR series but WITH active energy fall back to + * an active-energy-only proxy so a "logged but no series" workout still + * registers some strain (clearly the weaker signal — the provenance records + * which path produced the score). + * + * Honest confidence: the score is only stored when the day carried at least + * one workout HR series with usable samples OR a non-trivial active-energy + * total AND the user profile yields an HRmax (needs age) + a resting HR. + * Below that the engine returns `insufficient` and NO row is written. + * + * Row shape (identical posture to the Recovery + Stress scores): + * - `type = STRAIN_SCORE` + * - `source = COMPUTED` + * - `unit = "score"` + * - `value = 0..100` + * - `externalId = strain:YYYY-MM-DD` + * + * The score is descriptive — a daily training-load proxy, NOT a clinical + * assessment, and it is excluded from the doctor PDF. Server-only — runs + * from the nightly pg-boss job in `src/lib/jobs/strain-score.ts`. + */ +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; + +/** The per-day idempotency-key prefix for a stored Strain score row. */ +export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Reference day-total TRIMP that maps to a high (but not pinned) strain. + * ~150 TRIMP is a hard ~hour of threshold work; the saturating map sends a + * day at this load near the top of the band, with very heavy days pinned at + * 100. + */ +export const STRAIN_TRIMP_REFERENCE = 150; + +/** + * Reference daily active-energy (kcal) for the HR-series-less fallback. A + * busy active day (~600 kcal active) maps near the top of the band. The + * fallback is intentionally the weaker signal — it has no intensity + * distribution, only a volume total. + */ +export const STRAIN_ACTIVE_ENERGY_REFERENCE = 600; + +/** The UTC calendar day a `now` falls in, as `YYYY-MM-DD`. */ +export function strainDayKey(now: Date): string { + return now.toISOString().slice(0, 10); +} + +/** The full `externalId` for a given day's Strain score row. */ +export function strainExternalId(now: Date): string { + return `${STRAIN_SCORE_EXTERNAL_ID_PREFIX}${strainDayKey(now)}`; +} + +/** Noon UTC on the scored day — same convention as the other scores. */ +export function strainMeasuredAt(now: Date): Date { + return new Date(`${strainDayKey(now)}T12:00:00.000Z`); +} + +/** Tanaka 2001 age-predicted maximum heart rate. */ +export function tanakaHrMax(ageYears: number): number { + return 208 - 0.7 * ageYears; +} + +interface HrSample { + t: string; + hr?: number; +} + +/** + * Banister gender-weighted exponential TRIMP for one workout HR series. + * Integrates fractional-HR-reserve over the inter-sample intervals. Samples + * with no `hr`, or with an out-of-range fractional reserve, contribute + * zero. Returns 0 for a series with fewer than two usable HR samples (no + * interval to integrate). Pure — exported for unit testing. + */ +export function banisterTrimp( + samples: readonly HrSample[], + hrRest: number, + hrMax: number, + sex: "MALE" | "FEMALE" | null, +): number { + if (hrMax <= hrRest) return 0; + // Morton/Fitz-Clarke/Banister gender weights; the unisex default uses the + // male coefficients (the more common watch-cohort assumption) when sex is + // unknown. + const a = sex === "FEMALE" ? 0.86 : 0.64; + const b = sex === "FEMALE" ? 1.67 : 1.92; + + const usable = samples + .filter((s) => typeof s.hr === "number" && Number.isFinite(s.hr)) + .map((s) => ({ t: Date.parse(s.t), hr: s.hr as number })) + .filter((s) => Number.isFinite(s.t)) + .sort((x, y) => x.t - y.t); + if (usable.length < 2) return 0; + + let trimp = 0; + for (let i = 1; i < usable.length; i++) { + const dtMin = (usable[i].t - usable[i - 1].t) / 60_000; + if (dtMin <= 0) continue; + // Use the interval's mean HR for the reserve fraction. + const meanHr = (usable[i].hr + usable[i - 1].hr) / 2; + let hrr = (meanHr - hrRest) / (hrMax - hrRest); + if (!Number.isFinite(hrr)) continue; + if (hrr <= 0) continue; // below resting → no training impulse. + if (hrr > 1) hrr = 1; // clamp a spurious over-max sample. + trimp += dtMin * hrr * a * Math.exp(b * hrr); + } + return trimp; +} + +/** + * Saturating map of a non-negative load to 0–100 against a reference. Uses + * `1 − e^(−x/ref)` scaled to 100 so the score rises steeply at low load and + * saturates toward 100 for very heavy days — never pinned exactly at 100 + * until the load is well above the reference. Pure. + */ +export function saturateToScore(load: number, reference: number): number { + if (!(load > 0) || !(reference > 0)) return 0; + const score = 100 * (1 - Math.exp(-load / reference)); + return Math.max(0, Math.min(100, Math.round(score))); +} + +export interface StrainComputeResult { + /** The 0..100 score to persist, or null when the inputs gate. */ + score: number | null; + /** Why the result gated / which path produced the score. */ + reason: + | "trimp" + | "active_energy_fallback" + | "insufficient_profile" + | "insufficient_inputs"; + /** Day-total TRIMP (0 when no usable HR series). */ + dayTrimp: number; + /** Day-total active energy (kcal). */ + dayActiveEnergy: number; + /** Workouts with a usable HR series on the day. */ + workoutsWithSeries: number; +} + +export interface StrainProfile { + ageYears: number | null; + sex: "MALE" | "FEMALE" | null; +} + +/** + * Compute the Strain score for one user as of `now`. Reads the day's + * workouts (+ HR series) and active-energy total, computes day-total TRIMP, + * and maps it to 0–100. Falls back to an active-energy-only proxy when no + * HR series is usable. Pure of persistence. Gates (null score) when the + * profile yields no HRmax / resting HR, or there is no usable input at all. + */ +export async function computeStrainScore( + prisma: PrismaClient, + userId: string, + profile: StrainProfile, + hrRest: number | null, + now: Date, +): Promise { + const dayKey = strainDayKey(now); + const dayStart = new Date(`${dayKey}T00:00:00.000Z`); + const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY); + + // Day-total active energy first — it is the fallback signal and cheap. + const energyRows = await prisma.measurement.findMany({ + where: { + userId, + type: "ACTIVE_ENERGY_BURNED" as MeasurementType, + deletedAt: null, + measuredAt: { gte: dayStart, lt: dayEnd }, + }, + select: { value: true }, + take: 5000, + }); + const dayActiveEnergy = energyRows.reduce((s, r) => s + r.value, 0); + + // The day's workouts + their HR series. + const workouts = await prisma.workout.findMany({ + where: { userId, startedAt: { gte: dayStart, lt: dayEnd } }, + select: { id: true, samples: { select: { samples: true } } }, + take: 100, + }); + + const hrMax = + profile.ageYears != null ? tanakaHrMax(profile.ageYears) : null; + const canTrimp = hrMax != null && hrRest != null && hrMax > hrRest; + + let dayTrimp = 0; + let workoutsWithSeries = 0; + if (canTrimp) { + for (const w of workouts) { + const raw = w.samples?.samples; + if (!Array.isArray(raw)) continue; + const series = raw as unknown as HrSample[]; + const trimp = banisterTrimp(series, hrRest!, hrMax!, profile.sex); + if (trimp > 0) { + dayTrimp += trimp; + workoutsWithSeries += 1; + } + } + } + + if (dayTrimp > 0) { + return { + score: saturateToScore(dayTrimp, STRAIN_TRIMP_REFERENCE), + reason: "trimp", + dayTrimp, + dayActiveEnergy, + workoutsWithSeries, + }; + } + + // No usable HR series. Fall back to the active-energy-only proxy when + // there is a non-trivial active-energy total. A profile with no HRmax / + // resting HR can still get this fallback because it needs neither. + if (dayActiveEnergy > 0) { + return { + score: saturateToScore(dayActiveEnergy, STRAIN_ACTIVE_ENERGY_REFERENCE), + reason: "active_energy_fallback", + dayTrimp: 0, + dayActiveEnergy, + workoutsWithSeries: 0, + }; + } + + // Neither a usable HR series nor active energy → nothing to score. If the + // only reason TRIMP was unavailable is the profile, say so for the log. + return { + score: null, + reason: + workouts.length > 0 && !canTrimp + ? "insufficient_profile" + : "insufficient_inputs", + dayTrimp: 0, + dayActiveEnergy, + workoutsWithSeries: 0, + }; +} + +/** + * Build the Strain profile (age + sex) and resting HR from the user row + + * the latest resting-HR reading. `prisma` is the (worker) client. + */ +export async function loadStrainInputs( + prisma: PrismaClient, + userId: string, + now: Date, +): Promise<{ profile: StrainProfile; hrRest: number | null }> { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { dateOfBirth: true, gender: true }, + }); + const sex = + user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; + const profile: StrainProfile = { + ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), + sex, + }; + + // Most recent resting-HR reading within a 30-day window — the HRrest input + // for the TRIMP reserve fraction. Null when the user has no recent RHR. + const since = new Date(now.getTime() - 30 * MS_PER_DAY); + const rhr = await prisma.measurement.findFirst({ + where: { + userId, + type: "RESTING_HEART_RATE" as MeasurementType, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }); + return { profile, hrRest: rhr?.value ?? null }; +} + +export interface PersistStrainResult { + outcome: "stored" | "insufficient"; + score: number | null; + reason: StrainComputeResult["reason"]; +} + +/** + * Compute + persist one user's Strain score for the `now` day. Upserts on + * the `(userId, type, source, externalId)` key (idempotent per user per + * day). Writes NOTHING when the inputs gate. + */ +export async function persistStrainScore( + prisma: PrismaClient, + userId: string, + now: Date, +): Promise { + const { profile, hrRest } = await loadStrainInputs(prisma, userId, now); + const { score, reason } = await computeStrainScore( + prisma, + userId, + profile, + hrRest, + now, + ); + + if (score === null) { + return { outcome: "insufficient", score: null, reason }; + } + + const externalId = strainExternalId(now); + const measuredAt = strainMeasuredAt(now); + + await prisma.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId, + type: "STRAIN_SCORE", + source: "COMPUTED", + externalId, + }, + }, + create: { + userId, + type: "STRAIN_SCORE", + source: "COMPUTED", + value: score, + unit: "score", + measuredAt, + externalId, + }, + update: { + value: score, + measuredAt, + }, + }); + + return { outcome: "stored", score, reason }; +} diff --git a/src/lib/jobs/strain-score.ts b/src/lib/jobs/strain-score.ts new file mode 100644 index 000000000..364f306c9 --- /dev/null +++ b/src/lib/jobs/strain-score.ts @@ -0,0 +1,119 @@ +/** + * v1.10.0 — computed scores (WX-E). Nightly Strain-score computation + store. + * + * Once a night this cron computes each eligible user's Strain score from + * the day's per-workout heart-rate series (`WorkoutSamples`, WX-D) plus + * active-energy burned, via Banister's TRIMP cardio-load model (see + * `src/lib/insights/strain-score.ts`), and persists it as a `COMPUTED + * STRAIN_SCORE` Measurement row. 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 = strain:YYYY-MM-DD`, + * so a re-fired cron tick (or a manual re-run) overwrites the day's row in + * place. A user with no usable cardio-load input that day gets NO row. + * + * 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 { persistStrainScore } from "@/lib/insights/strain-score"; + +export const STRAIN_SCORE_QUEUE = "strain-score-compute"; + +/** + * Daily at 04:55 Europe/Berlin — inside the existing maintenance window, + * after the recovery-score (04:45) + stress-score (04:50) passes so the + * nightly score writes stay ordered and don't pile on one boss poll. + */ +export const STRAIN_SCORE_CRON = "55 4 * * *"; + +/** Per-run user cap — same starvation guard the other score passes use. */ +export const STRAIN_SCORE_BATCH_CAP = 500; + +/** Trailing window (days) a user must have a recent strain-input row in. */ +export const STRAIN_SCORE_RECENCY_DAYS = 2; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export interface StrainScoreRunResult { + considered: number; + stored: number; + insufficient: number; + errored: number; +} + +/** + * Discover users worth scoring tonight: anyone with a workout started in the + * recency window OR an `ACTIVE_ENERGY_BURNED` row in it. A user with neither + * cannot produce a Strain score, so skipping them keeps the pass cheap. + * Bounded by `cap`. + */ +export async function findStrainScoreCandidates( + prisma: PrismaClient, + now: Date, + cap: number, +): Promise { + const since = new Date(now.getTime() - STRAIN_SCORE_RECENCY_DAYS * MS_PER_DAY); + const [workoutUsers, energyUsers] = await Promise.all([ + prisma.workout.findMany({ + where: { startedAt: { gte: since } }, + select: { userId: true }, + distinct: ["userId"], + take: cap, + }), + prisma.measurement.findMany({ + where: { + type: "ACTIVE_ENERGY_BURNED", + deletedAt: null, + measuredAt: { gte: since }, + }, + select: { userId: true }, + distinct: ["userId"], + take: cap, + }), + ]); + const ids = new Set(); + for (const r of workoutUsers) ids.add(r.userId); + for (const r of energyUsers) ids.add(r.userId); + return Array.from(ids).slice(0, cap); +} + +/** + * Run one Strain-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 runStrainScore( + prisma: PrismaClient, + options: { now?: Date; cap?: number } = {}, +): Promise { + const now = options.now ?? new Date(); + const cap = options.cap ?? STRAIN_SCORE_BATCH_CAP; + + const userIds = await findStrainScoreCandidates(prisma, now, cap); + + let stored = 0; + let insufficient = 0; + let errored = 0; + for (const userId of userIds) { + try { + const result = await persistStrainScore(prisma, userId, now); + if (result.outcome === "stored") stored += 1; + else insufficient += 1; + } catch { + errored += 1; + } + } + + annotate({ + action: { + name: "insights.strain.compute", + details: { considered: userIds.length, stored, insufficient, errored }, + }, + }); + + return { considered: userIds.length, stored, insufficient, errored }; +}