diff --git a/src/lib/daily/__tests__/briefing-today-warm.test.ts b/src/lib/daily/__tests__/briefing-today-warm.test.ts new file mode 100644 index 000000000..164cc1a96 --- /dev/null +++ b/src/lib/daily/__tests__/briefing-today-warm.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const checkRateLimit = vi.hoisted(() => vi.fn()); +const enqueueTodayBriefingWarm = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit })); +vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({ + enqueueTodayBriefingWarm, +})); + +import { + __resetTodayWarmClaimsForTests, + requestTodayBriefingWarm, + todayWarmReasons, + type TodayWarmInput, +} from "@/lib/daily/briefing-today-warm"; + +/** The nightly warm: 04:30 in Berlin on 2026-10-05. */ +const NIGHTLY = "2026-10-05T02:30:00.000Z"; + +function input(over: Partial): TodayWarmInput { + return { + userId: "u1", + locale: "en", + timezone: "Europe/Berlin", + generatedAt: NIGHTLY, + lastSeenAt: () => null, + now: new Date("2026-10-05T10:00:00.000Z"), + ...over, + }; +} + +beforeEach(() => { + __resetTodayWarmClaimsForTests(); + checkRateLimit.mockReset().mockResolvedValue({ allowed: true }); + enqueueTodayBriefingWarm.mockReset().mockResolvedValue(undefined); +}); + +describe("todayWarmReasons — the reader's own morning", () => { + it("Berlin: the nightly briefing covers the day", () => { + expect(todayWarmReasons(input({}))).toEqual([]); + }); + + it("Los Angeles: the Berlin nightly is yesterday evening, so the morning asks", () => { + // 07:00 on 5 October in Los Angeles; the warm ran 19:30 on the 4th. + expect( + todayWarmReasons( + input({ + timezone: "America/Los_Angeles", + now: new Date("2026-10-05T14:00:00.000Z"), + }), + ), + ).toEqual(["day"]); + }); + + it("Seoul: after local midnight the 11:30 briefing is yesterday's", () => { + // 01:00 on 6 October in Seoul; the warm ran 11:30 on the 5th. + expect( + todayWarmReasons( + input({ + timezone: "Asia/Seoul", + now: new Date("2026-10-05T16:00:00.000Z"), + }), + ), + ).toEqual(["day"]); + // 14:00 on the 5th, after the warm: covered. + expect( + todayWarmReasons( + input({ + timezone: "Asia/Seoul", + now: new Date("2026-10-05T05:00:00.000Z"), + }), + ), + ).toEqual([]); + }); + + it("asks when a signal metric's first reading lands after the briefing", () => { + // Blood pressure at 07:10 Berlin, weight at 07:12, after the 04:30 warm. + const seen: Record = { + BLOOD_PRESSURE_SYS: "2026-10-05T05:10:00.000Z", + WEIGHT: "2026-10-05T05:12:00.000Z", + // Yesterday's pulse is not today's and asks for nothing. + PULSE: "2026-10-04T06:50:00.000Z", + }; + expect( + todayWarmReasons(input({ lastSeenAt: (t) => seen[t] ?? null })), + ).toEqual(["reading:BLOOD_PRESSURE_SYS", "reading:WEIGHT"]); + }); + + it("does not ask for a reading the briefing already read", () => { + expect( + todayWarmReasons( + input({ + lastSeenAt: (t) => + t === "WEIGHT" ? "2026-10-05T02:00:00.000Z" : null, + }), + ), + ).toEqual([]); + }); + + it("asks when the briefing's generation moment is unknown", () => { + expect(todayWarmReasons(input({ generatedAt: null }))).toEqual(["day"]); + }); +}); + +describe("requestTodayBriefingWarm — once per reason per day", () => { + it("enqueues an immediate warm for the day's first read with no briefing", async () => { + await requestTodayBriefingWarm( + input({ + timezone: "America/Los_Angeles", + now: new Date("2026-10-05T14:00:00.000Z"), + }), + ); + expect(checkRateLimit).toHaveBeenCalledWith( + "briefing-today:u1:2026-10-05:day", + 1, + expect.any(Number), + ); + expect(enqueueTodayBriefingWarm).toHaveBeenCalledWith({ + userId: "u1", + locale: "en", + immediate: true, + }); + }); + + it("waits out a burst of readings before the warm runs", async () => { + await requestTodayBriefingWarm( + input({ + lastSeenAt: (t) => + t === "BLOOD_PRESSURE_SYS" ? "2026-10-05T05:10:00.000Z" : null, + }), + ); + expect(enqueueTodayBriefingWarm).toHaveBeenCalledWith( + expect.objectContaining({ immediate: false }), + ); + }); + + it("asks once: a second read in the same day neither queries nor enqueues", async () => { + const la = input({ + timezone: "America/Los_Angeles", + now: new Date("2026-10-05T14:00:00.000Z"), + }); + await requestTodayBriefingWarm(la); + await requestTodayBriefingWarm(la); + expect(checkRateLimit).toHaveBeenCalledTimes(1); + expect(enqueueTodayBriefingWarm).toHaveBeenCalledTimes(1); + }); + + it("does not enqueue when another process already claimed the reason", async () => { + checkRateLimit.mockResolvedValue({ allowed: false }); + await requestTodayBriefingWarm(input({ generatedAt: null })); + expect(enqueueTodayBriefingWarm).not.toHaveBeenCalled(); + }); + + it("asks again on the next local day", async () => { + await requestTodayBriefingWarm(input({ generatedAt: null })); + await requestTodayBriefingWarm( + input({ + generatedAt: null, + now: new Date("2026-10-06T10:00:00.000Z"), + }), + ); + expect(enqueueTodayBriefingWarm).toHaveBeenCalledTimes(2); + }); +}); + +describe("requestTodayBriefingWarm — the worst day", () => { + it("enqueues at most one warm per reason per local day, however often it is read", async () => { + // A Postgres bucket in memory: one claim per key. + const buckets = new Map(); + checkRateLimit.mockImplementation(async (key: string) => { + const n = (buckets.get(key) ?? 0) + 1; + buckets.set(key, n); + return { allowed: n <= 1 }; + }); + // Every signal type gets its first reading at a different hour, and the + // dashboard is read every two minutes from midnight to midnight, with + // the briefing never refreshed (the worst case: every warm capped). + const types = [ + "BLOOD_PRESSURE_SYS", + "WEIGHT", + "PULSE", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "BODY_TEMPERATURE", + "BLOOD_GLUCOSE", + ]; + const dayStart = Date.parse("2026-10-04T22:00:00.000Z"); // 00:00 Berlin + for (let minute = 0; minute < 24 * 60; minute += 2) { + const now = new Date(dayStart + minute * 60_000); + await requestTodayBriefingWarm( + input({ + generatedAt: "2026-10-04T02:30:00.000Z", + now, + lastSeenAt: (type) => { + const hour = 6 + types.indexOf(type) * 2; + const at = dayStart + hour * 3_600_000; + return types.includes(type) && at <= now.getTime() + ? new Date(at).toISOString() + : null; + }, + }), + ); + } + // One for the day, one per signal reading type. + expect(enqueueTodayBriefingWarm).toHaveBeenCalledTimes(1 + types.length); + }); +}); diff --git a/src/lib/daily/briefing-today-warm.ts b/src/lib/daily/briefing-today-warm.ts new file mode 100644 index 000000000..885df5ecf --- /dev/null +++ b/src/lib/daily/briefing-today-warm.ts @@ -0,0 +1,126 @@ +/** + * Ask for a briefing written today, at the two moments the rule "a signal + * of the day rests on today's readings" needs one. + * + * - The day has no briefing yet. The nightly warm runs at 04:30 in Berlin, + * which is the previous evening in Los Angeles and the middle of the day + * in Seoul; a briefing written then is from an earlier day on the + * reader's calendar, and is not served. The first read of the reader's + * day asks for one. + * - A signal metric got its first reading of the day after the briefing + * was written. The briefing is otherwise only written at the nightly + * warm, the post-sleep morning refresh or a day-old page open, so a + * morning blood pressure or weigh-in would never become a signal. + * + * Both are read off the snapshot every surface already reads (the dashboard, + * iOS, the digest and the morning push), so no write path needs a hook. Each + * reason is claimed once per user per local day in a Postgres bucket (and + * remembered in-process, so a claimed reason costs no query on later reads); + * a newly claimed reason enqueues one today warm. What that warm may spend is + * bounded where every forced warm is bounded: the singleton window, the + * failure backoff, the daily forced-warm cap, and the generation's + * content-hash gate, which writes no new text without new data. + */ +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import type { Locale } from "@/lib/i18n/config"; +import { userDayKey } from "@/lib/tz/format"; +import { SIGNAL_READING_TYPE } from "@/lib/daily/briefing-today"; +import { enqueueTodayBriefingWarm } from "@/lib/jobs/insight-pregenerate-shared"; + +/** A claim outlives the longest local day (a 25 h DST day) with margin. */ +const CLAIM_WINDOW_MS = 36 * 60 * 60 * 1000; + +/** In-process memory of claimed reasons, so a claimed one costs no query. */ +const claimed = new Map(); +const CLAIMED_MAX = 10_000; + +export interface TodayWarmInput { + userId: string; + locale: Locale; + timezone: string; + /** When the cached briefing's text was generated, or null when unknown. */ + generatedAt: string | null; + /** The newest reading of a measurement type (ISO), or null when none. */ + lastSeenAt: (type: string) => string | null; + now: Date; +} + +/** + * Why a briefing for today is due, if it is: `day` when no briefing was + * written today, `reading:` for each signal reading type measured + * today after the briefing was written. Empty when the briefing already + * covers the day. + */ +export function todayWarmReasons(input: TodayWarmInput): string[] { + const today = userDayKey(input.now, input.timezone); + const generatedMs = + input.generatedAt === null ? null : Date.parse(input.generatedAt); + const reasons: string[] = []; + if ( + generatedMs === null || + userDayKey(new Date(generatedMs), input.timezone) !== today + ) { + reasons.push("day"); + } + for (const type of new Set(Object.values(SIGNAL_READING_TYPE))) { + const at = input.lastSeenAt(type); + if (at === null) continue; + const atMs = Date.parse(at); + if (userDayKey(new Date(atMs), input.timezone) !== today) continue; + if (generatedMs !== null && atMs <= generatedMs) continue; + reasons.push(`reading:${type}`); + } + return reasons; +} + +/** Claim a reason for the day; true only for the first claim. */ +async function claimOnce(key: string): Promise { + if (claimed.has(key)) return false; + if (claimed.size >= CLAIMED_MAX) claimed.clear(); + claimed.set(key, true); + try { + const result = await checkRateLimit(key, 1, CLAIM_WINDOW_MS); + return result.allowed; + } catch { + // A failed claim asks again on a later read rather than never. + claimed.delete(key); + return false; + } +} + +/** + * Enqueue a today warm when a reason is due and not yet claimed today. + * Fire-and-forget from the read path: it never throws and never delays the + * read beyond the claim query. + */ +export async function requestTodayBriefingWarm( + input: TodayWarmInput, +): Promise { + const reasons = todayWarmReasons(input); + if (reasons.length === 0) return; + const today = userDayKey(input.now, input.timezone); + const fresh: string[] = []; + for (const reason of reasons) { + if (await claimOnce(`briefing-today:${input.userId}:${today}:${reason}`)) { + fresh.push(reason); + } + } + if (fresh.length === 0) return; + annotate({ + action: { name: "daily.briefing.today_warm_requested" }, + meta: { reasons: fresh.join(","), timezone: input.timezone }, + }); + await enqueueTodayBriefingWarm({ + userId: input.userId, + locale: input.locale, + // Nobody waits on a burst of readings when the day simply has no + // briefing yet; a reading waits out its burst so one warm reads it all. + immediate: fresh.every((reason) => reason === "day"), + }); +} + +/** Test seam: forget the in-process claims. */ +export function __resetTodayWarmClaimsForTests(): void { + claimed.clear(); +} diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts index 25ef9d114..9be475763 100644 --- a/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts @@ -6,7 +6,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { User } from "@/generated/prisma/client"; -import { AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; +import { + AI_AVAILABLE, + aiUnavailable, +} from "@/__tests__/helpers/ai-capability-fixtures"; const BRIEFING = { paragraph: "Your pulse is well above its usual level today.", @@ -52,8 +55,15 @@ vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({ vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: async () => "en", })); +const capability = vi.hoisted(() => ({ current: null as unknown })); vi.mock("@/lib/ai/capabilities/gate", () => ({ - aiCapabilityToServe: async () => AI_AVAILABLE, + aiCapabilityToServe: async () => capability.current, +})); +const requestTodayBriefingWarm = vi.hoisted(() => + vi.fn(async (_input: unknown) => undefined), +); +vi.mock("@/lib/daily/briefing-today-warm", () => ({ + requestTodayBriefingWarm, })); const { readDashboardSnapshotCached } = await import("../snapshot-read"); @@ -75,6 +85,8 @@ function user(generatedAt: string | null): User { } beforeEach(() => { + capability.current = AI_AVAILABLE; + requestTodayBriefingWarm.mockClear(); __resetAllCachesForTests(); current = body("2026-10-04T06:50:00.000Z"); }); @@ -123,3 +135,32 @@ describe("readDashboardSnapshotCached — the briefing's own day", () => { ); }); }); + +describe("readDashboardSnapshotCached — asking for today's briefing", () => { + it("hands the read's view of the day to the today warm", async () => { + await readDashboardSnapshotCached( + user("2026-10-04T02:30:00.000Z"), + undefined, + { + now: NOW, + }, + ); + expect(requestTodayBriefingWarm).toHaveBeenCalledTimes(1); + const arg = requestTodayBriefingWarm.mock.calls[0][0] as { + generatedAt: string; + timezone: string; + now: Date; + lastSeenAt: (type: string) => string | null; + }; + expect(arg.generatedAt).toBe("2026-10-04T02:30:00.000Z"); + expect(arg.timezone).toBe("Europe/Berlin"); + expect(arg.now).toBe(NOW); + expect(arg.lastSeenAt("PULSE")).toBe("2026-10-04T06:50:00.000Z"); + }); + + it("asks for nothing while the briefing capability is unavailable", async () => { + capability.current = aiUnavailable("consent_required"); + await readDashboardSnapshotCached(user(null), undefined, { now: NOW }); + expect(requestTodayBriefingWarm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index 59949a411..6095f7d45 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -38,6 +38,7 @@ import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import type { Locale } from "@/lib/i18n/config"; import { briefingForToday } from "@/lib/daily/briefing-today"; +import { requestTodayBriefingWarm } from "@/lib/daily/briefing-today-warm"; import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import { userDayKey } from "@/lib/tz/format"; @@ -175,15 +176,28 @@ export async function readDashboardSnapshotCached( // capability is available, decided on every read rather than baked into // the cached body. The record's own state decides, whoever is reading, so a // delegate sees the owner's briefing exactly when the owner would. + const now = options.now ?? new Date(); + const generatedAt = readBriefingGeneratedAt(user.insightsCachedText); const body = applyBriefingForToday( applyBriefingCapability(cached, briefingAi), - { - generatedAt: readBriefingGeneratedAt(user.insightsCachedText), - timezone: user.timezone, - language: locale, - now: options.now ?? new Date(), - }, + { generatedAt, timezone: user.timezone, language: locale, now }, ); + // The day's first read with no briefing written today, or the first read + // after a signal metric's first reading of the day, asks for a briefing + // for today (`briefing-today-warm.ts`). Never awaited by the read beyond + // its claim, never thrown. + if (briefingAi.available) { + const lastSeen = cached.tiles?.lastSeenByType ?? {}; + void requestTodayBriefingWarm({ + userId: user.id, + locale, + timezone: user.timezone, + generatedAt, + lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null, + now, + }).catch(() => undefined); + } + return { body, locale }; } diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 163d0d209..846055bc1 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -1007,6 +1007,62 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { } }); + it("a today warm runs past the freshness window, under the daily cap, and warms no card", async () => { + // The weigh-in at 07:05 after the blood-pressure warm at 07:00: the + // briefing is minutes old, and still has to read the new reading. + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, findUnique } = makePrisma([]); + findUnique.mockResolvedValue({ + insightsCachedAt: new Date(now.getTime() - 5 * 60 * 1000), + insightsWarmFailedAt: null, + }); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "openai" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + now, + today: true, + }); + + expect(generate).toHaveBeenCalledTimes(1); + expect(result.comprehensive).toBe("generated"); + expect(checkRateLimit).toHaveBeenCalledWith( + "insight-pregenerate-daily:u1", + FORCE_WARM_DAILY_LIMIT, + expect.any(Number), + ); + for (const g of statusGenerators) expect(g).not.toHaveBeenCalled(); + expect(warmGenericMetrics).not.toHaveBeenCalled(); + }); + + it("a today warm stays inside the daily forced-warm cap", async () => { + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, findUnique } = makePrisma([]); + findUnique.mockResolvedValue({ + insightsCachedAt: new Date(now.getTime() - 5 * 60 * 1000), + insightsWarmFailedAt: null, + }); + checkRateLimit.mockResolvedValue({ allowed: false }); + const generate = vi.fn(); + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators: [], + warmGenericMetrics: vi.fn().mockResolvedValue(0), + now, + today: true, + }); + expect(generate).not.toHaveBeenCalled(); + expect(result.comprehensive).toBe("capped"); + }); + it("backs off after a recent failed attempt instead of re-driving the provider chain", async () => { const now = new Date("2026-06-10T12:00:00.000Z"); const { prisma, findUnique } = makePrisma([]); diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index e6353b37a..95e5aee07 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -45,6 +45,15 @@ export interface InsightPregeneratePayload { * unrecognised value to English. */ locale?: SupportedLocale; + /** + * A briefing-for-today warm (`enqueueTodayBriefingWarm`): the day started + * with no briefing written today, or a signal metric got its first + * reading of the day after the briefing was written. Runs the + * comprehensive briefing only, past the one-hour freshness gate (a + * reading at 07:05 after a warm at 07:00 is exactly what it is for), and + * under the same failure backoff and daily forced-warm cap. + */ + today?: boolean; } /** @@ -108,6 +117,63 @@ export async function enqueueForceWarm(payload: { } } +/** + * How long a today warm waits before it runs, and the window that folds + * every request inside it into one job. A morning weigh-in, the blood + * pressure five minutes later and the sync that brings both arrive as one + * burst; one warm after the burst reads all of it. + */ +export const TODAY_WARM_DELAY_SECONDS = 5 * 60; + +/** + * Enqueue the briefing-for-today warm. `immediate` is for the day's first + * read with no briefing written today, where nobody is waiting on a burst + * of readings; otherwise the job starts after the burst window. The + * singleton per user folds requests in the window into one job; what runs + * is still bounded by the warm's own gates (failure backoff, the daily + * forced-warm cap) and the generation's content-hash gate. + */ +export async function enqueueTodayBriefingWarm(payload: { + userId: string; + locale: SupportedLocale; + immediate: boolean; +}): Promise { + const boss = getGlobalBoss(); + if (!boss) { + annotate({ + action: { name: "insights.pregenerate.today.no_boss" }, + meta: { locale: payload.locale }, + }); + return; + } + try { + await boss.send( + INSIGHT_PREGENERATE_QUEUE, + { + userId: payload.userId, + force: true, + today: true, + locale: payload.locale, + } satisfies InsightPregeneratePayload, + { + singletonKey: `today:${payload.userId}`, + singletonSeconds: TODAY_WARM_DELAY_SECONDS, + startAfter: payload.immediate ? 0 : TODAY_WARM_DELAY_SECONDS, + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, + retryLimit: 2, + retryDelay: 60, + retryBackoff: true, + }, + ); + annotate({ + action: { name: "insights.pregenerate.today.enqueued" }, + meta: { locale: payload.locale, immediate: payload.immediate }, + }); + } catch { + // Best-effort, like every warm enqueue: the next read asks again. + } +} + /** * Delay before the single bounded retry of a failed nightly comprehensive * warm. 45 minutes: far enough out that a transient 02:30–04:30 provider diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index cdf911576..5e37cea90 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -898,6 +898,14 @@ export async function forceWarmUser( ) => Promise; /** Injected for the test — defaults to `new Date()`. */ now?: Date; + /** + * A briefing-for-today warm (`enqueueTodayBriefingWarm`): the + * comprehensive briefing only, past the one-hour freshness gate. The + * failure backoff, the daily forced-warm cap and the generation's own + * content-hash gate still hold, so a warm with nothing new to read + * costs no provider call. + */ + today?: boolean; } = {}, ): Promise { // v1.18.11 P3 — one feature-cache scope for the whole forced warm of this @@ -1001,6 +1009,7 @@ export async function forceWarmUser( const cachedAt = freshness?.insightsCachedAt ?? null; const failedAt = freshness?.insightsWarmFailedAt ?? null; const isFresh = + !options.today && cachedAt !== null && now.getTime() - cachedAt.getTime() < FORCE_WARM_FRESH_MS; const inBackoff = @@ -1125,7 +1134,9 @@ export async function forceWarmUser( // comprehensive is safe and bounded by their own per-card budgets. The only // outcome that has nothing to warm is a missing provider, and the // generators detect that themselves at near-zero cost. - if (statusText.available) { + // A today warm is about the briefing alone; the cards refill through + // their own read path when someone opens them. + if (statusText.available && !options.today) { // Refill-only (v1.16.8): a card already generated today is a cheap // cache read; a cold card runs its generator, whose content-hash gate // skips the LLM when the underlying data did not change. There is no diff --git a/src/lib/jobs/reminder/insights-handlers.ts b/src/lib/jobs/reminder/insights-handlers.ts index 4f529f204..c0443ac43 100644 --- a/src/lib/jobs/reminder/insights-handlers.ts +++ b/src/lib/jobs/reminder/insights-handlers.ts @@ -334,7 +334,9 @@ export async function handleInsightPregenerateJob( // GERMAN, so a forced warm produced German prose for a French reader. const locale = normalizeLocale(job.data.locale); try { - const summary = await forceWarmUser(getWorkerPrisma(), userId, locale); + const summary = await forceWarmUser(getWorkerPrisma(), userId, locale, { + today: job.data.today === true, + }); evt.addMeta( "force_warm", `${summary.comprehensive}:${summary.assessmentsWarmed}+${summary.metricAssessmentsWarmed}`,