feat: add cardio PRs for longest distance and fastest pace by range
All checks were successful
CI / update (push) Successful in 2m14s

Track longestDistance and fastestPace PRs for cardio exercises with
activity-specific distance ranges: running (0-3, 3-7, 7-21.1, 21.1-42.2,
42.2+ km), swimming (0-0.4, 0.4-1.5, 1.5-5, 5-10, 10+ km), cycling
(0-15, 15-40, 40-100, 100-200, 200+ km), hiking (0-5, 5-15, 15-30,
30-50, 50+ km), rowing (0-2, 2-5, 5-10, 10-21.1, 21.1+ km).

Shared detection logic in cardioPrRanges.ts used by both session save
and recalculate endpoints. Display support in history detail and workout
completion summary.
This commit is contained in:
2026-03-24 20:41:19 +01:00
parent 81bb3a2428
commit f3a89d2590
6 changed files with 236 additions and 16 deletions

View File

@@ -0,0 +1,164 @@
type CardioCategory = 'running' | 'swimming' | 'cycling' | 'hiking' | 'rowing';
interface PaceRange {
min: number;
max: number;
}
const CATEGORY_MAP: Record<string, CardioCategory> = {
'running': 'running',
'walking': 'hiking',
'hiking': 'hiking',
'cycling-outdoor': 'cycling',
'cycling-indoor': 'cycling',
'swimming': 'swimming',
'rowing-machine': 'rowing',
'rowing-outdoor': 'rowing',
'elliptical': 'running',
'stair-climber': 'running',
};
const PACE_RANGES: Record<CardioCategory, PaceRange[]> = {
running: [
{ min: 0, max: 3 },
{ min: 3, max: 7 },
{ min: 7, max: 21.1 },
{ min: 21.1, max: 42.2 },
{ min: 42.2, max: Infinity },
],
swimming: [
{ min: 0, max: 0.4 },
{ min: 0.4, max: 1.5 },
{ min: 1.5, max: 5 },
{ min: 5, max: 10 },
{ min: 10, max: Infinity },
],
cycling: [
{ min: 0, max: 15 },
{ min: 15, max: 40 },
{ min: 40, max: 100 },
{ min: 100, max: 200 },
{ min: 200, max: Infinity },
],
hiking: [
{ min: 0, max: 5 },
{ min: 5, max: 15 },
{ min: 15, max: 30 },
{ min: 30, max: 50 },
{ min: 50, max: Infinity },
],
rowing: [
{ min: 0, max: 2 },
{ min: 2, max: 5 },
{ min: 5, max: 10 },
{ min: 10, max: 21.1 },
{ min: 21.1, max: Infinity },
],
};
export function getCardioCategory(exerciseId: string): CardioCategory | undefined {
return CATEGORY_MAP[exerciseId];
}
export function getPaceRanges(exerciseId: string): PaceRange[] {
const cat = CATEGORY_MAP[exerciseId];
return cat ? PACE_RANGES[cat] : PACE_RANGES.running;
}
interface SetData {
distance?: number;
duration?: number;
completed: boolean;
}
interface ExerciseData {
exerciseId: string;
sets: SetData[];
}
interface SessionData {
exercises: ExerciseData[];
}
interface CardioPr {
exerciseId: string;
type: string;
value: number;
}
export function detectCardioPrs(
exerciseId: string,
currentSets: SetData[],
previousSessions: SessionData[]
): CardioPr[] {
const ranges = getPaceRanges(exerciseId);
const prs: CardioPr[] = [];
let bestDistance = 0;
const bestPaces = new Map<string, number>();
for (const s of currentSets) {
if (!s.completed || !s.distance || s.distance <= 0) continue;
if (s.distance > bestDistance) bestDistance = s.distance;
if (s.duration && s.duration > 0) {
const pace = s.duration / s.distance;
const range = ranges.find(r => s.distance! >= r.min && s.distance! < r.max);
if (range) {
const key = `${range.min}:${range.max}`;
const cur = bestPaces.get(key);
if (!cur || pace < cur) bestPaces.set(key, pace);
}
}
}
let prevBestDistance = 0;
const prevBestPaces = new Map<string, number>();
for (const ps of previousSessions) {
const pe = ps.exercises.find(e => e.exerciseId === exerciseId);
if (!pe) continue;
for (const s of pe.sets) {
if (!s.completed || !s.distance || s.distance <= 0) continue;
if (s.distance > prevBestDistance) prevBestDistance = s.distance;
if (s.duration && s.duration > 0) {
const pace = s.duration / s.distance;
const range = ranges.find(r => s.distance! >= r.min && s.distance! < r.max);
if (range) {
const key = `${range.min}:${range.max}`;
const cur = prevBestPaces.get(key);
if (!cur || pace < cur) prevBestPaces.set(key, pace);
}
}
}
}
if (bestDistance > prevBestDistance && prevBestDistance > 0) {
prs.push({ exerciseId, type: 'longestDistance', value: Math.round(bestDistance * 100) / 100 });
}
for (const [key, pace] of bestPaces) {
const prevPace = prevBestPaces.get(key);
if (prevPace && pace < prevPace) {
prs.push({ exerciseId, type: `fastestPace:${key}`, value: Math.round(pace * 100) / 100 });
}
}
return prs;
}
export function formatPaceRangeLabel(type: string): string {
const match = type.match(/^fastestPace:(.+):(.+)$/);
if (!match) return type;
const [, minStr, maxStr] = match;
const max = parseFloat(maxStr);
if (!isFinite(max)) return `${minStr}+ km`;
return `${minStr}${maxStr} km`;
}
export function formatPaceValue(minPerKm: number): string {
const mins = Math.floor(minPerKm);
const secs = Math.round((minPerKm - mins) * 60);
return `${mins}:${secs.toString().padStart(2, '0')} min/km`;
}

