From efa132b1aac21fd9269c4a9a56e2c15d22fbe677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 19:28:32 +0200 Subject: [PATCH] fix(insights): make the score candidate finders deterministic The recovery / stress / strain nightly finders used distinct + take with no order, so when more than the cap of users qualified the selected set was arbitrary across runs. Switch to a grouped query ordered by each user's newest input first with userId as the stable tiebreak, so the cap takes the most-recently-active users reproducibly. The strain finder interleaves its workout and active-energy lists so the merged cap favours recency across both sources. --- src/lib/jobs/__tests__/recovery-score.test.ts | 19 ++++++++----- src/lib/jobs/recovery-score.ts | 12 ++++++--- src/lib/jobs/strain-score.ts | 27 +++++++++++++------ src/lib/jobs/stress-score.ts | 10 ++++--- 4 files changed, 48 insertions(+), 20 deletions(-) diff --git a/src/lib/jobs/__tests__/recovery-score.test.ts b/src/lib/jobs/__tests__/recovery-score.test.ts index 472433673..ac2b423d4 100644 --- a/src/lib/jobs/__tests__/recovery-score.test.ts +++ b/src/lib/jobs/__tests__/recovery-score.test.ts @@ -26,14 +26,14 @@ import { const NOW = new Date("2026-06-02T08:00:00Z"); function makePrisma(userIds: string[]) { - const findMany = vi + const groupBy = vi .fn() .mockResolvedValue(userIds.map((userId) => ({ userId }))); return { - prisma: { measurement: { findMany } } as unknown as Parameters< + prisma: { measurement: { groupBy } } as unknown as Parameters< typeof runRecoveryScore >[0], - findMany, + groupBy, }; } @@ -43,11 +43,12 @@ beforeEach(() => { describe("findRecoveryScoreCandidates", () => { it("queries live recovery-input rows inside the recency window", async () => { - const { prisma, findMany } = makePrisma(["a", "b"]); + const { prisma, groupBy } = makePrisma(["a", "b"]); const ids = await findRecoveryScoreCandidates(prisma, NOW, 100); expect(ids).toEqual(["a", "b"]); - const where = findMany.mock.calls[0][0].where; + const arg = groupBy.mock.calls[0][0]; + const where = arg.where; expect(where.type.in).toEqual( expect.arrayContaining([ "RESTING_HEART_RATE", @@ -62,7 +63,13 @@ describe("findRecoveryScoreCandidates", () => { NOW.getTime() - RECOVERY_SCORE_RECENCY_DAYS * 24 * 60 * 60 * 1000, ); expect(where.measuredAt.gte.getTime()).toBe(expectedSince.getTime()); - expect(findMany.mock.calls[0][0].distinct).toEqual(["userId"]); + expect(arg.by).toEqual(["userId"]); + // Deterministic recency-under-cap: newest input first, userId tiebreak. + expect(arg.orderBy).toEqual([ + { _max: { measuredAt: "desc" } }, + { userId: "asc" }, + ]); + expect(arg.take).toBe(100); }); }); diff --git a/src/lib/jobs/recovery-score.ts b/src/lib/jobs/recovery-score.ts index 08918f274..e0b3808e3 100644 --- a/src/lib/jobs/recovery-score.ts +++ b/src/lib/jobs/recovery-score.ts @@ -67,7 +67,14 @@ export async function findRecoveryScoreCandidates( const since = new Date(now.getTime() - RECOVERY_SCORE_RECENCY_DAYS * MS_PER_DAY); // The input-type set is a closed compile-time list of enum members — // splice-free; Prisma binds the `type IN (...)` array as parameters. - const rows = await prisma.measurement.findMany({ + // + // Deterministic recency-under-cap: group by user, order by each user's + // newest input first, with `userId` as the stable tiebreak so the capped + // set is reproducible. `distinct` + `take` without an order picks an + // arbitrary set when more than `cap` users qualify; `groupBy` makes the + // cap take the most-recently-active users every run. + const rows = await prisma.measurement.groupBy({ + by: ["userId"], where: { type: { in: [ @@ -80,8 +87,7 @@ export async function findRecoveryScoreCandidates( deletedAt: null, measuredAt: { gte: since }, }, - select: { userId: true }, - distinct: ["userId"], + orderBy: [{ _max: { measuredAt: "desc" } }, { userId: "asc" }], take: cap, }); return rows.map((r) => r.userId); diff --git a/src/lib/jobs/strain-score.ts b/src/lib/jobs/strain-score.ts index 4415ffb48..3104f1ec3 100644 --- a/src/lib/jobs/strain-score.ts +++ b/src/lib/jobs/strain-score.ts @@ -57,27 +57,38 @@ export async function findStrainScoreCandidates( cap: number, ): Promise { const since = new Date(now.getTime() - STRAIN_SCORE_RECENCY_DAYS * MS_PER_DAY); + // Deterministic recency-under-cap on both source queries: group by user, + // newest activity first, `userId` tiebreak. `distinct` + `take` without an + // order picked an arbitrary set when more than `cap` users qualified; the + // ordered `groupBy` makes each side take the most-recently-active users, and + // the merge below is order-stable. const [workoutUsers, energyUsers] = await Promise.all([ - prisma.workout.findMany({ + prisma.workout.groupBy({ + by: ["userId"], where: { startedAt: { gte: since } }, - select: { userId: true }, - distinct: ["userId"], + orderBy: [{ _max: { startedAt: "desc" } }, { userId: "asc" }], take: cap, }), - prisma.measurement.findMany({ + prisma.measurement.groupBy({ + by: ["userId"], where: { type: "ACTIVE_ENERGY_BURNED", deletedAt: null, measuredAt: { gte: since }, }, - select: { userId: true }, - distinct: ["userId"], + orderBy: [{ _max: { measuredAt: "desc" } }, { userId: "asc" }], take: cap, }), ]); + // Interleave the two recency-ordered lists so the merged cap favours the + // most-recently-active users across both sources rather than draining one + // list first. Both inputs are already deterministic, so the merge is too. const ids = new Set(); - for (const r of workoutUsers) ids.add(r.userId); - for (const r of energyUsers) ids.add(r.userId); + const maxLen = Math.max(workoutUsers.length, energyUsers.length); + for (let i = 0; i < maxLen && ids.size < cap; i++) { + if (i < workoutUsers.length) ids.add(workoutUsers[i].userId); + if (ids.size < cap && i < energyUsers.length) ids.add(energyUsers[i].userId); + } return Array.from(ids).slice(0, cap); } diff --git a/src/lib/jobs/stress-score.ts b/src/lib/jobs/stress-score.ts index 2064a9ddc..e555253bd 100644 --- a/src/lib/jobs/stress-score.ts +++ b/src/lib/jobs/stress-score.ts @@ -58,14 +58,18 @@ export async function findStressScoreCandidates( cap: number, ): Promise { const since = new Date(now.getTime() - STRESS_SCORE_RECENCY_DAYS * MS_PER_DAY); - const rows = await prisma.measurement.findMany({ + // Deterministic recency-under-cap: group by user, newest HRV first, `userId` + // tiebreak. `distinct` + `take` without an order picks an arbitrary set when + // more than `cap` users qualify; `groupBy` makes the cap take the + // most-recently-active users every run. + const rows = await prisma.measurement.groupBy({ + by: ["userId"], where: { type: "HEART_RATE_VARIABILITY", deletedAt: null, measuredAt: { gte: since }, }, - select: { userId: true }, - distinct: ["userId"], + orderBy: [{ _max: { measuredAt: "desc" } }, { userId: "asc" }], take: cap, }); return rows.map((r) => r.userId);