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

View File

@@ -148,6 +148,92 @@
}
</div>
</div>
@if (data.performance && data.performance.length > 0) {
<div class="panel">
<div class="section-title">
<h2>Leistungskennzahlen</h2>
</div>
<div class="performance-grid">
@for (kpi of data.performance; track kpi.key) {
<div class="kpi-card">
<span class="kpi-label">{{ kpi.label }}</span>
<strong class="kpi-value">{{ formatPerformanceKpi(kpi) }}</strong>
<small class="kpi-desc">{{ kpi.description }}</small>
</div>
}
</div>
</div>
}
@if (data.injuryRisk) {
<div class="panel">
<div class="section-title">
<h2>Verletzungsrisiko</h2>
</div>
<div class="risk-display">
<div class="risk-badge" [class.risk-low]="data.injuryRisk.riskLevel === 'low'"
[class.risk-medium]="data.injuryRisk.riskLevel === 'medium'"
[class.risk-high]="data.injuryRisk.riskLevel === 'high'">
{{ data.injuryRisk.riskLevel | uppercase }}
</div>
<div class="risk-meter">
<div class="risk-fill" [style.width.%]="data.injuryRisk.score"></div>
</div>
<p class="risk-recommendation">{{ data.injuryRisk.recommendation }}</p>
</div>
</div>
}
@if (data.readiness) {
<div class="panel">
<div class="section-title">
<h2>Bereitschaft</h2>
</div>
<div class="readiness-display">
<div class="readiness-badge" [class.status-ready]="data.readiness.status === 'ready'"
[class.status-cautious]="data.readiness.status === 'cautious'"
[class.status-rest]="data.readiness.status === 'rest'">
{{ data.readiness.score }} / 100
</div>
<div class="readiness-status">{{ data.readiness.status | uppercase }}</div>
<div class="readiness-reasons">
@for (reason of data.readiness.reasons; track reason) {
<span class="reason-tag">{{ reason }}</span>
}
</div>
</div>
</div>
}
@if (data.predictions) {
<div class="panel">
<div class="section-title">
<h2>Vorhersagen</h2>
</div>
<div class="predictions-grid">
<div class="prediction-card">
<h3>VO2max</h3>
<strong>{{ data.predictions.vo2max ?? '-' }} ml/kg/min</strong>
<small>Schatzung der aeroben Kapazitat</small>
</div>
<div class="prediction-card">
<h3>Nachster Meilenstein</h3>
<strong>{{ data.predictions.nextMilestone.target }}</strong>
<small>{{ data.predictions.nextMilestone.type }}</small>
</div>
</div>
<div class="race-times">
<h3>Geschatzte Rennzeiten</h3>
<div class="race-grid">
<div><span>5 km:</span><strong>{{ formatRaceTime(data.predictions.raceTimes.distance5k) }}</strong></div>
<div><span>10 km:</span><strong>{{ formatRaceTime(data.predictions.raceTimes.distance10k) }}</strong></div>
<div><span>Halbmarathon:</span><strong>{{ formatRaceTime(data.predictions.raceTimes.distanceHalfMarathon) }}</strong></div>
<div><span>Marathon:</span><strong>{{ formatRaceTime(data.predictions.raceTimes.distanceMarathon) }}</strong></div>
</div>
</div>
</div>
}
}
}
</section>

View File

