From b6ded4d1f9fdeda8748fed89bc98d132665eda24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 22:57:27 +0200 Subject: [PATCH] feat(daily): finalise the morning digest when last night's sleep lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nightly insight pre-pass runs ~04:30, before last night's sleep has synced, so the morning digest and health score reference the day without the current sleep. Close the gap the event-driven way instead of an on-mount generation. A day's digest is now provisional until the sleep-dependent output has been regenerated with last night's sleep, and final after. The lifecycle is driven by a real marker (User.morningDigestRefreshedOn, the user-local date the refresh completed), not a heuristic: it flips the instant the refresh runs, ahead of the snapshot cache that feeds the sleep last-seen fallback. A day with no sleep device stays honestly provisional and keeps the sleep-pending note rather than showing a stale final score. When a completed sleep segment for last night lands from any of the three sleep transports — Withings sync-sleep, the WHOOP sync, the Apple-Health measurement batch — a debounced morning-digest-refresh job is enqueued. "Last night" is judged in the user's profile timezone, so a full-history re-sync never re-triggers for an old night. The queue singletonKey plus the marker collapse the many samples of one night, across all sources, into one refresh per user per local morning; the job re-runs the same comprehensive pipeline the nightly cron uses (forced, so the content-hash gate turns a no-change regen into a cheap no-op) and stamps the marker. A hard generation failure leaves the day provisional and hands recovery to the existing 45-minute briefing-retry machinery. --- .../migration.sql | 15 ++ prisma/schema.prisma | 9 + src/app/api/measurements/batch/route.ts | 17 ++ src/lib/daily/__tests__/digest.test.ts | 29 ++++ .../__tests__/morning-refresh-trigger.test.ts | 137 +++++++++++++++ src/lib/daily/digest.ts | 28 ++- src/lib/daily/load-digest.ts | 12 ++ src/lib/daily/morning-refresh-trigger.ts | 111 ++++++++++++ .../__tests__/morning-digest-refresh.test.ts | 159 ++++++++++++++++++ src/lib/jobs/morning-digest-refresh-shared.ts | 75 +++++++++ src/lib/jobs/morning-digest-refresh.ts | 128 ++++++++++++++ src/lib/jobs/reminder/register-status.ts | 42 +++++ src/lib/whoop/sync-sleep.ts | 23 ++- src/lib/withings/sync-sleep.ts | 10 ++ 14 files changed, 790 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/0244_morning_digest_refresh_marker/migration.sql create mode 100644 src/lib/daily/__tests__/morning-refresh-trigger.test.ts create mode 100644 src/lib/daily/morning-refresh-trigger.ts create mode 100644 src/lib/jobs/__tests__/morning-digest-refresh.test.ts create mode 100644 src/lib/jobs/morning-digest-refresh-shared.ts create mode 100644 src/lib/jobs/morning-digest-refresh.ts diff --git a/prisma/migrations/0244_morning_digest_refresh_marker/migration.sql b/prisma/migrations/0244_morning_digest_refresh_marker/migration.sql new file mode 100644 index 000000000..febf3b48d --- /dev/null +++ b/prisma/migrations/0244_morning_digest_refresh_marker/migration.sql @@ -0,0 +1,15 @@ +-- Morning-freshness marker (S4 of the unified daily-value system). +-- +-- The nightly insight pre-pass runs ~04:30, before last night's sleep has +-- synced, so the morning digest/health-score reference the day WITHOUT the +-- current sleep. This column records the user-local YYYY-MM-DD (their profile +-- timezone) for which the event-driven morning refresh has completed: last +-- night's sleep arrived AND the sleep-dependent insight/score cache was +-- regenerated with it. The daily digest reports `final` once this equals the +-- user's current local date, `provisional` otherwise (honest degradation when +-- sleep is late or never arrives). Stamped by the `morning-digest-refresh` +-- pg-boss job the sleep-arrival trigger enqueues, debounced once per local +-- morning. NULL for every existing row = provisional until the next sleep +-- landing, which is the correct pre-feature reading. +ALTER TABLE "users" + ADD COLUMN "morning_digest_refreshed_on" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 39c642f81..eca80c5a9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -111,6 +111,15 @@ model User { // without re-paying for the findings. This stamps the day a re-roll ran so // the gate stays at most one extra call per day. insightsBriefingRerollDate String? @map("insights_briefing_reroll_date") + // Morning-freshness marker: the user-local YYYY-MM-DD (their profile + // timezone) for which the event-driven morning refresh has run — i.e. last + // night's sleep has arrived AND the sleep-dependent insight/score cache was + // regenerated with it. The daily digest reads `final` when this equals the + // user's current local date, `provisional` otherwise. Stamped by the + // `morning-digest-refresh` job the sleep-arrival trigger enqueues; the + // string form makes the "same local morning" comparison timezone-honest + // without a second timestamp read. + morningDigestRefreshedOn String? @map("morning_digest_refreshed_on") // v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors // `coachPrefsJson.excludeMetrics`. The /api/insights/generate route // filters matching keys off `features` before JSON.stringify so the diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 354c68d60..24e4e9131 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -64,6 +64,7 @@ import { deviceTypeEnum } from "@/lib/validations/source-priority"; import { checkRateLimit } from "@/lib/rate-limit"; import { enqueuePrDetection } from "@/lib/jobs/pr-detection"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; +import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { @@ -764,6 +765,22 @@ async function postBatch(request: NextRequest): Promise { } catch (err) { console.warn("[measurements] rollup recompute failed", err); } + + // S4 — Apple-Health sleep is the third sleep transport. If any SLEEP_DURATION + // row for last night just landed, kick the debounced morning refresh so the + // digest/score finalise with the current sleep. The trigger judges "last + // night" in the user's profile tz, so a historical Apple export replaying + // months of nights never re-triggers for an old night. Fire-and-forget. + void maybeEnqueueMorningRefresh( + user.id, + prepared + .filter((p) => p.row.type === "SLEEP_DURATION") + .map((p) => + p.row.measuredAt instanceof Date + ? p.row.measuredAt + : new Date(p.row.measuredAt as string), + ), + ).catch(() => {}); } return apiSuccess({ diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index d2a147af0..b98156f08 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -53,6 +53,7 @@ function input(over: Partial = {}): DailyDigestInput { briefing, medsToday: meds(), sleepLastSeenDaysAgo: 0, + morningRefreshedToday: false, syncIssues: [], preventiveDue: [], coachPlans: [], @@ -151,6 +152,34 @@ describe("buildDailyDigest — freshness (provisional/final)", () => { expect(d.phase).toBe("final"); expect(d.sleepPending).toBe(false); }); + + it("the morning-refresh marker finalises the day even while the snapshot's sleep last-seen is still stale (S4 fast path)", () => { + // Sleep last-seen still lags at 1 day (snapshot cache not yet expired), but + // the sleep-arrival refresh has stamped the marker for today — the digest + // must read `final` immediately off the authoritative marker. + const provisional = buildDailyDigest( + input({ sleepLastSeenDaysAgo: 1, morningRefreshedToday: false }), + t, + ); + expect(provisional.phase).toBe("provisional"); + expect(provisional.sleepPending).toBe(true); + + const finalised = buildDailyDigest( + input({ sleepLastSeenDaysAgo: 1, morningRefreshedToday: true }), + t, + ); + expect(finalised.phase).toBe("final"); + expect(finalised.sleepPending).toBe(false); + }); + + it("stays provisional when sleep never arrives (no marker, no reading)", () => { + const d = buildDailyDigest( + input({ sleepLastSeenDaysAgo: null, morningRefreshedToday: false }), + t, + ); + expect(d.phase).toBe("provisional"); + expect(d.sleepPending).toBe(true); + }); }); describe("buildDailyDigest — worth-a-look rail item builders", () => { diff --git a/src/lib/daily/__tests__/morning-refresh-trigger.test.ts b/src/lib/daily/__tests__/morning-refresh-trigger.test.ts new file mode 100644 index 000000000..17c206559 --- /dev/null +++ b/src/lib/daily/__tests__/morning-refresh-trigger.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// The queue send is inspected through a fake global boss so the debounce key + +// payload can be asserted without a running worker. +const sendMock = vi.fn(); +let bossInstance: { send: typeof sendMock } | null = { send: sendMock }; +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: () => bossInstance, +})); + +// Module gate + tz resolver are stubbed; the tz FORMAT helpers stay real so the +// "last night in the profile tz" logic is exercised for real. +const resolveModuleMapMock = vi.fn( + async (): Promise> => ({}), +); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: () => resolveModuleMapMock(), +})); + +let tz = "America/Los_Angeles"; +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: async () => tz, +})); + +const userFindUniqueMock = vi.fn( + async (): Promise<{ morningDigestRefreshedOn: string | null } | null> => ({ + morningDigestRefreshedOn: null, + }), +); +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: () => userFindUniqueMock() } }, +})); + +import { + isLastNightLocal, + maybeEnqueueMorningRefresh, +} from "../morning-refresh-trigger"; + +describe("isLastNightLocal", () => { + it("counts a segment whose LOCAL date is yesterday but whose UTC date is today", () => { + // LA (UTC-7 in July). now = 07:00 local on the 16th. + const now = new Date("2026-07-16T14:00:00Z"); + // 06:00Z = 23:00 local on the 15th → yesterday LOCALLY, but the 16th in UTC. + const measuredAt = new Date("2026-07-16T06:00:00Z"); + expect(isLastNightLocal(measuredAt, now, "America/Los_Angeles")).toBe(true); + }); + + it("counts a this-morning segment across the UTC date line (Auckland)", () => { + // Auckland (UTC+12). now = 12:30 local on the 16th; the segment is 06:00 + // local on the 16th (this morning) but 15th in UTC — a naive UTC-day check + // would wrongly reject it. + const now = new Date("2026-07-16T00:30:00Z"); + const measuredAt = new Date("2026-07-15T18:00:00Z"); + expect(isLastNightLocal(measuredAt, now, "Pacific/Auckland")).toBe(true); + }); + + it("rejects an old backfilled segment", () => { + const now = new Date("2026-07-16T14:00:00Z"); + const measuredAt = new Date("2026-07-10T06:00:00Z"); + expect(isLastNightLocal(measuredAt, now, "America/Los_Angeles")).toBe( + false, + ); + }); + + it("rejects a future-dated sample", () => { + const now = new Date("2026-07-16T14:00:00Z"); + const measuredAt = new Date("2026-07-16T20:00:00Z"); + expect(isLastNightLocal(measuredAt, now, "America/Los_Angeles")).toBe( + false, + ); + }); +}); + +describe("maybeEnqueueMorningRefresh", () => { + const NOW = new Date("2026-07-16T14:00:00Z"); // 07:00 on the 16th in LA + const lastNight = new Date("2026-07-16T06:00:00Z"); // 23:00 on the 15th, LA + const oldNight = new Date("2026-07-10T06:00:00Z"); + + beforeEach(() => { + sendMock.mockReset(); + bossInstance = { send: sendMock }; + tz = "America/Los_Angeles"; + resolveModuleMapMock.mockReset(); + resolveModuleMapMock.mockResolvedValue({}); + userFindUniqueMock.mockReset(); + userFindUniqueMock.mockResolvedValue({ morningDigestRefreshedOn: null }); + }); + + it("enqueues exactly one debounced refresh for many last-night samples", async () => { + await maybeEnqueueMorningRefresh( + "u1", + [lastNight, lastNight, lastNight, lastNight], + NOW, + ); + expect(sendMock).toHaveBeenCalledTimes(1); + const [queue, payload, opts] = sendMock.mock.calls[0]!; + expect(queue).toBe("morning-digest-refresh"); + // The debounce key + payload date are the user's LOCAL date (the 16th), + // not the UTC date of the samples. + expect(payload).toMatchObject({ userId: "u1", localDate: "2026-07-16" }); + expect((opts as { singletonKey: string }).singletonKey).toBe( + "morning-refresh:u1:2026-07-16", + ); + }); + + it("does NOT trigger for an old/backfilled sleep only", async () => { + await maybeEnqueueMorningRefresh("u1", [oldNight, oldNight], NOW); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it("no-ops when the sleep module is off", async () => { + resolveModuleMapMock.mockResolvedValue({ sleep: false }); + await maybeEnqueueMorningRefresh("u1", [lastNight], NOW); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it("skips the enqueue when the day is already finalised (marker == today)", async () => { + userFindUniqueMock.mockResolvedValue({ + morningDigestRefreshedOn: "2026-07-16", + }); + await maybeEnqueueMorningRefresh("u1", [lastNight], NOW); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it("no-ops with no sleep samples", async () => { + await maybeEnqueueMorningRefresh("u1", [], NOW); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it("never throws when a dependency fails", async () => { + resolveModuleMapMock.mockRejectedValue(new Error("db down")); + await expect( + maybeEnqueueMorningRefresh("u1", [lastNight], NOW), + ).resolves.toBeUndefined(); + expect(sendMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 131c012e4..4f4ff9753 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -121,6 +121,16 @@ export interface DailyDigestInput { medsToday: MedsTodayBlock; /** Days since the freshest sleep reading; null when never recorded. */ sleepLastSeenDaysAgo: number | null; + /** + * S4 freshness (§E) — whether the event-driven morning refresh has already + * run for the user's CURRENT local date. Derived in the IO seam by comparing + * `User.morningDigestRefreshedOn` against today's local date (profile tz): + * `true` once last night's sleep arrived AND the sleep-dependent generation + * was re-run with it. This is the authoritative `final` signal — it flips + * immediately on the refresh, before the snapshot cache (which feeds + * `sleepLastSeenDaysAgo`) has expired. + */ + morningRefreshedToday: boolean; syncIssues: DailyDigestSyncIssue[]; preventiveDue: DailyDigestPreventiveDue[]; /** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */ @@ -348,11 +358,21 @@ export function buildDailyDigest( input: DailyDigestInput, t: Translate, ): DailyDigest { + // §E freshness lifecycle. The day is `final` once ANY of: + // - sleep is disabled (nothing to wait for); + // - the event-driven morning refresh has run for today (authoritative, + // fast — flips the instant the sleep-arrival job stamps the marker); + // - last night's sleep is already visibly in the record (`daysAgo === 0`) — + // the eventual-consistency backstop for when the refresh job never ran + // (no boss worker / keyless self-hoster) but the snapshot has caught up. + // Otherwise it stays `provisional`, and `sleepPending` drives the honest + // "last night's sleep not yet in" note. const sleepEnabled = moduleEnabled(input.modules, "sleep"); - const sleepPending = - sleepEnabled && - (input.sleepLastSeenDaysAgo === null || input.sleepLastSeenDaysAgo >= 1); - const phase: DailyDigest["phase"] = sleepPending ? "provisional" : "final"; + const lastNightSleepIn = input.sleepLastSeenDaysAgo === 0; + const isFinal = + !sleepEnabled || input.morningRefreshedToday || lastNightSleepIn; + const sleepPending = sleepEnabled && !isFinal; + const phase: DailyDigest["phase"] = isFinal ? "final" : "provisional"; const topSignal = input.briefing?.signalsOfDay?.[0] ?? null; const briefingLead = firstSentence(input.briefing?.paragraph); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 76f021406..8747c5908 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -21,6 +21,7 @@ import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { userDayKey } from "@/lib/tz/format"; import { buildDailyDigest, type DailyDigest, @@ -145,6 +146,16 @@ export async function loadDailyDigest( const sleepSlot = snapshot.tiles.lastSeenByType["SLEEP_DURATION"]; const sleepLastSeenDaysAgo = sleepSlot ? sleepSlot.daysAgo : null; + // S4 freshness (§E) — the day is `final` once the sleep-arrival morning + // refresh has stamped `User.morningDigestRefreshedOn` with the user's current + // local date (profile tz). Read fresh off the row here, so it flips the + // instant the refresh job runs — ahead of the snapshot cache that feeds + // `sleepLastSeenDaysAgo`. + const todayLocalDate = userDayKey(now, user.timezone); + const morningRefreshedToday = + user.morningDigestRefreshedOn !== null && + user.morningDigestRefreshedOn === todayLocalDate; + const syncIssues: DailyDigestSyncIssue[] = syncRows.map((row) => ({ integration: row.integration, state: row.state, @@ -171,6 +182,7 @@ export async function loadDailyDigest( briefing: snapshot.briefing, medsToday: snapshot.medsToday, sleepLastSeenDaysAgo, + morningRefreshedToday, syncIssues, preventiveDue, coachPlans, diff --git a/src/lib/daily/morning-refresh-trigger.ts b/src/lib/daily/morning-refresh-trigger.ts new file mode 100644 index 000000000..a24483855 --- /dev/null +++ b/src/lib/daily/morning-refresh-trigger.ts @@ -0,0 +1,111 @@ +/** + * S4 — the sleep-arrival trigger for the morning digest refresh. + * + * The nightly insight pre-pass (`insight-pregenerate`, 04:30) runs before last + * night's sleep has synced, so the morning digest + health score reference the + * day WITHOUT the current sleep. This trigger closes that gap the event-driven + * way: when a completed sleep segment for LAST NIGHT lands from any of the + * three sleep transports (Withings sync-sleep / the WHOOP sync / the + * Apple-Health measurement batch), it enqueues a debounced + * `morning-digest-refresh` job that re-runs the sleep-dependent generation and + * flips the day `provisional → final`. + * + * There is no single canonical sleep-persist function in the tree — each + * transport upserts through its own helper — so the three write seams each call + * `maybeEnqueueMorningRefresh` with the measuredAt of every sleep row they just + * wrote. The debounce (queue `singletonKey` + the `User.morningDigestRefreshedOn` + * marker) collapses the many samples of one night, and across all three + * sources, into ONE refresh per user per local morning. + * + * Timezone correctness is load-bearing: "last night" is judged in the user's + * PROFILE timezone, never UTC, so a 30-day backfill re-sync never re-triggers + * for historical nights and a sleep that is "last night" locally but a + * different UTC date still triggers. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { enqueueMorningDigestRefresh } from "@/lib/jobs/morning-digest-refresh-shared"; + +const MS_PER_DAY = 86_400_000; + +/** + * True when `measuredAt` belongs to "last night" for an observer in `tz`: its + * local calendar date is today or yesterday. An older backfilled segment (local + * date ≥ 2 days ago) returns false, so a full WHOOP / Apple / Withings re-sync + * that replays a month of nights never re-triggers the morning refresh for a + * historical night. A future-dated sample (clock skew) is rejected too. + * + * Pure + timezone-explicit so the tz-boundary cases are unit-testable without + * touching the DB or the queue. + */ +export function isLastNightLocal( + measuredAt: Date, + now: Date, + tz: string, +): boolean { + if (measuredAt.getTime() > now.getTime()) return false; + const todayKey = userDayKey(now, tz); + const yesterdayKey = userDayKey(new Date(now.getTime() - MS_PER_DAY), tz); + const segKey = userDayKey(measuredAt, tz); + return segKey === todayKey || segKey === yesterdayKey; +} + +/** + * Enqueue the debounced morning refresh when at least one of the just-written + * sleep segments belongs to last night in the user's profile timezone. + * + * Best-effort and self-contained: every read is wrapped so a failure can never + * fail the sleep sync that called it. Callers still `void … .catch(() => {})` + * for defence in depth. + * + * Gates, in order: + * 1. No sleep rows → nothing to do. + * 2. `sleep` module off → the digest is always `final` (no sleep to wait + * for), so a refresh would be pointless; no-op. + * 3. No segment is "last night" locally → an old backfill; no-op. + * 4. `User.morningDigestRefreshedOn` already equals today's local date → the + * refresh already ran this morning; skip the enqueue (the handler + * re-checks the same marker, so this only saves queue churn). + */ +export async function maybeEnqueueMorningRefresh( + userId: string, + sleepMeasuredAts: Date[], + now: Date = new Date(), +): Promise { + try { + if (sleepMeasuredAts.length === 0) return; + + const modules = await resolveModuleMap(userId); + if (modules.sleep === false) return; + + const tz = await resolveUserTimezone(userId); + const hasLastNight = sleepMeasuredAts.some((at) => + isLastNightLocal(at, now, tz), + ); + if (!hasLastNight) return; + + const localDate = userDayKey(now, tz); + + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { morningDigestRefreshedOn: true }, + }); + if (row?.morningDigestRefreshedOn === localDate) return; + + await enqueueMorningDigestRefresh({ userId, localDate }); + annotate({ + action: { name: "daily.morning_refresh.triggered" }, + meta: { + local_date: localDate, + sleep_samples: sleepMeasuredAts.length, + }, + }); + } catch { + // Never let a freshness trigger fail an ingest. The next sleep landing and + // the nightly cron are the catch-net; the digest stays honestly + // provisional until one of them refreshes it. + } +} diff --git a/src/lib/jobs/__tests__/morning-digest-refresh.test.ts b/src/lib/jobs/__tests__/morning-digest-refresh.test.ts new file mode 100644 index 000000000..844d2fbda --- /dev/null +++ b/src/lib/jobs/__tests__/morning-digest-refresh.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { runMorningDigestRefresh } from "../morning-digest-refresh"; +import type { GenerateOutcome } from "@/lib/insights/comprehensive-generate"; + +const LOCAL_DATE = "2026-07-16"; + +function makePrisma( + user: { locale: string | null; morningDigestRefreshedOn: string | null } | null, +) { + const updateMock = vi.fn(async () => ({})); + const prisma = { + user: { + findUnique: vi.fn(async () => user), + update: updateMock, + }, + }; + return { prisma, updateMock }; +} + +describe("runMorningDigestRefresh", () => { + const retryMock = vi.fn(async () => {}); + + beforeEach(() => { + retryMock.mockReset(); + }); + + it("regenerates and stamps the marker → finalises the day", async () => { + const { prisma, updateMock } = makePrisma({ + locale: "en", + morningDigestRefreshedOn: null, + }); + const generate = vi.fn( + async (): Promise => ({ + status: "generated", + providerType: "mock", + }), + ); + + const result = await runMorningDigestRefresh( + prisma as never, + { userId: "u1", localDate: LOCAL_DATE }, + { generate, enqueueRetry: retryMock }, + ); + + expect(generate).toHaveBeenCalledWith("u1", { locale: "en", force: true }); + expect(updateMock).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { morningDigestRefreshedOn: LOCAL_DATE }, + }); + expect(result.status).toBe("finalised"); + expect(retryMock).not.toHaveBeenCalled(); + }); + + it("finalises on an `unchanged` regen (content-hash no-op is still fresh)", async () => { + const { prisma, updateMock } = makePrisma({ + locale: "de", + morningDigestRefreshedOn: null, + }); + const generate = vi.fn( + async (): Promise => ({ status: "unchanged" }), + ); + + const result = await runMorningDigestRefresh( + prisma as never, + { userId: "u1", localDate: LOCAL_DATE }, + { generate, enqueueRetry: retryMock }, + ); + + expect(generate).toHaveBeenCalledWith("u1", { locale: "de", force: true }); + expect(updateMock).toHaveBeenCalled(); + expect(result).toEqual({ status: "finalised", comprehensive: "unchanged" }); + }); + + it("finalises a keyless user (`skipped` no-provider) — nothing to wait for", async () => { + const { prisma, updateMock } = makePrisma({ + locale: "en", + morningDigestRefreshedOn: null, + }); + const generate = vi.fn( + async (): Promise => ({ + status: "skipped", + reason: "no-provider", + }), + ); + + const result = await runMorningDigestRefresh( + prisma as never, + { userId: "u1", localDate: LOCAL_DATE }, + { generate, enqueueRetry: retryMock }, + ); + + expect(updateMock).toHaveBeenCalled(); + expect(result.status).toBe("finalised"); + }); + + it("is idempotent: a second run for the same morning no-ops via the marker", async () => { + const { prisma, updateMock } = makePrisma({ + locale: "en", + morningDigestRefreshedOn: LOCAL_DATE, + }); + const generate = vi.fn( + async (): Promise => ({ + status: "generated", + providerType: "mock", + }), + ); + + const result = await runMorningDigestRefresh( + prisma as never, + { userId: "u1", localDate: LOCAL_DATE }, + { generate, enqueueRetry: retryMock }, + ); + + expect(generate).not.toHaveBeenCalled(); + expect(updateMock).not.toHaveBeenCalled(); + expect(result.status).toBe("already-final"); + }); + + it("leaves the day provisional on a hard generation failure and hands off to the retry machinery", async () => { + const { prisma, updateMock } = makePrisma({ + locale: "en", + morningDigestRefreshedOn: null, + }); + const generate = vi.fn( + async (): Promise => ({ + status: "failed", + reason: "provider-timeout", + }), + ); + + const result = await runMorningDigestRefresh( + prisma as never, + { userId: "u1", localDate: LOCAL_DATE }, + { generate, enqueueRetry: retryMock }, + ); + + expect(updateMock).not.toHaveBeenCalled(); + expect(retryMock).toHaveBeenCalledWith({ userId: "u1", locale: "en" }); + expect(result.status).toBe("failed"); + }); + + it("reports missing-user when the row is gone", async () => { + const { prisma, updateMock } = makePrisma(null); + const generate = vi.fn( + async (): Promise => ({ status: "unchanged" }), + ); + + const result = await runMorningDigestRefresh( + prisma as never, + { userId: "ghost", localDate: LOCAL_DATE }, + { generate, enqueueRetry: retryMock }, + ); + + expect(generate).not.toHaveBeenCalled(); + expect(updateMock).not.toHaveBeenCalled(); + expect(result.status).toBe("missing-user"); + }); +}); diff --git a/src/lib/jobs/morning-digest-refresh-shared.ts b/src/lib/jobs/morning-digest-refresh-shared.ts new file mode 100644 index 000000000..a7486efd2 --- /dev/null +++ b/src/lib/jobs/morning-digest-refresh-shared.ts @@ -0,0 +1,75 @@ +/** + * S4 — generator-free contract for the `morning-digest-refresh` queue. + * + * The sleep-arrival trigger (`@/lib/daily/morning-refresh-trigger`) — reached + * from the Withings, WHOOP, and Apple-Health sleep-write seams — enqueues a + * debounced morning refresh here WITHOUT importing the comprehensive generator + * (which would drag the whole insight tree into every sleep-sync bundle and + * risk an import cycle through the transports). The worker-only pipeline + * (`runMorningDigestRefresh`) lives in `morning-digest-refresh.ts`, which + * re-exports the queue name from here so there is a single source of truth. + * + * Mirrors the `insight-pregenerate-shared.ts` split exactly: queue name + + * payload type + enqueue helper in the generator-free module, the concrete + * dispatch in the worker module. + */ +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { annotate } from "@/lib/logging/context"; + +export const MORNING_DIGEST_REFRESH_QUEUE = "morning-digest-refresh"; + +export interface MorningDigestRefreshPayload { + userId: string; + /** + * The user-local YYYY-MM-DD (their profile timezone) this refresh finalises. + * Doubles as the debounce key and the value the handler stamps onto + * `User.morningDigestRefreshedOn` so the digest reads `final` for the day. + */ + localDate: string; +} + +/** + * Fire-and-forget enqueue for the sleep-arrival trigger. The `singletonKey` + * carries the user AND the local date, so the many sleep samples of one night + * collapse into ONE queued job: while a refresh for this morning is + * created/active no duplicate can be inserted. Once it completes, the durable + * `User.morningDigestRefreshedOn` marker (re-checked in the handler AND + * pre-checked in the trigger) stops any later sample from re-running it — the + * pair is at-most-once per user per local morning. + * + * No `singletonSeconds`: a time-slot throttle would let a second morning's + * refresh be swallowed if it fell inside the window; the local-date-scoped key + * is already unique per morning, so the plain active-job constraint is exactly + * the debounce we want. No-ops cleanly when no global boss instance is + * available (a web process without an embedded worker) — the next sleep + * landing, and the nightly cron, remain the catch-net. + */ +export async function enqueueMorningDigestRefresh( + payload: MorningDigestRefreshPayload, +): Promise { + const boss = getGlobalBoss(); + if (!boss) { + annotate({ + action: { name: "daily.morning_refresh.no_boss" }, + meta: { local_date: payload.localDate }, + }); + return; + } + try { + await boss.send(MORNING_DIGEST_REFRESH_QUEUE, payload, { + singletonKey: `morning-refresh:${payload.userId}:${payload.localDate}`, + // A transient provider / pool failure retries with backoff rather than + // leaving the day provisional until the next sleep sample lands. + retryLimit: 2, + retryDelay: 60, + retryBackoff: true, + }); + annotate({ + action: { name: "daily.morning_refresh.enqueued" }, + meta: { local_date: payload.localDate }, + }); + } catch { + // Best-effort: a failed enqueue leaves the next sleep landing + the nightly + // cron as the catch-net; the digest stays honestly provisional meanwhile. + } +} diff --git a/src/lib/jobs/morning-digest-refresh.ts b/src/lib/jobs/morning-digest-refresh.ts new file mode 100644 index 000000000..acaa6151d --- /dev/null +++ b/src/lib/jobs/morning-digest-refresh.ts @@ -0,0 +1,128 @@ +/** + * S4 — the `morning-digest-refresh` worker. + * + * Runs ONE extra insight generation per user per local morning, event-driven + * off the arrival of last night's sleep (never warm-on-mount, never per page + * visit). It re-runs the SAME comprehensive pipeline the nightly + * `insight-pregenerate` cron uses — forced so the 24 h cache short-circuit is + * bypassed — then stamps `User.morningDigestRefreshedOn` so the daily digest + * flips the day `provisional → final`. + * + * Cheap by construction: the generator's own content-hash gate turns a + * no-change regeneration into an `unchanged` timestamp refresh with no provider + * call, so a morning whose sleep did not actually move the feature snapshot + * costs a hash compare, not an LLM round-trip. A real change — last night's + * sleep now folded into the features — regenerates the briefing paragraph AND + * warms the score's read path. + * + * Honest degradation (§E): on a hard generation failure the marker is NOT + * stamped, so the day stays `provisional` and the digest keeps showing the + * sleep-pending note. The generator already recorded the omit-reason via + * `recordBriefingFailure` on its failure paths, and we re-enqueue the SAME + * 45-minute bounded retry the nightly cron uses (`enqueuePregenerateFailureRetry`) + * — no parallel freshness machinery is invented here. + * + * Idempotency: a second run for the same local morning no-ops on the marker + * re-check. The enqueue-side `singletonKey` collapses the concurrent burst; the + * marker is the durable "already done" guard the singleton cannot provide once + * the first job has completed. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { annotate } from "@/lib/logging/context"; +import { + generateComprehensiveInsight, + type GenerateOutcome, +} from "@/lib/insights/comprehensive-generate"; +import { enqueuePregenerateFailureRetry } from "@/lib/jobs/insight-pregenerate-shared"; +import { normalizeLocale } from "@/lib/insights/status-shared"; +import { + MORNING_DIGEST_REFRESH_QUEUE, + type MorningDigestRefreshPayload, +} from "@/lib/jobs/morning-digest-refresh-shared"; + +export { MORNING_DIGEST_REFRESH_QUEUE }; +export type { MorningDigestRefreshPayload }; + +export interface MorningDigestRefreshResult { + status: "finalised" | "already-final" | "missing-user" | "failed"; + /** The comprehensive generator's own outcome, when it ran. */ + comprehensive?: GenerateOutcome["status"]; +} + +type GenerateFn = ( + userId: string, + options: { locale: "de" | "en"; force?: boolean }, +) => Promise; + +/** + * Execute the morning refresh for one user. Pure of queue concerns — the boss + * handler in `register-status.ts` wraps this in the background-event envelope. + * + * `deps` is injected by the tests: a fake generator + failure-retry so the + * marker lifecycle and idempotency can be asserted without a provider or a + * running boss. + */ +export async function runMorningDigestRefresh( + prisma: PrismaClient, + payload: MorningDigestRefreshPayload, + deps: { + generate?: GenerateFn; + enqueueRetry?: (args: { + userId: string; + locale: "de" | "en"; + }) => Promise; + } = {}, +): Promise { + const generate: GenerateFn = deps.generate ?? generateComprehensiveInsight; + const enqueueRetry = deps.enqueueRetry ?? enqueuePregenerateFailureRetry; + const { userId, localDate } = payload; + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true, morningDigestRefreshedOn: true }, + }); + if (!user) { + annotate({ + action: { name: "daily.morning_refresh.missing_user" }, + meta: { local_date: localDate }, + }); + return { status: "missing-user" }; + } + + // Idempotency: a re-fire for the same local morning is a no-op. + if (user.morningDigestRefreshedOn === localDate) { + annotate({ + action: { name: "daily.morning_refresh.already_final" }, + meta: { local_date: localDate }, + }); + return { status: "already-final" }; + } + + const locale = normalizeLocale(user.locale); + const outcome = await generate(userId, { locale, force: true }); + + // A hard provider failure leaves the day provisional and hands recovery to + // the existing 45-minute briefing-retry machinery. Every other outcome — + // including `skipped` (no provider / no consent: there is no LLM briefing to + // wait for, so the deterministic score + digest are as final as they get + // once last night's sleep is in the record) — finalises the day. + if (outcome.status === "failed") { + annotate({ + action: { name: "daily.morning_refresh.deferred" }, + meta: { local_date: localDate, cause: `generator:${outcome.reason}` }, + }); + await enqueueRetry({ userId, locale }); + return { status: "failed", comprehensive: outcome.status }; + } + + await prisma.user.update({ + where: { id: userId }, + data: { morningDigestRefreshedOn: localDate }, + }); + annotate({ + action: { name: "daily.morning_refresh.finalised" }, + meta: { local_date: localDate, comprehensive: outcome.status }, + }); + return { status: "finalised", comprehensive: outcome.status }; +} diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index bba28d96a..4198962df 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -24,6 +24,11 @@ import { INSIGHT_PREGENERATE_CRON, type InsightPregeneratePayload, } from "@/lib/jobs/insight-pregenerate"; +import { + MORNING_DIGEST_REFRESH_QUEUE, + runMorningDigestRefresh, + type MorningDigestRefreshPayload, +} from "@/lib/jobs/morning-digest-refresh"; import { RECOVERY_SCORE_QUEUE, RECOVERY_SCORE_CRON, @@ -154,6 +159,11 @@ const allQueues = [ // provisions the queue and the enqueue silently drops. No cron schedule — // a send-only queue driven by navigation. INSIGHT_STATUS_GENERATE_QUEUE, + // S4 — event-driven morning digest refresh, enqueued by the sleep-arrival + // trigger when last night's completed sleep lands. No cron schedule — a + // send-only queue driven by sleep ingest; without this entry pg-boss never + // provisions the queue and every enqueue silently drops. + MORNING_DIGEST_REFRESH_QUEUE, // v1.10.0 — computed scores (WX-C / WX-E). Nightly Recovery / Stress / // Strain compute + store. The queue MUST be registered here or pg-boss // never provisions it and the nightly schedule silently never fires. @@ -295,6 +305,38 @@ export async function registerStatusQueues( { localConcurrency: 2 }, handleInsightPregenerateJob, ); + // S4 — event-driven morning digest refresh. One forced comprehensive + // regeneration per user per local morning, enqueued by the sleep-arrival + // trigger. Single-flight: the enqueue-side singletonKey already collapses a + // night's samples into one job, and the handler's marker re-check makes a + // rare double-fire a no-op, so one slot is enough and bounds the provider + // concurrency the same way the nightly path does. + await boss.work( + MORNING_DIGEST_REFRESH_QUEUE, + { localConcurrency: 1 }, + async (jobs) => { + await withBackgroundEvent("job.morning_digest_refresh", async (evt) => { + for (const job of jobs) { + try { + const result = await runMorningDigestRefresh( + getWorkerPrisma(), + job.data, + ); + evt.addMeta( + "morning_refresh", + `${result.status}:${result.comprehensive ?? "none"}`, + ); + } catch (err) { + recordError(); + workerLog("error", "[morning-digest-refresh] pass failed", err); + // Rethrow so the queue's retry policy re-runs the refresh; the + // marker re-check keeps a retry idempotent. + 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 diff --git a/src/lib/whoop/sync-sleep.ts b/src/lib/whoop/sync-sleep.ts index 84828749b..0202b68b7 100644 --- a/src/lib/whoop/sync-sleep.ts +++ b/src/lib/whoop/sync-sleep.ts @@ -25,6 +25,7 @@ import { sweepStaleSleepSegments, type SleepSegmentSweep, } from "@/lib/sleep/sweep-stale-segments"; +import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { fetchSleeps, fetchSleepById, mapSleep } from "./client"; import { @@ -95,6 +96,15 @@ export async function syncUserSleep( const imported = await upsertWhoopMeasurements(userId, readings); await markResourceSynced(userId, "sleep"); + + // S4 — trigger the debounced morning refresh on a last-night segment landing. + void maybeEnqueueMorningRefresh( + userId, + readings + .filter((r) => r.type === "SLEEP_DURATION") + .map((r) => r.measuredAt), + ).catch(() => {}); + return imported; } @@ -142,5 +152,16 @@ export async function syncWhoopSleepById( { prefix: `${record.id}:`, keepIds }, ]); - return upsertWhoopMeasurements(userId, readings); + const imported = await upsertWhoopMeasurements(userId, readings); + + // S4 — a webhook-driven single-record refresh is the freshest possible + // last-night signal; kick the debounced morning refresh on its segments. + void maybeEnqueueMorningRefresh( + userId, + readings + .filter((r) => r.type === "SLEEP_DURATION") + .map((r) => r.measuredAt), + ).catch(() => {}); + + return imported; } diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 791e89726..37565c3fd 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -48,6 +48,7 @@ import { recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { sweepStaleSleepSegments, @@ -636,6 +637,15 @@ export async function syncUserSleep( ); } + // S4 — if a stage segment for last night just landed, kick the debounced + // morning refresh so the digest/score finalise with the current sleep. The + // trigger judges "last night" in the user's profile tz and no-ops on a + // backfill; fire-and-forget so a freshness enqueue never fails the sync. + void maybeEnqueueMorningRefresh( + userId, + touched.filter((tt) => tt.type === SLEEP_TYPE).map((tt) => tt.measuredAt), + ).catch(() => {}); + await recordSyncSuccess(userId, "withings"); return imported; }