diff --git a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts new file mode 100644 index 000000000..a2fa990f8 --- /dev/null +++ b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts @@ -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>; + 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", + ); + }); +}); diff --git a/src/lib/personal-records/pr-detection-worker.ts b/src/lib/personal-records/pr-detection-worker.ts new file mode 100644 index 000000000..2bcd9f8ba --- /dev/null +++ b/src/lib/personal-records/pr-detection-worker.ts @@ -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 { + 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 { + 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, + slot: WorkoutSlotDefinition, +): Promise { + 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, +};