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).
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 22:49:43 +02:00
1 parent 57d9cc1674
commit 6935feda15
3 files changed
+738

No files matched your search

@@ -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<typeof persistStrainScore>[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);
});
});
+361
View File
@@ -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<StrainComputeResult> {
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<PersistStrainResult> {
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 };
}
+119
View File
@@ -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<string[]> {
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<string>();
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<StrainScoreRunResult> {
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 };
}