View File

@@ -31,7 +31,7 @@ export interface ICompletedExercise {
export interface IPr {
exerciseId: string;
type: string; // 'est1rm' | 'maxWeight' | 'bestSetVolume' | 'repMax'
type: string; // 'est1rm' | 'maxWeight' | 'bestSetVolume' | 'repMax' | 'longestDistance' | 'fastestPace:<min>:<max>'
value: number;
reps?: number;
}

View File

@@ -5,6 +5,7 @@ import { WorkoutSession } from '$models/WorkoutSession';
import type { IPr } from '$models/WorkoutSession';
import { WorkoutTemplate } from '$models/WorkoutTemplate';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { detectCardioPrs } from '$lib/data/cardioPrRanges';
function estimatedOneRepMax(weight: number, reps: number): number {
if (reps <= 0 || weight <= 0) return 0;
@@ -89,7 +90,6 @@ export const POST: RequestHandler = async ({ request, locals }) => {
const exercise = getExerciseById(ex.exerciseId);
const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance');
if (isCardio) continue;
const completedSets = (ex.sets ?? []).filter((s: { completed: boolean }) => s.completed);
if (completedSets.length === 0) continue;
@@ -100,6 +100,11 @@ export const POST: RequestHandler = async ({ request, locals }) => {
'exercises.exerciseId': ex.exerciseId,
}).sort({ startTime: -1 }).limit(50).lean();
if (isCardio) {
prs.push(...detectCardioPrs(ex.exerciseId, completedSets, prevSessions));
continue;
}
const isBilateral = exercise?.bilateral ?? false;
const weightMul = isBilateral ? 2 : 1;

View File

@@ -4,6 +4,7 @@ import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession';
import type { IPr } from '$models/WorkoutSession';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { detectCardioPrs } from '$lib/data/cardioPrRanges';
import { simplifyTrack } from '$lib/server/simplifyTrack';
import mongoose from 'mongoose';
@@ -64,7 +65,7 @@ export const POST: RequestHandler = async ({ params, locals }) => {
for (const ex of workoutSession.exercises) {
const exercise = getExerciseById(ex.exerciseId);
const metrics = getExerciseMetrics(exercise);
if (metrics.includes('distance')) continue;
const isCardio = metrics.includes('distance');
const completedSets = ex.sets.filter(s => s.completed);
if (completedSets.length === 0) continue;
@@ -76,6 +77,11 @@ export const POST: RequestHandler = async ({ params, locals }) => {
startTime: { $lt: workoutSession.startTime }
}).sort({ startTime: -1 }).limit(50).lean();
if (isCardio) {
prs.push(...detectCardioPrs(ex.exerciseId, completedSets, prevSessions));
continue;
}
const isBilateral = exercise?.bilateral ?? false;
const weightMul = isBilateral ? 2 : 1;

View File

@@ -7,6 +7,7 @@
const lang = $derived(detectFitnessLang($page.url.pathname));
const sl = $derived(fitnessSlugs(lang));
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
import { formatPaceRangeLabel, formatPaceValue } from '$lib/data/cardioPrRanges';
import { estimateWorkoutKcal } from '$lib/data/kcalEstimate';
import { estimateCardioKcal } from '$lib/data/cardioKcalEstimate';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
@@ -773,9 +774,15 @@
{:else if pr.type === 'maxWeight'}Max Weight
{:else if pr.type === 'bestSetVolume'}Best Set Volume
{:else if pr.type === 'repMax'}{pr.reps}-rep max
{:else if pr.type === 'longestDistance'}Longest Distance
{:else if pr.type.startsWith('fastestPace:')}Fastest Pace ({formatPaceRangeLabel(pr.type)})
{:else}{pr.type}{/if}
</span>
<span class="pr-value">{pr.value} kg</span>
<span class="pr-value">
{#if pr.type === 'longestDistance'}{pr.value} km
{:else if pr.type.startsWith('fastestPace:')}{formatPaceValue(pr.value)}
{:else}{pr.value} kg{/if}
</span>
</div>
{/each}
</div>

View File

@@ -10,6 +10,7 @@
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { getGpsTracker, trackDistance } from '$lib/js/gps.svelte';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { getPaceRanges, formatPaceRangeLabel, formatPaceValue } from '$lib/data/cardioPrRanges';
import { estimateWorkoutKcal } from '$lib/data/kcalEstimate';
import { estimateCardioKcal } from '$lib/data/cardioKcalEstimate';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
@@ -283,15 +284,12 @@
// Detect PRs by comparing against previous session
if (prev.length > 0) {
let prevBestWeight = 0;
let prevBestEst1rm = 0;
let prevBestVolume = 0;
let prevBestDistance = 0;
if (!isCardio) {
let prevBestWeight = 0;
let prevBestEst1rm = 0;
let prevBestVolume = 0;
for (const ps of prev) {
if (isCardio) {
prevBestDistance += ps.distance ?? 0;
} else {
for (const ps of prev) {
const pw = ps.weight ?? 0;
const pr = ps.reps ?? 0;
if (pw > prevBestWeight) prevBestWeight = pw;
@@ -300,9 +298,7 @@
const pv = pw * pr * (isBilateral ? 2 : 1);
if (pv > prevBestVolume) prevBestVolume = pv;
}
}
if (!isCardio) {
if (bestWeight > prevBestWeight && prevBestWeight > 0) {
prs.push({ exerciseId: ex.exerciseId, type: 'Max Weight', value: `${bestWeight} kg` });
}
@@ -313,8 +309,50 @@
prs.push({ exerciseId: ex.exerciseId, type: 'Best Set Volume', value: `${Math.round(bestVolume)} kg` });
}
} else {
if (exDistance > prevBestDistance && prevBestDistance > 0) {
prs.push({ exerciseId: ex.exerciseId, type: 'Distance', value: `${exDistance.toFixed(1)} km` });
const ranges = getPaceRanges(ex.exerciseId);
let curBestDist = 0;
/** @type {Map<string, number>} */
const curBestPaces = new Map();
for (const s of ex.sets) {
if (!s.completed || !s.distance || s.distance <= 0) continue;
if (s.distance > curBestDist) curBestDist = s.distance;
if (s.duration && s.duration > 0) {
const p = s.duration / s.distance;
const range = ranges.find(r => s.distance >= r.min && s.distance < r.max);
if (range) {
const key = `${range.min}:${range.max}`;
const cur = curBestPaces.get(key);
if (!cur || p < cur) curBestPaces.set(key, p);
}
}
}
let prevBestDist = 0;
/** @type {Map<string, number>} */
const prevBestPaces = new Map();
for (const ps of prev) {
if (!ps.distance || ps.distance <= 0) continue;
if (ps.distance > prevBestDist) prevBestDist = ps.distance;
if (ps.duration && ps.duration > 0) {
const p = ps.duration / ps.distance;
const range = ranges.find(r => ps.distance >= r.min && ps.distance < r.max);
if (range) {
const key = `${range.min}:${range.max}`;
const cur = prevBestPaces.get(key);
if (!cur || p < cur) prevBestPaces.set(key, p);
}
}
}
if (curBestDist > prevBestDist && prevBestDist > 0) {
prs.push({ exerciseId: ex.exerciseId, type: 'Longest Distance', value: `${curBestDist.toFixed(1)} km` });
}
for (const [key, pace] of curBestPaces) {
const prevPace = prevBestPaces.get(key);
if (prevPace && pace < prevPace) {
prs.push({ exerciseId: ex.exerciseId, type: `Fastest Pace (${formatPaceRangeLabel(`fastestPace:${key}`)})`, value: formatPaceValue(pace) });
}
}
}
}