mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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;
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user