Files
homepage/src/lib/data/cardioPrRanges.ts
T
Alexander 540c9ae2dd 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.
2026-03-24 20:41:23 +01:00

165 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`;
}