mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
f1ca0e28eb
commit
efa132b1aa
4 files changed
+48
-20
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user