@@ -234,7 +234,10 @@ a.pr-row:hover,
.kpis,
.chart-grid,
.progression-grid {
.progression-grid,
.performance-grid,
.predictions-grid,
.race-grid {
grid-template-columns: 1fr;
}
@@ -242,3 +245,259 @@ a.pr-row:hover,
min-width: 0;
}
}
/* Performance KPIs Grid */
.performance-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 14px;
}
.kpi-card {
border: 1px solid #eef2f7;
border-radius: 6px;
padding: 16px;
display: grid;
gap: 6px;
}
.kpi-label {
color: #687386;
font-size: 0.82rem;
font-weight: 700;
}
.kpi-value {
display: block;
font-size: 1.25rem;
margin-top: 4px;
}
.kpi-desc {
color: #687386;
font-size: 0.75rem;
}
/* Risk Display */
.risk-display {
display: grid;
gap: 16px;
margin-top: 14px;
}
.risk-badge {
display: inline-block;
padding: 6px 16px;
border-radius: 20px;
font-weight: 800;
font-size: 0.82rem;
text-align: center;
}
.risk-low {
background: #d1f5ed;
color: #1c8b76;
}
.risk-medium {
background: #f5e6b8;
color: #d2a21f;
}
.risk-high {
background: #fdeded;
color: #d63f4c;
}
.risk-meter {
background: #eef2f7;
border-radius: 999px;
height: 12px;
overflow: hidden;
}
.risk-fill {
border-radius: inherit;
height: 100%;
background: linear-gradient(90deg, #d63f4c, #d2a21f, #1c8b76);
}
.risk-recommendation {
color: #4e5a6b;
font-size: 0.9rem;
margin: 0;
}
.risk-factors {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, 1fr);
margin-top: 12px;
}
.risk-factors div {
display: flex;
justify-content: space-between;
}
.risk-factors span {
color: #687386;
font-size: 0.82rem;
}
.risk-factors strong {
color: #4e5a6b;
}
/* Readiness Display */
.readiness-display {
display: grid;
gap: 16px;
margin-top: 14px;
}
.readiness-badge {
display: inline-block;
padding: 8px 20px;
border-radius: 50%;
font-weight: 800;
font-size: 1.1rem;
text-align: center;
}
.status-ready {
background: #d1f5ed;
color: #1c8b76;
}
.status-cautious {
background: #f5e6b8;
color: #d2a21f;
}
.status-rest {
background: #fdeded;
color: #d63f4c;
}
.readiness-status {
font-weight: 700;
font-size: 1.1rem;
text-transform: uppercase;
}
.readiness-reasons {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.reason-tag {
background: #eef2f7;
border-radius: 12px;
color: #4e5a6b;
font-size: 0.82rem;
padding: 4px 12px;
}
.readiness-factors {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, 1fr);
margin-top: 12px;
}
.readiness-factors div {
display: flex;
justify-content: space-between;
}
.readiness-factors span {
color: #687386;
font-size: 0.82rem;
}
.readiness-factors strong {
color: #4e5a6b;
}
/* Predictions */
.predictions-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 14px;
}
.prediction-card {
border: 1px solid #eef2f7;
border-radius: 6px;
padding: 16px;
display: grid;
gap: 8px;
}
.prediction-card h3 {
color: #4e5a6b;
font-size: 0.9rem;
margin: 0;
}
.prediction-card strong {
display: block;
font-size: 1.25rem;
}
.prediction-card small {
color: #687386;
font-size: 0.82rem;
}
.race-times {
margin-top: 18px;
}
.race-times h3 {
color: #4e5a6b;
font-size: 0.9rem;
margin: 0 0 12px;
}
.race-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.race-grid div {
border: 1px solid #eef2f7;
border-radius: 6px;
padding: 12px;
display: flex;
justify-content: space-between;
}
.race-grid span {
color: #687386;
font-size: 0.82rem;
}
.race-grid strong {
color: #4e5a6b;
font-size: 1rem;
}
@media (max-width: 980px) {
.performance-grid,
.predictions-grid,
.race-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.risk-factors,
.readiness-factors {
grid-template-columns: 1fr;
}
}

View File

@@ -23,6 +23,10 @@ import {
} from './running-format';
import {
RunningKpiDashboard,
RunningPerformanceKpi,
RunningInjuryRisk,
RunningReadinessScore,
RunningPredictions,
RunningProgressionMetric,
} from './running.types';
import { RunningService } from './running.service';
@@ -153,6 +157,51 @@ export class RunningKpiDashboardComponent implements OnInit, OnDestroy {
protected pace = pace;
protected shortDate = shortDate;
protected formatPerformanceKpi(kpi: RunningPerformanceKpi): string {
if (kpi.value === null) return '-';
switch (kpi.key) {
case 'avgPace':
return pace(kpi.value);
case 'longestRun':
return `${(kpi.value / 1000).toFixed(1)} km`;
case 'streakDays':
case 'consistencyScore':
case 'elevationPerKm':
case 'vo2max':
return `${kpi.value} ${kpi.unit}`;
default:
return `${kpi.value} ${kpi.unit}`;
}
}
protected getTrendClass(trend: RunningPerformanceKpi['trend']): string {
switch (trend) {
case 'up': return 'positive';
case 'down': return 'negative';
case 'stable': return 'neutral';
default: return '';
}
}
protected getRiskLevelClass(riskLevel: RunningInjuryRisk['riskLevel']): string {
switch (riskLevel) {
case 'low': return 'risk-low';
case 'medium': return 'risk-medium';
case 'high': return 'risk-high';
default: return '';
}
}
protected getReadinessStatusClass(status: RunningReadinessScore['status']): string {
switch (status) {
case 'ready': return 'status-ready';
case 'cautious': return 'status-cautious';
case 'rest': return 'status-rest';
default: return '';
}
}
private renderCharts(): void {
const data = this.kpis();
if (!data) {

View File

@@ -134,4 +134,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;
}