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

Leistungskennzahlen

+
+
+ @for (kpi of data.performance; track kpi.key) { +
+ {{ kpi.label }} + {{ formatPerformanceKpi(kpi) }} + {{ kpi.description }} +
+ } +
+
+ } + + @if (data.injuryRisk) { +
+
+

Verletzungsrisiko

+
+
+
+ {{ data.injuryRisk.riskLevel | uppercase }} +
+
+
+
+

{{ data.injuryRisk.recommendation }}

+
+
+ } + + @if (data.readiness) { +
+
+

Bereitschaft

+
+
+
+ {{ data.readiness.score }} / 100 +
+
{{ data.readiness.status | uppercase }}
+
+ @for (reason of data.readiness.reasons; track reason) { + {{ reason }} + } +
+
+
+ } + + @if (data.predictions) { +
+
+

Vorhersagen

+
+
+
+

VO2max

+ {{ data.predictions.vo2max ?? '-' }} ml/kg/min + Schatzung der aeroben Kapazitat +
+
+

Nachster Meilenstein

+ {{ data.predictions.nextMilestone.target }} + {{ data.predictions.nextMilestone.type }} +
+
+
+

Geschatzte Rennzeiten

+
+
5 km:{{ formatRaceTime(data.predictions.raceTimes.distance5k) }}
+
10 km:{{ formatRaceTime(data.predictions.raceTimes.distance10k) }}
+
Halbmarathon:{{ formatRaceTime(data.predictions.raceTimes.distanceHalfMarathon) }}
+
Marathon:{{ formatRaceTime(data.predictions.raceTimes.distanceMarathon) }}
+
+
+
+ } } } diff --git a/client/src/app/running/running-kpi-dashboard.component.scss b/client/src/app/running/running-kpi-dashboard.component.scss index c2887f7..f03ff68 100644 --- a/client/src/app/running/running-kpi-dashboard.component.scss +++ b/client/src/app/running/running-kpi-dashboard.component.scss @@ -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; + } +} diff --git a/client/src/app/running/running-kpi-dashboard.component.ts b/client/src/app/running/running-kpi-dashboard.component.ts index f8f453b..c9e2956 100644 --- a/client/src/app/running/running-kpi-dashboard.component.ts +++ b/client/src/app/running/running-kpi-dashboard.component.ts @@ -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) { diff --git a/client/src/app/running/running.types.ts b/client/src/app/running/running.types.ts index 260d233..16c4de4 100644 --- a/client/src/app/running/running.types.ts +++ b/client/src/app/running/running.types.ts @@ -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; }