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