diff --git a/src/lib/data/cardioPrRanges.ts b/src/lib/data/cardioPrRanges.ts new file mode 100644 index 0000000..3de4b84 --- /dev/null +++ b/src/lib/data/cardioPrRanges.ts @@ -0,0 +1,164 @@ +type CardioCategory = 'running' | 'swimming' | 'cycling' | 'hiking' | 'rowing'; + +interface PaceRange { + min: number; + max: number; +} + +const CATEGORY_MAP: Record = { + '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 = { + 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(); + + 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(); + + 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`; +} diff --git a/src/models/WorkoutSession.ts b/src/models/WorkoutSession.ts index 8565eec..10beb8c 100644 --- a/src/models/WorkoutSession.ts +++ b/src/models/WorkoutSession.ts @@ -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::' value: number; reps?: number; } diff --git a/src/routes/api/fitness/sessions/+server.ts b/src/routes/api/fitness/sessions/+server.ts index 7cc62c0..45d6bdd 100644 --- a/src/routes/api/fitness/sessions/+server.ts +++ b/src/routes/api/fitness/sessions/+server.ts @@ -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; diff --git a/src/routes/api/fitness/sessions/[id]/recalculate/+server.ts b/src/routes/api/fitness/sessions/[id]/recalculate/+server.ts index 617b09f..d842abe 100644 --- a/src/routes/api/fitness/sessions/[id]/recalculate/+server.ts +++ b/src/routes/api/fitness/sessions/[id]/recalculate/+server.ts @@ -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; diff --git a/src/routes/fitness/[history=fitnessHistory]/[id]/+page.svelte b/src/routes/fitness/[history=fitnessHistory]/[id]/+page.svelte index 4f28e78..8b83080 100644 --- a/src/routes/fitness/[history=fitnessHistory]/[id]/+page.svelte +++ b/src/routes/fitness/[history=fitnessHistory]/[id]/+page.svelte @@ -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} - {pr.value} kg + + {#if pr.type === 'longestDistance'}{pr.value} km + {:else if pr.type.startsWith('fastestPace:')}{formatPaceValue(pr.value)} + {:else}{pr.value} kg{/if} + {/each} diff --git a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte index 371180b..b708de6 100644 --- a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte +++ b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte @@ -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} */ + 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} */ + 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) }); + } } } }