mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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.
This commit is contained in:
1 parent
d430981caa
commit
442d97cc66
6 files changed
+33
-29
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Response> {
|
||||
|
||||
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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user