feat: add cardio PRs for longest distance and fastest pace by range

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 03f9194903
commit 540c9ae2dd
6 changed files with 236 additions and 16 deletions
+164
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`;
}