perf(coach): bound buildCoachSnapshot to last 90 days

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 18:02:43 +02:00
1 parent e632e26854
commit 249607d0b2
2 files changed
+26 -3

No files matched your search

+11 -1
View File
@@ -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<CoachSnapshotResult> {
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
+15 -2
View File
@@ -256,6 +256,7 @@ function computeHistoricalComparison(
export async function extractFeatures(
userId: string,
includeRaw: boolean,
options: { sinceDays?: number } = {},
): Promise<AggregatedFeatures | RawFeatures> {
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" },
});