kpis
This commit is contained in:
@@ -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