From 4eee7c1de59440f52eae3658fb94bd4fa57baeb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:49:45 +0200 Subject: [PATCH] fix(insights): collapse WHOOP and computed recovery to one canonical night MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native WHOOP recovery and the computed proxy stamp the same night on opposite clocks: WHOOP files the wake-morning instant (day D), the proxy files noon-UTC of the day that ended (day D-1). Bucketing raw measuredAt by calendar day leaves both alive as two competing recovery days. Bucket each source by the user's local wake-day instead — shifting the proxy's day-that-ended stamp forward one day to meet WHOOP's wake-morning stamp — so a single night collapses to one canonical row while distinct nights stay separate. Resolve the coach-nudge recovery trend and the coach-snapshot dedup through the same path so no surface blends the two series or drops a genuine native value on a numeric tie. --- .../coach/__tests__/derived-snapshot.test.ts | 49 ++++++++++-- src/lib/ai/coach/derived-snapshot.ts | 39 ++++++++-- .../__tests__/recovery-resolve.test.ts | 60 +++++++++++---- .../derived/__tests__/wellness-scores.test.ts | 16 +++- src/lib/insights/derived/recovery-resolve.ts | 68 +++++++++++++--- src/lib/insights/derived/wellness-scores.ts | 19 +++++ src/lib/jobs/__tests__/coach-nudge.test.ts | 77 +++++++++++++++++++ src/lib/jobs/coach-nudge.ts | 15 +++- 8 files changed, 295 insertions(+), 48 deletions(-) diff --git a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts index 01a1259d5..7bdacb9aa 100644 --- a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts @@ -5,10 +5,21 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => { return { ...actual, computeDerivedMetric: vi.fn() }; }); +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findFirst: vi.fn() } }, +})); + import { computeDerivedMetric } from "@/lib/insights/derived"; +import { prisma } from "@/lib/db"; import { buildDerivedSnapshotBlock } from "../derived-snapshot"; const compute = computeDerivedMetric as unknown as ReturnType; +const whoopFindFirst = prisma.measurement + .findFirst as unknown as ReturnType; +/** Make the WHOOP-native recovery probe report present / absent. */ +function setWhoopNativePresent(present: boolean) { + whoopFindFirst.mockResolvedValue(present ? { id: "whoop-1" } : null); +} const PROFILE = { ageYears: 40, sex: "MALE" as const, heightCm: 180 }; const NOW = new Date("2026-06-02T08:00:00Z"); @@ -28,7 +39,13 @@ const insufficient = { reason: "no_score_in_window", }; -beforeEach(() => compute.mockReset()); +beforeEach(() => { + compute.mockReset(); + whoopFindFirst.mockReset(); + // Default: no WHOOP-native recovery row in the window. Tests that exercise + // the native-present path flip this explicitly. + setWhoopNativePresent(false); +}); describe("buildDerivedSnapshotBlock", () => { it("returns null when every metric is insufficient", async () => { @@ -38,6 +55,9 @@ describe("buildDerivedSnapshotBlock", () => { }); it("emits compact value + band + confidence per ok metric, omits insufficient", async () => { + // A WHOOP-native recovery is present, so the recovery line survives + // alongside readiness (the dedup only drops the COMPUTED proxy). + setWhoopNativePresent(true); compute.mockImplementation(async (args?: { metric?: string }) => { if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" }); if (args?.metric === "RECOVERY_SCORE") return ok({ score: 72, band: "green" }); @@ -61,9 +81,11 @@ describe("buildDerivedSnapshotBlock", () => { ]); }); - it("drops RECOVERY_SCORE when it mirrors READINESS (COMPUTED proxy, no native)", async () => { - // With no WHOOP-native recovery, the COMPUTED proxy IS the readiness blend - // verbatim — feeding both labels hands the model the same number twice. + it("drops RECOVERY_SCORE when it is the COMPUTED proxy (no WHOOP-native row)", async () => { + // No WHOOP-native recovery in the window → the resolved recovery IS the + // COMPUTED proxy, which equals readiness. Feeding both hands the model the + // same number twice, so the recovery line is dropped. + setWhoopNativePresent(false); compute.mockImplementation(async (args?: { metric?: string }) => { if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" }); if (args?.metric === "RECOVERY_SCORE") return ok({ score: 64, band: "yellow" }); @@ -72,11 +94,26 @@ describe("buildDerivedSnapshotBlock", () => { const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW); expect(block).not.toBeNull(); expect(block!.READINESS).toBeDefined(); - // The redundant proxy is removed — readiness once, not twice. expect(block!.RECOVERY_SCORE).toBeUndefined(); }); - it("keeps RECOVERY_SCORE when the WHOOP-native value differs from READINESS", async () => { + it("keeps a WHOOP-native RECOVERY_SCORE that coincidentally equals READINESS (L4)", async () => { + // A genuine WHOOP recovery happens to match readiness on the nose. The old + // numeric-equality drop would silence the device's ground-truth number; the + // source-aware drop keeps it because a WHOOP-native row exists. + setWhoopNativePresent(true); + compute.mockImplementation(async (args?: { metric?: string }) => { + if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" }); + if (args?.metric === "RECOVERY_SCORE") return ok({ score: 64, band: "yellow" }); + return insufficient; + }); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW); + expect(block!.READINESS?.value).toBe(64); + expect(block!.RECOVERY_SCORE?.value).toBe(64); + }); + + it("keeps RECOVERY_SCORE when a WHOOP-native value differs from READINESS", async () => { + setWhoopNativePresent(true); compute.mockImplementation(async (args?: { metric?: string }) => { if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" }); if (args?.metric === "RECOVERY_SCORE") return ok({ score: 80, band: "green" }); diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts index ed672955b..3e33a3b22 100644 --- a/src/lib/ai/coach/derived-snapshot.ts +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -20,6 +20,14 @@ import { type DerivedMetricId, type BaselineProfile, } from "@/lib/insights/derived"; +import { prisma } from "@/lib/db"; + +/** + * Trailing window the recovery dedup probes for a WHOOP-native row — matches + * the wellness reader's default trend window so "is the resolved recovery the + * COMPUTED proxy?" is answered against the same set of days. + */ +const RECOVERY_DEDUP_WINDOW_DAYS = 14; /** The high-signal derived metrics worth a Coach prompt slot. The vitals * baseline is omitted — the per-vital aggregate block already carries those @@ -127,14 +135,29 @@ export async function buildDerivedSnapshotBlock( // Recovery dedup (one number, one engine). The COMPUTED recovery proxy IS the // readiness blend verbatim, so when no WHOOP-native row exists the resolved // recovery equals readiness — feeding both labels would hand the model the - // identical score under two names. Drop the redundant recovery line in that - // case; a WHOOP-native recovery resolves to a DIFFERENT number and both stay. - if ( - block.READINESS !== undefined && - block.RECOVERY_SCORE !== undefined && - block.RECOVERY_SCORE.value === block.READINESS.value - ) { - delete block.RECOVERY_SCORE; + // identical score under two names. Drop the redundant recovery line ONLY when + // the resolved recovery is that COMPUTED proxy (no WHOOP-native row present). + // A genuine WHOOP recovery stays even if it coincidentally equals readiness — + // a numeric tie must never silence the device's ground-truth number. + if (block.READINESS !== undefined && block.RECOVERY_SCORE !== undefined) { + const whoopRecovery = await prisma.measurement.findFirst({ + where: { + userId, + type: "RECOVERY_SCORE", + source: "WHOOP", + deletedAt: null, + measuredAt: { + gte: new Date( + now.getTime() - RECOVERY_DEDUP_WINDOW_DAYS * 24 * 60 * 60 * 1000, + ), + lte: now, + }, + }, + select: { id: true }, + }); + if (whoopRecovery === null) { + delete block.RECOVERY_SCORE; + } } return Object.keys(block).length > 0 ? block : null; diff --git a/src/lib/insights/derived/__tests__/recovery-resolve.test.ts b/src/lib/insights/derived/__tests__/recovery-resolve.test.ts index b73054353..5fac7090f 100644 --- a/src/lib/insights/derived/__tests__/recovery-resolve.test.ts +++ b/src/lib/insights/derived/__tests__/recovery-resolve.test.ts @@ -8,11 +8,21 @@ function row(iso: string, value: number, source: string): RecoveryRow { return { value, measuredAt: new Date(iso), source: source as never }; } +// All stamps below use REALISTIC source clocks for one physiological night: +// - WHOOP stamps the wake-morning instant (`updated_at`). +// - COMPUTED stamps noon-UTC of the day-that-ended (`scoreDayKey = run − 1d`), +// so for the SAME night it lands one calendar day BEFORE the WHOOP row. +// Europe/Berlin (the resolver default) is UTC+2 in June, so a noon-UTC stamp +// reads as the same local day and a ~06:00-UTC wake stamp reads as the wake day. describe("resolveCanonicalRecovery", () => { - it("prefers the WHOOP-native row over the COMPUTED proxy for the same day", () => { + it("collapses the same night's WHOOP + COMPUTED rows to the WHOOP value", () => { + // Night of Jun 01 → wake Jun 02. WHOOP scores Jun 02 morning; the COMPUTED + // proxy for that readiness is filed under Jun 01 (the day that ended). With + // realistic stamps the two land on DIFFERENT calendar days — the resolver's + // source-aware wake-day key must still fold them into ONE canonical night. const rows = [ - row("2026-06-01T12:00:00Z", 50, "COMPUTED"), - row("2026-06-01T06:00:00Z", 80, "WHOOP"), + row("2026-06-01T12:00:00Z", 50, "COMPUTED"), // day-that-ended stamp + row("2026-06-02T06:00:00Z", 80, "WHOOP"), // wake-morning stamp ]; const resolved = resolveCanonicalRecovery(rows); expect(resolved).toHaveLength(1); @@ -20,7 +30,7 @@ describe("resolveCanonicalRecovery", () => { expect(resolved[0].value).toBe(80); }); - it("falls back to COMPUTED when no WHOOP row exists for the day", () => { + it("falls back to COMPUTED when no WHOOP row exists for the night", () => { const rows = [row("2026-06-01T12:00:00Z", 55, "COMPUTED")]; const resolved = resolveCanonicalRecovery(rows); expect(resolved).toHaveLength(1); @@ -28,31 +38,49 @@ describe("resolveCanonicalRecovery", () => { expect(resolved[0].value).toBe(55); }); - it("resolves per day — WHOOP one day, COMPUTED another", () => { + it("keeps a genuinely SEPARATE next night separate", () => { + // Two nights: Jun 01→02 (WHOOP, wake Jun 02) and Jun 02→03 (COMPUTED proxy + // filed under Jun 02). Despite both touching the Jun 02 calendar day on raw + // stamps, they are DIFFERENT nights and must stay two canonical rows. const rows = [ - row("2026-06-02T12:00:00Z", 51, "COMPUTED"), - row("2026-06-01T12:00:00Z", 49, "COMPUTED"), - row("2026-06-01T06:00:00Z", 70, "WHOOP"), + row("2026-06-02T12:00:00Z", 51, "COMPUTED"), // night Jun02→03, wake Jun03 + row("2026-06-02T06:00:00Z", 70, "WHOOP"), // night Jun01→02, wake Jun02 ]; const resolved = resolveCanonicalRecovery(rows); - // Sorted desc by measuredAt: 06-02 COMPUTED first, then 06-01 WHOOP. expect(resolved).toHaveLength(2); - expect(resolved[0].source).toBe("COMPUTED"); - expect(resolved[0].value).toBe(51); - expect(resolved[1].source).toBe("WHOOP"); - expect(resolved[1].value).toBe(70); + // Sorted desc by measuredAt: the Jun02-noon COMPUTED first, then the + // Jun02-06:00 WHOOP. Both survive — no night was merged away. + expect(resolved.map((r) => r.value).sort()).toEqual([51, 70]); + expect(resolved.map((r) => r.source).sort()).toEqual(["COMPUTED", "WHOOP"]); }); - it("keeps the latest same-source row within a day", () => { + it("keeps the latest same-source row within a night", () => { const rows = [ - row("2026-06-01T06:00:00Z", 60, "WHOOP"), - row("2026-06-01T18:00:00Z", 75, "WHOOP"), + row("2026-06-02T06:00:00Z", 60, "WHOOP"), + row("2026-06-02T08:00:00Z", 75, "WHOOP"), // a morning re-score ]; const resolved = resolveCanonicalRecovery(rows); expect(resolved).toHaveLength(1); expect(resolved[0].value).toBe(75); }); + it("buckets by the user's local wake-day, not UTC", () => { + // A WHOOP wake stamp just after local midnight in a far-east zone. Read in + // UTC it would file under the previous calendar day; in the user's zone it + // is the wake day, matching the COMPUTED proxy's forward-shifted anchor. + const rows = [ + // Pacific/Auckland (UTC+12 in June): 2026-06-02T13:00:00Z = 2026-06-03 01:00 local. + row("2026-06-02T13:00:00Z", 82, "WHOOP"), + // COMPUTED proxy for the same wake (Jun 03 local): filed Jun 02 noon UTC, + // which is Jun 03 00:00 local → shifted forward to Jun 03 local wake day. + row("2026-06-02T00:00:00Z", 40, "COMPUTED"), + ]; + const resolved = resolveCanonicalRecovery(rows, "Pacific/Auckland"); + expect(resolved).toHaveLength(1); + expect(resolved[0].source).toBe("WHOOP"); + expect(resolved[0].value).toBe(82); + }); + it("returns an empty list for no rows", () => { expect(resolveCanonicalRecovery([])).toEqual([]); }); diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts index 1df5d0a46..58b3c8cd0 100644 --- a/src/lib/insights/derived/__tests__/wellness-scores.test.ts +++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts @@ -4,6 +4,7 @@ vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: vi.fn() }, strainTrimpCache: { findUnique: vi.fn() }, + user: { findUnique: vi.fn() }, }, })); @@ -19,11 +20,16 @@ const NOW = new Date("2026-06-02T08:00:00Z"); const findMany = prisma.measurement.findMany as ReturnType; const cacheFindUnique = prisma.strainTrimpCache .findUnique as ReturnType; +const userFindUnique = prisma.user.findUnique as ReturnType; beforeEach(() => { findMany.mockReset(); cacheFindUnique.mockReset(); cacheFindUnique.mockResolvedValue(null); + // The recovery resolver buckets by the user's local wake-day; the reader + // loads the zone when none is passed. Default the cohort to Europe/Berlin. + userFindUnique.mockReset(); + userFindUnique.mockResolvedValue({ timezone: "Europe/Berlin" }); }); describe("bandWellnessScore", () => { @@ -135,11 +141,13 @@ describe("computeWellnessScore", () => { }); it("RECOVERY reads BOTH sources and resolves the WHOOP-native row when present", async () => { - // Same day written by both the COMPUTED proxy AND the WHOOP-native sync. - // The native row is canonical → the tile shows 80, not the proxy's 50. + // ONE night written by both engines on their realistic clocks: the WHOOP + // wake-morning stamp (Jun 02) and the COMPUTED proxy filed under the + // day-that-ended (Jun 01). The native row is canonical → the tile shows 80, + // not the proxy's 50, and the off-by-one does NOT spawn two recovery days. findMany.mockResolvedValue([ - { value: 50, measuredAt: new Date("2026-06-02T12:00:00Z"), source: "COMPUTED" }, { value: 80, measuredAt: new Date("2026-06-02T06:00:00Z"), source: "WHOOP" }, + { value: 50, measuredAt: new Date("2026-06-01T12:00:00Z"), source: "COMPUTED" }, ]); const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, { now: NOW, @@ -151,6 +159,8 @@ describe("computeWellnessScore", () => { if (r.status === "ok") { expect((r.value as WellnessScoreValue).score).toBe(80); expect((r.value as WellnessScoreValue).band).toBe("green"); + // One night, one canonical row — the off-by-one collapsed. + expect((r.value as WellnessScoreValue).daysInWindow).toBe(1); } }); diff --git a/src/lib/insights/derived/recovery-resolve.ts b/src/lib/insights/derived/recovery-resolve.ts index 6485ea332..85e432e30 100644 --- a/src/lib/insights/derived/recovery-resolve.ts +++ b/src/lib/insights/derived/recovery-resolve.ts @@ -14,11 +14,35 @@ * doctor PDF, the iOS feed — must resolve to the SAME canonical row per day so * none of them shows the proxy and the native value as two competing series. * - * Pure resolution: group rows by their calendar day and, per day, keep the - * WHOOP row when one exists, else the COMPUTED one. No DB access here — the - * caller does the bounded read and passes rows in. + * Pure resolution: group rows by the physiological NIGHT they describe and, + * per night, keep the WHOOP row when one exists, else the COMPUTED one. No DB + * access here — the caller does the bounded read and passes rows in. + * + * THE DAY-ANCHOR PROBLEM (v1.16.16). The two sources stamp the SAME night on + * opposite clocks: + * - WHOOP stamps `measuredAt = updated_at` — the MORNING instant WHOOP + * finished scoring the night → the WAKE day D. + * - COMPUTED stamps `measuredAt = noon-UTC of scoreDayKey` where + * `scoreDayKey = (run morning) − 1 day` → the day-that-ENDED, D−1 + * (`src/lib/insights/score-row.ts`). The 04:45 Europe/Berlin cron + * scores last night's signals but files them under the prior day. + * So for ONE night the WHOOP row lands on day D and the COMPUTED proxy on day + * D−1 — a systematic off-by-one. Bucketing raw `measuredAt` by calendar day + * therefore leaves BOTH alive as two competing recovery days. + * + * The fix is a SOURCE-AWARE wake-day key: the COMPUTED proxy's day stamp is + * shifted forward one day (the readiness of the day that ended IS the readiness + * you wake with the next morning), and both keys are read in the user's LOCAL + * timezone so a near-midnight / late re-score can't split a single night. The + * COMPUTED proxy's own stored stamp (shared with stress / strain) is NOT + * touched — the shift is a pure read-time alignment, so no ingest change and no + * backfill are needed and two genuinely different nights stay separate. */ import type { MeasurementSource } from "@/generated/prisma/client"; +import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; +import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; /** A `RECOVERY_SCORE` row as read for resolution. */ export interface RecoveryRow { @@ -41,23 +65,43 @@ function rankOf(source: MeasurementSource): number { return RECOVERY_SOURCE_RANK[source] ?? Number.MAX_SAFE_INTEGER; } -/** The UTC calendar-day key a recovery row is filed under. */ -function dayKeyOf(d: Date): string { - return d.toISOString().slice(0, 10); +/** + * The local WAKE-day key a recovery row describes. WHOOP's stamp already IS the + * wake morning, so its local day is the night's wake day directly. The COMPUTED + * proxy stamps the day-that-ended (`scoreDayKey = run − 1 day`), so its day is + * shifted forward by one before the local-day read — aligning it onto the same + * wake day the WHOOP row for that night carries. Reading the key in the user's + * timezone keeps a near-midnight or late re-score on the same night. + */ +function wakeDayKeyOf(d: Date, source: MeasurementSource, tz: string): string { + const anchor = + source === "COMPUTED" ? new Date(d.getTime() + MS_PER_DAY) : d; + return dayKeyForUserTz(anchor, tz); } /** - * Collapse a mixed-source `RECOVERY_SCORE` set to ONE canonical row per day: - * the WHOOP-native row wins when present, else the COMPUTED proxy. Within a - * source the latest `measuredAt` wins (a same-day re-score). Returns the + * Collapse a mixed-source `RECOVERY_SCORE` set to ONE canonical row per + * physiological NIGHT: the WHOOP-native row wins when present, else the + * COMPUTED proxy. Within a source the latest `measuredAt` wins (a same-night + * re-score). Rows are bucketed by their local WAKE-day (the COMPUTED proxy's + * day-that-ended stamp is shifted forward one day to meet WHOOP's wake-morning + * stamp) so a single night never splits into two competing days. Returns the * canonical rows in the same shape, sorted by `measuredAt` DESCENDING so the - * first element is the most recent canonical day — matching the order the + * first element is the most recent canonical night — matching the order the * wellness reader already expects. + * + * `timezone` is the user's IANA zone (falls back to `Europe/Berlin` when + * null/empty); the wake-day key is read in it so the bucket boundary tracks the + * user's midnight, not UTC's. */ -export function resolveCanonicalRecovery(rows: RecoveryRow[]): RecoveryRow[] { +export function resolveCanonicalRecovery( + rows: RecoveryRow[], + timezone?: string | null, +): RecoveryRow[] { + const tz = resolveUserTimezone(timezone ?? null); const byDay = new Map(); for (const row of rows) { - const key = dayKeyOf(row.measuredAt); + const key = wakeDayKeyOf(row.measuredAt, row.source, tz); const incumbent = byDay.get(key); if (incumbent === undefined) { byDay.set(key, row); diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts index 2e71d0123..1539e89d5 100644 --- a/src/lib/insights/derived/wellness-scores.ts +++ b/src/lib/insights/derived/wellness-scores.ts @@ -90,6 +90,13 @@ export interface WellnessScoreOpts { /** Trailing window for the trend mean (days). Defaults to 14. */ windowDays?: number; now?: Date; + /** + * The user's IANA timezone, used only by the RECOVERY canonical-night + * resolver to bucket WHOOP + COMPUTED rows by the local wake-day. When + * omitted, the reader loads it from the user row (falling back to + * `Europe/Berlin`). STRESS / STRAIN ignore it. + */ + timezone?: string | null; } /** @@ -132,6 +139,17 @@ export async function computeWellnessScore( // wins over COMPUTED). Non-recovery sets are already single-source, so the // resolver is recovery-only — the tile, doctor PDF, and iOS feed all read the // SAME canonical value. + let timezone = opts.timezone ?? null; + if (isRecovery && timezone === null) { + // The canonical-night resolver buckets by the user's local wake-day, so the + // off-by-one between WHOOP's wake-morning stamp and the COMPUTED proxy's + // day-that-ended stamp collapses onto one night. Load the zone once. + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + timezone = user?.timezone ?? null; + } const rows = isRecovery ? resolveCanonicalRecovery( rawRows.map((r) => ({ @@ -139,6 +157,7 @@ export async function computeWellnessScore( measuredAt: r.measuredAt, source: r.source, })), + timezone, ) : rawRows; diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts index 8082b5f0f..20f68e6af 100644 --- a/src/lib/jobs/__tests__/coach-nudge.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge.test.ts @@ -29,6 +29,7 @@ import { evaluateSelfContextTrigger, evaluateSleepDebtTrigger, evaluateWeightTrigger, + findTriggerForUser, runCoachNudgeTick, } from "../coach-nudge"; import { getAssistantFlags } from "@/lib/feature-flags"; @@ -165,6 +166,82 @@ describe("evaluateScoreTrigger", () => { }); }); +describe("findTriggerForUser RECOVERY_SCORE canonical resolution", () => { + const NOW = new Date("2026-06-15T08:00:00Z"); + const DAY = 24 * 60 * 60 * 1000; + // Vitals-only so the recovery branch is the only one with data; no height / + // dob / gender → the WEIGHT range can't resolve and that branch is skipped. + const VITALS_ONLY = { medication: false, vitals: true, routine: false }; + const baseUser = { + id: "u-recovery", + heightCm: null, + dateOfBirth: null, + gender: null, + thresholdsJson: null, + timezone: "Europe/Berlin", + }; + + /** A prisma stub serving ONLY the supplied RECOVERY_SCORE rows; every other + * measurement read comes back empty so no other vitals trigger fires. */ + function recoveryPrisma(recoveryRows: unknown[]) { + return { + measurement: { + findMany: vi.fn(async (args: { where: { type?: string } }) => + args.where.type === "RECOVERY_SCORE" ? recoveryRows : [], + ), + }, + } as unknown as PrismaClient; + } + + /** A recovery row on a source's realistic clock. */ + function rec(iso: string, value: number, source: "WHOOP" | "COMPUTED") { + return { value, measuredAt: new Date(iso), source }; + } + + it("does not fire when the canonical WHOOP series is stable but stray COMPUTED proxies dip", async () => { + // Healthy, flat WHOOP-native recovery across both windows (~70). For the + // SAME nights a low COMPUTED proxy (~40) also sits in the table. Blending + // both — the pre-fix behaviour — would drag the recent mean down enough to + // trip the drop trigger. Canonical resolution keeps only the WHOOP rows. + const rows: unknown[] = []; + for (let d = 1; d <= 12; d++) { + const day = new Date(NOW.getTime() - d * DAY); + const iso = day.toISOString().slice(0, 10); + rows.push(rec(`${iso}T06:00:00Z`, 70, "WHOOP")); + // COMPUTED proxy for the same night, filed one day earlier at noon UTC. + const prevIso = new Date(day.getTime() - DAY).toISOString().slice(0, 10); + rows.push(rec(`${prevIso}T12:00:00Z`, 40, "COMPUTED")); + } + const trigger = await findTriggerForUser( + recoveryPrisma(rows), + baseUser, + NOW, + VITALS_ONLY, + ); + expect(trigger).not.toBe("score"); + }); + + it("still fires on a genuine WHOOP-native week-over-week recovery drop", async () => { + // No COMPUTED rows — a real native collapse from ~72 to ~50 must trigger. + const rows: unknown[] = []; + for (let d = 1; d <= 6; d++) { + const day = new Date(NOW.getTime() - d * DAY).toISOString().slice(0, 10); + rows.push(rec(`${day}T06:00:00Z`, 50, "WHOOP")); + } + for (let d = 8; d <= 13; d++) { + const day = new Date(NOW.getTime() - d * DAY).toISOString().slice(0, 10); + rows.push(rec(`${day}T06:00:00Z`, 72, "WHOOP")); + } + const trigger = await findTriggerForUser( + recoveryPrisma(rows), + baseUser, + NOW, + VITALS_ONLY, + ); + expect(trigger).toBe("score"); + }); +}); + describe("buildCoachNudgePayload", () => { it("resolves the German payload", () => { const { title, body } = buildCoachNudgePayload("bp", "de"); diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index d931e1733..db20cf094 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -77,6 +77,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; import { getEvent } from "@/lib/logging/context"; +import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; // Trend thresholds shared with the dashboard hero's verdict resolver // live in a client-safe leaf module; re-exported below so server-side // imports keep their path. @@ -434,6 +435,7 @@ export async function findTriggerForUser( dateOfBirth: Date | null; gender: string | null; thresholdsJson: unknown; + timezone?: string | null; }, now: Date, groups: Record = { @@ -497,16 +499,22 @@ export async function findTriggerForUser( } } - // 3) Recovery score falling sharply week-over-week. - const scores = await prisma.measurement.findMany({ + // 3) Recovery score falling sharply week-over-week. RECOVERY_SCORE is + // written by TWO sources (WHOOP-native + the COMPUTED proxy); reading + // the raw rows would blend both series into the trend windows and let a + // single night appear twice. Resolve to the ONE canonical row per night + // (WHOOP wins when present) before the recent/prior split — the same + // "one number, one engine" rule the wellness tile applies. + const scoreRows = await prisma.measurement.findMany({ where: { userId: user.id, type: "RECOVERY_SCORE", deletedAt: null, measuredAt: { gte: fourteenDaysAgo, lte: now }, }, - select: { value: true, measuredAt: true }, + select: { value: true, measuredAt: true, source: true }, }); + const scores = resolveCanonicalRecovery(scoreRows, user.timezone ?? null); const recent = scores .filter((m) => m.measuredAt >= sevenDaysAgo) .map((m) => m.value); @@ -691,6 +699,7 @@ export async function runCoachNudgeTick( dateOfBirth: true, gender: true, thresholdsJson: true, + timezone: true, aiProvider: true, aiProviderChain: true, aiAnthropicKeyEncrypted: true,