Docker
This commit is contained in:
337
client/src/app/running/running-kpi-dashboard.component.ts
Normal file
337
client/src/app/running/running-kpi-dashboard.component.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
Chart,
|
||||
ChartConfiguration,
|
||||
registerables,
|
||||
} from 'chart.js';
|
||||
import { resolveApiBaseUrl } from '../shared/api-base-url';
|
||||
import {
|
||||
distanceKm,
|
||||
duration,
|
||||
number as formatNumber,
|
||||
pace,
|
||||
shortDate,
|
||||
} from './running-format';
|
||||
import {
|
||||
RunningKpiDashboard,
|
||||
RunningProgressionMetric,
|
||||
} from './running.types';
|
||||
import { RunningService } from './running.service';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
@Component({
|
||||
selector: 'app-running-kpi-dashboard',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
templateUrl: './running-kpi-dashboard.component.html',
|
||||
styleUrl: './running-kpi-dashboard.component.scss',
|
||||
})
|
||||
export class RunningKpiDashboardComponent implements OnInit, OnDestroy {
|
||||
@ViewChild('weeklyLoadCanvas') private weeklyLoadCanvas?: ElementRef<HTMLCanvasElement>;
|
||||
@ViewChild('loadRatioCanvas') private loadRatioCanvas?: ElementRef<HTMLCanvasElement>;
|
||||
@ViewChild('distributionCanvas') private distributionCanvas?: ElementRef<HTMLCanvasElement>;
|
||||
@ViewChild('progressionCanvas') private progressionCanvas?: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
private readonly runningService = inject(RunningService);
|
||||
private readonly apiBaseUrl = resolveApiBaseUrl();
|
||||
private readonly charts: Chart[] = [];
|
||||
|
||||
protected readonly kpis = signal<RunningKpiDashboard | null>(null);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyCharts();
|
||||
}
|
||||
|
||||
protected load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.runningService.getKpis(this.apiBaseUrl, 12).subscribe({
|
||||
next: (kpis) => {
|
||||
this.kpis.set(kpis);
|
||||
this.loading.set(false);
|
||||
window.setTimeout(() => this.renderCharts(), 0);
|
||||
},
|
||||
error: () => {
|
||||
this.error.set('KPI Dashboard konnte nicht geladen werden.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected totalRuns(data: RunningKpiDashboard): number {
|
||||
return data.load.weekly.reduce(
|
||||
(total, week) => total + week.activityCount,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
protected formatLoad(value: number | null | undefined): string {
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
protected formatRatio(value: number | null | undefined): string {
|
||||
return value === null || value === undefined ? '-' : value.toFixed(2);
|
||||
}
|
||||
|
||||
protected recoveryLabel(status: RunningKpiDashboard['recovery']['status']): string {
|
||||
switch (status) {
|
||||
case 'green':
|
||||
return 'Gruen';
|
||||
case 'yellow':
|
||||
return 'Gelb';
|
||||
case 'red':
|
||||
return 'Rot';
|
||||
}
|
||||
}
|
||||
|
||||
protected metricValue(metric: RunningProgressionMetric): string {
|
||||
switch (metric.unit) {
|
||||
case 'meters':
|
||||
return distanceKm(metric.current);
|
||||
case 'seconds':
|
||||
return duration(metric.current);
|
||||
case 'pace':
|
||||
return pace(metric.current);
|
||||
case 'count':
|
||||
case 'load':
|
||||
return formatNumber(metric.current);
|
||||
}
|
||||
}
|
||||
|
||||
protected previousMetricValue(metric: RunningProgressionMetric): string {
|
||||
switch (metric.unit) {
|
||||
case 'meters':
|
||||
return distanceKm(metric.previous);
|
||||
case 'seconds':
|
||||
return duration(metric.previous);
|
||||
case 'pace':
|
||||
return pace(metric.previous);
|
||||
case 'count':
|
||||
case 'load':
|
||||
return formatNumber(metric.previous);
|
||||
}
|
||||
}
|
||||
|
||||
protected changeLabel(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
return `${prefix}${value.toLocaleString('de-DE', {
|
||||
maximumFractionDigits: 1,
|
||||
})} %`;
|
||||
}
|
||||
|
||||
protected changeClass(value: number | null): string {
|
||||
if (value === null || value === 0) {
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
return value > 0 ? 'positive' : 'negative';
|
||||
}
|
||||
|
||||
protected distanceKm = distanceKm;
|
||||
protected duration = duration;
|
||||
protected pace = pace;
|
||||
protected shortDate = shortDate;
|
||||
|
||||
private renderCharts(): void {
|
||||
const data = this.kpis();
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.destroyCharts();
|
||||
this.renderWeeklyLoadChart(data);
|
||||
this.renderLoadRatioChart(data);
|
||||
this.renderDistributionChart(data);
|
||||
this.renderProgressionChart(data);
|
||||
}
|
||||
|
||||
private renderWeeklyLoadChart(data: RunningKpiDashboard): void {
|
||||
if (!this.weeklyLoadCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.createChart(this.weeklyLoadCanvas.nativeElement, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.load.weekly.map((week) => shortDate(week.weekStart)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Load',
|
||||
data: data.load.weekly.map((week) => week.load),
|
||||
backgroundColor: '#fc4c02',
|
||||
borderRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: this.baseOptions('Load'),
|
||||
});
|
||||
}
|
||||
|
||||
private renderLoadRatioChart(data: RunningKpiDashboard): void {
|
||||
if (!this.loadRatioCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = data.load.weekly.map((week) => shortDate(week.weekStart));
|
||||
const acuteSeries = data.load.weekly.map((_, index, weeks) =>
|
||||
this.sumLoad(weeks.slice(Math.max(0, index - 1), index + 1)),
|
||||
);
|
||||
const chronicSeries = data.load.weekly.map((_, index, weeks) =>
|
||||
Math.round(
|
||||
this.sumLoad(weeks.slice(Math.max(0, index - 3), index + 1)) /
|
||||
Math.min(4, index + 1),
|
||||
),
|
||||
);
|
||||
|
||||
this.createChart(this.loadRatioCanvas.nativeElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
this.lineDataset('Akut', acuteSeries, '#d63f4c'),
|
||||
this.lineDataset('Chronisch', chronicSeries, '#376fbd'),
|
||||
],
|
||||
},
|
||||
options: this.baseOptions('Load'),
|
||||
});
|
||||
}
|
||||
|
||||
private renderDistributionChart(data: RunningKpiDashboard): void {
|
||||
if (!this.distributionCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.createChart(this.distributionCanvas.nativeElement, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: data.intensityDistribution.map((bucket) => bucket.label),
|
||||
datasets: [
|
||||
{
|
||||
data: data.intensityDistribution.map((bucket) => bucket.share),
|
||||
backgroundColor: ['#1c8b76', '#d2a21f', '#d63f4c'],
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: '#4e5a6b', boxWidth: 12 },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (item) => `${item.label}: ${item.parsed} %`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private renderProgressionChart(data: RunningKpiDashboard): void {
|
||||
if (!this.progressionCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.createChart(this.progressionCanvas.nativeElement, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.progression.map((metric) => metric.label),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Veraenderung',
|
||||
data: data.progression.map((metric) => metric.changePercent ?? 0),
|
||||
backgroundColor: data.progression.map((metric) =>
|
||||
(metric.changePercent ?? 0) >= 0 ? '#1c8b76' : '#d63f4c',
|
||||
),
|
||||
borderRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: this.baseOptions('%'),
|
||||
});
|
||||
}
|
||||
|
||||
private createChart(
|
||||
canvas: HTMLCanvasElement,
|
||||
config: ChartConfiguration,
|
||||
): void {
|
||||
this.charts.push(new Chart(canvas, config));
|
||||
}
|
||||
|
||||
private baseOptions(yLabel: string): ChartConfiguration['options'] {
|
||||
return {
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: { color: '#4e5a6b', boxWidth: 12 },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: '#687386', maxRotation: 0 },
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(104, 115, 134, 0.14)' },
|
||||
ticks: { color: '#687386' },
|
||||
title: { color: '#687386', display: true, text: yLabel },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private lineDataset(
|
||||
label: string,
|
||||
data: number[],
|
||||
color: string,
|
||||
): ChartConfiguration<'line'>['data']['datasets'][number] {
|
||||
return {
|
||||
label,
|
||||
data,
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}22`,
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
pointRadius: 2,
|
||||
tension: 0.28,
|
||||
};
|
||||
}
|
||||
|
||||
private sumLoad(weeks: { load: number }[]): number {
|
||||
return weeks.reduce((total, week) => total + week.load, 0);
|
||||
}
|
||||
|
||||
private destroyCharts(): void {
|
||||
for (const chart of this.charts) {
|
||||
chart.destroy();
|
||||
}
|
||||
this.charts.length = 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user