diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts index 20f68e6af..64378ebef 100644 --- a/src/lib/jobs/__tests__/coach-nudge.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge.test.ts @@ -45,6 +45,13 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ decryptFromBytes: vi.fn(() => "morning blood pressure"), encryptToBytes: vi.fn(), })); +// The sleep-debt branch resolves the user's source priority through the +// real `@/lib/db` prisma; stub it so the unit suite stays DB-free. A +// null priority is the no-custom-priority default — `reconstructSleepNights` +// (pure) then runs normally on the stubbed measurement rows. +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => null), +})); function dose(taken: boolean, skipped = false, autoMissed?: boolean) { return { diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index b92107a2e..baea8459a 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -78,6 +78,9 @@ 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"; +import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; // 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. @@ -551,9 +554,19 @@ export async function findTriggerForUser( } } - // 5) Sleep-deficit series: per-night hours over the last 7 days, - // longest record per calendar night (multi-source rows collapse - // to the fullest one). + // 5) Sleep-deficit series: per-night asleep HOURS over the last 7 + // days, reconstructed through the canonical sleep engine. + // + // `SLEEP_DURATION` is stored one row per STAGE per night, in + // MINUTES. The trigger compares against + // `effectiveRange("SLEEP_DURATION").greenMin`, which is in HOURS + // (7). The prior inline model kept each night's MAX single stage + // row (a fragment, not the night) AND fed minutes straight into + // the hours comparison — so a real ~360-480 minute night never + // cleared `< 6.5` and the nudge could never fire. Reusing + // `reconstructSleepNights` gives the proper asleep total + // (CORE+DEEP+REM, multi-source de-duped, user-tz wake-day keyed); + // dividing by 60 converts to the hours the trigger expects. const sleepRows = await prisma.measurement.findMany({ where: { userId: user.id, @@ -561,20 +574,25 @@ export async function findTriggerForUser( deletedAt: null, measuredAt: { gte: sevenDaysAgo, lte: now }, }, - select: { value: true, measuredAt: true }, + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, }); if (sleepRows.length > 0) { - const byNight = new Map(); - for (const row of sleepRows) { - const night = row.measuredAt.toISOString().slice(0, 10); - const prev = byNight.get(night); - if (prev === undefined || row.value > prev) { - byNight.set(night, row.value); - } - } + const sourcePriority = await loadUserSourcePriority(user.id); + const nights = reconstructSleepNights( + sleepRows, + user.timezone ?? DEFAULT_TIMEZONE, + sourcePriority, + ); + const nightlyHours = nights.map((n) => n.asleepMinutes / 60); if ( evaluateSleepDebtTrigger( - [...byNight.values()], + nightlyHours, effectiveRange("SLEEP_DURATION")?.greenMin ?? null, ) ) {