diff --git a/src/lib/insights/__tests__/recovery-score.test.ts b/src/lib/insights/__tests__/recovery-score.test.ts index a705d9191..ac2b39a51 100644 --- a/src/lib/insights/__tests__/recovery-score.test.ts +++ b/src/lib/insights/__tests__/recovery-score.test.ts @@ -68,16 +68,17 @@ beforeEach(() => { }); describe("recovery-score helpers", () => { - it("keys the day + externalId by the UTC calendar day", () => { - expect(recoveryDayKey(NOW)).toBe("2026-06-02"); + it("scores the PREVIOUS UTC day (cron fires in the small hours)", () => { + // NOW is 2026-06-02 → the scored day is the just-completed 2026-06-01. + expect(recoveryDayKey(NOW)).toBe("2026-06-01"); expect(recoveryExternalId(NOW)).toBe( - `${RECOVERY_SCORE_EXTERNAL_ID_PREFIX}2026-06-02`, + `${RECOVERY_SCORE_EXTERNAL_ID_PREFIX}2026-06-01`, ); }); - it("anchors the canonical timestamp at noon UTC on the scored day", () => { + it("anchors the canonical timestamp at noon UTC on the scored (previous) day", () => { expect(recoveryMeasuredAt(NOW).toISOString()).toBe( - "2026-06-02T12:00:00.000Z", + "2026-06-01T12:00:00.000Z", ); }); }); @@ -96,7 +97,7 @@ describe("persistRecoveryScore", () => { userId: "user-1", type: "RECOVERY_SCORE", source: "COMPUTED", - externalId: "recovery:2026-06-02", + externalId: "recovery:2026-06-01", }); expect(arg.create).toMatchObject({ userId: "user-1", @@ -104,7 +105,7 @@ describe("persistRecoveryScore", () => { source: "COMPUTED", value: 72, unit: "score", - externalId: "recovery:2026-06-02", + externalId: "recovery:2026-06-01", }); expect(arg.update).toMatchObject({ value: 72 }); }); diff --git a/src/lib/insights/__tests__/score-row.test.ts b/src/lib/insights/__tests__/score-row.test.ts new file mode 100644 index 000000000..63c4fb4f6 --- /dev/null +++ b/src/lib/insights/__tests__/score-row.test.ts @@ -0,0 +1,148 @@ +/** + * v1.10.0 QA — shared score-row helpers. + * + * Pins the "score the PREVIOUS UTC day" contract (the cron fires ~03 UTC, so + * scoring the current UTC day would miss a just-completed day's data), the + * noon-UTC measuredAt, the prefixed externalId, the idempotent upsert, and + * the cohort tally / one-bad-user-does-not-block-the-rest guarantee of + * `runScoreBatch`. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const annotateMock = vi.fn(); +vi.mock("@/lib/logging/context", () => ({ + annotate: (...args: unknown[]) => annotateMock(...args), +})); + +import { + scoreDayKey, + scoreMeasuredAt, + scoreExternalId, + upsertScoreRow, + runScoreBatch, +} from "../score-row"; + +beforeEach(() => vi.clearAllMocks()); + +describe("scoreDayKey — previous UTC day", () => { + it("a 03:00 UTC run scores the prior calendar day", () => { + // 04:45 Europe/Berlin (summer) ≈ 02:45 UTC; a 03:00 UTC tick is the cron + // moment. The scored day must be the day that just ended. + const now = new Date("2026-06-02T03:00:00Z"); + expect(scoreDayKey(now)).toBe("2026-06-01"); + }); + + it("crosses a month boundary correctly", () => { + const now = new Date("2026-07-01T02:50:00Z"); + expect(scoreDayKey(now)).toBe("2026-06-30"); + }); + + it("crosses a year boundary correctly", () => { + const now = new Date("2027-01-01T03:00:00Z"); + expect(scoreDayKey(now)).toBe("2026-12-31"); + }); +}); + +describe("scoreMeasuredAt / scoreExternalId", () => { + const now = new Date("2026-06-02T03:00:00Z"); + it("anchors the row at noon UTC on the scored (previous) day", () => { + expect(scoreMeasuredAt(now).toISOString()).toBe("2026-06-01T12:00:00.000Z"); + }); + it("builds the prefixed externalId over the scored day", () => { + expect(scoreExternalId("recovery:", now)).toBe("recovery:2026-06-01"); + expect(scoreExternalId("strain:", now)).toBe("strain:2026-06-01"); + }); +}); + +describe("upsertScoreRow", () => { + it("upserts on the (userId, type, COMPUTED, externalId) key with the prev-day stamp", async () => { + const upsert = vi.fn().mockResolvedValue({}); + const prisma = { + measurement: { upsert }, + } as unknown as Parameters[0]; + const now = new Date("2026-06-02T03:00:00Z"); + + await upsertScoreRow(prisma, { + userId: "u1", + type: "RECOVERY_SCORE", + externalIdPrefix: "recovery:", + score: 77, + now, + }); + + const arg = upsert.mock.calls[0][0]; + expect(arg.where.userId_type_source_externalId).toEqual({ + userId: "u1", + type: "RECOVERY_SCORE", + source: "COMPUTED", + externalId: "recovery:2026-06-01", + }); + expect(arg.create).toMatchObject({ + type: "RECOVERY_SCORE", + source: "COMPUTED", + value: 77, + unit: "score", + externalId: "recovery:2026-06-01", + }); + expect(arg.create.measuredAt.toISOString()).toBe( + "2026-06-01T12:00:00.000Z", + ); + expect(arg.update).toEqual({ + value: 77, + measuredAt: arg.create.measuredAt, + }); + }); +}); + +describe("runScoreBatch", () => { + const NOW = new Date("2026-06-02T03:00:00Z"); + + it("tallies stored vs insufficient and annotates", async () => { + const persist = vi + .fn() + .mockResolvedValueOnce({ outcome: "stored" }) + .mockResolvedValueOnce({ outcome: "insufficient" }) + .mockResolvedValueOnce({ outcome: "stored" }); + + const result = await runScoreBatch( + ["a", "b", "c"], + NOW, + persist, + "insights.recovery.compute", + ); + + expect(result).toEqual({ + considered: 3, + stored: 2, + insufficient: 1, + errored: 0, + }); + expect(annotateMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: expect.objectContaining({ name: "insights.recovery.compute" }), + }), + ); + }); + + it("counts a per-user error and keeps processing the cohort", async () => { + const persist = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({ outcome: "stored" }); + + const result = await runScoreBatch(["a", "b"], NOW, persist, "x.compute"); + + expect(result).toEqual({ + considered: 2, + stored: 1, + insufficient: 0, + errored: 1, + }); + }); + + it("passes the run `now` through to the persist function", async () => { + const persist = vi.fn().mockResolvedValue({ outcome: "stored" }); + await runScoreBatch(["a"], NOW, persist, "x.compute"); + expect(persist).toHaveBeenCalledWith("a", NOW); + }); +}); diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index 1b26a91f5..4be24fe01 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -28,16 +28,18 @@ beforeEach(() => { }); describe("strain-score helpers", () => { - it("keys the day + externalId by the UTC calendar day", () => { - expect(strainDayKey(NOW)).toBe("2026-06-02"); + it("scores the PREVIOUS UTC day (cron fires in the small hours)", () => { + // NOW is 2026-06-02 → the scored day is the just-completed 2026-06-01, + // whose workouts + active-energy total are settled. + expect(strainDayKey(NOW)).toBe("2026-06-01"); expect(strainExternalId(NOW)).toBe( - `${STRAIN_SCORE_EXTERNAL_ID_PREFIX}2026-06-02`, + `${STRAIN_SCORE_EXTERNAL_ID_PREFIX}2026-06-01`, ); }); - it("anchors the canonical timestamp at noon UTC", () => { + it("anchors the canonical timestamp at noon UTC on the scored (previous) day", () => { expect(strainMeasuredAt(NOW).toISOString()).toBe( - "2026-06-02T12:00:00.000Z", + "2026-06-01T12:00:00.000Z", ); }); @@ -176,7 +178,7 @@ describe("persistStrainScore", () => { userId: "user-1", type: "STRAIN_SCORE", source: "COMPUTED", - externalId: "strain:2026-06-02", + externalId: "strain:2026-06-01", }); expect(arg.create).toMatchObject({ type: "STRAIN_SCORE", diff --git a/src/lib/insights/__tests__/stress-score.test.ts b/src/lib/insights/__tests__/stress-score.test.ts index 8d8ce2e8b..1427c4723 100644 --- a/src/lib/insights/__tests__/stress-score.test.ts +++ b/src/lib/insights/__tests__/stress-score.test.ts @@ -14,6 +14,9 @@ const computeVitalsBaselineMock = vi.fn(); vi.mock("@/lib/insights/derived/baseline", () => ({ computeVitalsBaseline: (...args: unknown[]) => computeVitalsBaselineMock(...args), + loadBaselineProfile: vi + .fn() + .mockResolvedValue({ ageYears: null, sex: "MALE", heightCm: 180 }), })); import { @@ -57,16 +60,18 @@ beforeEach(() => { }); describe("stress-score helpers", () => { - it("keys the day + externalId by the UTC calendar day", () => { - expect(stressDayKey(NOW)).toBe("2026-06-02"); + it("scores the PREVIOUS UTC day (cron fires in the small hours)", () => { + // NOW is 2026-06-02 → the scored day is the just-completed 2026-06-01, + // whose intra-day SDNN set is complete rather than a few hours old. + expect(stressDayKey(NOW)).toBe("2026-06-01"); expect(stressExternalId(NOW)).toBe( - `${STRESS_SCORE_EXTERNAL_ID_PREFIX}2026-06-02`, + `${STRESS_SCORE_EXTERNAL_ID_PREFIX}2026-06-01`, ); }); - it("anchors the canonical timestamp at noon UTC on the scored day", () => { + it("anchors the canonical timestamp at noon UTC on the scored (previous) day", () => { expect(stressMeasuredAt(NOW).toISOString()).toBe( - "2026-06-02T12:00:00.000Z", + "2026-06-01T12:00:00.000Z", ); }); }); @@ -107,13 +112,13 @@ describe("persistStressScore", () => { userId: "user-1", type: "STRESS_SCORE", source: "COMPUTED", - externalId: "stress:2026-06-02", + externalId: "stress:2026-06-01", }); expect(arg.create).toMatchObject({ type: "STRESS_SCORE", source: "COMPUTED", unit: "score", - externalId: "stress:2026-06-02", + externalId: "stress:2026-06-01", }); }); diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index 3da330ec4..aa46ba120 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -28,8 +28,9 @@ * statistics helpers below are exported so the unit test can assert the * composed-bucket baseline matches the raw-DAY baseline within tolerance. */ -import type { MeasurementType } from "@/generated/prisma/client"; +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { probeRollupCoverage, type RollupCoverageMap, @@ -79,6 +80,33 @@ export interface BaselineProfile { heightCm?: number | null; } +/** + * Build the `BaselineProfile` the baseline engine + readiness blend need from + * the `User` row. This is the one loader the persisting score engines and the + * live `/api/insights/derived` route share — previously each inlined a + * byte-identical `findUnique` + gender-narrow + `getAgeFromDateOfBirth`. The + * `prisma` arg is the caller's client (the nightly worker passes its shared + * connection; the route passes the request client) so both reads hit one + * pool. `now` is unused here but accepted so a future age-as-of-date refinement + * is a one-line change in this single place. + */ +export async function loadBaselineProfile( + prisma: PrismaClient, + userId: string, +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { dateOfBirth: true, gender: true, heightCm: true }, + }); + const sex = + user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; + return { + ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), + sex, + heightCm: user?.heightCm ?? null, + }; +} + export interface VitalsBaselineOpts { /** Which vital to baseline. */ type: MeasurementType; @@ -155,7 +183,7 @@ export function buildBaselineBand( * live-SQL fallback on a coverage miss. Returns the series plus the * provenance source the read resolved against. */ -async function readDayMeanSeries( +export async function readDayMeanSeries( userId: string, type: MeasurementType, windowDays: number, diff --git a/src/lib/insights/recovery-score.ts b/src/lib/insights/recovery-score.ts index aa470df68..40db18a88 100644 --- a/src/lib/insights/recovery-score.ts +++ b/src/lib/insights/recovery-score.ts @@ -36,40 +36,43 @@ * pg-boss job in `src/lib/jobs/recovery-score.ts`. */ import type { PrismaClient } from "@/generated/prisma/client"; -import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { computeReadiness, type ReadinessValue, } from "@/lib/insights/derived/readiness"; -import type { BaselineProfile } from "@/lib/insights/derived/baseline"; +import { + loadBaselineProfile, + type BaselineProfile, +} from "@/lib/insights/derived/baseline"; +import { + scoreDayKey, + scoreExternalId, + scoreMeasuredAt, + upsertScoreRow, +} from "@/lib/insights/score-row"; import type { Derived } from "@/lib/insights/derived/types"; /** The per-day idempotency-key prefix for a stored Recovery score row. */ export const RECOVERY_SCORE_EXTERNAL_ID_PREFIX = "recovery:"; /** - * The UTC calendar day a `now` falls in, as `YYYY-MM-DD`. The Recovery - * series is one row per UTC day — the same day-keying the rollup tier and - * the daily-mean consolidation use, so the score lines up with the buckets - * it summarises. + * The UTC calendar day a Recovery run scores — the PREVIOUS day relative to + * `now` (the cron fires in the small hours; the just-completed day is the one + * with a full signal set). Delegates to the shared `scoreDayKey` so all three + * score engines agree on the day stamp. */ export function recoveryDayKey(now: Date): string { - return now.toISOString().slice(0, 10); + return scoreDayKey(now); } -/** The full `externalId` for a given day's Recovery score row. */ +/** The full `externalId` for a given run's Recovery score row. */ export function recoveryExternalId(now: Date): string { - return `${RECOVERY_SCORE_EXTERNAL_ID_PREFIX}${recoveryDayKey(now)}`; + return scoreExternalId(RECOVERY_SCORE_EXTERNAL_ID_PREFIX, now); } -/** - * The canonical timestamp a stored Recovery row carries: noon UTC on the - * scored day. Noon (not midnight) keeps the row inside its own UTC day under - * any reasonable display-timezone offset, mirroring the daily-stats - * convention so a chart never mis-buckets the point at a day boundary. - */ +/** The canonical timestamp a stored Recovery row carries (noon UTC, scored day). */ export function recoveryMeasuredAt(now: Date): Date { - return new Date(`${recoveryDayKey(now)}T12:00:00.000Z`); + return scoreMeasuredAt(now); } export interface RecoveryComputeResult { @@ -101,25 +104,16 @@ export async function computeRecoveryScore( } /** - * Build the `BaselineProfile` the readiness blend needs from the user row — - * the same shape the live `/api/insights/derived` route builds. `prisma` is - * the (worker) client so the job shares one connection. + * Build the `BaselineProfile` the readiness blend needs from the user row. + * Thin alias over the shared `loadBaselineProfile` so the existing callers / + * tests keep their name; `prisma` is the (worker) client so the job shares one + * connection. */ export async function loadRecoveryProfile( prisma: PrismaClient, userId: string, ): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { dateOfBirth: true, gender: true, heightCm: true }, - }); - const sex = - user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; - return { - ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), - sex, - heightCm: user?.heightCm ?? null, - }; + return loadBaselineProfile(prisma, userId); } export interface PersistRecoveryResult { @@ -147,31 +141,12 @@ export async function persistRecoveryScore( return { outcome: "insufficient", score: null }; } - const externalId = recoveryExternalId(now); - const measuredAt = recoveryMeasuredAt(now); - - await prisma.measurement.upsert({ - where: { - userId_type_source_externalId: { - userId, - type: "RECOVERY_SCORE", - source: "COMPUTED", - externalId, - }, - }, - create: { - userId, - type: "RECOVERY_SCORE", - source: "COMPUTED", - value: score, - unit: "score", - measuredAt, - externalId, - }, - update: { - value: score, - measuredAt, - }, + await upsertScoreRow(prisma, { + userId, + type: "RECOVERY_SCORE", + externalIdPrefix: RECOVERY_SCORE_EXTERNAL_ID_PREFIX, + score, + now, }); return { outcome: "stored", score }; diff --git a/src/lib/insights/score-row.ts b/src/lib/insights/score-row.ts new file mode 100644 index 000000000..09b6ebce6 --- /dev/null +++ b/src/lib/insights/score-row.ts @@ -0,0 +1,149 @@ +/** + * v1.10.0 — computed scores (WX-C/E). Shared score-row helpers. + * + * The three nightly score engines (Recovery / Stress / Strain) all file one + * `COMPUTED` Measurement row per user per scored day, keyed by the same + * day-stamp triplet (a `prefix:YYYY-MM-DD` externalId, a noon-UTC + * `measuredAt`, the UTC day key) and written through the same idempotent + * `(userId, type, source, externalId)` upsert. Before this module each engine + * carried a byte-identical copy of that triplet + upsert, differing only by + * the `prefix` / `type` literal. + * + * SCORED DAY = THE PREVIOUS UTC DAY. The crons fire ~03:xx UTC (04:45–04:55 + * Europe/Berlin). At that moment the CURRENT UTC day is only a few hours old: + * the Strain engine would miss the just-completed day's workouts and the + * Stress engine would see a near-empty intra-day SDNN set and gate as + * `insufficient`. Every engine therefore scores the day that just ENDED — + * `now − 1 day` — resolved once, here, so the externalId / measuredAt / day + * key can never drift between the three. + * + * `scoreDayKey` / `scoreMeasuredAt` / `scoreExternalId` are pure (no Prisma) + * so the engines' day-stamp tests can drive them directly; `upsertScoreRow` + * takes the (worker) client. + */ +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * The UTC calendar day a score run scores — the PREVIOUS day relative to + * `now`. The cron runs in the small hours; the just-completed day is the one + * with a full set of signals, so we resolve `now − 1 day` and take its UTC + * `YYYY-MM-DD`. Resolved in ONE place so all three engines agree. + */ +export function scoreDayKey(now: Date): string { + return new Date(now.getTime() - MS_PER_DAY).toISOString().slice(0, 10); +} + +/** + * The canonical timestamp a stored score row carries: noon UTC on the scored + * (previous) day. Noon (not midnight) keeps the row inside its own UTC day + * under any reasonable display-timezone offset, mirroring the daily-stats + * convention so a chart never mis-buckets the point at a day boundary. + */ +export function scoreMeasuredAt(now: Date): Date { + return new Date(`${scoreDayKey(now)}T12:00:00.000Z`); +} + +/** + * The full per-day idempotency-key `externalId` for a score row: + * `prefix:YYYY-MM-DD` over the scored (previous) day. `prefix` carries its + * own trailing colon (`recovery:` / `stress:` / `strain:`). + */ +export function scoreExternalId(prefix: string, now: Date): string { + return `${prefix}${scoreDayKey(now)}`; +} + +/** + * Upsert one user's score row for the scored day. Idempotent on the + * `(userId, type, source = COMPUTED, externalId)` key so a re-fired cron tick + * (or a manual re-run) overwrites the day's row in place rather than minting a + * duplicate. The three engines differ only by `type` + `externalId` prefix; + * everything else (source, unit, the noon-UTC `measuredAt`) is shared. + */ +export async function upsertScoreRow( + prisma: PrismaClient, + args: { + userId: string; + type: MeasurementType; + externalIdPrefix: string; + score: number; + now: Date; + }, +): Promise { + const externalId = scoreExternalId(args.externalIdPrefix, args.now); + const measuredAt = scoreMeasuredAt(args.now); + + await prisma.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId: args.userId, + type: args.type, + source: "COMPUTED", + externalId, + }, + }, + create: { + userId: args.userId, + type: args.type, + source: "COMPUTED", + value: args.score, + unit: "score", + measuredAt, + externalId, + }, + update: { + value: args.score, + measuredAt, + }, + }); +} + +/** The tally every score pass returns. */ +export interface ScoreBatchResult { + considered: number; + stored: number; + insufficient: number; + errored: number; +} + +/** + * Run one score pass over a candidate cohort. Each user is scored + * independently through `persistFn`; a single user's error is recorded and + * the pass continues (one bad account never blocks the cohort). Emits the + * pass tally under `actionName`. This is the identical loop the three nightly + * jobs each carried — only the persist function + the annotate action name + * differed. + */ +export async function runScoreBatch( + userIds: string[], + now: Date, + persistFn: ( + userId: string, + now: Date, + ) => Promise<{ outcome: "stored" | "insufficient" }>, + actionName: string, +): Promise { + let stored = 0; + let insufficient = 0; + let errored = 0; + for (const userId of userIds) { + try { + const result = await persistFn(userId, now); + if (result.outcome === "stored") stored += 1; + else insufficient += 1; + } catch { + errored += 1; + } + } + + annotate({ + action: { + name: actionName, + details: { considered: userIds.length, stored, insufficient, errored }, + }, + }); + + return { considered: userIds.length, stored, insufficient, errored }; +} diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index f572a2540..3bc69f476 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -47,7 +47,13 @@ * 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"; +import { loadBaselineProfile } from "@/lib/insights/derived/baseline"; +import { + scoreDayKey, + scoreExternalId, + scoreMeasuredAt, + upsertScoreRow, +} from "@/lib/insights/score-row"; /** The per-day idempotency-key prefix for a stored Strain score row. */ export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:"; @@ -70,19 +76,25 @@ export const STRAIN_TRIMP_REFERENCE = 150; */ export const STRAIN_ACTIVE_ENERGY_REFERENCE = 600; -/** The UTC calendar day a `now` falls in, as `YYYY-MM-DD`. */ +/** + * The UTC calendar day a Strain run scores — the PREVIOUS day relative to + * `now`. The cron fires in the small hours; scoring the just-ended day is what + * lets the engine see that day's completed workouts + active-energy total + * rather than a few hours of the current day. Delegates to the shared + * `scoreDayKey` so all three engines agree. + */ export function strainDayKey(now: Date): string { - return now.toISOString().slice(0, 10); + return scoreDayKey(now); } -/** The full `externalId` for a given day's Strain score row. */ +/** The full `externalId` for a given run's Strain score row. */ export function strainExternalId(now: Date): string { - return `${STRAIN_SCORE_EXTERNAL_ID_PREFIX}${strainDayKey(now)}`; + return scoreExternalId(STRAIN_SCORE_EXTERNAL_ID_PREFIX, now); } -/** Noon UTC on the scored day — same convention as the other scores. */ +/** Noon UTC on the scored (previous) day — same convention as the other scores. */ export function strainMeasuredAt(now: Date): Date { - return new Date(`${strainDayKey(now)}T12:00:00.000Z`); + return scoreMeasuredAt(now); } /** Tanaka 2001 age-predicted maximum heart rate. */ @@ -274,15 +286,13 @@ export async function loadStrainInputs( 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; + // Reuse the shared profile loader, then narrow to the Strain profile + // (age + sex; height is not a TRIMP input). One loader for all three + // engines + the derived route. + const base = await loadBaselineProfile(prisma, userId); const profile: StrainProfile = { - ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), - sex, + ageYears: base.ageYears, + sex: base.sex, }; // Most recent resting-HR reading within a 30-day window — the HRrest input @@ -330,31 +340,12 @@ export async function persistStrainScore( 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, - }, + await upsertScoreRow(prisma, { + userId, + type: "STRAIN_SCORE", + externalIdPrefix: STRAIN_SCORE_EXTERNAL_ID_PREFIX, + score, + now, }); return { outcome: "stored", score, reason }; diff --git a/src/lib/insights/stress-score.ts b/src/lib/insights/stress-score.ts index 46eecc18d..471371608 100644 --- a/src/lib/insights/stress-score.ts +++ b/src/lib/insights/stress-score.ts @@ -48,10 +48,18 @@ * `src/lib/jobs/stress-score.ts`. */ import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; -import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; -import { computeVitalsBaseline } from "@/lib/insights/derived/baseline"; +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:"; @@ -68,23 +76,24 @@ export const STRESS_MIN_INTRADAY_SAMPLES = 3; const MS_PER_DAY = 24 * 60 * 60 * 1000; -/** The UTC calendar day a `now` falls in, as `YYYY-MM-DD`. */ -export function stressDayKey(now: Date): string { - return now.toISOString().slice(0, 10); -} - -/** The full `externalId` for a given day's Stress score row. */ -export function stressExternalId(now: Date): string { - return `${STRESS_SCORE_EXTERNAL_ID_PREFIX}${stressDayKey(now)}`; -} - /** - * The canonical timestamp a stored Stress row carries: noon UTC on the - * scored day — same day-keying convention as the Recovery score so the - * series lines up with the buckets it summarises. + * 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 new Date(`${stressDayKey(now)}T12:00:00.000Z`); + return scoreMeasuredAt(now); } /** @@ -198,24 +207,15 @@ export async function computeStressScore( } /** - * Build the `BaselineProfile` the SDNN baseline needs from the user row — - * the same shape the live `/api/insights/derived` route builds. + * 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 { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { dateOfBirth: true, gender: true, heightCm: true }, - }); - const sex = - user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; - return { - ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), - sex, - heightCm: user?.heightCm ?? null, - }; + return loadBaselineProfile(prisma, userId); } export interface PersistStressResult { @@ -252,31 +252,12 @@ export async function persistStressScore( return { outcome: "insufficient", score: null, reason }; } - const externalId = stressExternalId(now); - const measuredAt = stressMeasuredAt(now); - - await prisma.measurement.upsert({ - where: { - userId_type_source_externalId: { - userId, - type: "STRESS_SCORE", - source: "COMPUTED", - externalId, - }, - }, - create: { - userId, - type: "STRESS_SCORE", - source: "COMPUTED", - value: score, - unit: "score", - measuredAt, - externalId, - }, - update: { - value: score, - measuredAt, - }, + await upsertScoreRow(prisma, { + userId, + type: "STRESS_SCORE", + externalIdPrefix: STRESS_SCORE_EXTERNAL_ID_PREFIX, + score, + now, }); return { outcome: "stored", score, reason }; diff --git a/src/lib/jobs/recovery-score.ts b/src/lib/jobs/recovery-score.ts index 316d2bec4..08918f274 100644 --- a/src/lib/jobs/recovery-score.ts +++ b/src/lib/jobs/recovery-score.ts @@ -21,8 +21,8 @@ * comment guards against). */ import type { PrismaClient } from "@/generated/prisma/client"; -import { annotate } from "@/lib/logging/context"; import { persistRecoveryScore } from "@/lib/insights/recovery-score"; +import { runScoreBatch } from "@/lib/insights/score-row"; export const RECOVERY_SCORE_QUEUE = "recovery-score-compute"; @@ -101,30 +101,10 @@ export async function runRecoveryScore( const userIds = await findRecoveryScoreCandidates(prisma, now, cap); - let stored = 0; - let insufficient = 0; - let errored = 0; - for (const userId of userIds) { - try { - const result = await persistRecoveryScore(prisma, userId, now); - if (result.outcome === "stored") stored += 1; - else insufficient += 1; - } catch { - errored += 1; - } - } - - annotate({ - action: { - name: "insights.recovery.compute", - details: { - considered: userIds.length, - stored, - insufficient, - errored, - }, - }, - }); - - return { considered: userIds.length, stored, insufficient, errored }; + return runScoreBatch( + userIds, + now, + (userId, runNow) => persistRecoveryScore(prisma, userId, runNow), + "insights.recovery.compute", + ); } diff --git a/src/lib/jobs/strain-score.ts b/src/lib/jobs/strain-score.ts index 364f306c9..4415ffb48 100644 --- a/src/lib/jobs/strain-score.ts +++ b/src/lib/jobs/strain-score.ts @@ -18,8 +18,8 @@ * 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"; +import { runScoreBatch } from "@/lib/insights/score-row"; export const STRAIN_SCORE_QUEUE = "strain-score-compute"; @@ -95,25 +95,10 @@ export async function runStrainScore( 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 }; + return runScoreBatch( + userIds, + now, + (userId, runNow) => persistStrainScore(prisma, userId, runNow), + "insights.strain.compute", + ); } diff --git a/src/lib/jobs/stress-score.ts b/src/lib/jobs/stress-score.ts index 9f7accffb..2064a9ddc 100644 --- a/src/lib/jobs/stress-score.ts +++ b/src/lib/jobs/stress-score.ts @@ -19,8 +19,8 @@ * unregistered queue silently never drains. */ import type { PrismaClient } from "@/generated/prisma/client"; -import { annotate } from "@/lib/logging/context"; import { persistStressScore } from "@/lib/insights/stress-score"; +import { runScoreBatch } from "@/lib/insights/score-row"; export const STRESS_SCORE_QUEUE = "stress-score-compute"; @@ -85,25 +85,10 @@ export async function runStressScore( const userIds = await findStressScoreCandidates(prisma, now, cap); - let stored = 0; - let insufficient = 0; - let errored = 0; - for (const userId of userIds) { - try { - const result = await persistStressScore(prisma, userId, now); - if (result.outcome === "stored") stored += 1; - else insufficient += 1; - } catch { - errored += 1; - } - } - - annotate({ - action: { - name: "insights.stress.compute", - details: { considered: userIds.length, stored, insufficient, errored }, - }, - }); - - return { considered: userIds.length, stored, insufficient, errored }; + return runScoreBatch( + userIds, + now, + (userId, runNow) => persistStressScore(prisma, userId, runNow), + "insights.stress.compute", + ); }