From 249607d0b2c76d6e0f372e4fce3d3daabb1e23b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 18:02:43 +0200 Subject: [PATCH] perf(coach): bound buildCoachSnapshot to last 90 days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every POST /api/insights/chat called extractFeatures(userId, false), which ran prisma.measurement.findMany({ where: { userId } }) with no date filter — fetching every measurement the user had ever recorded. For power users with multi-year Withings imports that's tens of thousands of rows on every Coach turn. Add an optional sinceDays bound to extractFeatures (default unchanged = full history; analytics dashboard + insights/generate keep their existing behaviour) and have the Coach snapshot pass sinceDays: 90. The Coach already only narrates the last7 / last30 / last90 windows, so the 90-day floor preserves every metric the snapshot surfaces. Fixes Senior-HIGH-3. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/ai/coach/snapshot.ts | 12 +++++++++++- src/lib/insights/features.ts | 17 +++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index e383f8a05..96ddd8867 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -25,11 +25,21 @@ export interface CoachSnapshotResult { * `includeRaw=false` because the Coach replies are conversational and * the user is asking the model — they should never depend on raw * measurement timestamps that the privacy mode controls. + * + * Bounded to the last 90 days. Coach narrates the same windows the + * Daily Briefing renders (last7 / last30 / last90); fetching every + * historical measurement on each turn was unbounded I/O for power + * users with multi-year Withings imports. The 90-day floor is the + * widest window any Coach metric currently consumes. */ +const COACH_SNAPSHOT_WINDOW_DAYS = 90; + export async function buildCoachSnapshot( userId: string, ): Promise { - const features = await extractFeatures(userId, false); + const features = await extractFeatures(userId, false, { + sinceDays: COACH_SNAPSHOT_WINDOW_DAYS, + }); // Trim down to the metrics the Coach narrates. extractFeatures // returns more (sleep, steps, etc.) — the Coach surface keeps the diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 4f7594228..c802a7972 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -256,6 +256,7 @@ function computeHistoricalComparison( export async function extractFeatures( userId: string, includeRaw: boolean, + options: { sinceDays?: number } = {}, ): Promise { const user = await prisma.user.findUnique({ where: { id: userId }, @@ -268,9 +269,21 @@ export async function extractFeatures( const now = Date.now(); - // Fetch ALL measurements (not just 30 days) for full temporal context + // Fetch measurements. Default = ALL (full temporal context for the + // dashboard / insights generator). Callers that only consume the + // ≤90-day windows (Coach snapshot per turn) pass `sinceDays: 90` so + // the per-turn I/O stays bounded — `findMany({ where: { userId } })` + // is unbounded by user-history size and gets paid once per Coach turn + // for power users with multi-year Withings imports. + const sinceDays = options.sinceDays; + const sinceCutoff = + typeof sinceDays === "number" && sinceDays > 0 + ? new Date(now - sinceDays * 24 * 60 * 60 * 1000) + : null; const measurements = await prisma.measurement.findMany({ - where: { userId }, + where: sinceCutoff + ? { userId, measuredAt: { gte: sinceCutoff } } + : { userId }, orderBy: { measuredAt: "asc" }, });