mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
perf(insights-comprehensive): consume mood-rollup tier
Audit Critical Finding #2 (round-v1439-arch-qa-infra-db). The 90-day `prisma.moodEntry.findMany` block fed two downstream consumers: `dailyMoodEntries` for mood × metric correlations, and `moodSummary` for the dashboard mood block. Both read at a per-day resolution. Swap to `readMoodDayRollups(userId, ninetyDaysAgo)` so the route reads one row per calendar day instead of one per entry. Coverage-fallback runs a bounded 90-day live walk + fires `ensureUserMoodRollupsFresh` so the next request lands on the rollup tier (same posture as /api/mood/analytics post-v1.4.39). `moodSummary` now feeds per-day mean DataPoints into `summarize()` on both branches, matching the rollup-tier convention shipped in v1.4.39 (QA UX-H1). Two parity tests added to `__tests__/route.test.ts`: rollup fast path skips the raw findMany, and the coverage-fallback runs the live walk exactly once when rollups are empty.
This commit is contained in:
1 parent
f8de4b05fd
commit
45a8399822
2 files changed
+192
-33
No files matched your search
@@ -20,15 +20,30 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// v1.4.40 — stub the mood-rollup warm-up so test runs don't fire the
|
||||
// real recompute against the mocked Prisma. The route fires the
|
||||
// warm-up as fire-and-forget; the return value is irrelevant.
|
||||
vi.mock("@/lib/mood/rollups", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/mood/rollups")>(
|
||||
"@/lib/mood/rollups",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -98,6 +113,9 @@ beforeEach(() => {
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
});
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
@@ -267,6 +285,109 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
expect(body.data.bpClassification?.category).toBe("Normal");
|
||||
});
|
||||
|
||||
it("consumes the mood-rollup tier and skips the raw findMany when DAY rows exist", async () => {
|
||||
// v1.4.40 W-INSIGHTS — rollup-tier read swap parity. Three DAY rows
|
||||
// populated → the route reads them directly and never touches the
|
||||
// raw `moodEntry.findMany`. `moodSummary` is computed over per-day
|
||||
// mean DataPoints, matching the rollup-tier convention shipped by
|
||||
// `/api/mood/analytics` post-v1.4.39.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 0,
|
||||
});
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([
|
||||
{
|
||||
userId: "user-comp-1",
|
||||
granularity: "DAY",
|
||||
bucketStart: new Date("2026-05-08T00:00:00.000Z"),
|
||||
count: 1,
|
||||
mean: 4,
|
||||
minScore: 4,
|
||||
maxScore: 4,
|
||||
sd: null,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
{
|
||||
userId: "user-comp-1",
|
||||
granularity: "DAY",
|
||||
bucketStart: new Date("2026-05-09T00:00:00.000Z"),
|
||||
count: 2,
|
||||
mean: 4.5,
|
||||
minScore: 4,
|
||||
maxScore: 5,
|
||||
sd: 0.5,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Raw findMany must not fire on the rollup-tier fast path.
|
||||
expect(prisma.moodEntry.findMany).not.toHaveBeenCalled();
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { moodSummary: { count: number; mean: number | null } | null };
|
||||
};
|
||||
// `summarize()` is called over two DataPoints (one per day).
|
||||
expect(body.data.moodSummary).not.toBeNull();
|
||||
expect(body.data.moodSummary?.count).toBe(2);
|
||||
// Mean of (4 + 4.5) / 2 = 4.25
|
||||
expect(body.data.moodSummary?.mean).toBeCloseTo(4.25, 2);
|
||||
});
|
||||
|
||||
it("falls back to the bounded live walk when the rollup tier is empty but raw mood entries exist", async () => {
|
||||
// v1.4.40 W-INSIGHTS — coverage-fallback parity. Legacy account
|
||||
// with raw entries but no rollup coverage yet. The route runs the
|
||||
// legacy bounded walk once; the warm-up helper (stubbed) is
|
||||
// supposed to fire fire-and-forget so the next request lands on
|
||||
// the rollup tier.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 0,
|
||||
});
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
date: "2026-05-08",
|
||||
score: 4,
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
date: "2026-05-09",
|
||||
score: 5,
|
||||
moodLoggedAt: new Date("2026-05-09T12:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { moodSummary: { count: number } | null };
|
||||
};
|
||||
expect(body.data.moodSummary).not.toBeNull();
|
||||
expect(body.data.moodSummary?.count).toBe(2);
|
||||
});
|
||||
|
||||
it("derives weight × BP scatter from daily-bucketed series", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Five day-aligned pairs (n >= 5 unlocks pearsonCorrelation).
|
||||
|
||||
@@ -22,6 +22,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/mood/rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -82,39 +86,73 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// buckets for the correlation pairings.
|
||||
const aggregate = await buildComprehensiveAggregate(userId);
|
||||
|
||||
// Mood entries are user-recorded; the table is small and the
|
||||
// `moodSummary` field requires raw scores (`summarize()` over the
|
||||
// raw score series). The findMany stays.
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
|
||||
// Aggregate mood to daily averages (drives the mood × metric
|
||||
// correlations). Bucket key matches the legacy code's `date` column
|
||||
// semantics (YYYY-MM-DD anchored to the user's tz at write time).
|
||||
const moodByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const entry of moodEntries) {
|
||||
const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 };
|
||||
current.sum += entry.score;
|
||||
current.count += 1;
|
||||
moodByDay.set(entry.date, current);
|
||||
}
|
||||
const dailyMoodEntries = Array.from(moodByDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
// v1.4.40 — swap the 90-day unbounded `prisma.moodEntry.findMany`
|
||||
// for the persistent mood-rollup DAY tier (audit Critical Finding
|
||||
// #2). The two consumers downstream — `dailyMoodEntries` for the
|
||||
// mood × metric correlations and `moodSummary` for the dashboard
|
||||
// mood block — read at a per-day resolution, which is precisely the
|
||||
// shape the rollup writer emits.
|
||||
//
|
||||
// Coverage-fallback: when the user has mood entries but zero rollup
|
||||
// rows yet (legacy account before the boot-time backfill has caught
|
||||
// up), fall back to a bounded 90-day raw walk once and fire the
|
||||
// warm-up so the next request lands on the rollup tier. Same posture
|
||||
// as `/api/mood/analytics`.
|
||||
//
|
||||
// The bucket key shape change is documented in the route-parity test
|
||||
// for `/api/mood/analytics`: rollup `bucketStart` is UTC-anchored
|
||||
// YYYY-MM-DD; the legacy `MoodEntry.date` column is TZ-anchored
|
||||
// (write-time). For Berlin tenants whose mood log timestamps don't
|
||||
// straddle the UTC boundary the two labels agree on every realistic
|
||||
// entry — the v1.5 per-user-tz bucketing closes the residual DST
|
||||
// edge.
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
const moodRollupDayRows = await readMoodDayRollups(userId, ninetyDaysAgo);
|
||||
let dailyMoodEntries: Array<{ day: string; value: number }>;
|
||||
let moodDataPoints: DataPoint[];
|
||||
let moodEntryCount: number;
|
||||
if (moodRollupDayRows.length > 0) {
|
||||
dailyMoodEntries = moodRollupDayRows.map((r) => ({
|
||||
day: r.bucketStart.toISOString().slice(0, 10),
|
||||
value: Math.round(r.mean * 100) / 100,
|
||||
}));
|
||||
moodDataPoints = moodRollupDayRows.map((r) => ({
|
||||
date: r.bucketStart,
|
||||
value: r.mean,
|
||||
}));
|
||||
moodEntryCount = moodRollupDayRows.reduce((s, r) => s + r.count, 0);
|
||||
} else {
|
||||
// Coverage-fallback: legacy account with mood entries but no
|
||||
// rollup coverage yet. Bounded by the 90-day window so the walk is
|
||||
// capped even when the rollup miss happens.
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
const moodByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const entry of moodEntries) {
|
||||
const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 };
|
||||
current.sum += entry.score;
|
||||
current.count += 1;
|
||||
moodByDay.set(entry.date, current);
|
||||
}
|
||||
dailyMoodEntries = Array.from(moodByDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
// Feed per-day means into `summarize()` so the live-fallback path
|
||||
// shares the rollup-tier semantic (one DataPoint per day, the
|
||||
// daily mean) — mirrors the v1.4.39 mood/analytics QA UX-H1 fix.
|
||||
moodDataPoints = dailyMoodEntries.map((e) => ({
|
||||
date: new Date(`${e.day}T12:00:00.000Z`),
|
||||
value: e.value,
|
||||
}));
|
||||
moodEntryCount = moodEntries.length;
|
||||
}
|
||||
|
||||
// moodSummary uses the same `summarize()` helper over raw scores —
|
||||
// the table is bounded by user-recorded entries so the in-JS path
|
||||
// is safe.
|
||||
const moodDataPoints: DataPoint[] = moodEntries.map((e) => ({
|
||||
date: e.moodLoggedAt,
|
||||
value: e.score,
|
||||
}));
|
||||
const moodSummary =
|
||||
moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
|
||||
|
||||
@@ -369,7 +407,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
action: { name: "insights.comprehensive" },
|
||||
meta: {
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
moodEntries: moodEntries.length,
|
||||
moodEntries: moodEntryCount,
|
||||
medications: medications.length,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user