mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user