diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 0f582b50c..82480c191 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -703,3 +703,22 @@ describe("extractFeatures — blood-pressure spread", () => { expect(features.bloodPressure?.sdSys30).toBe(8.2); }); }); + +describe("extractFeatures — mood fallback window", () => { + it("reads the raw fallback over the rollup's five years, not one", async () => { + // Empty rollup tier: the raw fallback answers. + prismaMock.moodEntryRollup.findMany.mockResolvedValue([]); + await extractFeatures("user-mood-window", false, { + only: new Set(["mood"]), + }); + const call = prismaMock.moodEntry.findMany.mock.calls.find( + ([args]) => + (args as { where?: { moodLoggedAt?: unknown } })?.where?.moodLoggedAt, + ); + expect(call).toBeDefined(); + const since = (call![0] as { where: { moodLoggedAt: { gte: Date } } }).where + .moodLoggedAt.gte; + const years = (Date.now() - since.getTime()) / (365 * dayMs); + expect(years).toBeGreaterThan(4.9); + }); +}); diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index a13f3b384..4c52bcd09 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -1021,7 +1021,7 @@ async function readMoodFeature( // // Coverage-fallback: when raw entries exist but the rollup tier is // still empty (legacy account before boot-time backfill caught up), - // we fall back to a bounded 1-year raw walk and fire the warm-up so + // we fall back to a raw walk over the same window and fire the warm-up so // the next request lands on the rollup tier. Same posture as // `/api/mood/analytics`. void ensureUserMoodRollupsFresh(userId); @@ -1062,15 +1062,13 @@ async function readMoodFeature( }); moodLatestScore = latestEntry?.score ?? null; } else { - // Coverage-fallback. Bounded 1-year walk (instead of the legacy - // unbounded `findMany`) so even a cache miss is capped. - const oneYearAgo = laterDate( - new Date(Date.now() - 365 * 24 * 60 * 60 * 1000), - historyFloor, - ); + // Coverage-fallback over the same window the rollup read covers + // (`moodSince`, five years). A one-year fallback answered the same + // question with a different history while the tier was warming up; + // mood entries are a few a day, so five years stays a bounded read. const moodEntriesRaw = await prisma.moodEntry.findMany({ // v1.7.0 sync — exclude tombstoned rows. - where: { userId, deletedAt: null, moodLoggedAt: { gte: oneYearAgo } }, + where: { userId, deletedAt: null, moodLoggedAt: { gte: moodSince } }, orderBy: { moodLoggedAt: "asc" }, select: { score: true, moodLoggedAt: true, date: true }, });