feat(daily): finalise the morning digest when last night's sleep lands

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 22:57:27 +02:00
1 parent b7a6b4c898
commit b6ded4d1f9
14 files changed
+790 -5

No files matched your search

@@ -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;
+9
View File
@@ -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
+17
View File
@@ -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<Response> {
} 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({
+29
View File
@@ -53,6 +53,7 @@ function input(over: Partial<DailyDigestInput> = {}): 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", () => {
@@ -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<Record<string, boolean>> => ({}),
);
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();
});
});
+24 -4
View File
@@ -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);
+12
View File
@@ -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,
+111
View File
@@ -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<void> {
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.
}
}
@@ -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<GenerateOutcome> => ({
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<GenerateOutcome> => ({ 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<GenerateOutcome> => ({
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<GenerateOutcome> => ({
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<GenerateOutcome> => ({
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<GenerateOutcome> => ({ 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");
});
});
@@ -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<void> {
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.
}
}
+128
View File
@@ -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<GenerateOutcome>;
/**
* 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<void>;
} = {},
): Promise<MorningDigestRefreshResult> {
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 };
}
+42
View File
@@ -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<MorningDigestRefreshPayload>(
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
+22 -1
View File
@@ -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;
}
+10
View File
@@ -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;
}