This commit is contained in:
Bastian Wagner
2026-06-19 16:30:30 +02:00
parent 8e9f498d90
commit d8b1a47a2a
6 changed files with 789 additions and 3 deletions

View File

@@ -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));
}
}

View File

@@ -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;
}