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:
Marc-André Bombeck committed 2026-06-02 22:31:21 +02:00
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);
});
});
+178
View File
@@ -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" }),
}),
);
});
});
+130
View File
@@ -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 };
}
+36
View File
@@ -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