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; @ViewChild('loadRatioCanvas') private loadRatioCanvas?: ElementRef; @ViewChild('distributionCanvas') private distributionCanvas?: ElementRef; @ViewChild('progressionCanvas') private progressionCanvas?: ElementRef; private readonly runningService = inject(RunningService); private readonly apiBaseUrl = resolveApiBaseUrl(); private readonly charts: Chart[] = []; protected readonly kpis = signal(null); protected readonly loading = signal(true); protected readonly error = signal(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; } }