From 442d97cc66d54238d7e476e64b577dc7965d2719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 15:12:47 +0100 Subject: [PATCH] fix(nutrients): refresh the correlation scan after a water or nutrient write Water, caffeine and the nutrient channels feed the correlation scan behind /api/insights/correlations and the Coach read line on the metric pages, but the water and nutrient batch writes evicted only the dashboard snapshot. After logging water both could serve the read taken before it until the cell went stale. Both writes now sweep the record's analytics cells, as measurement and mood writes do. --- src/app/api/insights/correlations/route.ts | 4 ++-- .../nutrients/batch/__tests__/route.test.ts | 21 ++++++++++--------- src/app/api/nutrients/batch/route.ts | 15 ++++++------- .../nutrients/water/__tests__/route.test.ts | 6 +++--- src/app/api/nutrients/water/route.ts | 11 +++++----- src/lib/insights/derived/coach-read.ts | 5 +++-- 6 files changed, 33 insertions(+), 29 deletions(-) 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,