diff --git a/src/lib/insights/__tests__/recovery-score.test.ts b/src/lib/insights/__tests__/recovery-score.test.ts new file mode 100644 index 000000000..a705d9191 --- /dev/null +++ b/src/lib/insights/__tests__/recovery-score.test.ts @@ -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>>(); + +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 { + 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 { + 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[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); + }); +}); diff --git a/src/lib/insights/recovery-score.ts b/src/lib/insights/recovery-score.ts new file mode 100644 index 000000000..aa470df68 --- /dev/null +++ b/src/lib/insights/recovery-score.ts @@ -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`; 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; + /** 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 { + // 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 { + 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 { + 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 }; +} diff --git a/src/lib/jobs/__tests__/recovery-score-queue.test.ts b/src/lib/jobs/__tests__/recovery-score-queue.test.ts new file mode 100644 index 000000000..c2d203f2d --- /dev/null +++ b/src/lib/jobs/__tests__/recovery-score-queue.test.ts @@ -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"/); + }); +}); diff --git a/src/lib/jobs/__tests__/recovery-score.test.ts b/src/lib/jobs/__tests__/recovery-score.test.ts new file mode 100644 index 000000000..472433673 --- /dev/null +++ b/src/lib/jobs/__tests__/recovery-score.test.ts @@ -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" }), + }), + ); + }); +}); diff --git a/src/lib/jobs/recovery-score.ts b/src/lib/jobs/recovery-score.ts new file mode 100644 index 000000000..316d2bec4 --- /dev/null +++ b/src/lib/jobs/recovery-score.ts @@ -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 { + 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 { + 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 }; +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 2a3cf76d5..4bf3ad5ad 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -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