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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 10:10:39 +02:00
1 parent ae82b3e484
commit aeb72d1a08
2 files changed
+33 -8

No files matched your search

@@ -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 () => {
+14 -4
View File
@@ -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 });
}
}