kpis
This commit is contained in:
@@ -18,11 +18,15 @@ import {
|
|||||||
RunningActivityDetail,
|
RunningActivityDetail,
|
||||||
RunningChartPoint,
|
RunningChartPoint,
|
||||||
RunningIntensityDistributionBucket,
|
RunningIntensityDistributionBucket,
|
||||||
|
RunningInjuryRisk,
|
||||||
RunningIntensityZone,
|
RunningIntensityZone,
|
||||||
RunningKpiDashboard,
|
RunningKpiDashboard,
|
||||||
RunningLoadBucket,
|
RunningLoadBucket,
|
||||||
|
RunningPerformanceKpi,
|
||||||
RunningPersonalRecordEstimate,
|
RunningPersonalRecordEstimate,
|
||||||
|
RunningPredictions,
|
||||||
RunningProgressionMetric,
|
RunningProgressionMetric,
|
||||||
|
RunningReadinessScore,
|
||||||
RunningSplit,
|
RunningSplit,
|
||||||
RunningSummary,
|
RunningSummary,
|
||||||
} from './analytics.types';
|
} from './analytics.types';
|
||||||
@@ -164,8 +168,8 @@ export class AnalyticsService {
|
|||||||
const fourWeekAverageDistanceMeters =
|
const fourWeekAverageDistanceMeters =
|
||||||
lastFourWeeks.reduce((total, week) => total + week.distanceMeters, 0) /
|
lastFourWeeks.reduce((total, week) => total + week.distanceMeters, 0) /
|
||||||
Math.max(1, lastFourWeeks.length);
|
Math.max(1, lastFourWeeks.length);
|
||||||
const currentWeekDistance =
|
const weeklyArray = Array.from(weekly.values());
|
||||||
Array.from(weekly.values()).at(-1)?.distanceMeters ?? 0;
|
const currentWeekDistance = weeklyArray.length > 0 ? weeklyArray[weeklyArray.length - 1].distanceMeters : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
weeks,
|
weeks,
|
||||||
@@ -251,6 +255,10 @@ export class AnalyticsService {
|
|||||||
intensityDistribution: this.createIntensityDistribution(activityLoads),
|
intensityDistribution: this.createIntensityDistribution(activityLoads),
|
||||||
progression: this.createProgression(now, activityLoads),
|
progression: this.createProgression(now, activityLoads),
|
||||||
personalRecords: this.createPersonalRecords(runs, streamPointsByActivity),
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,4 +151,55 @@ export interface RunningKpiDashboard {
|
|||||||
intensityDistribution: RunningIntensityDistributionBucket[];
|
intensityDistribution: RunningIntensityDistributionBucket[];
|
||||||
progression: RunningProgressionMetric[];
|
progression: RunningProgressionMetric[];
|
||||||
personalRecords: RunningPersonalRecordEstimate[];
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,92 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
|
|||||||
@@ -234,7 +234,10 @@ a.pr-row:hover,
|
|||||||
|
|
||||||
.kpis,
|
.kpis,
|
||||||
.chart-grid,
|
.chart-grid,
|
||||||
.progression-grid {
|
.progression-grid,
|
||||||
|
.performance-grid,
|
||||||
|
.predictions-grid,
|
||||||
|
.race-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,3 +245,259 @@ a.pr-row:hover,
|
|||||||
min-width: 0;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
} from './running-format';
|
} from './running-format';
|
||||||
import {
|
import {
|
||||||
RunningKpiDashboard,
|
RunningKpiDashboard,
|
||||||
|
RunningPerformanceKpi,
|
||||||
|
RunningInjuryRisk,
|
||||||
|
RunningReadinessScore,
|
||||||
|
RunningPredictions,
|
||||||
RunningProgressionMetric,
|
RunningProgressionMetric,
|
||||||
} from './running.types';
|
} from './running.types';
|
||||||
import { RunningService } from './running.service';
|
import { RunningService } from './running.service';
|
||||||
@@ -153,6 +157,51 @@ export class RunningKpiDashboardComponent implements OnInit, OnDestroy {
|
|||||||
protected pace = pace;
|
protected pace = pace;
|
||||||
protected shortDate = shortDate;
|
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 {
|
private renderCharts(): void {
|
||||||
const data = this.kpis();
|
const data = this.kpis();
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
|||||||
@@ -134,4 +134,55 @@ export interface RunningKpiDashboard {
|
|||||||
intensityDistribution: RunningIntensityDistributionBucket[];
|
intensityDistribution: RunningIntensityDistributionBucket[];
|
||||||
progression: RunningProgressionMetric[];
|
progression: RunningProgressionMetric[];
|
||||||
personalRecords: RunningPersonalRecordEstimate[];
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user