diff --git a/api/src/analytics/analytics.service.ts b/api/src/analytics/analytics.service.ts index 40e115b..271cd51 100644 --- a/api/src/analytics/analytics.service.ts +++ b/api/src/analytics/analytics.service.ts @@ -18,11 +18,15 @@ import { RunningActivityDetail, RunningChartPoint, RunningIntensityDistributionBucket, + RunningInjuryRisk, RunningIntensityZone, RunningKpiDashboard, RunningLoadBucket, + RunningPerformanceKpi, RunningPersonalRecordEstimate, + RunningPredictions, RunningProgressionMetric, + RunningReadinessScore, RunningSplit, RunningSummary, } from './analytics.types'; @@ -164,8 +168,8 @@ export class AnalyticsService { const fourWeekAverageDistanceMeters = lastFourWeeks.reduce((total, week) => total + week.distanceMeters, 0) / Math.max(1, lastFourWeeks.length); - const currentWeekDistance = - Array.from(weekly.values()).at(-1)?.distanceMeters ?? 0; + const weeklyArray = Array.from(weekly.values()); + const currentWeekDistance = weeklyArray.length > 0 ? weeklyArray[weeklyArray.length - 1].distanceMeters : 0; return { weeks, @@ -251,6 +255,10 @@ export class AnalyticsService { intensityDistribution: this.createIntensityDistribution(activityLoads), progression: this.createProgression(now, activityLoads), personalRecords: this.createPersonalRecords(runs, streamPointsByActivity), + performance: this.createPerformanceKpis(runs, now), + injuryRisk: this.createInjuryRisk(runs, activityLoads, acuteChronicRatio, now), + readiness: this.createReadinessScore(runs, activityLoads, acuteChronicRatio, now), + predictions: this.createPredictions(runs, now), }; } @@ -1245,4 +1253,286 @@ export class AnalyticsService { ), ); } + + private createPerformanceKpis( + runs: StravaActivityEntity[], + now: Date, + ): RunningPerformanceKpi[] { + const kpis: RunningPerformanceKpi[] = []; + const totalDistance = runs.reduce((sum, run) => sum + (run.distance ?? 0), 0); + const totalRuns = runs.length; + + if (totalRuns === 0) { + return kpis; + } + + // 1. VO2max Schätzung aus schnellstem Lauf + const fastestPace = Math.min( + ...runs + .map((run) => this.activityPace(run)) + .filter((p): p is number => p !== null && p > 0), + ); + const vo2max = fastestPace ? Math.round(3.5 + 10.7 * Math.log10((60 / (fastestPace / 60)) * 1.44) - 0.035 * 35) : null; + kpis.push({ + key: 'vo2max', + label: 'VO₂max Schätzung', + value: vo2max, + unit: 'ml/kg/min', + trend: vo2max !== null && vo2max > 50 ? 'up' : vo2max !== null && vo2max > 40 ? 'stable' : 'down', + description: 'Schätzung der aeroben Kapazität basierend auf deinem schnellsten Lauf', + }); + + // 2. Durchschnitts-Pace + const avgPace = this.median( + runs + .map((run) => this.activityPace(run)) + .filter((p): p is number => p !== null), + ); + kpis.push({ + key: 'avgPace', + label: 'Durchschnitts-Pace', + value: avgPace, + unit: 'min/km', + trend: null, + description: 'Median-Pace aller Läufe im Zeitraum', + }); + + // 3. Längster Lauf + const longestDistance = Math.max(...runs.map((run) => run.distance ?? 0)); + kpis.push({ + key: 'longestRun', + label: 'Längster Lauf', + value: longestDistance, + unit: 'm', + trend: longestDistance > 21000 ? 'up' : longestDistance > 10000 ? 'stable' : 'down', + description: 'Längste Distanz in einem einzelnen Lauf', + }); + + // 4. Streak Tage + const streakDays = this.calculateStreakDays(runs, now); + kpis.push({ + key: 'streakDays', + label: 'Aktuelle Streak', + value: streakDays, + unit: 'Tage', + trend: streakDays !== null && streakDays > 7 ? 'up' : streakDays !== null && streakDays > 0 ? 'stable' : 'down', + description: 'Anzahl aufeinanderfolgender Tage mit mindestens einem Lauf', + }); + + // 5. Konsistenz Score + const consistencyScore = this.calculateConsistencyScore(runs, now, totalRuns); + kpis.push({ + key: 'consistencyScore', + label: 'Konsistenz', + value: consistencyScore, + unit: '%', + trend: consistencyScore !== null && consistencyScore > 80 ? 'up' : consistencyScore !== null && consistencyScore > 50 ? 'stable' : 'down', + description: 'Wie regelmäßig du trainierst (Ziel: 80%+)', + }); + + // 6. Höhenmeter pro km + const totalElevation = runs.reduce((sum, run) => sum + (run.totalElevationGain ?? 0), 0); + const elevationPerKm = totalDistance > 0 ? Math.round((totalElevation / (totalDistance / 1000)) * 10) / 10 : null; + kpis.push({ + key: 'elevationPerKm', + label: 'Höhenmeter pro km', + value: elevationPerKm, + unit: 'm/km', + trend: elevationPerKm !== null && elevationPerKm > 20 ? 'up' : elevationPerKm !== null && elevationPerKm > 10 ? 'stable' : 'down', + description: 'Durchschnittliche Höhenmeter pro Kilometer', + }); + + return kpis; + } + + private createInjuryRisk( + runs: StravaActivityEntity[], + activityLoads: ActivityLoad[], + acuteChronicRatio: number | null, + now: Date, + ): RunningInjuryRisk | null { + const sortedRuns = [...runs].sort((a, b) => (a.startDate?.getTime() ?? 0) - (b.startDate?.getTime() ?? 0)); + const lastRun = sortedRuns.length > 0 ? sortedRuns[sortedRuns.length - 1] : null; + const daysSinceLastRun = lastRun?.startDate ? this.daysBetween(lastRun.startDate, now) : null; + + // Faktor 1: Load Ratio (0-100) + const loadFactor = acuteChronicRatio !== null ? Math.min(acuteChronicRatio * 10, 100) : 50; + + // Faktor 2: Intensität - Anteil harter Läufe in den letzten 14 Tagen + const recentRuns = runs.filter((run) => run.startDate && this.daysBetween(run.startDate, now) <= 14); + const hardRuns = activityLoads.filter((al) => al.zone === 'hard' && al.activity.startDate && this.daysBetween(al.activity.startDate, now) <= 14); + const intensityFactor = recentRuns.length > 0 ? Math.round((hardRuns.length / recentRuns.length) * 100) : 0; + + // Faktor 3: Erholung (je mehr Tage seit letztem Lauf, desto besser) + const recoveryFactor = daysSinceLastRun !== null ? Math.max(0, 100 - daysSinceLastRun * 10) : 50; + + // Gesamter Risk Score (0-100) + const score = Math.round(loadFactor * 0.4 + intensityFactor * 0.3 + recoveryFactor * 0.3); + const riskLevel = score < 30 ? 'low' : score < 60 ? 'medium' : 'high'; + + const recommendations: Record<'low' | 'medium' | 'high', string> = { + low: 'Belastung sieht gut aus. Weiter so!', + medium: 'Belastung im letzten Monat gestiegen. Leichte Woche einlegen, um Verletzungen vorzubeugen.', + high: 'Hohe Verletzungsgefahr! Sofort Belastung reduzieren und Erholung einplanen.', + }; + + return { + riskLevel, + score, + factors: { + load: Math.round(loadFactor), + recovery: Math.round(100 - recoveryFactor), + intensity: intensityFactor, + }, + recommendation: recommendations[riskLevel], + }; + } + + private createReadinessScore( + runs: StravaActivityEntity[], + activityLoads: ActivityLoad[], + acuteChronicRatio: number | null, + now: Date, + ): RunningReadinessScore | null { + const sortedRuns = [...runs].sort((a, b) => (a.startDate?.getTime() ?? 0) - (b.startDate?.getTime() ?? 0)); + const lastRun = sortedRuns.length > 0 ? sortedRuns[sortedRuns.length - 1] : null; + const daysSinceLastRun = lastRun?.startDate ? this.daysBetween(lastRun.startDate, now) : null; + + // Form: Vergleich letzte 4 Wochen vs. vorherige 4 Wochen + const formScore = this.calculateFormScore(activityLoads, now); + + // Fatigue: Basierend auf akuter Belastung + const fatigueScore = acuteChronicRatio !== null ? Math.max(0, 100 - (acuteChronicRatio - 1) * 50) : 75; + + // Recovery + const recoveryScore = daysSinceLastRun === null ? 50 : + daysSinceLastRun === 0 ? 20 : + daysSinceLastRun === 1 ? 50 : + daysSinceLastRun >= 2 ? 80 : 70; + + // Gesamt-Readiness + const score = Math.round(formScore * 0.4 + fatigueScore * 0.3 + recoveryScore * 0.3); + const status = score >= 80 ? 'ready' : score >= 50 ? 'cautious' : 'rest'; + + const reasons: string[] = []; + if (formScore >= 70) reasons.push('Gute Form'); + if (fatigueScore >= 70) reasons.push('Geringe Ermüdung'); + if (recoveryScore >= 70) reasons.push('Gut erholt'); + if (formScore < 50) reasons.push('Form schwach'); + if (fatigueScore < 50) reasons.push('Hohe Ermüdung'); + if (recoveryScore < 50) reasons.push('Unzureichend erholt'); + + return { + score, + status, + factors: { + form: Math.round(formScore), + fatigue: Math.round(fatigueScore), + recovery: Math.round(recoveryScore), + }, + reasons, + }; + } + + private createPredictions( + runs: StravaActivityEntity[], + now: Date, + ): RunningPredictions | null { + if (runs.length === 0) { + return null; + } + + // VO2max Schätzung + const fastestPace = Math.min( + ...runs + .map((run) => this.activityPace(run)) + .filter((p): p is number => p !== null && p > 0), + ); + const vo2max = fastestPace ? Math.round(3.5 + 10.7 * Math.log10((60 / (fastestPace / 60)) * 1.44) - 0.035 * 35) : null; + + // Einfache Race Time Predictions basierend auf medianem Pace + const paces = runs + .map((run) => this.activityPace(run)) + .filter((p): p is number => p !== null && p > 0); + + const sortedPaces = [...paces].sort((a, b) => a - b); + const medianPace = sortedPaces.length > 0 ? sortedPaces[Math.floor(sortedPaces.length / 2)] : null; + + // Nächster Meilenstein + const totalDistance = runs.reduce((sum, run) => sum + (run.distance ?? 0), 0); + const nextDistanceMilestone = totalDistance < 100000 ? 100000 : totalDistance < 500000 ? 500000 : 1000000; + const nextMilestoneType = 'distance'; + const nextMilestoneTarget = nextDistanceMilestone === 100000 ? '100 km' : nextDistanceMilestone === 500000 ? '500 km' : '1.000 km'; + + return { + raceTimes: { + distance5k: medianPace ? Math.round(medianPace * 5) : null, + distance10k: medianPace ? Math.round(medianPace * 10) : null, + distanceHalfMarathon: medianPace ? Math.round(medianPace * 21.0975 * 1.05) : null, + distanceMarathon: medianPace ? Math.round(medianPace * 42.195 * 1.15) : null, + }, + nextMilestone: { + type: nextMilestoneType, + target: nextMilestoneTarget, + predictedDate: null, + confidence: 0, + }, + vo2max, + }; + } + + private calculateStreakDays(runs: StravaActivityEntity[], now: Date): number { + if (runs.length === 0) { + return 0; + } + + const sortedRuns = [...runs] + .filter((run) => run.startDate) + .sort((a, b) => (a.startDate?.getTime() ?? 0) - (b.startDate?.getTime() ?? 0)); + + const lastRunDate = sortedRuns[sortedRuns.length - 1].startDate; + if (!lastRunDate) { + return 0; + } + + let streak = 1; + for (let i = sortedRuns.length - 2; i >= 0; i--) { + const currentRunDate = sortedRuns[i].startDate; + if (!currentRunDate) continue; + + const daysDiff = this.daysBetween(currentRunDate, lastRunDate); + if (daysDiff === 1) { + streak++; + } else if (daysDiff > 1) { + break; + } + } + + return streak; + } + + private calculateConsistencyScore(runs: StravaActivityEntity[], now: Date, totalRuns: number): number { + const daysInPeriod = 28; + const expectedRunsPerWeek = 3; + const expectedTotalRuns = expectedRunsPerWeek * Math.ceil(daysInPeriod / 7); + return Math.round((totalRuns / Math.max(1, expectedTotalRuns)) * 100); + } + + private calculateFormScore(activityLoads: ActivityLoad[], now: Date): number { + const fourWeeksAgo = this.addDays(now, -28); + const eightWeeksAgo = this.addDays(now, -56); + + const recentLoads = activityLoads.filter((al) => al.activity.startDate && al.activity.startDate >= fourWeeksAgo); + const previousLoads = activityLoads.filter((al) => al.activity.startDate && al.activity.startDate >= eightWeeksAgo && al.activity.startDate < fourWeeksAgo); + + const recentTSS = recentLoads.reduce((sum, load) => sum + load.load, 0); + const previousTSS = previousLoads.reduce((sum, load) => sum + load.load, 0); + + if (previousTSS === 0) { + return recentTSS > 0 ? 80 : 50; + } + + const ratio = recentTSS / previousTSS; + return Math.max(0, Math.min(100, 50 + (ratio - 1) * 25)); + } } diff --git a/api/src/analytics/analytics.types.ts b/api/src/analytics/analytics.types.ts index dc87043..5cc89c9 100644 --- a/api/src/analytics/analytics.types.ts +++ b/api/src/analytics/analytics.types.ts @@ -151,4 +151,55 @@ export interface RunningKpiDashboard { intensityDistribution: RunningIntensityDistributionBucket[]; progression: RunningProgressionMetric[]; personalRecords: RunningPersonalRecordEstimate[]; + performance: RunningPerformanceKpi[]; + injuryRisk: RunningInjuryRisk | null; + readiness: RunningReadinessScore | null; + predictions: RunningPredictions | null; +} + +export interface RunningPerformanceKpi { + key: string; + label: string; + value: number | null; + unit: string; + trend: 'up' | 'down' | 'stable' | null; + description: string; +} + +export interface RunningInjuryRisk { + riskLevel: 'low' | 'medium' | 'high'; + score: number; + factors: { + load: number; + recovery: number; + intensity: number; + }; + recommendation: string; +} + +export interface RunningReadinessScore { + score: number; + status: 'ready' | 'cautious' | 'rest'; + factors: { + form: number; + fatigue: number; + recovery: number; + }; + reasons: string[]; +} + +export interface RunningPredictions { + raceTimes: { + distance5k: number | null; + distance10k: number | null; + distanceHalfMarathon: number | null; + distanceMarathon: number | null; + }; + nextMilestone: { + type: string; + target: string; + predictedDate: string | null; + confidence: number; + }; + vo2max: number | null; } diff --git a/client/src/app/running/running-kpi-dashboard.component.html b/client/src/app/running/running-kpi-dashboard.component.html index 9e0f90f..74768f6 100644 --- a/client/src/app/running/running-kpi-dashboard.component.html +++ b/client/src/app/running/running-kpi-dashboard.component.html @@ -148,6 +148,92 @@ } + + @if (data.performance && data.performance.length > 0) { +
{{ data.injuryRisk.recommendation }}
+