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

+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({