diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 245c33421..0e06c3503 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -85,8 +85,8 @@ export const GET = apiHandler(async () => { // EVERY mount. Cache the built body in the analytics bucket (60 s fresh / // 1 h stale — the bucket defaults are exactly the contract this read wants). // Eviction truth comes free: every measurement / mood / medication / - // illness / custom-metric write sweeps the `${userId}|` prefix in this - // bucket, and a pattern dismiss goes through + // illness / nutrient / custom-metric write sweeps the `${userId}|` prefix + // in this bucket, and a pattern dismiss goes through // `invalidateUserCorrelationPatterns`, which sweeps it too. The // `syncAcceptedPatterns` calls are DB writes and live INSIDE the builder, // so a cache hit performs no write on the read path. diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts index 63079a20b..1c8f17cf4 100644 --- a/src/app/api/nutrients/batch/__tests__/route.test.ts +++ b/src/app/api/nutrients/batch/__tests__/route.test.ts @@ -47,7 +47,7 @@ vi.mock("@/lib/idempotency", () => ({ })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/cache/invalidate", () => ({ - invalidateUserDashboardSnapshot: vi.fn(), + invalidateUserHealthContext: vi.fn(), })); vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), @@ -65,7 +65,7 @@ import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { auditLog } from "@/lib/auth/audit"; -import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { nutrientEntryResultSchema } from "@/lib/validations/nutrients"; const SESSION_OK = { @@ -317,10 +317,11 @@ describe("POST /api/nutrients/batch — write semantics", () => { expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled(); }); - // v1.30.3 — a landed row must evict the dashboard snapshot so - // the water/nutrient tile reflects the new total on the very next read, - // matching the manual water POST's own posture (`nutrients/water/route.ts`). - it("evicts the dashboard snapshot when a row lands", async () => { + // v1.30.3 — a landed row must evict the record's analytics cells so + // the water/nutrient tile and the correlation scan reflect the new total + // on the very next read, matching the manual water POST's own posture + // (`nutrients/water/route.ts`). + it("evicts the analytics cells when a row lands", async () => { const day = recentDay(); const res = await POST( postReq({ @@ -328,11 +329,11 @@ describe("POST /api/nutrients/batch — write semantics", () => { }), ); expect(res.status).toBe(200); - expect(invalidateUserDashboardSnapshot).toHaveBeenCalledTimes(1); - expect(invalidateUserDashboardSnapshot).toHaveBeenCalledWith("user-1"); + expect(invalidateUserHealthContext).toHaveBeenCalledTimes(1); + expect(invalidateUserHealthContext).toHaveBeenCalledWith("user-1"); }); - it("does NOT evict the dashboard snapshot when nothing lands (every entry skipped)", async () => { + it("does NOT evict the analytics cells when nothing lands (every entry skipped)", async () => { const res = await POST( postReq({ entries: [ @@ -348,7 +349,7 @@ describe("POST /api/nutrients/batch — write semantics", () => { const body = (await res.json()) as BatchResponse; expect(body.data.inserted).toBe(0); expect(body.data.updated).toBe(0); - expect(invalidateUserDashboardSnapshot).not.toHaveBeenCalled(); + expect(invalidateUserHealthContext).not.toHaveBeenCalled(); }); it("reports updated (never duplicate) when the composite key already exists", async () => { diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts index ac2d88f2c..45823fe65 100644 --- a/src/app/api/nutrients/batch/route.ts +++ b/src/app/api/nutrients/batch/route.ts @@ -44,7 +44,7 @@ import { import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; -import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog"; import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds"; import { @@ -347,12 +347,13 @@ async function postBatch(request: NextRequest): Promise { if (inserted + updated > 0) { // v1.30.3 — a landed row changes the dashboard's water/nutrient - // tile; without this the snapshot kept serving the pre-sync figure - // until the analytics fresh TTL lapsed (~3 min). Mirrors the manual - // water POST's own hard-evict (`nutrients/water/route.ts`) so an - // Apple-Health-synced total reflects on the very next dashboard read, - // same as a manually logged one. - invalidateUserDashboardSnapshot(user.id); + // tile and the correlation scan (water, caffeine and nutrient channels) + // behind the metric pages and the Coach read; without this they kept + // serving the pre-sync figure until the analytics fresh TTL lapsed. + // Mirrors the manual water POST's own hard-evict + // (`nutrients/water/route.ts`) so an Apple-Health-synced total + // reflects on the very next read, same as a manually logged one. + invalidateUserHealthContext(user.id); } annotate({ diff --git a/src/app/api/nutrients/water/__tests__/route.test.ts b/src/app/api/nutrients/water/__tests__/route.test.ts index b1ad8c701..c46353725 100644 --- a/src/app/api/nutrients/water/__tests__/route.test.ts +++ b/src/app/api/nutrients/water/__tests__/route.test.ts @@ -26,7 +26,7 @@ vi.mock("@/lib/modules/gate", () => ({ })); vi.mock("@/lib/cache/invalidate", () => ({ - invalidateUserDashboardSnapshot: vi.fn(), + invalidateUserHealthContext: vi.fn(), })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -59,7 +59,7 @@ import { apiError } from "@/lib/api-response"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; -import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog"; import { auditLog } from "@/lib/auth/audit"; @@ -194,7 +194,7 @@ describe("POST /api/nutrients/water", () => { }, update: { amount: { increment: 250 } }, }); - expect(invalidateUserDashboardSnapshot).toHaveBeenCalledWith("user-1"); + expect(invalidateUserHealthContext).toHaveBeenCalledWith("user-1"); }); it("set mode overwrites the MANUAL row's amount instead of incrementing", async () => { diff --git a/src/app/api/nutrients/water/route.ts b/src/app/api/nutrients/water/route.ts index 3377f3bd7..daf53e56a 100644 --- a/src/app/api/nutrients/water/route.ts +++ b/src/app/api/nutrients/water/route.ts @@ -26,7 +26,7 @@ import { parseJsonBody } from "@/lib/api/body"; import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; -import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog"; import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds"; import { nutrientWaterWriteSchema } from "@/lib/validations/nutrients"; @@ -138,10 +138,11 @@ async function postWater(request: NextRequest): Promise { }); } - // Interactive single-entry write — hard-evict (not mark-stale) so the - // dashboard water tile reflects the new total on the very next read, - // matching the mood / medication / measurement posture. - invalidateUserDashboardSnapshot(user.id); + // Interactive single-entry write: hard-evict (not mark-stale) the record's + // analytics cells, so the dashboard water tile and the correlation scan + // behind the metric pages and the Coach read reflect the new total on the + // very next read, matching the mood / medication / measurement posture. + invalidateUserHealthContext(user.id); // A health-data write earns a durable row, like every other one. This was // the last manual write in the nutrients family that only reached the wide diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts index 0b152516f..6f3736560 100644 --- a/src/lib/insights/derived/coach-read.ts +++ b/src/lib/insights/derived/coach-read.ts @@ -100,8 +100,9 @@ function latestDay( * channel over 180 days and does not depend on the metric, so without the cell * each metric page paid the whole scan again for the one line it picks. Same * cell contract as `/api/insights/correlations`: measurement, mood, medication, - * illness, custom-metric and pattern writes sweep the `${userId}|` prefix, and - * the switched-off modules are part of the key. A failed read is not cached. + * illness, nutrient, custom-metric and pattern writes sweep the `${userId}|` + * prefix, and the switched-off modules are part of the key. A failed read is + * not cached. */ async function readDrivers( userId: string,