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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 19:31:16 +02:00
1 parent f1ca0e28eb
commit efa132b1aa
4 files changed
+48 -20

No files matched your search

+13 -6
View File
@@ -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);
});
});
+9 -3
View File
@@ -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);
+19 -8
View File
@@ -57,27 +57,38 @@ export async function findStrainScoreCandidates(
cap: number,
): Promise<string[]> {
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<string>();
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);
}
+7 -3
View File
@@ -58,14 +58,18 @@ export async function findStressScoreCandidates(
cap: number,
): Promise<string[]> {
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);