This commit is contained in:
Bastian Wagner
2026-06-17 10:45:09 +02:00
parent 38141c0358
commit c94a02e6d0
51 changed files with 4220 additions and 628 deletions

View File

@@ -1,5 +1,13 @@
node_modules
dist
coverage
.angular
.cache
.env
.env.*
npm-debug.log*
Dockerfile
.dockerignore
.git
.gitignore
README.md

View File

@@ -1,14 +1,25 @@
FROM node:24-alpine AS build
# syntax=docker/dockerfile:1.7
FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:24-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS production
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/client/browser /usr/share/nginx/html
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
EXPOSE 80

View File

@@ -5,7 +5,33 @@ server {
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types
application/javascript
application/json
image/svg+xml
text/css
text/plain;
location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
access_log off;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location ~ ^/(analytics|auth|strava)(/|$) {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://api:3000;
}
location / {
add_header Cache-Control "no-store";
try_files $uri $uri/ /index.html;
}
}

View File

@@ -14,6 +14,7 @@
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"chart.js": "^4.5.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
@@ -2058,6 +2059,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@listr2/prompt-adapter-inquirer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
@@ -4572,6 +4579,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",

View File

@@ -17,6 +17,7 @@
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"chart.js": "^4.5.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
@@ -29,4 +30,4 @@
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}
}

View File

@@ -1,137 +1,12 @@
<main class="shell">
<section class="panel" aria-labelledby="page-title">
<div class="heading">
<p class="eyebrow">Strava Datenimport</p>
<h1 id="page-title">Verbindung zu Strava</h1>
<p class="intro">
Verbinde deinen Strava Account einmalig, damit die API Aktivitaeten und
Streams serverseitig abrufen kann.
</p>
</div>
<nav class="nav" aria-label="Hauptnavigation">
<a routerLink="/dashboard" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Dashboard</a>
<a routerLink="/running" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Laufen</a>
<a routerLink="/running/kpis" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">KPI Dashboard</a>
<a routerLink="/settings" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Settings</a>
</nav>
@if (justConnected()) {
<div class="notice success" role="status">
Strava wurde verbunden. Der Token wurde im Backend gespeichert.
</div>
}
@if (connectionCanceled()) {
<div class="notice error" role="alert">
Strava wurde nicht verbunden. Starte den Verbindungsprozess erneut.
</div>
}
@if (error()) {
<div class="notice error" role="alert">
{{ error() }}
</div>
}
@if (syncError()) {
<div class="notice error" role="alert">
{{ syncError() }}
</div>
}
<div class="status-row">
<div>
<span class="label">Status</span>
@if (loading()) {
<strong>Pruefe Verbindung...</strong>
} @else if (status()?.connected) {
<strong class="connected">Verbunden</strong>
} @else {
<strong class="disconnected">Nicht verbunden</strong>
}
</div>
<button
type="button"
class="icon-button"
(click)="loadStatus()"
[disabled]="loading()"
title="Status aktualisieren"
>
<span aria-hidden="true">R</span>
<span class="sr-only">Status aktualisieren</span>
</button>
</div>
@if (status()?.connected && status()?.athlete; as athlete) {
<div class="athlete">
@if (athlete.profile) {
<img [src]="athlete.profile" alt="" />
} @else {
<div class="avatar" aria-hidden="true">
{{ athleteName().slice(0, 1) }}
</div>
}
<div>
<span class="label">Account</span>
<strong>{{ athleteName() }}</strong>
<span class="meta">Strava ID {{ athlete.stravaAthleteId }}</span>
</div>
</div>
}
<div class="sync-panel">
<div class="sync-header">
<div>
<span class="label">Activities Sync</span>
<strong>{{ syncStatusLabel() }}</strong>
</div>
<button
type="button"
class="secondary"
(click)="startSync()"
[disabled]="!canStartSync()"
>
@if (syncLoading() || isSyncActive()) {
Sync laeuft
} @else {
Activities synchronisieren
}
</button>
</div>
@if (syncJob(); as job) {
<div class="sync-stats">
<div>
<span class="label">Activities</span>
<strong>{{ job.activityCount }}</strong>
</div>
<div>
<span class="label">Details</span>
<strong>{{ job.detailCount }}</strong>
</div>
<div>
<span class="label">Stream Punkte</span>
<strong>{{ job.streamPointCount }}</strong>
</div>
</div>
@if (job.errorMessage) {
<p class="job-error">{{ job.errorMessage }}</p>
}
} @else {
<p class="sync-empty">
Noch kein Sync gestartet.
</p>
}
</div>
<div class="actions">
<button type="button" class="primary" (click)="connectStrava()">
Mit Strava verbinden
</button>
</div>
<section class="content">
<router-outlet />
</section>
<app-dashboard
[apiBaseUrl]="apiBaseUrl"
[connected]="status()?.connected ?? false"
[refreshKey]="dashboardRefreshKey()"
/>
</main>

View File

@@ -1,3 +1,16 @@
import { Routes } from '@angular/router';
import { DashboardComponent } from './dashboard/dashboard.component';
import { RunningActivityDetailComponent } from './running/running-activity-detail.component';
import { RunningDashboardComponent } from './running/running-dashboard.component';
import { RunningKpiDashboardComponent } from './running/running-kpi-dashboard.component';
import { SettingsComponent } from './settings/settings.component';
export const routes: Routes = [];
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'running', component: RunningDashboardComponent },
{ path: 'running/kpis', component: RunningKpiDashboardComponent },
{ path: 'running/:id', component: RunningActivityDetailComponent },
{ path: 'settings', component: SettingsComponent },
{ path: '**', redirectTo: 'dashboard' },
];

View File

