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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 22:05:48 +01:00
1 parent 20fc03bd61
commit 9b2c68fbe5
2 files changed
+156 -21

No files matched your search

@@ -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<typeof import("@/lib/modules/gate")>()),
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<ReturnType<typeof getSession>>);
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
} as unknown as Awaited<ReturnType<typeof checkAnalyticsReadRateLimit>>);
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 },
);
});
});
+43 -21
View File
@@ -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<HealthStatusBody>,
`${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<ReturnType<typeof buildHealthStatus>>;
async function buildHealthStatus(
userId: string,
timezone: string | undefined,
now: Date,
modules: Awaited<ReturnType<typeof resolveModuleMap>>,
) {
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(),
});
});
};
}