From aeb72d1a08d68f654e3e5d8d69431020c5fb8382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 10:09:53 +0200 Subject: [PATCH] fix(insights): regenerate only the user's locale on a measurement invalidation The write-path regenerate-on-invalidate enqueued a fresh assessment for both de and en on every dirtying measurement, while the read-path only ever serves the user's resolved locale. A frequent Apple-Health sync thus fanned out two provider calls per scope, half for a language the user never opens. Resolve the user's locale the way the read-path does (normalizeLocale of user.locale) and enqueue that single locale. The nightly warm pass still covers both locales for the rare locale switch. --- .../invalidate-status-insights.test.ts | 23 +++++++++++++++---- src/lib/insights/comprehensive-generate.ts | 18 +++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index 5412fcfec..9fe03980b 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -12,10 +12,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const deleteMany = vi.fn(); const enqueueStatusGeneration = vi.fn(); +const userFindUnique = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { auditLog: { deleteMany: (...a: unknown[]) => deleteMany(...a) }, + user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, }, })); @@ -29,6 +31,7 @@ beforeEach(() => { vi.clearAllMocks(); deleteMany.mockResolvedValue({ count: 0 }); enqueueStatusGeneration.mockResolvedValue(undefined); + userFindUnique.mockResolvedValue({ locale: "de" }); }); /** Distinct scopes the invalidator enqueued a regenerate for. */ @@ -100,17 +103,29 @@ describe("invalidateStatusInsightsForTypes", () => { expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); - it("enqueues a debounced regenerate for every dirtied scope, both locales", async () => { + it("enqueues a debounced regenerate for every dirtied scope, the user's locale only", async () => { + userFindUnique.mockResolvedValue({ locale: "de" }); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); - // weight + bmi + general, each warmed for de + en. + // weight + bmi + general, each warmed once for the user's resolved locale. expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); - expect(enqueueStatusGeneration).toHaveBeenCalledTimes(6); + expect(enqueueStatusGeneration).toHaveBeenCalledTimes(3); const locales = new Set( enqueueStatusGeneration.mock.calls.map( (c) => (c[0] as { locale: string }).locale, ), ); - expect([...locales].sort()).toEqual(["de", "en"]); + expect([...locales]).toEqual(["de"]); + }); + + it("resolves the user's locale (en) for the regenerate, never the unused one", async () => { + userFindUnique.mockResolvedValue({ locale: "en" }); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + const locales = new Set( + enqueueStatusGeneration.mock.calls.map( + (c) => (c[0] as { locale: string }).locale, + ), + ); + expect([...locales]).toEqual(["en"]); }); it("does not blanket-evict general for an unrelated synced type", async () => { diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index ba175209d..dd5203b33 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -48,6 +48,7 @@ import { } from "@/lib/ai/provider-runner"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { enqueueStatusGeneration } from "@/lib/jobs/insight-status-generate-shared"; +import { normalizeLocale } from "@/lib/insights/status-shared"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -240,13 +241,22 @@ export async function invalidateStatusInsightsForTypes( // 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. + // burst of samples collapses into one regenerate per scope. // The stale-while-revalidate read keeps the previous assessment visible // until the fresh one lands, so the user never waits on a card. + // + // v1.8.7 — regenerate only the user's resolved locale, matching the + // read-path (every `*-status` GET serves `normalizeLocale(user.locale)`). + // Warming both locales doubled provider spend on every sync, half of it + // for a language the user never opens. The nightly warm pass still covers + // both locales for the rare locale switch. + const localeRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const locale = normalizeLocale(localeRow?.locale); for (const scope of scopes) { - for (const locale of ["de", "en"] as const) { - void enqueueStatusGeneration({ userId, metric: scope, locale }); - } + void enqueueStatusGeneration({ userId, metric: scope, locale }); } }