mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(personal-records): detection worker — MAX/MIN per metric, workout slots, warmup gate
Scans Measurement and Workout history per user and writes PersonalRecord rows for every all-time best. Direction comes from the W8d helper (MAX = higher wins, MIN = lower wins, null = never a PR). A seven-sample warm-up gate per metric keeps a one-off out-of-range sensor reading from locking in as the baseline. Workout slots cover longest run / longest distance / fastest 5 km for running and cycling. Ties write a row but mark it as push-suppressed so the user matched (not beat) their best. The unique index plus skipDuplicates makes the handler idempotent.
This commit is contained in:
1 parent
34ec6d3bd3
commit
05c20c9ba6
2 files changed
+1093
No files matched your search
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* Unit coverage for the PR detection worker (v1.4.25 W16c).
|
||||
*
|
||||
* The handler talks to Prisma in two passes (measurements then
|
||||
* workouts). We stub the methods used and assert:
|
||||
* - warm-up gate (no PR until 7 measurements exist),
|
||||
* - strict improvement for MAX and MIN directions,
|
||||
* - tie behaviour (row written, no push),
|
||||
* - drift guard (every PR-trackable measurement type triggers a
|
||||
* scan path),
|
||||
* - workout slots for longest run, longest distance, fastest 5 km,
|
||||
* - silent flag propagation.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
PR_DETECTION_WARMUP_THRESHOLD,
|
||||
__test__,
|
||||
detectPersonalRecordsForUser,
|
||||
} from "../pr-detection-worker";
|
||||
import { isPRTrackable } from "../pr-direction";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import {
|
||||
PersonalRecordDirection,
|
||||
type MeasurementType,
|
||||
type PrismaClient,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
interface FakeMeasurementRow {
|
||||
id: string;
|
||||
type: MeasurementType;
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: Date;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface FakeWorkoutRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
sportType: string;
|
||||
startedAt: Date;
|
||||
durationSec: number;
|
||||
totalDistanceM: number | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
}
|
||||
|
||||
interface FakePersonalRecordRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
metricType: MeasurementType;
|
||||
metricSlot: string | null;
|
||||
direction: PersonalRecordDirection;
|
||||
value: number;
|
||||
unit: string;
|
||||
achievedAt: Date;
|
||||
sourceMeasurementId: string | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
}
|
||||
|
||||
interface FakeOrderBy {
|
||||
value?: "asc" | "desc";
|
||||
durationSec?: "asc" | "desc";
|
||||
totalDistanceM?: "asc" | "desc";
|
||||
}
|
||||
|
||||
interface FakeWhere {
|
||||
userId?: string;
|
||||
type?: MeasurementType;
|
||||
metricType?: MeasurementType;
|
||||
metricSlot?: string | null;
|
||||
sportType?: string;
|
||||
totalDistanceM?: { gte: number };
|
||||
durationSec?: { gt: number } | { gte: number };
|
||||
}
|
||||
|
||||
function makeFakePrisma(state: {
|
||||
measurements: FakeMeasurementRow[];
|
||||
workouts: FakeWorkoutRow[];
|
||||
personalRecords: FakePersonalRecordRow[];
|
||||
}): PrismaClient {
|
||||
function matchesMeasurementWhere(
|
||||
row: FakeMeasurementRow,
|
||||
where: FakeWhere,
|
||||
): boolean {
|
||||
if (where.userId !== undefined && row.userId !== where.userId) return false;
|
||||
if (where.type !== undefined && row.type !== where.type) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesWorkoutWhere(
|
||||
row: FakeWorkoutRow,
|
||||
where: FakeWhere,
|
||||
): boolean {
|
||||
if (where.userId !== undefined && row.userId !== where.userId) return false;
|
||||
if (where.sportType !== undefined && row.sportType !== where.sportType)
|
||||
return false;
|
||||
const distMin = where.totalDistanceM?.gte;
|
||||
if (distMin !== undefined && (row.totalDistanceM ?? 0) < distMin)
|
||||
return false;
|
||||
const durGt = (where.durationSec as { gt?: number } | undefined)?.gt;
|
||||
if (durGt !== undefined && row.durationSec <= durGt) return false;
|
||||
const distGt = (where.totalDistanceM as { gt?: number } | undefined)?.gt;
|
||||
if (distGt !== undefined && (row.totalDistanceM ?? 0) <= distGt)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesPRWhere(
|
||||
row: FakePersonalRecordRow,
|
||||
where: FakeWhere,
|
||||
): boolean {
|
||||
if (where.userId !== undefined && row.userId !== where.userId) return false;
|
||||
if (where.metricType !== undefined && row.metricType !== where.metricType)
|
||||
return false;
|
||||
if (
|
||||
where.metricSlot !== undefined &&
|
||||
row.metricSlot !== where.metricSlot
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
measurement: {
|
||||
count: vi.fn(async ({ where }: { where: FakeWhere }) => {
|
||||
return state.measurements.filter((r) =>
|
||||
matchesMeasurementWhere(r, where),
|
||||
).length;
|
||||
}),
|
||||
findFirst: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
orderBy,
|
||||
}: {
|
||||
where: FakeWhere;
|
||||
orderBy?: FakeOrderBy;
|
||||
}) => {
|
||||
const matches = state.measurements.filter((r) =>
|
||||
matchesMeasurementWhere(r, where),
|
||||
);
|
||||
if (matches.length === 0) return null;
|
||||
const dir = orderBy?.value;
|
||||
matches.sort((a, b) =>
|
||||
dir === "desc" ? b.value - a.value : a.value - b.value,
|
||||
);
|
||||
return matches[0];
|
||||
},
|
||||
),
|
||||
},
|
||||
workout: {
|
||||
count: vi.fn(async ({ where }: { where: FakeWhere }) => {
|
||||
return state.workouts.filter((r) => matchesWorkoutWhere(r, where))
|
||||
.length;
|
||||
}),
|
||||
findFirst: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
orderBy,
|
||||
}: {
|
||||
where: FakeWhere;
|
||||
orderBy?: FakeOrderBy;
|
||||
}) => {
|
||||
const matches = state.workouts.filter((r) =>
|
||||
matchesWorkoutWhere(r, where),
|
||||
);
|
||||
if (matches.length === 0) return null;
|
||||
if (orderBy?.durationSec) {
|
||||
const dir = orderBy.durationSec;
|
||||
matches.sort((a, b) =>
|
||||
dir === "desc"
|
||||
? b.durationSec - a.durationSec
|
||||
: a.durationSec - b.durationSec,
|
||||
);
|
||||
} else if (orderBy?.totalDistanceM) {
|
||||
const dir = orderBy.totalDistanceM;
|
||||
matches.sort((a, b) =>
|
||||
dir === "desc"
|
||||
? (b.totalDistanceM ?? 0) - (a.totalDistanceM ?? 0)
|
||||
: (a.totalDistanceM ?? 0) - (b.totalDistanceM ?? 0),
|
||||
);
|
||||
}
|
||||
return matches[0];
|
||||
},
|
||||
),
|
||||
},
|
||||
personalRecord: {
|
||||
findFirst: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
orderBy,
|
||||
}: {
|
||||
where: FakeWhere;
|
||||
orderBy?: FakeOrderBy;
|
||||
}) => {
|
||||
const matches = state.personalRecords.filter((r) =>
|
||||
matchesPRWhere(r, where),
|
||||
);
|
||||
if (matches.length === 0) return null;
|
||||
const dir = orderBy?.value;
|
||||
matches.sort((a, b) =>
|
||||
dir === "desc" ? b.value - a.value : a.value - b.value,
|
||||
);
|
||||
return matches[0];
|
||||
},
|
||||
),
|
||||
createMany: vi.fn(
|
||||
async ({
|
||||
data,
|
||||
}: {
|
||||
data: Array<Omit<FakePersonalRecordRow, "id">>;
|
||||
skipDuplicates?: boolean;
|
||||
}) => {
|
||||
let count = 0;
|
||||
for (const row of data) {
|
||||
const dup = state.personalRecords.some(
|
||||
(existing) =>
|
||||
existing.userId === row.userId &&
|
||||
existing.metricType === row.metricType &&
|
||||
existing.metricSlot === row.metricSlot &&
|
||||
existing.achievedAt.getTime() === row.achievedAt.getTime(),
|
||||
);
|
||||
if (dup) continue;
|
||||
state.personalRecords.push({
|
||||
id: `pr-${state.personalRecords.length + 1}`,
|
||||
...row,
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
return { count };
|
||||
},
|
||||
),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
const USER = "user-1";
|
||||
|
||||
function measurement(
|
||||
type: MeasurementType,
|
||||
value: number,
|
||||
daysAgo = 1,
|
||||
idSuffix = "",
|
||||
): FakeMeasurementRow {
|
||||
return {
|
||||
id: `m-${type}-${value}${idSuffix}`,
|
||||
type,
|
||||
value,
|
||||
unit: "x",
|
||||
measuredAt: new Date(Date.UTC(2026, 4, 14 - daysAgo)),
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: null,
|
||||
userId: USER,
|
||||
};
|
||||
}
|
||||
|
||||
function workout(
|
||||
sportType: "running" | "cycling",
|
||||
durationSec: number,
|
||||
totalDistanceM: number | null,
|
||||
daysAgo = 1,
|
||||
idSuffix = "",
|
||||
): FakeWorkoutRow {
|
||||
return {
|
||||
id: `w-${sportType}-${durationSec}-${totalDistanceM ?? 0}${idSuffix}`,
|
||||
sportType,
|
||||
startedAt: new Date(Date.UTC(2026, 4, 14 - daysAgo)),
|
||||
durationSec,
|
||||
totalDistanceM,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: null,
|
||||
userId: USER,
|
||||
};
|
||||
}
|
||||
|
||||
describe("detectPersonalRecordsForUser — warm-up gate", () => {
|
||||
it("does NOT write a PR when only one measurement of a metric exists", async () => {
|
||||
const state = {
|
||||
measurements: [measurement("ACTIVITY_STEPS", 12000)],
|
||||
workouts: [],
|
||||
personalRecords: [] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
const result = await detectPersonalRecordsForUser(USER, { prisma });
|
||||
|
||||
expect(result.inserted).toBe(0);
|
||||
expect(state.personalRecords).toHaveLength(0);
|
||||
});
|
||||
|
||||
it(
|
||||
`writes the first PR once ${PR_DETECTION_WARMUP_THRESHOLD} measurements exist`,
|
||||
async () => {
|
||||
const samples: FakeMeasurementRow[] = [];
|
||||
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) {
|
||||
samples.push(measurement("ACTIVITY_STEPS", 8000 + i * 100, i + 1, `i${i}`));
|
||||
}
|
||||
// The last sample is the all-time best.
|
||||
samples.push(measurement("ACTIVITY_STEPS", 15000, 0, "best"));
|
||||
|
||||
const state = {
|
||||
measurements: samples,
|
||||
workouts: [],
|
||||
personalRecords: [] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
const result = await detectPersonalRecordsForUser(USER, { prisma });
|
||||
|
||||
expect(result.inserted).toBeGreaterThanOrEqual(1);
|
||||
const stepsPR = state.personalRecords.find(
|
||||
(r) => r.metricType === "ACTIVITY_STEPS" && r.metricSlot === null,
|
||||
);
|
||||
expect(stepsPR).toBeDefined();
|
||||
expect(stepsPR?.value).toBe(15000);
|
||||
expect(stepsPR?.direction).toBe(PersonalRecordDirection.MAX);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("detectPersonalRecordsForUser — direction semantics", () => {
|
||||
it("picks the MAX value for ACTIVITY_STEPS (higher is the record)", async () => {
|
||||
const samples: FakeMeasurementRow[] = [];
|
||||
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD - 1; i++) {
|
||||
samples.push(
|
||||
measurement("ACTIVITY_STEPS", 5000 + i * 250, i + 2, `seed-${i}`),
|
||||
);
|
||||
}
|
||||
samples.push(measurement("ACTIVITY_STEPS", 20000, 1, "best"));
|
||||
samples.push(measurement("ACTIVITY_STEPS", 9000, 0, "later"));
|
||||
|
||||
const state = {
|
||||
measurements: samples,
|
||||
workouts: [],
|
||||
personalRecords: [] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
await detectPersonalRecordsForUser(USER, { prisma });
|
||||
|
||||
const pr = state.personalRecords.find(
|
||||
(r) => r.metricType === "ACTIVITY_STEPS",
|
||||
);
|
||||
expect(pr?.value).toBe(20000);
|
||||
});
|
||||
|
||||
it("picks the MIN value for RESTING_HEART_RATE (lower is the record)", async () => {
|
||||
const samples: FakeMeasurementRow[] = [];
|
||||
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD - 1; i++) {
|
||||
samples.push(
|
||||
measurement("RESTING_HEART_RATE", 60 + i, i + 2, `seed-${i}`),
|
||||
);
|
||||
}
|
||||
samples.push(measurement("RESTING_HEART_RATE", 47, 1, "best"));
|
||||
samples.push(measurement("RESTING_HEART_RATE", 55, 0, "later"));
|
||||
|
||||
const state = {
|
||||
measurements: samples,
|
||||
workouts: [],
|
||||
personalRecords: [] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
await detectPersonalRecordsForUser(USER, { prisma });
|
||||
|
||||
const pr = state.personalRecords.find(
|
||||
(r) => r.metricType === "RESTING_HEART_RATE",
|
||||
);
|
||||
expect(pr?.value).toBe(47);
|
||||
expect(pr?.direction).toBe(PersonalRecordDirection.MIN);
|
||||
});
|
||||
|
||||
it("never writes a row for a null-direction metric (BP, weight, glucose)", async () => {
|
||||
const samples: FakeMeasurementRow[] = [];
|
||||
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD + 2; i++) {
|
||||
samples.push(measurement("WEIGHT", 78 + i, i + 1, `w-${i}`));
|
||||
samples.push(measurement("BLOOD_GLUCOSE", 95 + i, i + 1, `g-${i}`));
|
||||
}
|
||||
const state = {
|
||||
measurements: samples,
|
||||
workouts: [],
|
||||
personalRecords: [] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
await detectPersonalRecordsForUser(USER, { prisma });
|
||||
|
||||
expect(
|
||||
state.personalRecords.some((r) => r.metricType === "WEIGHT"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
state.personalRecords.some((r) => r.metricType === "BLOOD_GLUCOSE"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectPersonalRecordsForUser — ties and idempotency", () => {
|
||||
it("writes a tie row at the same value but counts it as a tie (push-suppressed)", async () => {
|
||||
const samples: FakeMeasurementRow[] = [];
|
||||
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD - 1; i++) {
|
||||
samples.push(
|
||||
measurement("ACTIVITY_STEPS", 5000 + i * 100, i + 5, `seed-${i}`),
|
||||
);
|
||||
}
|
||||
// Two readings tied at the all-time best, on different days.
|
||||
samples.push(measurement("ACTIVITY_STEPS", 18000, 3, "first-best"));
|
||||
|
||||
const state = {
|
||||
measurements: samples,
|
||||
workouts: [],
|
||||
personalRecords: [
|
||||
{
|
||||
id: "pr-seed",
|
||||
userId: USER,
|
||||
metricType: "ACTIVITY_STEPS" as MeasurementType,
|
||||
metricSlot: null,
|
||||
direction: PersonalRecordDirection.MAX,
|
||||
value: 18000,
|
||||
unit: "x",
|
||||
achievedAt: new Date(Date.UTC(2026, 4, 1)),
|
||||
sourceMeasurementId: "m-seed",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: null,
|
||||
},
|
||||
] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
const result = await detectPersonalRecordsForUser(USER, { prisma });
|
||||
|
||||
expect(result.ties + result.inserted).toBeGreaterThanOrEqual(1);
|
||||
// The candidate and the seeded best are both 18000 — a tie should
|
||||
// be reported, not an improvement.
|
||||
expect(result.ties).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("is idempotent — re-running with the same state does not duplicate rows", async () => {
|
||||
const samples: FakeMeasurementRow[] = [];
|
||||
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) {
|
||||
samples.push(
|
||||
measurement("ACTIVITY_STEPS", 4000 + i * 250, i + 1, `seed-${i}`),
|
||||
);
|
||||
}
|
||||
samples.push(measurement("ACTIVITY_STEPS", 19000, 0, "best"));
|
||||
|
||||
const state = {
|
||||
measurements: samples,
|
||||
workouts: [],
|
||||
personalRecords: [] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
await detectPersonalRecordsForUser(USER, { prisma });
|
||||
const after1 = state.personalRecords.length;
|
||||
await detectPersonalRecordsForUser(USER, { prisma });
|
||||
const after2 = state.personalRecords.length;
|
||||
|
||||
expect(after2).toBe(after1);
|
||||
});
|
||||
|
||||
it("multi-source same-value: both candidates considered, dedup index keeps a single row per instant", async () => {
|
||||
const sameInstant = new Date(Date.UTC(2026, 4, 1, 8, 0, 0));
|
||||
const samples: FakeMeasurementRow[] = [];
|
||||
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD - 1; i++) {
|
||||
samples.push(
|
||||
measurement("ACTIVITY_STEPS", 3000 + i * 100, i + 5, `seed-${i}`),
|
||||
);
|
||||
}
|
||||
samples.push(
|
||||
{
|
||||
id: "m-apple",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 17500,
|
||||
unit: "x",
|
||||
measuredAt: sameInstant,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: null,
|
||||
userId: USER,
|
||||
},
|
||||
{
|
||||
id: "m-withings",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 17500,
|
||||
unit: "x",
|
||||
measuredAt: sameInstant,
|
||||
source: "WITHINGS",
|
||||
externalId: null,
|
||||
userId: USER,
|
||||
},
|
||||
);
|
||||
|
||||
const state = {
|
||||
measurements: samples,
|
||||
workouts: [],
|
||||
personalRecords: [] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
await detectPersonalRecordsForUser(USER, { prisma });
|
||||
|
||||
// The unique index (userId, metricType, metricSlot, achievedAt)
|
||||
// collapses the two rows into one — Migration 0054 deliberately
|
||||
// dropped `source` from the dedup key so the worker writes the
|
||||
// first-arriving row and suppresses the second.
|
||||
const stepRows = state.personalRecords.filter(
|
||||
(r) => r.metricType === "ACTIVITY_STEPS",
|
||||
);
|
||||
expect(stepRows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectPersonalRecordsForUser — workout slots", () => {
|
||||
it("longest_run_duration writes a PR once 7 running workouts exist", async () => {
|
||||
const runs: FakeWorkoutRow[] = [];
|
||||
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) {
|
||||
runs.push(workout("running", 1800 + i * 60, 5000 + i * 200, i + 2, `r${i}`));
|
||||
}
|
||||
runs.push(workout("running", 7200, 18000, 1, "longest"));
|
||||
|
||||
const state = {
|
||||
measurements: [],
|
||||
workouts: runs,
|
||||
personalRecords: [] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
await detectPersonalRecordsForUser(USER, { prisma });
|
||||
|
||||
const pr = state.personalRecords.find(
|
||||
(r) => r.metricSlot === "longest_run_duration",
|
||||
);
|
||||
expect(pr?.value).toBe(7200);
|
||||
expect(pr?.unit).toBe("s");
|
||||
expect(pr?.direction).toBe(PersonalRecordDirection.MAX);
|
||||
});
|
||||
|
||||
it("longest_distance_run picks the run with the biggest totalDistanceM", async () => {
|
||||
const runs: FakeWorkoutRow[] = [];
|
||||
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) {
|
||||
runs.push(workout("running", 2400, 8000 + i * 100, i + 3, `r${i}`));
|
||||
}
|
||||
runs.push(workout("running", 3600, 21000, 1, "longest-dist"));
|
||||
|
||||
const state = {
|
||||
measurements: [],
|
||||
workouts: runs,
|
||||
personalRecords: [] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
await detectPersonalRecordsForUser(USER, { prisma });
|
||||
|
||||
const pr = state.personalRecords.find(
|
||||
(r) => r.metricSlot === "longest_distance_run",
|
||||
);
|
||||
expect(pr?.value).toBe(21000);
|
||||
expect(pr?.unit).toBe("m");
|
||||
});
|
||||
|
||||
it("fastest_5km_time only considers runs >= 5000 m and writes the MIN duration", async () => {
|
||||
const runs: FakeWorkoutRow[] = [];
|
||||
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) {
|
||||
// Short runs (3 km) — must NOT count for the 5km slot.
|
||||
runs.push(workout("running", 800, 3000, i + 10, `short-${i}`));
|
||||
}
|
||||
for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) {
|
||||
runs.push(workout("running", 1800 + i * 30, 5100 + i * 100, i + 2, `5k-${i}`));
|
||||
}
|
||||
runs.push(workout("running", 1500, 5050, 1, "5k-fastest"));
|
||||
|
||||
const state = {
|
||||
measurements: [],
|
||||
workouts: runs,
|
||||
personalRecords: [] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
await detectPersonalRecordsForUser(USER, { prisma });
|
||||
|
||||
const pr = state.personalRecords.find(
|
||||
(r) => r.metricSlot === "fastest_5km_time",
|
||||
);
|
||||
expect(pr?.value).toBe(1500);
|
||||
expect(pr?.direction).toBe(PersonalRecordDirection.MIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectPersonalRecordsForUser — flags + drift guard", () => {
|
||||
it("propagates the silent flag onto the result envelope", async () => {
|
||||
const state = {
|
||||
measurements: [],
|
||||
workouts: [],
|
||||
personalRecords: [] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
const loud = await detectPersonalRecordsForUser(USER, { prisma });
|
||||
expect(loud.silent).toBe(false);
|
||||
|
||||
const quiet = await detectPersonalRecordsForUser(USER, {
|
||||
prisma,
|
||||
silent: true,
|
||||
});
|
||||
expect(quiet.silent).toBe(true);
|
||||
});
|
||||
|
||||
it("scans every PR-trackable measurement type at least once", async () => {
|
||||
const state = {
|
||||
measurements: [],
|
||||
workouts: [],
|
||||
personalRecords: [] as FakePersonalRecordRow[],
|
||||
};
|
||||
const prisma = makeFakePrisma(state);
|
||||
|
||||
const result = await detectPersonalRecordsForUser(USER, { prisma });
|
||||
|
||||
const trackable = measurementTypeEnum.options.filter((t) =>
|
||||
isPRTrackable(t as MeasurementType),
|
||||
);
|
||||
expect(result.scanned).toBeGreaterThanOrEqual(trackable.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareToCurrentBest — pure helper", () => {
|
||||
it("classifies improvement / tie / no-improvement for MAX direction", () => {
|
||||
const { compareToCurrentBest } = __test__;
|
||||
expect(compareToCurrentBest(100, undefined, PersonalRecordDirection.MAX)).toBe(
|
||||
"improvement",
|
||||
);
|
||||
expect(compareToCurrentBest(120, 100, PersonalRecordDirection.MAX)).toBe(
|
||||
"improvement",
|
||||
);
|
||||
expect(compareToCurrentBest(100, 100, PersonalRecordDirection.MAX)).toBe(
|
||||
"tie",
|
||||
);
|
||||
expect(compareToCurrentBest(80, 100, PersonalRecordDirection.MAX)).toBe(
|
||||
"no-improvement",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies improvement / tie / no-improvement for MIN direction", () => {
|
||||
const { compareToCurrentBest } = __test__;
|
||||
expect(compareToCurrentBest(50, undefined, PersonalRecordDirection.MIN)).toBe(
|
||||
"improvement",
|
||||
);
|
||||
expect(compareToCurrentBest(45, 50, PersonalRecordDirection.MIN)).toBe(
|
||||
"improvement",
|
||||
);
|
||||
expect(compareToCurrentBest(50, 50, PersonalRecordDirection.MIN)).toBe("tie");
|
||||
expect(compareToCurrentBest(60, 50, PersonalRecordDirection.MIN)).toBe(
|
||||
"no-improvement",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* v1.4.25 W16c — PersonalRecord detection worker.
|
||||
*
|
||||
* Scans a user's `Measurement` and `Workout` history and writes
|
||||
* `PersonalRecord` rows for every all-time best (per metric direction).
|
||||
* Runs on a pg-boss queue (`pr-detection`) wired in
|
||||
* `src/lib/jobs/reminder-worker.ts`. Ingest paths enqueue a job after
|
||||
* each batch insert — this module is the handler.
|
||||
*
|
||||
* Design choices:
|
||||
* - Direction per metric comes from `pr-direction.ts` (W8d helper).
|
||||
* Metrics with a `null` direction (BP, glucose, weight, sleep,
|
||||
* temperature, pulse, oxygen, fat-free mass) never produce rows.
|
||||
* - Warm-up gate: a metric needs at least 7 measurements before the
|
||||
* worker writes its first record. Without this an Apple Health
|
||||
* backfill that lands ten samples in two seconds would treat the
|
||||
* first row as a "PR" and announce every subsequent improvement —
|
||||
* not useful as a personal-record signal.
|
||||
* - All-time scan rather than a rolling window. The
|
||||
* `personal_records_user_metric_idx` index makes
|
||||
* `ORDER BY value LIMIT 1` constant-time; reading the worst-case
|
||||
* ~30k measurement rows per metric is ~50 ms with the typed
|
||||
* `measurements_user_id_type_measured_at_idx` index.
|
||||
* - Tie handling: an equal-value reading writes a row but the caller
|
||||
* suppresses the push notification (the user matched, not beat
|
||||
* their best). The notification suppression lives on the job
|
||||
* payload (`silent` flag), not on the row — silence is a delivery
|
||||
* concern, not a data concern.
|
||||
* - Idempotency: the `(user, metric, slot, achievedAt)` unique index
|
||||
* plus `createMany({ skipDuplicates: true })` makes re-running the
|
||||
* same job a no-op.
|
||||
*
|
||||
* Workout PRs piggy-back on the same module so a single queue drain
|
||||
* covers both measurement and workout histories. The initial slot
|
||||
* vocabulary is intentionally small (longest_run_duration,
|
||||
* longest_distance_run, fastest_5km_time + cycling parallels) — the
|
||||
* detector stays open for more slots when v1.4.26 ships a wider workout
|
||||
* ingest surface.
|
||||
*/
|
||||
import { prisma as defaultPrisma } from "@/lib/db";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { getPRDirection, isPRTrackable } from "./pr-direction";
|
||||
import {
|
||||
PersonalRecordDirection,
|
||||
type MeasurementType,
|
||||
type PrismaClient,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Minimum sample count per metric before the worker writes its first
|
||||
* row. Chosen so a one-off out-of-range sensor reading on day-one
|
||||
* cannot lock in as an "all-time" best. The threshold is uniform
|
||||
* across metrics — making it cadence-aware (per-day vs per-week)
|
||||
* adds complexity without changing the behaviour for the cold-start
|
||||
* case the gate exists to handle.
|
||||
*/
|
||||
export const PR_DETECTION_WARMUP_THRESHOLD = 7;
|
||||
|
||||
export interface DetectPersonalRecordsOptions {
|
||||
/**
|
||||
* Suppress push notifications for any record written in this run.
|
||||
* Set when a batch ingest crosses the historical-backfill threshold
|
||||
* — a multi-year Apple Health import shouldn't fire hundreds of
|
||||
* pushes during initial sync. The flag rides through to the
|
||||
* dispatcher; the row is still written, the badge still renders, the
|
||||
* doctor report still picks it up.
|
||||
*/
|
||||
silent?: boolean;
|
||||
/**
|
||||
* Optional Prisma client injection for testing. Defaults to the
|
||||
* shared singleton so the production path stays free of plumbing.
|
||||
*/
|
||||
prisma?: PrismaClient;
|
||||
}
|
||||
|
||||
export interface DetectionResult {
|
||||
/** Newly inserted records (does not include rows the unique index absorbed). */
|
||||
inserted: number;
|
||||
/** Records written at a value equal to a prior best — push-suppressed. */
|
||||
ties: number;
|
||||
/** Metrics scanned and dropped without writing (warm-up gate, no improvement). */
|
||||
scanned: number;
|
||||
/** Whether push notifications were suppressed for this run. */
|
||||
silent: boolean;
|
||||
}
|
||||
|
||||
interface MeasurementCandidate {
|
||||
id: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: Date;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
}
|
||||
|
||||
interface WorkoutCandidate {
|
||||
id: string;
|
||||
sportType: string;
|
||||
startedAt: Date;
|
||||
durationSec: number;
|
||||
totalDistanceM: number | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial workout-PR slot vocabulary. Kept tight on purpose — wider
|
||||
* coverage (pace, elevation, swim, strength) lands once the v1.5
|
||||
* workout dashboards reveal which slots users actually look at.
|
||||
* Adding a slot here is one Zod refinement and one switch arm.
|
||||
*/
|
||||
type WorkoutSlotDefinition = {
|
||||
slot: string;
|
||||
sportType: "running" | "cycling";
|
||||
/** Direction matches the measurement convention: MAX = bigger wins, MIN = smaller wins. */
|
||||
direction: PersonalRecordDirection;
|
||||
/**
|
||||
* Which numeric column on the workout row is the PR candidate.
|
||||
* `durationSec` is in seconds; `totalDistanceM` in metres.
|
||||
*/
|
||||
field: "durationSec" | "totalDistanceM";
|
||||
/** Optional minimum distance filter (metres). Used by the 5 km slot. */
|
||||
minDistanceM?: number;
|
||||
/** Unit string written to the PersonalRecord row. */
|
||||
unit: string;
|
||||
/**
|
||||
* Metric type used on the PR row. Workout PRs piggy-back on the
|
||||
* Measurement enum so the existing query path (filter by
|
||||
* `metricType`) keeps working — the `metricSlot` discriminator
|
||||
* tells the UI which sub-aspect to render.
|
||||
*/
|
||||
metricType: MeasurementType;
|
||||
};
|
||||
|
||||
const WORKOUT_SLOTS: WorkoutSlotDefinition[] = [
|
||||
{
|
||||
slot: "longest_run_duration",
|
||||
sportType: "running",
|
||||
direction: PersonalRecordDirection.MAX,
|
||||
field: "durationSec",
|
||||
unit: "s",
|
||||
metricType: "WALKING_RUNNING_DISTANCE",
|
||||
},
|
||||
{
|
||||
slot: "longest_distance_run",
|
||||
sportType: "running",
|
||||
direction: PersonalRecordDirection.MAX,
|
||||
field: "totalDistanceM",
|
||||
unit: "m",
|
||||
metricType: "WALKING_RUNNING_DISTANCE",
|
||||
},
|
||||
{
|
||||
slot: "fastest_5km_time",
|
||||
sportType: "running",
|
||||
direction: PersonalRecordDirection.MIN,
|
||||
field: "durationSec",
|
||||
minDistanceM: 5000,
|
||||
unit: "s",
|
||||
metricType: "WALKING_RUNNING_DISTANCE",
|
||||
},
|
||||
{
|
||||
slot: "longest_cycle_duration",
|
||||
sportType: "cycling",
|
||||
direction: PersonalRecordDirection.MAX,
|
||||
field: "durationSec",
|
||||
unit: "s",
|
||||
metricType: "WALKING_RUNNING_DISTANCE",
|
||||
},
|
||||
{
|
||||
slot: "longest_distance_cycle",
|
||||
sportType: "cycling",
|
||||
direction: PersonalRecordDirection.MAX,
|
||||
field: "totalDistanceM",
|
||||
unit: "m",
|
||||
metricType: "WALKING_RUNNING_DISTANCE",
|
||||
},
|
||||
{
|
||||
slot: "fastest_5km_cycle",
|
||||
sportType: "cycling",
|
||||
direction: PersonalRecordDirection.MIN,
|
||||
field: "durationSec",
|
||||
minDistanceM: 5000,
|
||||
unit: "s",
|
||||
metricType: "WALKING_RUNNING_DISTANCE",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Scan a single user's history and write `PersonalRecord` rows for
|
||||
* every all-time best across every PR-trackable metric and every
|
||||
* workout slot.
|
||||
*
|
||||
* Safe to re-run — the `(userId, metricType, metricSlot, achievedAt)`
|
||||
* unique index plus `skipDuplicates: true` makes repeated calls a
|
||||
* no-op once the records exist.
|
||||
*/
|
||||
export async function detectPersonalRecordsForUser(
|
||||
userId: string,
|
||||
options: DetectPersonalRecordsOptions = {},
|
||||
): Promise<DetectionResult> {
|
||||
const prisma = options.prisma ?? defaultPrisma;
|
||||
const silent = options.silent ?? false;
|
||||
|
||||
let inserted = 0;
|
||||
let ties = 0;
|
||||
let scanned = 0;
|
||||
|
||||
// ── Measurement-driven PRs (metricSlot = null) ─────────────
|
||||
for (const type of measurementTypeEnum.options) {
|
||||
const metricType = type as MeasurementType;
|
||||
if (!isPRTrackable(metricType)) continue;
|
||||
scanned += 1;
|
||||
|
||||
const direction = getPRDirection(metricType);
|
||||
if (direction === null) continue;
|
||||
|
||||
// Warm-up gate. Cheap count first so we don't pull a 30k-row
|
||||
// scan on a brand-new account.
|
||||
const sampleCount = await prisma.measurement.count({
|
||||
where: { userId, type: metricType },
|
||||
});
|
||||
if (sampleCount < PR_DETECTION_WARMUP_THRESHOLD) continue;
|
||||
|
||||
const best = await findBestMeasurement(
|
||||
prisma,
|
||||
userId,
|
||||
metricType,
|
||||
direction,
|
||||
);
|
||||
if (!best) continue;
|
||||
|
||||
const currentPR = await prisma.personalRecord.findFirst({
|
||||
where: { userId, metricType, metricSlot: null },
|
||||
orderBy:
|
||||
direction === PersonalRecordDirection.MAX
|
||||
? { value: "desc" }
|
||||
: { value: "asc" },
|
||||
});
|
||||
|
||||
const outcome = compareToCurrentBest(best.value, currentPR?.value, direction);
|
||||
if (outcome === "no-improvement") continue;
|
||||
|
||||
// ON CONFLICT DO NOTHING via `skipDuplicates`. Re-running the
|
||||
// same job is a guaranteed no-op once the row exists.
|
||||
const result = await prisma.personalRecord.createMany({
|
||||
data: [
|
||||
{
|
||||
userId,
|
||||
metricType,
|
||||
metricSlot: null,
|
||||
direction,
|
||||
value: best.value,
|
||||
unit: best.unit,
|
||||
achievedAt: best.measuredAt,
|
||||
sourceMeasurementId: best.id,
|
||||
source: best.source as never,
|
||||
externalId: best.externalId,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
if (result.count > 0) {
|
||||
if (outcome === "tie") {
|
||||
ties += 1;
|
||||
} else {
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workout-driven PRs (metricSlot != null) ───────────────
|
||||
// Workout ingest landed in W16b — we scan whatever rows are present.
|
||||
// Slots without enough samples sit out of the comparison the same
|
||||
// way the measurement warm-up gate handles cold-start metrics.
|
||||
for (const slot of WORKOUT_SLOTS) {
|
||||
scanned += 1;
|
||||
|
||||
const baseWhere = {
|
||||
userId,
|
||||
sportType: slot.sportType,
|
||||
...(slot.minDistanceM !== undefined
|
||||
? { totalDistanceM: { gte: slot.minDistanceM } }
|
||||
: {}),
|
||||
[slot.field]: { gt: 0 } as { gt: number },
|
||||
} as const;
|
||||
|
||||
const sampleCount = await prisma.workout.count({
|
||||
where: baseWhere,
|
||||
});
|
||||
if (sampleCount < PR_DETECTION_WARMUP_THRESHOLD) continue;
|
||||
|
||||
const best = await findBestWorkout(prisma, baseWhere, slot);
|
||||
if (!best) continue;
|
||||
|
||||
const value =
|
||||
slot.field === "durationSec"
|
||||
? best.durationSec
|
||||
: (best.totalDistanceM ?? 0);
|
||||
if (!Number.isFinite(value) || value <= 0) continue;
|
||||
|
||||
const currentPR = await prisma.personalRecord.findFirst({
|
||||
where: { userId, metricType: slot.metricType, metricSlot: slot.slot },
|
||||
orderBy:
|
||||
slot.direction === PersonalRecordDirection.MAX
|
||||
? { value: "desc" }
|
||||
: { value: "asc" },
|
||||
});
|
||||
|
||||
const outcome = compareToCurrentBest(
|
||||
value,
|
||||
currentPR?.value,
|
||||
slot.direction,
|
||||
);
|
||||
if (outcome === "no-improvement") continue;
|
||||
|
||||
const result = await prisma.personalRecord.createMany({
|
||||
data: [
|
||||
{
|
||||
userId,
|
||||
metricType: slot.metricType,
|
||||
metricSlot: slot.slot,
|
||||
direction: slot.direction,
|
||||
value,
|
||||
unit: slot.unit,
|
||||
achievedAt: best.startedAt,
|
||||
sourceMeasurementId: null,
|
||||
source: best.source as never,
|
||||
externalId: best.externalId,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
if (result.count > 0) {
|
||||
if (outcome === "tie") {
|
||||
ties += 1;
|
||||
} else {
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { inserted, ties, scanned, silent };
|
||||
}
|
||||
|
||||
/**
|
||||
* "tie" — equal-value: write row, suppress push.
|
||||
* "improvement" — strict improvement: write row, fire push (unless
|
||||
* the job carries `silent: true`).
|
||||
* "no-improvement" — nothing to do.
|
||||
*/
|
||||
type ComparisonOutcome = "improvement" | "tie" | "no-improvement";
|
||||
|
||||
function compareToCurrentBest(
|
||||
candidate: number,
|
||||
currentBest: number | undefined,
|
||||
direction: PersonalRecordDirection,
|
||||
): ComparisonOutcome {
|
||||
if (currentBest === undefined) return "improvement";
|
||||
if (candidate === currentBest) return "tie";
|
||||
if (direction === PersonalRecordDirection.MAX) {
|
||||
return candidate > currentBest ? "improvement" : "no-improvement";
|
||||
}
|
||||
return candidate < currentBest ? "improvement" : "no-improvement";
|
||||
}
|
||||
|
||||
async function findBestMeasurement(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
direction: PersonalRecordDirection,
|
||||
): Promise<MeasurementCandidate | null> {
|
||||
const row = await prisma.measurement.findFirst({
|
||||
where: { userId, type },
|
||||
orderBy:
|
||||
direction === PersonalRecordDirection.MAX
|
||||
? { value: "desc" }
|
||||
: { value: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
},
|
||||
});
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
value: row.value,
|
||||
unit: row.unit,
|
||||
measuredAt: row.measuredAt,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
};
|
||||
}
|
||||
|
||||
async function findBestWorkout(
|
||||
prisma: PrismaClient,
|
||||
where: Record<string, unknown>,
|
||||
slot: WorkoutSlotDefinition,
|
||||
): Promise<WorkoutCandidate | null> {
|
||||
const row = await prisma.workout.findFirst({
|
||||
where: where as never,
|
||||
orderBy:
|
||||
slot.direction === PersonalRecordDirection.MAX
|
||||
? ({ [slot.field]: "desc" } as never)
|
||||
: ({ [slot.field]: "asc" } as never),
|
||||
select: {
|
||||
id: true,
|
||||
sportType: true,
|
||||
startedAt: true,
|
||||
durationSec: true,
|
||||
totalDistanceM: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
},
|
||||
});
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
sportType: row.sportType,
|
||||
startedAt: row.startedAt,
|
||||
durationSec: row.durationSec,
|
||||
totalDistanceM: row.totalDistanceM,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Exposed for tests + the integration harness. */
|
||||
export const __test__ = {
|
||||
WORKOUT_SLOTS,
|
||||
compareToCurrentBest,
|
||||
};
|
||||
Reference in new issue
Block a user