kpis
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user