Files
strava-mcp/client/src/app/running/running-kpi-dashboard.component.ts
Bastian Wagner c94a02e6d0 Docker
2026-06-17 10:45:09 +02:00

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;
}
}