338 lines
8.6 KiB
TypeScript
338 lines
8.6 KiB
TypeScript
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;
|
|
}
|
|
}
|