diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index 90c68a940..5412fcfec 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const deleteMany = vi.fn(); +const enqueueStatusGeneration = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { @@ -18,13 +19,29 @@ vi.mock("@/lib/db", () => ({ }, })); +vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ + enqueueStatusGeneration: (...a: unknown[]) => enqueueStatusGeneration(...a), +})); + import { invalidateStatusInsightsForTypes } from "../comprehensive-generate"; beforeEach(() => { vi.clearAllMocks(); deleteMany.mockResolvedValue({ count: 0 }); + enqueueStatusGeneration.mockResolvedValue(undefined); }); +/** Distinct scopes the invalidator enqueued a regenerate for. */ +function enqueuedScopes(): string[] { + return [ + ...new Set( + enqueueStatusGeneration.mock.calls.map( + (c) => (c[0] as { metric: string }).metric, + ), + ), + ].sort(); +} + function deletedScopes(): string[] { const arg = deleteMany.mock.calls[0][0]; // Each OR clause is `{ action: { startsWith: "insights.-status." } }`. @@ -80,5 +97,28 @@ describe("invalidateStatusInsightsForTypes", () => { it("is a no-op for an empty type set (no DB call)", async () => { await invalidateStatusInsightsForTypes("u1", []); expect(deleteMany).not.toHaveBeenCalled(); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + }); + + it("enqueues a debounced regenerate for every dirtied scope, both locales", async () => { + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + // weight + bmi + general, each warmed for de + en. + expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); + expect(enqueueStatusGeneration).toHaveBeenCalledTimes(6); + const locales = new Set( + enqueueStatusGeneration.mock.calls.map( + (c) => (c[0] as { locale: string }).locale, + ), + ); + expect([...locales].sort()).toEqual(["de", "en"]); + }); + + it("does not blanket-evict general for an unrelated synced type", async () => { + // A steps sample touches only the general overview, never the weight / + // pulse / bp scopes — the constant Apple-Health sync must not thrash + // every card's cache. + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]); + expect(deletedScopes()).toEqual(["general"]); + expect(enqueuedScopes()).toEqual(["general"]); }); }); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 040cadd9a..ba175209d 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -47,6 +47,7 @@ import { runRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; +import { enqueueStatusGeneration } from "@/lib/jobs/insight-status-generate-shared"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -231,6 +232,22 @@ export async function invalidateStatusInsightsForTypes( })), }, }); + + // v1.8.7 — regenerate-on-invalidate. Deleting the today-row alone left + // the card to re-warm only on the user's next category open (a miss → a + // worker round-trip while they wait) or the nightly cron. Instead, + // proactively enqueue a debounced regenerate for each dirtied scope so + // the cache is re-warmed in the background. The enqueue is coalesced per + // `(user, metric, locale)` via the queue's `singletonKey` (120 s window), + // so the constant Apple-Health sync cannot fan out a storm of jobs — a + // burst of samples collapses into one regenerate per scope per locale. + // The stale-while-revalidate read keeps the previous assessment visible + // until the fresh one lands, so the user never waits on a card. + for (const scope of scopes) { + for (const locale of ["de", "en"] as const) { + void enqueueStatusGeneration({ userId, metric: scope, locale }); + } + } } interface GenerateOptions {