fix(insights): score the previous day in the nightly score engines

The Recovery/Stress/Strain crons fire in the small hours (04:45-04:55
Europe/Berlin, ~03 UTC) but scored the CURRENT UTC day, only a few hours
old: Strain missed the just-completed day's workouts and Stress saw a
near-empty intra-day SDNN set and falsely gated as insufficient. Score the
day that just ended (now - 1 day) across all three engines and their
measuredAt / externalId day stamps.

Resolve the previous-day key in one place by extracting the shared score-row
helpers the three engines each duplicated: scoreDayKey / scoreMeasuredAt /
scoreExternalId, the idempotent upsertScoreRow, and the cohort runScoreBatch
tally loop into src/lib/insights/score-row.ts; and loadBaselineProfile into
the baseline engine (the three byte-identical profile loaders + the partial
strain variant now route through it). The derived route should adopt
loadBaselineProfile too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-02 23:40:24 +02:00
1 parent 78c8177cd3
commit 148e6cd600
12 files changed
+472 -242

No files matched your search

@@ -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 });
});
@@ -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<typeof upsertScoreRow>[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);
});
});
@@ -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",
@@ -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",
});
});
+30 -2
View File
@@ -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<BaselineProfile> {
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,
+30 -55
View File
@@ -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<BaselineProfile> {
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 };
+149
View File
@@ -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<void> {
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<ScoreBatchResult> {
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 };
}
+31 -40
View File
@@ -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 };
+35 -54
View File
@@ -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<BaselineProfile> {
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 };
+7 -27
View File
@@ -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",
);
}
+7 -22
View File
@@ -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",
);
}
+7 -22
View File
@@ -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",
);
}