@@ -13,267 +13,48 @@
}
.shell {
align-items: start;
background:
linear-gradient(135deg, rgba(252, 76, 2, 0.1), transparent 34%),
#f7f8fb;
display: grid;
gap: 24px;
grid-template-columns: minmax(360px, 520px) minmax(0, 1fr);
min-height: 100dvh;
padding: 32px;
}
.panel {
.nav {
align-items: center;
background: #ffffff;
border: 1px solid #d9dee7;
border-radius: 8px;
box-shadow: 0 16px 40px rgba(24, 33, 47, 0.08);
padding: 32px;
width: 100%;
border-bottom: 1px solid #d9dee7;
display: flex;
gap: 8px;
padding: 14px 32px;
position: sticky;
top: 0;
z-index: 10;
}
.heading {
margin-bottom: 28px;
}
.eyebrow,
.label,
.meta {
color: #687386;
display: block;
font-size: 0.82rem;
}
.eyebrow {
font-weight: 700;
letter-spacing: 0.08em;
margin: 0 0 10px;
text-transform: uppercase;
}
h1 {
font-size: 2rem;
line-height: 1.15;
margin: 0;
}
.intro {
.nav a {
border-radius: 6px;
color: #4e5a6b;
line-height: 1.6;
margin: 14px 0 0;
}
.notice {
border-radius: 6px;
margin-bottom: 18px;
padding: 12px 14px;
}
.success {
background: #ecf8ef;
color: #17612a;
}
.error {
background: #fff0ed;
color: #9b2915;
}
.status-row,
.athlete {
align-items: center;
border: 1px solid #dfe4ec;
border-radius: 8px;
display: flex;
justify-content: space-between;
margin-bottom: 16px;
min-height: 72px;
padding: 16px;
}
.connected {
color: #17612a;
}
.disconnected {
color: #9b2915;
}
.icon-button,
.primary,
.secondary {
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease;
}
.icon-button {
align-items: center;
background: #ffffff;
border: 1px solid #c8d0dc;
border-radius: 6px;
color: #18212f;
display: inline-flex;
font-size: 1.15rem;
height: 40px;
justify-content: center;
width: 40px;
}
.icon-button:hover {
background: #f1f4f8;
}
.icon-button:disabled {
cursor: progress;
opacity: 0.55;
}
.athlete {
gap: 14px;
justify-content: flex-start;
}
.athlete img,
.avatar {
border-radius: 50%;
height: 48px;
width: 48px;
}
.avatar {
align-items: center;
background: #fc4c02;
color: #ffffff;
display: flex;
font-weight: 800;
justify-content: center;
text-transform: uppercase;
padding: 10px 12px;
text-decoration: none;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 24px;
}
.primary {
background: #fc4c02;
border: 1px solid #fc4c02;
border-radius: 6px;
color: #ffffff;
font-weight: 800;
min-height: 44px;
padding: 0 18px;
}
.primary:hover {
background: #d64002;
border-color: #d64002;
}
.secondary {
.nav a:hover,
.nav a.active {
background: #18212f;
border: 1px solid #18212f;
border-radius: 6px;
color: #ffffff;
font-weight: 800;
min-height: 40px;
padding: 0 14px;
}
.secondary:hover:not(:disabled) {
background: #2d394c;
border-color: #2d394c;
}
.secondary:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.sync-panel {
border: 1px solid #dfe4ec;
border-radius: 8px;
margin-top: 16px;
padding: 16px;
}
.sync-header {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
}
.sync-stats {
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 16px;
}
.sync-stats > div {
background: #f6f8fb;
border-radius: 6px;
padding: 12px;
}
.sync-empty,
.job-error {
color: #687386;
margin: 14px 0 0;
}
.job-error {
color: #9b2915;
}
.sr-only {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
.content {
padding: 32px;
}
@media (max-width: 640px) {
.shell {
align-items: stretch;
grid-template-columns: 1fr;
.nav {
padding: 10px 16px;
}
.content {
padding: 16px;
}
.panel {
padding: 22px;
}
h1 {
font-size: 1.6rem;
}
.actions {
justify-content: stretch;
}
.primary,
.secondary {
width: 100%;
}
.sync-header {
align-items: stretch;
flex-direction: column;
}
.sync-stats {
grid-template-columns: 1fr;
}
}

View File

@@ -1,176 +1,10 @@
import { HttpClient } from '@angular/common/http';
import { Component, computed, inject, signal } from '@angular/core';
import { DashboardModule } from './dashboard/dashboard.module';
interface StravaAuthStatus {
connected: boolean;
athlete: {
id: string;
stravaAthleteId: string;
username: string | null;
firstName: string | null;
lastName: string | null;
profile: string | null;
updatedAt: string;
} | null;
}
interface StravaSyncJob {
id: string;
status: 'queued' | 'running' | 'completed' | 'failed' | 'rate_limited';
activityCount: number;
detailCount: number;
streamPointCount: number;
errorMessage: string | null;
retryAfter: string | null;
startedAt: string | null;
finishedAt: string | null;
createdAt: string;
updatedAt: string;
}
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [DashboardModule],
imports: [RouterLink, RouterLinkActive, RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {
private readonly http = inject(HttpClient);
protected readonly status = signal<StravaAuthStatus | null>(null);
protected readonly loading = signal(true);
protected readonly error = signal<string | null>(null);
protected readonly syncError = signal<string | null>(null);
protected readonly syncJob = signal<StravaSyncJob | null>(null);
protected readonly syncLoading = signal(false);
protected readonly dashboardRefreshKey = signal(0);
protected readonly justConnected = signal(false);
protected readonly connectionCanceled = signal(false);
protected readonly apiBaseUrl = this.resolveApiBaseUrl();
protected readonly athleteName = computed(() => {
const athlete = this.status()?.athlete;
const fullName = [athlete?.firstName, athlete?.lastName]
.filter(Boolean)
.join(' ')
.trim();
return fullName || athlete?.username || 'Strava Account';
});
protected readonly canStartSync = computed(
() =>
Boolean(this.status()?.connected) &&
!this.loading() &&
!this.syncLoading() &&
!this.isSyncActive(),
);
protected readonly isSyncActive = computed(() => {
const status = this.syncJob()?.status;
return status === 'queued' || status === 'running';
});
protected readonly syncStatusLabel = computed(() => {
switch (this.syncJob()?.status) {
case 'queued':
return 'Wartet';
case 'running':
return 'Laeuft';
case 'completed':
return 'Abgeschlossen';
case 'failed':
return 'Fehlgeschlagen';
case 'rate_limited':
return 'Rate Limit erreicht';
default:
return 'Noch nicht gestartet';
}
});
constructor() {
const authResult = new URLSearchParams(window.location.search).get('strava');
this.justConnected.set(authResult === 'connected');
this.connectionCanceled.set(authResult === 'error');
this.loadStatus();
}
protected connectStrava(): void {
window.location.href = `${this.apiBaseUrl}/auth/strava/connect`;
}
protected loadStatus(): void {
this.loading.set(true);
this.error.set(null);
this.http
.get<StravaAuthStatus>(`${this.apiBaseUrl}/auth/strava/status`)
.subscribe({
next: (status) => {
this.status.set(status);
this.loading.set(false);
if (status.connected) {
this.refreshDashboard();
}
},
error: () => {
this.error.set('API nicht erreichbar oder Strava-Status konnte nicht geladen werden.');
this.loading.set(false);
},
});
}
protected startSync(): void {
if (!this.canStartSync()) {
return;
}
this.syncLoading.set(true);
this.syncError.set(null);
this.http
.post<StravaSyncJob>(`${this.apiBaseUrl}/strava/sync`, {})
.subscribe({
next: (job) => {
this.syncJob.set(job);
this.syncLoading.set(false);
this.pollSyncJob(job.id);
},
error: () => {
this.syncError.set('Sync konnte nicht gestartet werden.');
this.syncLoading.set(false);
},
});
}
private pollSyncJob(jobId: string): void {
window.setTimeout(() => {
this.http
.get<StravaSyncJob>(`${this.apiBaseUrl}/strava/sync/jobs/${jobId}`)
.subscribe({
next: (job) => {
this.syncJob.set(job);
if (job.status === 'queued' || job.status === 'running') {
this.pollSyncJob(job.id);
} else if (job.status === 'completed') {
this.refreshDashboard();
}
},
error: () => {
this.syncError.set('Sync-Status konnte nicht geladen werden.');
},
});
}, 2000);
}
private refreshDashboard(): void {
this.dashboardRefreshKey.update((value) => value + 1);
}
private resolveApiBaseUrl(): string {
const { protocol, hostname, port, origin } = window.location;
if (port === '4200' || port === '8080') {
return `${protocol}//${hostname}:3000`;
}
return origin;
}
}
export class App {}

View File

@@ -24,7 +24,7 @@
type="button"
class="icon-button"
(click)="loadDashboard()"
[disabled]="dashboardLoading() || !connected"
[disabled]="dashboardLoading()"
title="Dashboard aktualisieren"
>
<span aria-hidden="true">R</span>

View File

@@ -1,12 +1,11 @@
import {
Component,
Input,
OnChanges,
SimpleChanges,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { resolveApiBaseUrl } from '../shared/api-base-url';
import { AnalyticsDashboard } from './dashboard.types';
import { DashboardService } from './dashboard.service';
@@ -16,12 +15,9 @@ import { DashboardService } from './dashboard.service';
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.scss',
})
export class DashboardComponent implements OnChanges {
@Input({ required: true }) apiBaseUrl = '';
@Input() connected = false;
@Input() refreshKey = 0;
export class DashboardComponent implements OnInit {
private readonly dashboardService = inject(DashboardService);
private readonly apiBaseUrl = resolveApiBaseUrl();
protected readonly dashboard = signal<AnalyticsDashboard | null>(null);
protected readonly dashboardLoading = signal(false);
protected readonly dashboardError = signal<string | null>(null);
@@ -39,20 +35,11 @@ export class DashboardComponent implements OnChanges {
),
);
ngOnChanges(changes: SimpleChanges): void {
if (
this.connected &&
(changes['connected'] || changes['refreshKey'] || changes['apiBaseUrl'])
) {
this.loadDashboard();
}
ngOnInit(): void {
this.loadDashboard();
}
protected loadDashboard(): void {
if (!this.connected) {
return;
}
this.dashboardLoading.set(true);
this.dashboardError.set(null);

View File

@@ -3,7 +3,7 @@ import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { AnalyticsDashboard } from './dashboard.types';
@Injectable()
@Injectable({ providedIn: 'root' })
export class DashboardService {
private readonly http = inject(HttpClient);

View File

@@ -0,0 +1,96 @@
<section class="page">
<a routerLink="/running" class="back">Zurueck zur Laufanalyse</a>
@if (error()) {
<div class="notice error">{{ error() }}</div>
}
@if (loading()) {
<div class="empty-state">Laufdetails werden geladen...</div>
} @else if (detail(); as data) {
<div class="hero">
<p class="eyebrow">{{ data.activity.sportType ?? 'Run' }} | {{ shortDate(data.activity.startDate) }}</p>
<h1>{{ data.activity.name }}</h1>
<div class="kpis">
<div><span>Distanz</span><strong>{{ distanceKm(data.activity.distanceMeters) }}</strong></div>
<div><span>Zeit</span><strong>{{ duration(data.activity.movingTimeSeconds) }}</strong></div>
<div><span>Pace</span><strong>{{ pace(data.activity.averageSpeedMetersPerSecond ? 1000 / data.activity.averageSpeedMetersPerSecond : null) }}</strong></div>
<div><span>Hoehenmeter</span><strong>{{ elevation(data.activity.elevationGainMeters) }}</strong></div>
</div>
</div>
<div class="panel">
<h2>Kilometer Splits</h2>
@if (data.splits.length === 0) {
<p class="empty-text">Keine Stream-Daten fuer Splits vorhanden.</p>
} @else {
<table>
<thead>
<tr>
<th>Km</th>
<th>Pace</th>
<th>Zeit</th>
<th>HF</th>
<th>Kadenz</th>
<th>HM+</th>
</tr>
</thead>
<tbody>
@for (split of data.splits; track split.kilometer) {
<tr>
<td>{{ split.kilometer }}</td>
<td>{{ pace(split.paceSecondsPerKm) }}</td>
<td>{{ duration(split.movingTimeSeconds) }}</td>
<td>{{ number(split.averageHeartRate, ' bpm') }}</td>
<td>{{ number(split.averageCadence) }}</td>
<td>{{ elevation(split.elevationGainMeters) }}</td>
</tr>
}
</tbody>
</table>
}
</div>
<div class="chart-grid">
<app-running-metric-chart
title="Pace"
metric="paceSecondsPerKm"
valueKind="pace"
yAxisLabel="min/km"
color="#fc4c02"
fillColor="rgba(252, 76, 2, 0.14)"
emptyText="Keine Pace-Streamdaten vorhanden."
[data]="data.series"
/>
<app-running-metric-chart
title="Herzfrequenz"
metric="heartRate"
unit=" bpm"
yAxisLabel="bpm"
color="#d63f4c"
fillColor="rgba(214, 63, 76, 0.14)"
emptyText="Keine Herzfrequenz-Streamdaten vorhanden."
[data]="data.series"
/>
<app-running-metric-chart
title="Hoehe"
metric="altitude"
unit=" m"
yAxisLabel="Meter"
color="#1c8b76"
fillColor="rgba(28, 139, 118, 0.14)"
emptyText="Keine Hoehen-Streamdaten vorhanden."
[data]="data.series"
/>
<app-running-metric-chart
title="Kadenz"
metric="cadence"
yAxisLabel="spm"
color="#376fbd"
fillColor="rgba(55, 111, 189, 0.14)"
emptyText="Keine Kadenz-Streamdaten vorhanden."
[data]="data.series"
/>
</div>
}
</section>

View File

@@ -0,0 +1,93 @@
.page {
display: grid;
gap: 18px;
}
.back {
color: #4e5a6b;
font-weight: 800;
text-decoration: none;
}
.hero,
.panel,
.notice,
.empty-state {
background: #ffffff;
border: 1px solid #dfe4ec;
border-radius: 8px;
padding: 18px;
}
.error {
background: #fff0ed;
color: #9b2915;
}
.eyebrow,
.kpis span,
.empty-text {
color: #687386;
font-size: 0.82rem;
}
.eyebrow {
font-weight: 700;
letter-spacing: 0.08em;
margin: 0 0 10px;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
}
.kpis {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 18px;
}
.kpis > div {
border: 1px solid #eef2f7;
border-radius: 6px;
padding: 12px;
}
.kpis strong {
display: block;
margin-top: 4px;
}
table {
border-collapse: collapse;
margin-top: 14px;
width: 100%;
}
th,
td {
border-bottom: 1px solid #eef2f7;
padding: 10px;
text-align: left;
}
th {
color: #687386;
font-size: 0.82rem;
}
.chart-grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 760px) {
.kpis,
.chart-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,57 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { resolveApiBaseUrl } from '../shared/api-base-url';
import {
distanceKm,
duration,
elevation,
number,
pace,
shortDate,
} from './running-format';
import { RunningActivityDetail } from './running.types';
import { RunningMetricChartComponent } from './running-metric-chart.component';
import { RunningService } from './running.service';
@Component({
selector: 'app-running-activity-detail',
standalone: true,
imports: [RouterLink, RunningMetricChartComponent],
templateUrl: './running-activity-detail.component.html',
styleUrl: './running-activity-detail.component.scss',
})
export class RunningActivityDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly runningService = inject(RunningService);
private readonly apiBaseUrl = resolveApiBaseUrl();
protected readonly detail = signal<RunningActivityDetail | null>(null);
protected readonly loading = signal(true);
protected readonly error = signal<string | null>(null);
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
if (!id) {
this.error.set('Lauf wurde nicht gefunden.');
this.loading.set(false);
return;
}
this.runningService.getActivityDetail(this.apiBaseUrl, id).subscribe({
next: (detail) => {
this.detail.set(detail);
this.loading.set(false);
},
error: () => {
this.error.set('Laufdetails konnten nicht geladen werden.');
this.loading.set(false);
},
});
}
protected distanceKm = distanceKm;
protected duration = duration;
protected elevation = elevation;
protected pace = pace;
protected number = number;
protected shortDate = shortDate;
}

View File

@@ -0,0 +1,68 @@
<section class="page">
<div class="heading">
<div>
<p class="eyebrow">Laufanalyse</p>
<h1>Running Dashboard</h1>
</div>
<button type="button" class="icon-button" (click)="load()" [disabled]="loading()">R</button>
</div>
@if (error()) {
<div class="notice error">{{ error() }}</div>
}
@if (loading()) {
<div class="empty-state">Laufanalyse wird geladen...</div>
} @else if (summary(); as data) {
@if (data.totals.activityCount === 0) {
<div class="empty-state">Keine Laeufe im Zeitraum gefunden.</div>
} @else {
<div class="kpis">
<div><span>Laeufe</span><strong>{{ data.totals.activityCount }}</strong></div>
<div><span>Distanz</span><strong>{{ distanceKm(data.totals.distanceMeters) }}</strong></div>
<div><span>Zeit</span><strong>{{ duration(data.totals.movingTimeSeconds) }}</strong></div>
<div><span>Pace</span><strong>{{ pace(data.averages.paceSecondsPerKm) }}</strong></div>
<div><span>4W Schnitt</span><strong>{{ distanceKm(data.fourWeekAverageDistanceMeters) }}</strong></div>
<div><span>Laengster Lauf</span><strong>{{ distanceKm(data.longestRun?.distanceMeters) }}</strong></div>
<div><span>HM / km</span><strong>{{ number(data.elevationGainPerKm, ' m') }}</strong></div>
<div><span>Longrun Anteil</span><strong>{{ number(data.longRunShare, ' %') }}</strong></div>
</div>
<div class="panel">
<div class="section-title">
<h2>Wochenumfang</h2>
<span>{{ data.rangeStart }} bis {{ data.rangeEnd }}</span>
</div>
<div class="weekly-bars">
@for (week of data.weekly; track week.weekStart) {
<div class="week-bar">
<div class="bar-track">
<div class="bar-fill" [style.height.%]="percent(week.distanceMeters, maxWeeklyDistance())"></div>
</div>
<span>{{ shortDate(week.weekStart) }}</span>
</div>
}
</div>
</div>
<div class="panel">
<div class="section-title">
<h2>Letzte Laeufe</h2>
</div>
<div class="run-list">
@for (activity of activities(); track activity.id) {
<a class="run-row" [routerLink]="['/running', activity.id]">
<div>
<strong>{{ activity.name }}</strong>
<span>{{ shortDate(activity.startDate) }}</span>
</div>
<span>{{ distanceKm(activity.distanceMeters) }}</span>
<span>{{ duration(activity.movingTimeSeconds) }}</span>
<span>{{ elevation(activity.elevationGainMeters) }}</span>
</a>
}
</div>
</div>
}
}
</section>

View File

@@ -0,0 +1,134 @@
.page {
display: grid;
gap: 18px;
}
.heading,
.section-title {
align-items: center;
display: flex;
justify-content: space-between;
}
.eyebrow {
color: #687386;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.08em;
margin: 0 0 10px;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
}
.icon-button {
background: #ffffff;
border: 1px solid #c8d0dc;
border-radius: 6px;
height: 40px;
width: 40px;
}
.notice,
.empty-state,
.panel,
.kpis > div {
background: #ffffff;
border: 1px solid #dfe4ec;
border-radius: 8px;
}
.notice,
.empty-state,
.panel {
padding: 18px;
}
.error {
background: #fff0ed;
color: #9b2915;
}
.kpis {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.kpis > div {
padding: 16px;
}
.kpis span,
.section-title span,
.run-row span {
color: #687386;
font-size: 0.82rem;
}
.kpis strong {
display: block;
font-size: 1.25rem;
margin-top: 4px;
}
.weekly-bars {
align-items: end;
display: grid;
gap: 8px;
grid-template-columns: repeat(12, minmax(0, 1fr));
min-height: 180px;
}
.week-bar {
align-items: center;
display: flex;
flex-direction: column;
gap: 8px;
}
.bar-track {
align-items: end;
background: #eef2f7;
border-radius: 5px;
display: flex;
height: 136px;
overflow: hidden;
width: 100%;
}
.bar-fill {
background: #fc4c02;
width: 100%;
}
.run-list {
display: grid;
gap: 10px;
}
.run-row {
align-items: center;
border: 1px solid #eef2f7;
border-radius: 6px;
color: inherit;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto auto auto;
padding: 12px;
text-decoration: none;
}
.run-row:hover {
background: #f6f8fb;
}
@media (max-width: 760px) {
.kpis,
.run-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,59 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { resolveApiBaseUrl } from '../shared/api-base-url';
import { distanceKm, duration, elevation, number, pace, shortDate } from './running-format';
import { RunningActivity, RunningSummary } from './running.types';
import { RunningService } from './running.service';
@Component({
selector: 'app-running-dashboard',
standalone: true,
imports: [RouterLink],
templateUrl: './running-dashboard.component.html',
styleUrl: './running-dashboard.component.scss',
})
export class RunningDashboardComponent implements OnInit {
private readonly runningService = inject(RunningService);
private readonly apiBaseUrl = resolveApiBaseUrl();
protected readonly summary = signal<RunningSummary | null>(null);
protected readonly activities = signal<RunningActivity[]>([]);
protected readonly loading = signal(true);
protected readonly error = signal<string | null>(null);
protected readonly maxWeeklyDistance = computed(() =>
Math.max(
1,
...(this.summary()?.weekly.map((week) => week.distanceMeters) ?? [0]),
),
);
ngOnInit(): void {
this.load();
}
protected load(): void {
this.loading.set(true);
this.error.set(null);
this.runningService.getSummary(this.apiBaseUrl, 12).subscribe({
next: (summary) => {
this.summary.set(summary);
this.activities.set(summary.recentRuns);
this.loading.set(false);
},
error: () => {
this.error.set('Laufanalyse konnte nicht geladen werden.');
this.loading.set(false);
},
});
}
protected percent(value: number, max: number): number {
return Math.max(3, Math.round((value / max) * 100));
}
protected distanceKm = distanceKm;
protected duration = duration;
protected elevation = elevation;
protected pace = pace;
protected number = number;
protected shortDate = shortDate;
}

View File

@@ -0,0 +1,51 @@
export const distanceKm = (meters: number | null | undefined): string =>
`${((meters ?? 0) / 1000).toLocaleString('de-DE', {
maximumFractionDigits: 1,
})} km`;
export const duration = (seconds: number | null | undefined): string => {
const totalSeconds = seconds ?? 0;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.round((totalSeconds % 3600) / 60);
return hours > 0 ? `${hours} h ${minutes} min` : `${minutes} min`;
};
export const elevation = (meters: number | null | undefined): string =>
`${Math.round(meters ?? 0).toLocaleString('de-DE')} m`;
export const pace = (secondsPerKm: number | null | undefined): string => {
if (!secondsPerKm) {
return '-';
}
const minutes = Math.floor(secondsPerKm / 60);
const seconds = Math.round(secondsPerKm % 60)
.toString()
.padStart(2, '0');
return `${minutes}:${seconds} /km`;
};
export const number = (
value: number | null | undefined,
suffix = '',
): string => {
if (value === null || value === undefined) {
return '-';
}
return `${Math.round(value).toLocaleString('de-DE')}${suffix}`;
};
export const shortDate = (value: string | null): string => {
if (!value) {
return '-';
}
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
}).format(new Date(value));
};

View File

@@ -0,0 +1,153 @@
<section class="page">
<div class="heading">
<div>
<p class="eyebrow">Laufanalyse</p>
<h1>KPI Dashboard</h1>
</div>
<div class="heading-actions">
<a routerLink="/running" class="secondary-link">Laufen</a>
<button type="button" class="icon-button" (click)="load()" [disabled]="loading()">R</button>
</div>
</div>
@if (error()) {
<div class="notice error">{{ error() }}</div>
}
@if (loading()) {
<div class="empty-state">KPI Dashboard wird geladen...</div>
} @else if (kpis(); as data) {
@if (totalRuns(data) === 0) {
<div class="empty-state">Keine Laeufe im Zeitraum gefunden.</div>
} @else {
<div class="kpis">
<div>
<span>Akute Belastung</span>
<strong>{{ formatLoad(data.load.acute) }}</strong>
</div>
<div>
<span>Chronische Belastung</span>
<strong>{{ formatLoad(data.load.chronic) }}</strong>
</div>
<div>
<span>A/C Ratio</span>
<strong>{{ formatRatio(data.load.acuteChronicRatio) }}</strong>
</div>
<div>
<span>Erholung</span>
<strong [class]="data.recovery.status">{{ data.recovery.score }}</strong>
</div>
<div>
<span>Monotony</span>
<strong>{{ formatRatio(data.monotony.value) }}</strong>
</div>
<div>
<span>Strain</span>
<strong>{{ formatLoad(data.strain.value) }}</strong>
</div>
<div>
<span>Letzter harter Lauf</span>
<strong>{{ formatLoad(data.recovery.daysSinceLastHardRun) }} d</strong>
</div>
<div>
<span>Status</span>
<strong [class]="data.recovery.status">{{ recoveryLabel(data.recovery.status) }}</strong>
</div>
</div>
<div class="panel recovery-panel">
<div>
<h2>Erholungsindikator</h2>
<p>{{ data.recovery.message }}</p>
</div>
<div class="recovery-meter">
<div class="meter-track">
<div class="meter-fill" [class]="data.recovery.status" [style.width.%]="data.recovery.score"></div>
</div>
<span>{{ data.recovery.score }} / 100</span>
</div>
</div>
<div class="chart-grid">
<div class="panel chart-panel">
<div class="section-title">
<h2>Weekly Load</h2>
<span>{{ data.rangeStart }} bis {{ data.rangeEnd }}</span>
</div>
<div class="canvas-wrap"><canvas #weeklyLoadCanvas></canvas></div>
</div>
<div class="panel chart-panel">
<div class="section-title">
<h2>Akut vs. chronisch</h2>
<span>Load Trend</span>
</div>
<div class="canvas-wrap"><canvas #loadRatioCanvas></canvas></div>
</div>
<div class="panel chart-panel">
<div class="section-title">
<h2>Easy / Moderate / Hard</h2>
<span>Zeitanteil</span>
</div>
<div class="canvas-wrap"><canvas #distributionCanvas></canvas></div>
</div>
<div class="panel chart-panel">
<div class="section-title">
<h2>Progression</h2>
<span>4 Wochen vs. vorherige 4 Wochen</span>
</div>
<div class="canvas-wrap"><canvas #progressionCanvas></canvas></div>
</div>
</div>
<div class="panel">
<div class="section-title">
<h2>Progression</h2>
<span>Positive Werte sind besser</span>
</div>
<div class="progression-grid">
@for (metric of data.progression; track metric.key) {
<div class="progression-card">
<span>{{ metric.label }}</span>
<strong>{{ metricValue(metric) }}</strong>
<small>Vorher: {{ previousMetricValue(metric) }}</small>
<em [class]="changeClass(metric.changePercent)">{{ changeLabel(metric.changePercent) }}</em>
</div>
}
</div>
</div>
<div class="panel">
<div class="section-title">
<h2>PR Schaetzungen</h2>
<span>Aus Streams, sonst Pace-Fallback</span>
</div>
<div class="pr-list">
@for (record of data.personalRecords; track record.distanceMeters) {
@if (record.activityId) {
<a class="pr-row" [routerLink]="['/running', record.activityId]">
<strong>{{ distanceKm(record.distanceMeters) }}</strong>
<span>{{ duration(record.timeSeconds) }}</span>
<span>{{ pace(record.paceSecondsPerKm) }}</span>
<span>{{ record.activityName }}</span>
<span>{{ shortDate(record.startDate) }}</span>
<span>{{ record.estimated ? 'geschaetzt' : 'exakt' }}</span>
</a>
} @else {
<div class="pr-row">
<strong>{{ distanceKm(record.distanceMeters) }}</strong>
<span>-</span>
<span>-</span>
<span>Keine Daten</span>
<span>-</span>
<span>geschaetzt</span>
</div>
}
}
</div>
</div>
}
}
</section>

View File

@@ -0,0 +1,244 @@
.page {
display: grid;
gap: 18px;
}
.heading,
.heading-actions,
.section-title,
.recovery-panel {
align-items: center;
display: flex;
justify-content: space-between;
}
.heading-actions {
gap: 10px;
}
.eyebrow {
color: #687386;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.08em;
margin: 0 0 10px;
text-transform: uppercase;
}
h1,
h2,
p {
margin: 0;
}
.icon-button {
background: #ffffff;
border: 1px solid #c8d0dc;
border-radius: 6px;
height: 40px;
width: 40px;
}
.secondary-link {
border: 1px solid #c8d0dc;
border-radius: 6px;
color: #4e5a6b;
font-weight: 800;
min-height: 40px;
padding: 9px 12px;
text-decoration: none;
}
.notice,
.empty-state,
.panel,
.kpis > div {
background: #ffffff;
border: 1px solid #dfe4ec;
border-radius: 8px;
}
.notice,
.empty-state,
.panel {
padding: 18px;
}
.error {
background: #fff0ed;
color: #9b2915;
}
.kpis {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.kpis > div {
padding: 16px;
}
.kpis span,
.section-title span,
.progression-card span,
.progression-card small,
.pr-row span,
.recovery-panel p {
color: #687386;
font-size: 0.82rem;
}
.kpis strong,
.progression-card strong {
display: block;
font-size: 1.25rem;
margin-top: 4px;
}
.green {
color: #1c8b76;
}
.yellow {
color: #9a6b00;
}
.red {
color: #d63f4c;
}
.recovery-panel {
gap: 18px;
}
.recovery-meter {
display: grid;
gap: 8px;
min-width: 220px;
}
.meter-track {
background: #eef2f7;
border-radius: 999px;
height: 10px;
overflow: hidden;
}
.meter-fill {
border-radius: inherit;
height: 100%;
}
.meter-fill.green {
background: #1c8b76;
}
.meter-fill.yellow {
background: #d2a21f;
}
.meter-fill.red {
background: #d63f4c;
}
.chart-grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chart-panel {
min-width: 0;
}
.canvas-wrap {
height: 260px;
margin-top: 14px;
position: relative;
}
.progression-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(5, minmax(0, 1fr));
margin-top: 14px;
}
.progression-card {
border: 1px solid #eef2f7;
border-radius: 6px;
display: grid;
gap: 5px;
padding: 12px;
}
.progression-card em {
font-style: normal;
font-weight: 800;
}
.positive {
color: #1c8b76;
}
.negative {
color: #d63f4c;
}
.neutral {
color: #687386;
}
.pr-list {
display: grid;
gap: 10px;
margin-top: 14px;
}
.pr-row {
align-items: center;
border: 1px solid #eef2f7;
border-radius: 6px;
color: inherit;
display: grid;
gap: 12px;
grid-template-columns: 90px 90px 90px minmax(0, 1fr) 90px 90px;
padding: 12px;
text-decoration: none;
}
a.pr-row:hover,
.secondary-link:hover {
background: #f6f8fb;
}
@media (max-width: 980px) {
.kpis,
.chart-grid,
.progression-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pr-row {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) {
.heading,
.recovery-panel {
align-items: stretch;
flex-direction: column;
}
.kpis,
.chart-grid,
.progression-grid {
grid-template-columns: 1fr;
}
.recovery-meter {
min-width: 0;
}
}

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

View File

@@ -0,0 +1,16 @@
<div class="chart-shell">
<div class="chart-heading">
<h2>{{ title }}</h2>
@if (summaryLabel()) {
<span>{{ summaryLabel() }}</span>
}
</div>
<div class="canvas-wrap" [class.is-empty]="!hasData()">
<canvas #canvas></canvas>
</div>
@if (!hasData()) {
<p class="empty-text">{{ emptyText }}</p>
}
</div>

View File

@@ -0,0 +1,45 @@
.chart-shell {
background: #ffffff;
border: 1px solid #dfe4ec;
border-radius: 8px;
padding: 18px;
}
.chart-heading {
align-items: baseline;
display: flex;
gap: 12px;
justify-content: space-between;
}
h2 {
margin: 0;
}
.chart-heading span {
color: #4e5a6b;
font-size: 0.86rem;
font-weight: 800;
}
.canvas-wrap {
height: 240px;
margin-top: 14px;
position: relative;
}
.canvas-wrap.is-empty {
display: none;
}
.empty-text {
color: #687386;
font-size: 0.82rem;
margin: 14px 0 0;
}
@media (max-width: 760px) {
.canvas-wrap {
height: 220px;
}
}

View File

@@ -0,0 +1,209 @@
import {
AfterViewInit,
Component,
ElementRef,
Input,
OnChanges,
OnDestroy,
SimpleChanges,
ViewChild,
} from '@angular/core';
import {
Chart,
ChartDataset,
ChartOptions,
TooltipItem,
registerables,
} from 'chart.js';
import { RunningChartPoint } from './running.types';
Chart.register(...registerables);
type MetricKey = 'paceSecondsPerKm' | 'heartRate' | 'altitude' | 'cadence';
type MetricPoint = { x: number; y: number };
type ValueKind = 'number' | 'pace';
@Component({
selector: 'app-running-metric-chart',
standalone: true,
templateUrl: './running-metric-chart.component.html',
styleUrl: './running-metric-chart.component.scss',
})
export class RunningMetricChartComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@ViewChild('canvas', { static: true })
private readonly canvas!: ElementRef<HTMLCanvasElement>;
@Input({ required: true }) title = '';
@Input({ required: true }) metric!: MetricKey;
@Input() data: RunningChartPoint[] = [];
@Input() color = '#fc4c02';
@Input() fillColor = 'rgba(252, 76, 2, 0.12)';
@Input() emptyText = 'Keine Streamdaten vorhanden.';
@Input() yAxisLabel = '';
@Input() unit = '';
@Input() valueKind: ValueKind = 'number';
private chart: Chart<'line', MetricPoint[]> | null = null;
private viewReady = false;
ngAfterViewInit(): void {
this.viewReady = true;
this.renderChart();
}
ngOnChanges(_: SimpleChanges): void {
this.renderChart();
}
ngOnDestroy(): void {
this.destroyChart();
}
protected hasData(): boolean {
return this.points().length > 0;
}
protected summaryLabel(): string {
const values = this.points().map((point) => point.y);
if (values.length === 0) {
return '';
}
const average =
values.reduce((sum, value) => sum + value, 0) / values.length;
return `Ø ${this.formatValue(average)}`;
}
private renderChart(): void {
if (!this.viewReady) {
return;
}
const points = this.points();
if (points.length === 0) {
this.destroyChart();
return;
}
const dataset: ChartDataset<'line', MetricPoint[]> = {
data: points,
borderColor: this.color,
backgroundColor: this.fillColor,
borderWidth: 2,
fill: true,
pointHitRadius: 10,
pointRadius: 0,
tension: 0.28,
};
if (!this.chart) {
this.chart = new Chart(this.canvas.nativeElement, {
type: 'line',
data: { datasets: [dataset] },
options: this.chartOptions(),
});
return;
}
this.chart.data.datasets = [dataset];
this.chart.options = this.chartOptions();
this.chart.update();
}
private chartOptions(): ChartOptions<'line'> {
return {
animation: { duration: 250 },
interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: false,
normalized: true,
parsing: false,
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#18212f',
borderColor: 'rgba(255, 255, 255, 0.12)',
borderWidth: 1,
displayColors: false,
padding: 10,
callbacks: {
title: (items) => this.tooltipTitle(items),
label: (item) =>
`${this.title}: ${this.formatValue(Number(item.parsed.y))}`,
},
},
},
scales: {
x: {
grid: { color: 'rgba(104, 115, 134, 0.14)' },
ticks: {
color: '#687386',
callback: (value) => `${Number(value).toFixed(1)} km`,
maxTicksLimit: 6,
},
title: {
color: '#687386',
display: true,
text: 'Distanz',
},
type: 'linear',
},
y: {
grid: { color: 'rgba(104, 115, 134, 0.14)' },
reverse: this.valueKind === 'pace',
ticks: {
color: '#687386',
callback: (value) => this.formatValue(Number(value)),
maxTicksLimit: 5,
},
title: {
color: '#687386',
display: Boolean(this.yAxisLabel),
text: this.yAxisLabel,
},
},
},
};
}
private tooltipTitle(items: TooltipItem<'line'>[]): string {
const distance = Number(items[0]?.parsed.x);
return Number.isFinite(distance) ? `${distance.toFixed(2)} km` : '';
}
private points(): MetricPoint[] {
return this.data
.map((point) => ({
x: (point.distanceMeters ?? 0) / 1000,
y: point[this.metric],
}))
.filter(
(point): point is MetricPoint =>
point.y !== null &&
Number.isFinite(point.x) &&
Number.isFinite(point.y),
);
}
private formatValue(value: number): string {
if (!Number.isFinite(value)) {
return '-';
}
if (this.valueKind === 'pace') {
const rounded = Math.round(value);
const minutes = Math.floor(rounded / 60);
const seconds = rounded % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}/km`;
}
return `${Math.round(value)}${this.unit}`;
}
private destroyChart(): void {
this.chart?.destroy();
this.chart = null;
}
}

View File

@@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { RunningActivityDetailComponent } from './running-activity-detail.component';
import { RunningDashboardComponent } from './running-dashboard.component';
import { RunningService } from './running.service';
@NgModule({
imports: [RunningDashboardComponent, RunningActivityDetailComponent],
exports: [RunningDashboardComponent, RunningActivityDetailComponent],
providers: [RunningService],
})
export class RunningModule {}

View File

@@ -0,0 +1,47 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import {
RunningActivity,
RunningActivityDetail,
RunningKpiDashboard,
RunningSummary,
} from './running.types';
@Injectable({ providedIn: 'root' })
export class RunningService {
private readonly http = inject(HttpClient);
getSummary(apiBaseUrl: string, weeks = 12): Observable<RunningSummary> {
const params = new HttpParams().set('weeks', weeks);
return this.http.get<RunningSummary>(
`${apiBaseUrl}/analytics/running/summary`,
{ params },
);
}
getActivities(apiBaseUrl: string, weeks = 12): Observable<RunningActivity[]> {
const params = new HttpParams().set('weeks', weeks);
return this.http.get<RunningActivity[]>(
`${apiBaseUrl}/analytics/running/activities`,
{ params },
);
}
getKpis(apiBaseUrl: string, weeks = 12): Observable<RunningKpiDashboard> {
const params = new HttpParams().set('weeks', weeks);
return this.http.get<RunningKpiDashboard>(
`${apiBaseUrl}/analytics/running/kpis`,
{ params },
);
}
getActivityDetail(
apiBaseUrl: string,
id: string,
): Observable<RunningActivityDetail> {
return this.http.get<RunningActivityDetail>(
`${apiBaseUrl}/analytics/running/activities/${id}`,
);
}
}

View File

@@ -0,0 +1,137 @@
export interface AnalyticsTotals {
activityCount: number;
distanceMeters: number;
movingTimeSeconds: number;
elevationGainMeters: number;
calories: number;
}
export interface AnalyticsAverages {
speedMetersPerSecond: number | null;
paceSecondsPerKm: number | null;
heartRate: number | null;
watts: number | null;
cadence: number | null;
}
export interface RunningActivity {
id: string;
stravaActivityId: string;
name: string;
sportType: string | null;
startDate: string | null;
distanceMeters: number | null;
movingTimeSeconds: number | null;
elevationGainMeters: number | null;
averageSpeedMetersPerSecond: number | null;
averageHeartrate: number | null;
averageWatts: number | null;
}
export interface RunningWeeklyBucket extends AnalyticsTotals {
weekStart: string;
weekEnd: string;
}
export interface RunningSummary {
weeks: number;
rangeStart: string;
rangeEnd: string;
totals: AnalyticsTotals;
averages: AnalyticsAverages;
fourWeekAverageDistanceMeters: number;
longestRun: RunningActivity | null;
elevationGainPerKm: number | null;
longRunShare: number | null;
weekly: RunningWeeklyBucket[];
recentRuns: RunningActivity[];
}
export interface RunningSplit {
kilometer: number;
distanceMeters: number;
movingTimeSeconds: number;
paceSecondsPerKm: number | null;
averageHeartRate: number | null;
averageCadence: number | null;
elevationGainMeters: number;
}
export interface RunningChartPoint {
distanceMeters: number;
timeSeconds: number | null;
paceSecondsPerKm: number | null;
heartRate: number | null;
altitude: number | null;
cadence: number | null;
}
export interface RunningActivityDetail {
activity: RunningActivity;
splits: RunningSplit[];
series: RunningChartPoint[];
}
export type RunningIntensityZone = 'easy' | 'moderate' | 'hard';
export interface RunningLoadBucket extends AnalyticsTotals {
weekStart: string;
weekEnd: string;
load: number;
}
export interface RunningIntensityDistributionBucket extends AnalyticsTotals {
zone: RunningIntensityZone;
label: string;
load: number;
share: number;
}
export interface RunningProgressionMetric {
key: 'distance' | 'time' | 'load' | 'pace' | 'runs';
label: string;
unit: 'meters' | 'seconds' | 'load' | 'pace' | 'count';
current: number | null;
previous: number | null;
changePercent: number | null;
}
export interface RunningPersonalRecordEstimate {
distanceMeters: number;
timeSeconds: number | null;
paceSecondsPerKm: number | null;
activityId: string | null;
activityName: string | null;
startDate: string | null;
estimated: boolean;
}
export interface RunningKpiDashboard {
weeks: number;
rangeStart: string;
rangeEnd: string;
load: {
acute: number;
chronic: number;
acuteChronicRatio: number | null;
weekly: RunningLoadBucket[];
};
monotony: {
value: number | null;
dailyAverage: number;
dailyStandardDeviation: number;
};
strain: {
value: number | null;
};
recovery: {
status: 'green' | 'yellow' | 'red';
score: number;
daysSinceLastRun: number | null;
daysSinceLastHardRun: number | null;
message: string;
};
intensityDistribution: RunningIntensityDistributionBucket[];
progression: RunningProgressionMetric[];
personalRecords: RunningPersonalRecordEstimate[];
}

View File

@@ -0,0 +1,116 @@
<section class="panel" aria-labelledby="page-title">
<div class="heading">
<p class="eyebrow">Strava Datenimport</p>
<h1 id="page-title">Verbindung zu Strava</h1>
<p class="intro">
Verbinde deinen Strava Account einmalig, damit die API Aktivitaeten und
Streams serverseitig abrufen kann.
</p>
</div>
@if (justConnected()) {
<div class="notice success" role="status">
Strava wurde verbunden. Der Token wurde im Backend gespeichert.
</div>
}
@if (connectionCanceled()) {
<div class="notice error" role="alert">
Strava wurde nicht verbunden. Starte den Verbindungsprozess erneut.
</div>
}
@if (error()) {
<div class="notice error" role="alert">{{ error() }}</div>
}
@if (syncError()) {
<div class="notice error" role="alert">{{ syncError() }}</div>
}
<div class="status-row">
<div>
<span class="label">Status</span>
@if (loading()) {
<strong>Pruefe Verbindung...</strong>
} @else if (status()?.connected) {
<strong class="connected">Verbunden</strong>
} @else {
<strong class="disconnected">Nicht verbunden</strong>
}
</div>
<button type="button" class="icon-button" (click)="loadStatus()" [disabled]="loading()" title="Status aktualisieren">
<span aria-hidden="true">R</span>
<span class="sr-only">Status aktualisieren</span>
</button>
</div>
@if (status()?.connected && status()?.athlete; as athlete) {
<div class="athlete">
@if (athlete.profile) {
<img [src]="athlete.profile" alt="" />
} @else {
<div class="avatar" aria-hidden="true">{{ athleteName().slice(0, 1) }}</div>
}
<div>
<span class="label">Account</span>
<strong>{{ athleteName() }}</strong>
<span class="meta">Strava ID {{ athlete.stravaAthleteId }}</span>
</div>
</div>
}
<div class="sync-panel">
<div class="sync-header">
<div>
<span class="label">Activities Sync</span>
<strong>{{ syncStatusLabel() }}</strong>
</div>
<button type="button" class="secondary" (click)="startSync()" [disabled]="!canStartSync()">
@if (isRateLimited()) {
Retry geplant
} @else if (syncLoading() || isSyncActive()) {
Sync laeuft
} @else {
Activities synchronisieren
}
</button>
</div>
@if (syncJob(); as job) {
<div class="sync-stats">
<div>
<span class="label">Activities</span>
<strong>{{ job.activityCount }}</strong>
</div>
<div>
<span class="label">Details</span>
<strong>{{ job.detailCount }}</strong>
</div>
<div>
<span class="label">Stream Punkte</span>
<strong>{{ job.streamPointCount }}</strong>
</div>
</div>
@if (job.errorMessage) {
<p class="job-error">{{ job.errorMessage }}</p>
}
@if (retryLabel(); as retry) {
<p class="retry-info">{{ retry }}</p>
}
} @else {
<p class="sync-empty">Noch kein Sync gestartet.</p>
}
</div>
<div class="actions">
<button type="button" class="primary" (click)="connectStrava()">
Mit Strava verbinden
</button>
</div>
</section>

View File

@@ -0,0 +1,224 @@
.panel {
background: #ffffff;
border: 1px solid #d9dee7;
border-radius: 8px;
box-shadow: 0 16px 40px rgba(24, 33, 47, 0.08);
max-width: 760px;
padding: 32px;
}
.heading {
margin-bottom: 28px;
}
.eyebrow,
.label,
.meta {
color: #687386;
display: block;
font-size: 0.82rem;
}
.eyebrow {
font-weight: 700;
letter-spacing: 0.08em;
margin: 0 0 10px;
text-transform: uppercase;
}
h1 {
font-size: 2rem;
line-height: 1.15;
margin: 0;
}
.intro {
color: #4e5a6b;
line-height: 1.6;
margin: 14px 0 0;
}
.notice {
border-radius: 6px;
margin-bottom: 18px;
padding: 12px 14px;
}
.success {
background: #ecf8ef;
color: #17612a;
}
.error {
background: #fff0ed;
color: #9b2915;
}
.status-row,
.athlete {
align-items: center;
border: 1px solid #dfe4ec;
border-radius: 8px;
display: flex;
justify-content: space-between;
margin-bottom: 16px;
min-height: 72px;
padding: 16px;
}
.connected {
color: #17612a;
}
.disconnected,
.job-error {
color: #9b2915;
}
.icon-button,
.primary,
.secondary {
cursor: pointer;
}
.icon-button {
align-items: center;
background: #ffffff;
border: 1px solid #c8d0dc;
border-radius: 6px;
color: #18212f;
display: inline-flex;
height: 40px;
justify-content: center;
width: 40px;
}
.athlete {
gap: 14px;
justify-content: flex-start;
}
.athlete img,
.avatar {
border-radius: 50%;
height: 48px;
width: 48px;
}
.avatar {
align-items: center;
background: #fc4c02;
color: #ffffff;
display: flex;
font-weight: 800;
justify-content: center;
text-transform: uppercase;
}
.sync-panel {
border: 1px solid #dfe4ec;
border-radius: 8px;
margin-top: 16px;
padding: 16px;
}
.sync-header {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
}
.sync-stats {
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 16px;
}
.sync-stats > div {
background: #f6f8fb;
border-radius: 6px;
padding: 12px;
}
.sync-empty,
.job-error,
.retry-info {
margin: 14px 0 0;
}
.sync-empty {
color: #687386;
}
.retry-info {
color: #4e5a6b;
font-weight: 700;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 24px;
}
.primary,
.secondary {
border-radius: 6px;
color: #ffffff;
font-weight: 800;
min-height: 40px;
padding: 0 14px;
}
.primary {
background: #fc4c02;
border: 1px solid #fc4c02;
}
.secondary {
background: #18212f;
border: 1px solid #18212f;
}
.secondary:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.sr-only {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
@media (max-width: 640px) {
.panel {
padding: 22px;
}
.actions {
justify-content: stretch;
}
.primary,
.secondary {
width: 100%;
}
.sync-header {
align-items: stretch;
flex-direction: column;
}
.sync-stats {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,226 @@
import { HttpClient } from '@angular/common/http';
import { Component, OnDestroy, computed, inject, signal } from '@angular/core';
import { resolveApiBaseUrl } from '../shared/api-base-url';
interface StravaAuthStatus {
connected: boolean;
athlete: {
id: string;
stravaAthleteId: string;
username: string | null;
firstName: string | null;
lastName: string | null;
profile: string | null;
updatedAt: string;
} | null;
}
interface StravaSyncJob {
id: string;
status: 'queued' | 'running' | 'completed' | 'failed' | 'rate_limited';
activityCount: number;
detailCount: number;
streamPointCount: number;
errorMessage: string | null;
retryAfter: string | null;
startedAt: string | null;
finishedAt: string | null;
createdAt: string;
updatedAt: string;
}
@Component({
selector: 'app-settings',
standalone: true,
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss',
})
export class SettingsComponent {
private readonly http = inject(HttpClient);
protected readonly status = signal<StravaAuthStatus | null>(null);
protected readonly loading = signal(true);
protected readonly error = signal<string | null>(null);
protected readonly syncError = signal<string | null>(null);
protected readonly syncJob = signal<StravaSyncJob | null>(null);
protected readonly syncLoading = signal(false);
protected readonly now = signal(Date.now());
protected readonly justConnected = signal(false);
protected readonly connectionCanceled = signal(false);
protected readonly apiBaseUrl = resolveApiBaseUrl();
private syncPollTimer: number | null = null;
private clockTimer: number | null = null;
protected readonly athleteName = computed(() => {
const athlete = this.status()?.athlete;
const fullName = [athlete?.firstName, athlete?.lastName]
.filter(Boolean)
.join(' ')
.trim();
return fullName || athlete?.username || 'Strava Account';
});
protected readonly canStartSync = computed(
() =>
Boolean(this.status()?.connected) &&
!this.loading() &&
!this.syncLoading() &&
!this.isSyncActive(),
);
protected readonly isSyncActive = computed(() => {
const status = this.syncJob()?.status;
return status === 'queued' || status === 'running' || status === 'rate_limited';
});
protected readonly isRateLimited = computed(
() => this.syncJob()?.status === 'rate_limited',
);
protected readonly syncStatusLabel = computed(() => {
switch (this.syncJob()?.status) {
case 'queued':
return 'Wartet';
case 'running':
return 'Laeuft';
case 'completed':
return 'Abgeschlossen';
case 'failed':
return 'Fehlgeschlagen';
case 'rate_limited':
return 'Rate Limit erreicht';
default:
return 'Noch nicht gestartet';
}
});
protected readonly retryLabel = computed(() => {
const retryAfter = this.syncJob()?.retryAfter;
if (!retryAfter) {
return null;
}
const timestamp = new Date(retryAfter).getTime();
if (!Number.isFinite(timestamp)) {
return null;
}
const remainingMs = Math.max(timestamp - this.now(), 0);
const minutes = Math.floor(remainingMs / 60000);
const seconds = Math.ceil((remainingMs % 60000) / 1000);
if (remainingMs <= 0) {
return 'Retry wird gestartet...';
}
return `Naechster Versuch in ${minutes}:${String(seconds).padStart(2, '0')} min`;
});
constructor() {
const authResult = new URLSearchParams(window.location.search).get('strava');
this.justConnected.set(authResult === 'connected');
this.connectionCanceled.set(authResult === 'error');
this.clockTimer = window.setInterval(() => this.now.set(Date.now()), 1000);
this.loadStatus();
this.loadLatestSyncJob();
}
ngOnDestroy(): void {
if (this.syncPollTimer !== null) {
window.clearTimeout(this.syncPollTimer);
}
if (this.clockTimer !== null) {
window.clearInterval(this.clockTimer);
}
}
protected connectStrava(): void {
window.location.href = `${this.apiBaseUrl}/auth/strava/connect`;
}
protected loadStatus(): void {
this.loading.set(true);
this.error.set(null);
this.http
.get<StravaAuthStatus>(`${this.apiBaseUrl}/auth/strava/status`)
.subscribe({
next: (status) => {
this.status.set(status);
this.loading.set(false);
},
error: () => {
this.error.set(
'API nicht erreichbar oder Strava-Status konnte nicht geladen werden.',
);
this.loading.set(false);
},
});
}
protected startSync(): void {
if (!this.canStartSync()) {
return;
}
this.syncLoading.set(true);
this.syncError.set(null);
this.http
.post<StravaSyncJob>(`${this.apiBaseUrl}/strava/sync`, {})
.subscribe({
next: (job) => {
this.syncJob.set(job);
this.syncLoading.set(false);
this.pollSyncJob(job.id);
},
error: () => {
this.syncError.set('Sync konnte nicht gestartet werden.');
this.syncLoading.set(false);
},
});
}
private loadLatestSyncJob(): void {
this.http
.get<StravaSyncJob | null>(`${this.apiBaseUrl}/strava/sync/jobs/latest`)
.subscribe({
next: (job) => {
this.syncJob.set(job);
if (job && this.shouldPoll(job)) {
this.pollSyncJob(job.id);
}
},
error: () => {
this.syncError.set('Sync-Status konnte nicht geladen werden.');
},
});
}
private pollSyncJob(jobId: string): void {
if (this.syncPollTimer !== null) {
window.clearTimeout(this.syncPollTimer);
}
this.syncPollTimer = window.setTimeout(() => {
this.http
.get<StravaSyncJob>(`${this.apiBaseUrl}/strava/sync/jobs/${jobId}`)
.subscribe({
next: (job) => {
this.syncJob.set(job);
if (this.shouldPoll(job)) {
this.pollSyncJob(job.id);
}
},
error: () => {
this.syncError.set('Sync-Status konnte nicht geladen werden.');
},
});
}, 2000);
}
private shouldPoll(job: StravaSyncJob): boolean {
return (
job.status === 'queued' ||
job.status === 'running' ||
job.status === 'rate_limited'
);
}
}

View File

@@ -0,0 +1,8 @@
import { NgModule } from '@angular/core';
import { SettingsComponent } from './settings.component';
@NgModule({
imports: [SettingsComponent],
exports: [SettingsComponent],
})
export class SettingsModule {}

View File

@@ -0,0 +1,9 @@
export const resolveApiBaseUrl = (): string => {
const { protocol, hostname, port, origin } = window.location;
if (port === '4200') {
return `${protocol}//${hostname}:3000`;
}
return origin;
};