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