mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(insights): compute and store a nightly recovery score
Add a recovery-score engine that turns a user's already-stored nightly signals into a stored 0-100 score. It reuses the readiness blend — the resting-heart-rate, HRV (SDNN), sleep and respiratory-rate deviation from the personal baseline, with the null-redistribution the health score already uses — so the stored series and the live readiness surface can never drift. The lineage is the recovery-marker literature (Plews 2013, Buchheit 2014): a higher score reads as more recovered. It is a descriptive wellness proxy, not a clinical or training-recovery assessment, and it is only stored when enough components are present — below that floor no row is written rather than a headline from one signal. A nightly job computes each eligible user's score and upserts it as a COMPUTED RECOVERY_SCORE row, idempotent per user per day on the externalId day key, so a re-fired tick overwrites in place instead of duplicating. The score is never computed on a page visit. The queue is registered in the worker's queue inventory, scheduled, and wired to a handler; a grep guard pins all four points so it cannot silently never run.
This commit is contained in:
1 parent
33b75361f3
commit
3dcec54726
6 files changed
+669
No files matched your search
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* v1.10.0 — computed scores (WX-C). Recovery-score engine + persistence.
|
||||
*
|
||||
* The engine reuses the READINESS derived blend (mocked here) and writes a
|
||||
* `COMPUTED RECOVERY_SCORE` row. These tests pin:
|
||||
* - the per-day idempotency-key + canonical-timestamp helpers,
|
||||
* - the "store on ok, write nothing on insufficient" gate,
|
||||
* - the upsert shape (idempotent per user per day).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { Derived } from "@/lib/insights/derived/types";
|
||||
import type { ReadinessValue } from "@/lib/insights/derived/readiness";
|
||||
|
||||
const computeReadinessMock =
|
||||
vi.fn<(...args: unknown[]) => Promise<Derived<ReadinessValue>>>();
|
||||
|
||||
vi.mock("@/lib/insights/derived/readiness", () => ({
|
||||
computeReadiness: (...args: unknown[]) => computeReadinessMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
recoveryDayKey,
|
||||
recoveryExternalId,
|
||||
recoveryMeasuredAt,
|
||||
persistRecoveryScore,
|
||||
RECOVERY_SCORE_EXTERNAL_ID_PREFIX,
|
||||
} from "../recovery-score";
|
||||
|
||||
const NOW = new Date("2026-06-02T08:30:00Z");
|
||||
|
||||
function okReadiness(score: number): Derived<ReadinessValue> {
|
||||
return {
|
||||
status: "ok",
|
||||
value: { score, band: "green", components: [] },
|
||||
coverage: { requiredInputs: 5, presentInputs: 4, historyDays: 30, missing: [] },
|
||||
confidence: { score: 80, band: "high" },
|
||||
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: NOW.toISOString() },
|
||||
};
|
||||
}
|
||||
|
||||
function insufficientReadiness(): Derived<ReadinessValue> {
|
||||
return {
|
||||
status: "insufficient",
|
||||
coverage: { requiredInputs: 5, presentInputs: 1, historyDays: 0, missing: ["hrv"] },
|
||||
provenance: { inputs: [], source: "none", windowDays: 30, computedAt: NOW.toISOString() },
|
||||
reason: "insufficient_components",
|
||||
};
|
||||
}
|
||||
|
||||
function makePrisma() {
|
||||
const upsert = vi.fn().mockResolvedValue({});
|
||||
const findUnique = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ dateOfBirth: null, gender: "MALE", heightCm: 180 });
|
||||
return {
|
||||
prisma: {
|
||||
measurement: { upsert },
|
||||
user: { findUnique },
|
||||
} as unknown as Parameters<typeof persistRecoveryScore>[0],
|
||||
upsert,
|
||||
findUnique,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("recovery-score helpers", () => {
|
||||
it("keys the day + externalId by the UTC calendar day", () => {
|
||||
expect(recoveryDayKey(NOW)).toBe("2026-06-02");
|
||||
expect(recoveryExternalId(NOW)).toBe(
|
||||
`${RECOVERY_SCORE_EXTERNAL_ID_PREFIX}2026-06-02`,
|
||||
);
|
||||
});
|
||||
|
||||
it("anchors the canonical timestamp at noon UTC on the scored day", () => {
|
||||
expect(recoveryMeasuredAt(NOW).toISOString()).toBe(
|
||||
"2026-06-02T12:00:00.000Z",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistRecoveryScore", () => {
|
||||
it("stores a COMPUTED RECOVERY_SCORE row when the blend is ok", async () => {
|
||||
const { prisma, upsert } = makePrisma();
|
||||
computeReadinessMock.mockResolvedValue(okReadiness(72));
|
||||
|
||||
const result = await persistRecoveryScore(prisma, "user-1", NOW);
|
||||
|
||||
expect(result).toEqual({ outcome: "stored", score: 72 });
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
const arg = upsert.mock.calls[0][0];
|
||||
expect(arg.where.userId_type_source_externalId).toEqual({
|
||||
userId: "user-1",
|
||||
type: "RECOVERY_SCORE",
|
||||
source: "COMPUTED",
|
||||
externalId: "recovery:2026-06-02",
|
||||
});
|
||||
expect(arg.create).toMatchObject({
|
||||
userId: "user-1",
|
||||
type: "RECOVERY_SCORE",
|
||||
source: "COMPUTED",
|
||||
value: 72,
|
||||
unit: "score",
|
||||
externalId: "recovery:2026-06-02",
|
||||
});
|
||||
expect(arg.update).toMatchObject({ value: 72 });
|
||||
});
|
||||
|
||||
it("writes NOTHING when the readiness blend is insufficient", async () => {
|
||||
const { prisma, upsert } = makePrisma();
|
||||
computeReadinessMock.mockResolvedValue(insufficientReadiness());
|
||||
|
||||
const result = await persistRecoveryScore(prisma, "user-1", NOW);
|
||||
|
||||
expect(result).toEqual({ outcome: "insufficient", score: null });
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent per user per day — re-runs upsert the same key", async () => {
|
||||
const { prisma, upsert } = makePrisma();
|
||||
computeReadinessMock.mockResolvedValue(okReadiness(64));
|
||||
|
||||
await persistRecoveryScore(prisma, "user-1", NOW);
|
||||
await persistRecoveryScore(prisma, "user-1", NOW);
|
||||
|
||||
expect(upsert).toHaveBeenCalledTimes(2);
|
||||
const firstKey = upsert.mock.calls[0][0].where.userId_type_source_externalId;
|
||||
const secondKey = upsert.mock.calls[1][0].where.userId_type_source_externalId;
|
||||
expect(secondKey).toEqual(firstKey);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* v1.10.0 — computed scores (WX-C). The Recovery score ENGINE + persistence.
|
||||
*
|
||||
* This is the PERSISTING counterpart to the compute-on-read `READINESS`
|
||||
* derived engine (`src/lib/insights/derived/readiness.ts`). The derived
|
||||
* engine answers a live request with a `Derived<T>`; this module reuses that
|
||||
* exact math and writes the result as a queryable / chartable /
|
||||
* iOS-readable `Measurement` row so the score has the same first-class
|
||||
* storage shape as a vital — a daily 0–100 series the dashboard, charts, and
|
||||
* the native client can read without recomputing.
|
||||
*
|
||||
* The row is:
|
||||
* - `type = RECOVERY_SCORE`
|
||||
* - `source = COMPUTED` (server-owned; a client can never POST it)
|
||||
* - `unit = "score"` (the canonical unit; see `validations/measurement`)
|
||||
* - `value = 0..100` (plausibility-pinned to {min:0, max:100})
|
||||
* - `externalId = recovery:YYYY-MM-DD` (the per-day idempotency key)
|
||||
*
|
||||
* Idempotent per user per day: a re-run upserts on
|
||||
* `(userId, type, source, externalId)` rather than minting a duplicate, so
|
||||
* the nightly job is safe to re-fire and a same-day re-compute (e.g. after a
|
||||
* late watch sync) overwrites the day's score in place.
|
||||
*
|
||||
* Honest confidence: the score is only stored when the underlying readiness
|
||||
* blend reaches its minimum-component floor (`READINESS_MIN_COMPONENTS`).
|
||||
* Below that the engine returns `insufficient` and NO row is written — the
|
||||
* series never carries a headline derived from 1-of-N signals.
|
||||
*
|
||||
* Standard / framing: RHR elevation + HRV (SDNN) suppression as recovery
|
||||
* markers — Plews et al. 2013, Sports Medicine 43(9):773–781; Buchheit 2014,
|
||||
* Frontiers in Physiology 5:73 (same lineage as the READINESS derived
|
||||
* engine). The score is descriptive — a daily wellness proxy, NOT a clinical
|
||||
* or training-recovery assessment, and it is excluded from the doctor PDF.
|
||||
*
|
||||
* Server-only — never call this on a page visit. It runs from the nightly
|
||||
* 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 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.
|
||||
*/
|
||||
export function recoveryDayKey(now: Date): string {
|
||||
return now.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** The full `externalId` for a given day's Recovery score row. */
|
||||
export function recoveryExternalId(now: Date): string {
|
||||
return `${RECOVERY_SCORE_EXTERNAL_ID_PREFIX}${recoveryDayKey(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.
|
||||
*/
|
||||
export function recoveryMeasuredAt(now: Date): Date {
|
||||
return new Date(`${recoveryDayKey(now)}T12:00:00.000Z`);
|
||||
}
|
||||
|
||||
export interface RecoveryComputeResult {
|
||||
/** The readiness blend the score is read from (exposed for the job log + tests). */
|
||||
readiness: Derived<ReadinessValue>;
|
||||
/** The 0..100 score to persist, or null when the blend was insufficient. */
|
||||
score: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the Recovery score for one user as of `now`. Pure of persistence —
|
||||
* the job calls this, then writes the row. Reuses the READINESS derived
|
||||
* engine verbatim (its RHR/HRV/sleep/respiratory deviation blend IS the
|
||||
* recovery model) so the two surfaces can never drift.
|
||||
*/
|
||||
export async function computeRecoveryScore(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
profile: BaselineProfile,
|
||||
now: Date,
|
||||
): Promise<RecoveryComputeResult> {
|
||||
// The derived engine reads `@/lib/db`'s prisma; it does not accept an
|
||||
// injected client. We only pass `prisma` through for the profile read in
|
||||
// the helper below so the job can share one client. `now` is injected so
|
||||
// the result is deterministic for tests.
|
||||
const readiness = await computeReadiness(userId, profile, { now });
|
||||
const score = readiness.status === "ok" ? readiness.value.score : null;
|
||||
return { readiness, score };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PersistRecoveryResult {
|
||||
/** "stored" when a row was upserted; "insufficient" when the blend gated. */
|
||||
outcome: "stored" | "insufficient";
|
||||
/** The persisted score, or null when insufficient. */
|
||||
score: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute + persist one user's Recovery score for the `now` day. Upserts on
|
||||
* the `(userId, type, source, externalId)` key so a re-run overwrites the
|
||||
* day's row in place rather than duplicating it. Writes NOTHING when the
|
||||
* readiness blend is insufficient — no phantom score from too few signals.
|
||||
*/
|
||||
export async function persistRecoveryScore(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
now: Date,
|
||||
): Promise<PersistRecoveryResult> {
|
||||
const profile = await loadRecoveryProfile(prisma, userId);
|
||||
const { score } = await computeRecoveryScore(prisma, userId, profile, now);
|
||||
|
||||
if (score === null) {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
return { outcome: "stored", score };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* v1.10.0 — computed scores (WX-C). Recovery-score queue registration guard.
|
||||
*
|
||||
* Same source-text-grep approach as the other queue-wiring guards: assert
|
||||
* the queue is imported, registered in `allQueues`, scheduled, and wired to
|
||||
* a `boss.work` handler — without booting pg-boss + Prisma. An unregistered
|
||||
* queue silently never drains (the recurring past bug this guards against).
|
||||
*
|
||||
* Also pins the iOS-write rejection of the server-owned COMPUTED source on
|
||||
* the batch ingest route: the `batchSourceEnum` allowlist must stay exactly
|
||||
* `{APPLE_HEALTH, MANUAL}`, so COMPUTED (like WITHINGS / IMPORT) can never be
|
||||
* forged by a client.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
const workerSource = readFileSync(REMINDER_WORKER_PATH, "utf8");
|
||||
|
||||
const BATCH_ROUTE_PATH = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"app",
|
||||
"api",
|
||||
"measurements",
|
||||
"batch",
|
||||
"route.ts",
|
||||
);
|
||||
const batchSource = readFileSync(BATCH_ROUTE_PATH, "utf8");
|
||||
|
||||
describe("reminder-worker — recovery-score wiring", () => {
|
||||
it("imports the queue symbols from the recovery-score module", () => {
|
||||
expect(workerSource).toMatch(
|
||||
/from\s*["']@\/lib\/jobs\/recovery-score["']/,
|
||||
);
|
||||
expect(workerSource).toMatch(/\bRECOVERY_SCORE_QUEUE\b/);
|
||||
expect(workerSource).toMatch(/\bRECOVERY_SCORE_CRON\b/);
|
||||
expect(workerSource).toMatch(/\brunRecoveryScore\b/);
|
||||
});
|
||||
|
||||
it("registers the recovery-score queue in the allQueues loop", () => {
|
||||
const allQueuesMatch = workerSource.match(
|
||||
/const allQueues\s*=\s*\[([\s\S]*?)\];/,
|
||||
);
|
||||
expect(allQueuesMatch).not.toBeNull();
|
||||
expect(allQueuesMatch![1]).toMatch(/\bRECOVERY_SCORE_QUEUE\b/);
|
||||
});
|
||||
|
||||
it("schedules the recovery-score cron", () => {
|
||||
expect(workerSource).toMatch(
|
||||
/\[RECOVERY_SCORE_QUEUE,\s*RECOVERY_SCORE_CRON\]/,
|
||||
);
|
||||
});
|
||||
|
||||
it("registers a boss.work handler that runs the recovery-score pass", () => {
|
||||
expect(workerSource).toMatch(
|
||||
/boss\.work[\s\S]{0,200}RECOVERY_SCORE_QUEUE[\s\S]{0,400}runRecoveryScore/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("measurements batch route — COMPUTED iOS-write rejection", () => {
|
||||
it("keeps the batch source allowlist to APPLE_HEALTH + MANUAL only", () => {
|
||||
// The server-owned sources (COMPUTED / WITHINGS / IMPORT) are excluded
|
||||
// by construction: the client-facing batch enum is exactly this pair.
|
||||
expect(batchSource).toMatch(
|
||||
/batchSourceEnum\s*=\s*z\.enum\(\[\s*"APPLE_HEALTH"\s*,\s*"MANUAL"\s*\]\)/,
|
||||
);
|
||||
expect(batchSource).not.toMatch(/batchSourceEnum[\s\S]{0,80}"COMPUTED"/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* v1.10.0 — computed scores (WX-C). Recovery-score nightly run logic.
|
||||
*
|
||||
* Drives `runRecoveryScore` with a stubbed prisma + a mocked per-user
|
||||
* persist so the tally (stored / insufficient / errored) and the
|
||||
* one-bad-user-does-not-block-the-cohort guarantee are pinned without a DB.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const persistMock = vi.fn();
|
||||
const annotateMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/insights/recovery-score", () => ({
|
||||
persistRecoveryScore: (...args: unknown[]) => persistMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: (...args: unknown[]) => annotateMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
runRecoveryScore,
|
||||
findRecoveryScoreCandidates,
|
||||
RECOVERY_SCORE_RECENCY_DAYS,
|
||||
} from "../recovery-score";
|
||||
|
||||
const NOW = new Date("2026-06-02T08:00:00Z");
|
||||
|
||||
function makePrisma(userIds: string[]) {
|
||||
const findMany = vi
|
||||
.fn()
|
||||
.mockResolvedValue(userIds.map((userId) => ({ userId })));
|
||||
return {
|
||||
prisma: { measurement: { findMany } } as unknown as Parameters<
|
||||
typeof runRecoveryScore
|
||||
>[0],
|
||||
findMany,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("findRecoveryScoreCandidates", () => {
|
||||
it("queries live recovery-input rows inside the recency window", async () => {
|
||||
const { prisma, findMany } = makePrisma(["a", "b"]);
|
||||
const ids = await findRecoveryScoreCandidates(prisma, NOW, 100);
|
||||
|
||||
expect(ids).toEqual(["a", "b"]);
|
||||
const where = findMany.mock.calls[0][0].where;
|
||||
expect(where.type.in).toEqual(
|
||||
expect.arrayContaining([
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"SLEEP_DURATION",
|
||||
"RESPIRATORY_RATE",
|
||||
]),
|
||||
);
|
||||
expect(where.deletedAt).toBeNull();
|
||||
// recency floor is `now - RECENCY_DAYS`.
|
||||
const expectedSince = new Date(
|
||||
NOW.getTime() - RECOVERY_SCORE_RECENCY_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
expect(where.measuredAt.gte.getTime()).toBe(expectedSince.getTime());
|
||||
expect(findMany.mock.calls[0][0].distinct).toEqual(["userId"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRecoveryScore", () => {
|
||||
it("tallies stored vs insufficient across the cohort", async () => {
|
||||
const { prisma } = makePrisma(["a", "b", "c"]);
|
||||
persistMock
|
||||
.mockResolvedValueOnce({ outcome: "stored", score: 70 })
|
||||
.mockResolvedValueOnce({ outcome: "insufficient", score: null })
|
||||
.mockResolvedValueOnce({ outcome: "stored", score: 55 });
|
||||
|
||||
const result = await runRecoveryScore(prisma, { now: NOW });
|
||||
|
||||
expect(result).toEqual({
|
||||
considered: 3,
|
||||
stored: 2,
|
||||
insufficient: 1,
|
||||
errored: 0,
|
||||
});
|
||||
expect(persistMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("counts a per-user error and keeps processing the rest", async () => {
|
||||
const { prisma } = makePrisma(["a", "b"]);
|
||||
persistMock
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce({ outcome: "stored", score: 80 });
|
||||
|
||||
const result = await runRecoveryScore(prisma, { now: NOW });
|
||||
|
||||
expect(result).toEqual({
|
||||
considered: 2,
|
||||
stored: 1,
|
||||
insufficient: 0,
|
||||
errored: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("annotates the pass with the tally", async () => {
|
||||
const { prisma } = makePrisma(["a"]);
|
||||
persistMock.mockResolvedValue({ outcome: "stored", score: 90 });
|
||||
|
||||
await runRecoveryScore(prisma, { now: NOW });
|
||||
|
||||
expect(annotateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({ name: "insights.recovery.compute" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* v1.10.0 — computed scores (WX-C). Nightly Recovery-score computation +
|
||||
* store.
|
||||
*
|
||||
* Once a night this cron computes each eligible user's Recovery score from
|
||||
* their already-stored nightly signals (RHR / HRV / sleep / respiratory
|
||||
* deviation from the personal baseline) and persists it as a `COMPUTED`
|
||||
* `RECOVERY_SCORE` Measurement row (see `src/lib/insights/recovery-score.ts`).
|
||||
* The score is NEVER computed on a page visit — the read surfaces query the
|
||||
* stored row like any other daily series.
|
||||
*
|
||||
* Idempotent per user per day: the per-user persist upserts on
|
||||
* `(userId, type, source, externalId)` with `externalId = recovery:YYYY-MM-DD`,
|
||||
* so a re-fired cron tick (or a manual re-run) overwrites the day's row in
|
||||
* place rather than duplicating it. A user whose readiness blend is below the
|
||||
* minimum-component floor gets NO row that night — the series stays honest.
|
||||
*
|
||||
* The queue name MUST be registered in `allQueues` in
|
||||
* `src/lib/jobs/reminder-worker.ts` so pg-boss provisions it at boot; an
|
||||
* unregistered queue silently never drains (the recurring past bug this
|
||||
* comment guards against).
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { persistRecoveryScore } from "@/lib/insights/recovery-score";
|
||||
|
||||
export const RECOVERY_SCORE_QUEUE = "recovery-score-compute";
|
||||
|
||||
/**
|
||||
* Daily at 04:45 Europe/Berlin — inside the existing 03:xx–04:xx maintenance
|
||||
* window, after the cumulative drain (03:45) + the daily-mean consolidation
|
||||
* and the insight pre-generation (04:30) so the nightly signals it reads are
|
||||
* already folded into the rollup tier by the time it runs.
|
||||
*/
|
||||
export const RECOVERY_SCORE_CRON = "45 4 * * *";
|
||||
|
||||
/**
|
||||
* Per-run user cap. A nightly fan-out across every user could starve the
|
||||
* pool; the discovery query is ordered so users with the most recent vital
|
||||
* data are served first and the long tail catches up over successive nights.
|
||||
*/
|
||||
export const RECOVERY_SCORE_BATCH_CAP = 500;
|
||||
|
||||
/** Trailing window (days) a user must have a recent recovery-input row in to be a candidate. */
|
||||
export const RECOVERY_SCORE_RECENCY_DAYS = 2;
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface RecoveryScoreRunResult {
|
||||
considered: number;
|
||||
stored: number;
|
||||
insufficient: number;
|
||||
errored: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover users worth scoring tonight: anyone with at least one LIVE
|
||||
* recovery-input row (RHR / HRV / sleep / respiratory) in the recency window.
|
||||
* A user with no recent nightly signal cannot produce a score, so skipping
|
||||
* them keeps the pass cheap. Bounded by `cap`, newest-data first.
|
||||
*/
|
||||
export async function findRecoveryScoreCandidates(
|
||||
prisma: PrismaClient,
|
||||
now: Date,
|
||||
cap: number,
|
||||
): Promise<string[]> {
|
||||
const since = new Date(now.getTime() - RECOVERY_SCORE_RECENCY_DAYS * MS_PER_DAY);
|
||||
// The input-type set is a closed compile-time list of enum members —
|
||||
// splice-free; Prisma binds the `type IN (...)` array as parameters.
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
type: {
|
||||
in: [
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"SLEEP_DURATION",
|
||||
"RESPIRATORY_RATE",
|
||||
],
|
||||
},
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
select: { userId: true },
|
||||
distinct: ["userId"],
|
||||
take: cap,
|
||||
});
|
||||
return rows.map((r) => r.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one Recovery-score pass. Pure of pg-boss so the unit test drives it
|
||||
* directly. Each user is scored independently; a single user's error is
|
||||
* recorded and the pass continues (one bad account never blocks the cohort).
|
||||
*/
|
||||
export async function runRecoveryScore(
|
||||
prisma: PrismaClient,
|
||||
options: { now?: Date; cap?: number } = {},
|
||||
): Promise<RecoveryScoreRunResult> {
|
||||
const now = options.now ?? new Date();
|
||||
const cap = options.cap ?? RECOVERY_SCORE_BATCH_CAP;
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -68,6 +68,11 @@ import {
|
||||
forceWarmUser,
|
||||
type InsightPregeneratePayload,
|
||||
} from "@/lib/jobs/insight-pregenerate";
|
||||
import {
|
||||
RECOVERY_SCORE_QUEUE,
|
||||
RECOVERY_SCORE_CRON,
|
||||
runRecoveryScore,
|
||||
} from "@/lib/jobs/recovery-score";
|
||||
import {
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
INSIGHT_STATUS_GENERATE_CONCURRENCY,
|
||||
@@ -2128,6 +2133,11 @@ export async function startReminderWorker() {
|
||||
// schedule silently no-ops and pruned-past-retention tombstones pile
|
||||
// up forever.
|
||||
MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE,
|
||||
// v1.10.0 — computed scores (WX-C). Nightly Recovery-score compute +
|
||||
// store. The cron tick fans out one stored `COMPUTED RECOVERY_SCORE` row
|
||||
// per eligible user. The queue MUST be registered here or pg-boss never
|
||||
// provisions it and the 04:45 schedule silently never fires.
|
||||
RECOVERY_SCORE_QUEUE,
|
||||
];
|
||||
|
||||
for (const q of allQueues) {
|
||||
@@ -2218,6 +2228,10 @@ export async function startReminderWorker() {
|
||||
// v1.7.0 — daily 03:40 Europe/Berlin prune for expired measurement
|
||||
// tombstones.
|
||||
[MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, MEASUREMENT_TOMBSTONE_CLEANUP_CRON],
|
||||
// v1.10.0 — computed scores (WX-C). Nightly 04:45 Europe/Berlin
|
||||
// Recovery-score compute + store, after the rollup-feeding consolidation
|
||||
// + drain so the signals it reads are already folded.
|
||||
[RECOVERY_SCORE_QUEUE, RECOVERY_SCORE_CRON],
|
||||
];
|
||||
|
||||
for (const [name, cron] of schedules) {
|
||||
@@ -2374,6 +2388,28 @@ export async function startReminderWorker() {
|
||||
{ localConcurrency: 1 },
|
||||
handleInsightPregenerateJob,
|
||||
);
|
||||
// v1.10.0 — computed scores (WX-C). Nightly Recovery-score compute +
|
||||
// store. The cron tick carries an empty payload; the runner iterates every
|
||||
// eligible user and upserts one `COMPUTED RECOVERY_SCORE` row per scored
|
||||
// day (idempotent — a re-fire overwrites in place). Single-flight so two
|
||||
// ticks never double-walk the cohort.
|
||||
await boss.work(
|
||||
RECOVERY_SCORE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
async () => {
|
||||
try {
|
||||
const summary = await runRecoveryScore(getWorkerPrisma());
|
||||
workerLog(
|
||||
"info",
|
||||
`[recovery-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog("error", "[recovery-score] pass failed", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
// v1.8.3 — on-demand per-metric status generation enqueued by the
|
||||
// read-only status route on a cold card. Low concurrency so a first
|
||||
// visit that cold-misses several cards can't saturate the Prisma pool
|
||||
|
||||
Reference in new issue
Block a user