From 9b2c68fbe54720627836d4cd1eb4ea25545635f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:46:09 +0100 Subject: [PATCH] perf(insights): keep the health status answer in the analytics cell `/api/insights/health-status` recomputed the coincident deviation and the 180-day changepoint scan over every vital on each open of Insights, the only read on that page that stayed slow when warm, for a body of a few hundred bytes. It now goes through the same analytics cell its sibling reads use. Measurement and mood writes sweep the record's cells; the reader's day and the switched-off modules are part of the key, so a new day or a module switch never serves the other answer. --- .../health-status/__tests__/route.test.ts | 113 ++++++++++++++++++ src/app/api/insights/health-status/route.ts | 64 ++++++---- 2 files changed, 156 insertions(+), 21 deletions(-) create mode 100644 src/app/api/insights/health-status/__tests__/route.test.ts diff --git a/src/app/api/insights/health-status/__tests__/route.test.ts b/src/app/api/insights/health-status/__tests__/route.test.ts new file mode 100644 index 000000000..4f2b428e1 --- /dev/null +++ b/src/app/api/insights/health-status/__tests__/route.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The health status card reads two engines that walk every vital over up to + * 180 days. The answer is kept in the record's analytics cell: a second open + * of Insights serves it, and a write that sweeps the record's cells, or a + * module switch, computes it again. + */ + +const computeCoincidentDeviation = vi.fn(); +const buildExtendedChangepointSignals = vi.fn(); +const resolveModuleMap = vi.fn(); + +vi.mock("@/lib/insights/derived/coincident-deviation", () => ({ + computeCoincidentDeviation: (...args: unknown[]) => + computeCoincidentDeviation(...args), +})); +vi.mock("@/lib/insights/derived/changepoint", () => ({ + buildExtendedChangepointSignals: (...args: unknown[]) => + buildExtendedChangepointSignals(...args), +})); +vi.mock("@/lib/insights/derived", () => ({ + loadBaselineProfile: vi.fn().mockResolvedValue({ ageYears: 40 }), +})); +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { create: vi.fn() }, + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + resolveModuleMap: (userId: string) => resolveModuleMap(userId), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +const call = () => GET(); + +beforeEach(() => { + __resetAllCachesForTests(); + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER", + timezone: "Europe/Berlin", + }, + } as unknown as Awaited>); + vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ + allowed: true, + } as unknown as Awaited>); + computeCoincidentDeviation + .mockReset() + .mockResolvedValue({ status: "insufficient" }); + buildExtendedChangepointSignals.mockReset().mockResolvedValue([]); + resolveModuleMap.mockReset().mockResolvedValue({}); +}); + +describe("GET /api/insights/health-status — the cached answer", () => { + it("computes once for two opens", async () => { + const first = await call(); + const second = await call(); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(computeCoincidentDeviation).toHaveBeenCalledTimes(1); + expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(1); + expect((await second.json()).data).toEqual((await first.json()).data); + }); + + it("computes again after a measurement write", async () => { + await call(); + invalidateUserMeasurements("user-1", { evict: true }); + await call(); + + expect(computeCoincidentDeviation).toHaveBeenCalledTimes(2); + }); + + it("computes again when a module is switched off", async () => { + await call(); + resolveModuleMap.mockResolvedValue({ sleep: false }); + await call(); + + expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(2); + expect(buildExtendedChangepointSignals).toHaveBeenLastCalledWith( + "user-1", + expect.any(Date), + { sleep: false }, + ); + }); +}); diff --git a/src/app/api/insights/health-status/route.ts b/src/app/api/insights/health-status/route.ts index 601c472ee..9c96fb33c 100644 --- a/src/app/api/insights/health-status/route.ts +++ b/src/app/api/insights/health-status/route.ts @@ -24,8 +24,10 @@ import { prisma } from "@/lib/db"; import { loadBaselineProfile } from "@/lib/insights/derived"; import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation"; import { buildExtendedChangepointSignals } from "@/lib/insights/derived/changepoint"; -import { resolveModuleMap } from "@/lib/modules/gate"; +import { MODULE_KEYS, resolveModuleMap } from "@/lib/modules/gate"; import { summariseHealthStatus } from "@/lib/insights/health-status"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; @@ -39,36 +41,56 @@ export const GET = apiHandler(async () => { return apiError("Too many analytics requests. Please retry later.", 429); } + // Both engines walk every vital over up to 180 days, so the answer goes + // through the analytics cell its sibling reads use. Measurement and mood + // writes sweep the record's cells; the reader's day and the switched-off + // modules are part of the key, so a new day or a module switch never serves + // the other answer. + const modules = await resolveModuleMap(user.id); + const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false); const now = new Date(); - const profile = await loadBaselineProfile(prisma, user.id); - - const coincident = await computeCoincidentDeviation(user.id, profile, { - tz: user.timezone, - now, - }); - // v1.44 — the wider scan: every vital with a stable daily level over 180 - // days, a metric whose module is off left unread. - const shifts = await buildExtendedChangepointSignals( - user.id, - now, - await resolveModuleMap(user.id), + const day = userDayKey(now, user.timezone ?? DEFAULT_TIMEZONE); + const body = await cachedSwr( + caches.analytics as ServerCache, + `${user.id}|health-status|${day}|off:${modulesOff.join(",")}`, + () => buildHealthStatus(user.id, user.timezone, now, modules), + annotate, ); - const vitals = coincident.status === "ok" ? coincident.value.vitals : []; - const summary = summariseHealthStatus(vitals, shifts); - annotate({ action: { name: "insights.health-status.read" }, meta: { - deviations: summary.deviations.length, - shifts: summary.shifts.length, + deviations: body.deviations.length, + shifts: body.shifts.length, }, }); - return apiSuccess({ + return apiSuccess(body); +}); + +type HealthStatusBody = Awaited>; + +async function buildHealthStatus( + userId: string, + timezone: string | undefined, + now: Date, + modules: Awaited>, +) { + const profile = await loadBaselineProfile(prisma, userId); + const coincident = await computeCoincidentDeviation(userId, profile, { + tz: timezone, + now, + }); + // v1.44 — the wider scan: every vital with a stable daily level over 180 + // days, a metric whose module is off left unread. + const shifts = await buildExtendedChangepointSignals(userId, now, modules); + + const vitals = coincident.status === "ok" ? coincident.value.vitals : []; + const summary = summariseHealthStatus(vitals, shifts); + return { present: summary.present, deviations: summary.deviations, shifts: summary.shifts, generatedAt: now.toISOString(), - }); -}); + }; +}