I spent years studying strength adaptation as a sports scientist before I wrote production code. When I built PR detection for my workout tracker, those two worlds collided in ways I didn't expect. Here's the algorithm, the sports science behind it, and what I learned building it in Next.js.
The problem with “personal records”
Most fitness apps track PRs naively: your heaviest lift for a given exercise is your PR. Bench pressed 100kg once? That's your record. But this breaks down immediately in real training.
If I bench 95kg for 5 reps and then 100kg for 1 rep, which is better? The 5-rep set requires significantly more total work — and in terms of maximal strength development, it likely represents a higher level of fitness. A raw weight comparison misses this entirely.
This is a solved problem in sports science. The solution is the one-rep maximum equivalent — a normalized estimate of what your maximum single-rep effort would be, regardless of how many reps you actually did. And there's a formula for it that's been validated across decades of research.
The Epley formula
Boyd Epley, a strength coach at the University of Nebraska, published his one-rep maximum prediction formula in 1985. It's been tested against measured 1RMs in powerlifters, bodybuilders, and recreational athletes across hundreds of studies. For sub-maximal sets (anything more than one rep), it consistently outperforms raw weight as a strength indicator.
The formula is deceptively simple:
// Epley formula for estimated one-rep maximum
// w = weight lifted, r = reps performed
e1RM = w × (1 + r / 30)If you bench 95kg for 5 reps: 95 × (1 + 5/30) = 95 × 1.167 = 110.8kg e1RM. If you then bench 100kg for 1 rep: 100 × (1 + 1/30) = 103.3kg e1RM. The 5-rep set wins — correctly.
A few implementation notes. First, the formula is only validated for sets of 2–10 reps. Above 10 reps, the prediction degrades; below 2, you're looking at near-maximal efforts where the 1RM is largely known. Second, different exercises have different “rep-to-1RM curves” — isolation movements (curls, leg extensions) tend to have steeper curves than compound lifts (squat, deadlift) — but Epley is a reasonable approximation across the board. Third, individual fatigue characteristics vary, so treat e1RM as a relative indicator, not an absolute measurement.
For my purposes — detecting whether a given set represents a new lifetime best — these caveats don't matter much. I'm comparing e1RM to e1RM within the same person's training history, which normalizes for individual variation.
Implementing the benchmarks server action
The core of the PR detection is a server action that fetches the historical maximum e1RM per exercise before a given workout date. When you view a workout, this runs and returns a map of exercise IDs to their all-time e1RM bests.
"use server";
export async function getWorkoutPRBenchmarks(
workoutId: string,
workoutDate: string
): Promise<Record<string, number>> {
const { userId } = await auth();
if (!userId) return {};
// Fetch all sets before this workout's date
const historicalSets = await prisma.set.findMany({
where: {
workout: {
userId,
date: { lt: workoutDate }, // strictly before this session
},
},
include: {
exercise: true,
},
});
// Build max e1RM per exercise from historical data
const benchmarks: Record<string, number> = {};
for (const set of historicalSets) {
const reps = set.reps ?? 1;
const weight = set.weight ?? 0;
// Epley: e1RM = weight × (1 + reps / 30)
const e1rm = weight * (1 + reps / 30);
if (!benchmarks[set.exerciseId] || e1rm > benchmarks[set.exerciseId]) {
benchmarks[set.exerciseId] = e1rm;
}
}
return benchmarks;
}The critical detail: date: { lt: workoutDate }. We only look at sets before this workout's date, which means within the workout being viewed, we can compare each set against the true historical baseline — not against sets from this same session.
This matters. If you hit a PR on your third set of bench press, the fourth set might also be a “PR” if we include the third set in the baseline. But the third set already broke the record — the fourth set is just maintaining it within the session. By using only pre-workout history, each set in the workout is cleanly evaluated against the lifetime baseline that existed when the athlete walked in.
Real-time evaluation: per-set PR detection
With the benchmarks in hand, detecting a PR on any given set is a single comparison. But there's a subtlety: within a single workout, as you move through sets, you might break your own PR multiple times. The first set with e1RM 112kg is a PR if your prior best was 110kg. A later set with e1RM 114kg is also a PR — but compared to the historical baseline, not the previous set.
I track two separate things: whether a set beats the historical baseline (the “true PR”), and whether it beats any set within the current workout (the “session best”). The badge only fires on the absolute historical PR — one badge per exercise per session, on the specific set that first broke the record.
// In the workout view component
const prExercises = new Set<string>(); // track which exercises have a PR this session
const enrichedSets = sets.map((set) => {
const weight = set.weight ?? 0;
const reps = set.reps ?? 1;
const e1rm = weight * (1 + reps / 30);
const historicalBest = benchmarks[set.exerciseId] ?? 0;
const isNewPR = e1rm > historicalBest && !prExercises.has(set.exerciseId);
if (isNewPR) {
prExercises.add(set.exerciseId); // mark exercise as PR'd this session
}
return { ...set, e1rm, isNewPR };
});The result: an amber badge on the exact set that broke the record. Not every set in the exercise — just the one that actually crossed the threshold. This is important for user trust. If the badge fires too liberally, athletes stop believing it. If it's too conservative, they miss real achievements.
The trend chart: seeing strength over time
The PR detection answers “did I just break a record?” The trend chart answers “how is my strength trajectory?” These are different questions.
For the /records page, I fetch exercise history — the max e1RM per session for each exercise — and plot it over time with Recharts. The server action aggregates this per-session:
export async function getExerciseHistory(exerciseId: string) {
const { userId } = await auth();
const workouts = await prisma.workout.findMany({
where: { userId },
include: {
sets: {
where: { exerciseId },
},
},
orderBy: { date: "asc" },
});
return workouts
.filter((w) => w.sets.length > 0)
.map((w) => {
const maxE1rm = Math.max(
...w.sets.map((s) => {
const reps = s.reps ?? 1;
const weight = s.weight ?? 0;
return weight * (1 + reps / 30);
})
);
return {
date: w.date,
e1rm: Math.round(maxE1rm * 10) / 10, // 1 decimal place
};
});
}One decision here: I take the maximum e1RM per session rather than an average. This reflects what strength coaches call the “daily max” — the best expression of strength you achieved that day. Volume and fatigue accumulate across a session, so later sets are often worse than earlier ones even if technique is perfect. The max captures the “what were you capable of today?” question accurately.
What the data actually shows
Building this feature taught me something I knew intellectually but now see concretely: strength gains are nonlinear and noisy. A well-trained athlete's e1RM trend over 12 weeks looks like a stock chart — clear upward trend, but lots of session-to-session variance. Individual factors (sleep, nutrition, previous day's training, stress) can move the curve ±10% without any change in actual fitness.
This is why I display the delta over N sessions (e.g., “+8.2kg e1RM (+9%) over 6 sessions”) rather than focusing on the last session. A single session comparison is too noisy to be meaningful. The trend is the signal; any individual session is partly noise.
The chart uses a smoothed line (Recharts' type=“monotone”) rather than connecting raw data points with sharp angles. This is a deliberate choice — it reduces visual noise and makes the underlying trend more readable, at the cost of slightly misrepresenting individual data points. For coaching purposes, the trend matters more than the individual session.
What I'd build next
The natural extension is training load modeling. Right now I have e1RM per exercise per session. With sets × reps × weight, I also have total volume load. The relationship between volume and e1RM over time is how you detect overtraining (volume up, e1RM down), deload effects (volume down, e1RM rebounds), and optimal training frequency.
The sports science literature has a framework for this: the fitness-fatigue model, which separates the positive adaptations from training (fitness) from the temporary performance decrements (fatigue). The net performance = fitness − fatigue. It's a simple model that's been validated extensively, and it would translate into a genuinely useful coaching feature: “your model suggests you're carrying significant fatigue — consider a deload week.”
That's the thing about building in an area you've studied academically. The problem space is already mapped. You're not exploring — you're translating known solutions into working software. The challenge isn't discovering the algorithm; it's making the algorithm trustworthy and legible to users who don't have a PhD in exercise physiology.
That translation problem — from domain knowledge to usable product — is what I find genuinely interesting about building in health and fitness. The science exists. The implementation is where it gets hard.