This commit is contained in:
Bastian Wagner
2026-06-16 12:15:29 +02:00
commit 38141c0358
80 changed files with 23444 additions and 0 deletions

5
client/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.angular
.env
.git

17
client/.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

44
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

12
client/.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

4
client/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
client/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

9
client/.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

42
client/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

14
client/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
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
EXPOSE 80

59
client/README.md Normal file
View File

@@ -0,0 +1,59 @@
# Client
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.6.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

78
client/angular.json Normal file
View File

@@ -0,0 +1,78 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "8kB",
"maximumError": "12kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "client:build:production"
},
"development": {
"buildTarget": "client:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

11
client/nginx.conf Normal file
View File

@@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

8709
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
client/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "client",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.12.1",
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.6",
"@angular/cli": "^21.2.6",
"@angular/compiler-cli": "^21.2.0",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,13 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(),
provideRouter(routes),
],
};

137
client/src/app/app.html Normal file
View File

@@ -0,0 +1,137 @@
<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>
@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>
<app-dashboard
[apiBaseUrl]="apiBaseUrl"
[connected]="status()?.connected ?? false"
[refreshKey]="dashboardRefreshKey()"
/>
</main>

View File

@@ -0,0 +1,3 @@
import { Routes } from '@angular/router';
export const routes: Routes = [];

279
client/src/app/app.scss Normal file
View File

@@ -0,0 +1,279 @@
:host {
color: #18212f;
display: block;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
min-height: 100dvh;
}
.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 {
background: #ffffff;
border: 1px solid #d9dee7;
border-radius: 8px;
box-shadow: 0 16px 40px rgba(24, 33, 47, 0.08);
padding: 32px;
width: 100%;
}
.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 {
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;
}
.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 {
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;
}
@media (max-width: 640px) {
.shell {
align-items: stretch;
grid-template-columns: 1fr;
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

@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, client');
});
});

176
client/src/app/app.ts Normal file
View File

@@ -0,0 +1,176 @@
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;
}
@Component({
selector: 'app-root',
imports: [DashboardModule],
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;
}
}

View File

@@ -0,0 +1,155 @@
<section class="dashboard" aria-labelledby="dashboard-title">
<div class="dashboard-heading">
<div>
<p class="eyebrow">Auswertung</p>
<h2 id="dashboard-title">Letzte 12 Wochen</h2>
</div>
<div class="dashboard-tools">
<label>
<span class="sr-only">Sportart filtern</span>
<select
[value]="selectedSportType() ?? 'all'"
(change)="selectSportType($any($event.target).value)"
[disabled]="dashboardLoading()"
>
<option value="all">Alle Sportarten</option>
@for (sportType of dashboard()?.availableSports ?? []; track sportType) {
<option [value]="sportType">{{ sportType }}</option>
}
</select>
</label>
<button
type="button"
class="icon-button"
(click)="loadDashboard()"
[disabled]="dashboardLoading() || !connected"
title="Dashboard aktualisieren"
>
<span aria-hidden="true">R</span>
<span class="sr-only">Dashboard aktualisieren</span>
</button>
</div>
</div>
@if (dashboardError()) {
<div class="notice error" role="alert">
{{ dashboardError() }}
</div>
}
@if (dashboardLoading()) {
<div class="empty-state">Dashboard wird geladen...</div>
} @else if (dashboard(); as data) {
@if (data.totals.activityCount === 0) {
<div class="empty-state">
Keine Aktivitaeten fuer diesen Filter im Zeitraum.
</div>
} @else {
<div class="kpis">
<div>
<span class="label">Aktivitaeten</span>
<strong>{{ data.totals.activityCount }}</strong>
</div>
<div>
<span class="label">Distanz</span>
<strong>{{ distanceKm(data.totals.distanceMeters) }}</strong>
</div>
<div>
<span class="label">Zeit</span>
<strong>{{ duration(data.totals.movingTimeSeconds) }}</strong>
</div>
<div>
<span class="label">Hoehenmeter</span>
<strong>{{ elevation(data.totals.elevationGainMeters) }}</strong>
</div>
</div>
<div class="insights">
<div>
<span class="label">Pace</span>
<strong>{{ pace(data.averages.paceSecondsPerKm) }}</strong>
</div>
<div>
<span class="label">Speed</span>
<strong>{{ speed(data.averages.speedMetersPerSecond) }}</strong>
</div>
<div>
<span class="label">Herzfrequenz</span>
<strong>{{ number(data.averages.heartRate, ' bpm') }}</strong>
</div>
<div>
<span class="label">Leistung</span>
<strong>{{ number(data.averages.watts, ' W') }}</strong>
</div>
</div>
<div class="chart-panel">
<div class="section-title">
<h3>Wochenverlauf</h3>
<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="dashboard-grid">
<div class="chart-panel">
<div class="section-title">
<h3>Sportarten</h3>
</div>
<div class="sport-list">
@for (sport of data.sports; track sport.sportType) {
<div class="sport-row">
<div>
<strong>{{ sport.sportType }}</strong>
<span>{{ sport.activityCount }} Activities</span>
</div>
<div class="sport-meter">
<span [style.width.%]="percent(sport.distanceMeters, maxSportDistance())"></span>
</div>
<span>{{ distanceKm(sport.distanceMeters) }}</span>
</div>
}
</div>
</div>
<div class="chart-panel">
<div class="section-title">
<h3>Letzte Activities</h3>
</div>
<div class="recent-list">
@for (activity of data.recentActivities; track activity.id) {
<div class="recent-row">
<div>
<strong>{{ activity.name }}</strong>
<span>{{ activity.sportType ?? 'Unbekannt' }} | {{ shortDate(activity.startDate) }}</span>
</div>
<span>{{ distanceKm(activity.distanceMeters) }}</span>
</div>
}
</div>
</div>
</div>
}
} @else {
<div class="empty-state">
Verbinde Strava und starte den Sync, um Auswertungen zu sehen.
</div>
}
</section>

View File

@@ -0,0 +1,293 @@
:host {
display: block;
min-width: 0;
}
.dashboard {
background: #ffffff;
border: 1px solid #d9dee7;
border-radius: 8px;
box-shadow: 0 16px 40px rgba(24, 33, 47, 0.08);
min-width: 0;
padding: 32px;
width: 100%;
}
.dashboard-heading,
.section-title {
align-items: center;
display: flex;
justify-content: space-between;
}
.dashboard-heading {
margin-bottom: 22px;
}
.dashboard-tools {
align-items: center;
display: flex;
gap: 10px;
}
.dashboard-tools select {
background: #ffffff;
border: 1px solid #c8d0dc;
border-radius: 6px;
color: #18212f;
font: inherit;
min-height: 40px;
padding: 0 34px 0 12px;
}
.dashboard-tools select:disabled {
cursor: progress;
opacity: 0.55;
}
.eyebrow,
.label {
color: #687386;
display: block;
font-size: 0.82rem;
}
.eyebrow {
font-weight: 700;
letter-spacing: 0.08em;
margin: 0 0 10px;
text-transform: uppercase;
}
h2,
h3 {
margin: 0;
}
h2 {
font-size: 1.55rem;
}
h3 {
font-size: 1rem;
}
.icon-button {
align-items: center;
background: #ffffff;
border: 1px solid #c8d0dc;
border-radius: 6px;
color: #18212f;
cursor: pointer;
display: inline-flex;
font-size: 1.15rem;
height: 40px;
justify-content: center;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease;
width: 40px;
}
.icon-button:hover {
background: #f1f4f8;
}
.icon-button:disabled {
cursor: progress;
opacity: 0.55;
}
.notice {
border-radius: 6px;
margin-bottom: 18px;
padding: 12px 14px;
}
.error {
background: #fff0ed;
color: #9b2915;
}
.section-title {
gap: 16px;
margin-bottom: 16px;
}
.section-title span {
color: #687386;
font-size: 0.82rem;
}
.kpis,
.insights {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.kpis > div,
.insights > div,
.chart-panel,
.empty-state {
border: 1px solid #dfe4ec;
border-radius: 8px;
}
.kpis > div,
.insights > div {
padding: 16px;
}
.kpis strong {
display: block;
font-size: 1.45rem;
margin-top: 4px;
}
.insights {
margin-top: 12px;
}
.chart-panel {
margin-top: 18px;
padding: 18px;
}
.weekly-bars {
align-items: end;
display: grid;
gap: 8px;
grid-template-columns: repeat(12, minmax(0, 1fr));
min-height: 190px;
}
.week-bar {
align-items: center;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.bar-track {
align-items: end;
background: #eef2f7;
border-radius: 5px;
display: flex;
height: 144px;
overflow: hidden;
width: 100%;
}
.bar-fill {
background: #fc4c02;
border-radius: 5px 5px 0 0;
width: 100%;
}
.week-bar span {
color: #687386;
font-size: 0.72rem;
}
.dashboard-grid {
display: grid;
gap: 18px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
}
.sport-list,
.recent-list {
display: grid;
gap: 12px;
}
.sport-row,
.recent-row {
align-items: center;
display: grid;
gap: 12px;
}
.sport-row {
grid-template-columns: minmax(120px, 1fr) minmax(120px, 1fr) auto;
}
.recent-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.sport-row span,
.recent-row span {
color: #687386;
font-size: 0.82rem;
}
.sport-meter {
background: #eef2f7;
border-radius: 999px;
height: 8px;
overflow: hidden;
}
.sport-meter span {
background: #18212f;
display: block;
height: 100%;
}
.empty-state {
color: #687386;
padding: 22px;
}
.sr-only {
clip: rect(0, 0, 0, 0);
border: 0;
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
@media (max-width: 640px) {
.dashboard {
padding: 22px;
}
.dashboard-heading,
.section-title {
align-items: flex-start;
flex-direction: column;
}
.dashboard-tools,
.dashboard-tools label,
.dashboard-tools select {
width: 100%;
}
.kpis,
.insights,
.dashboard-grid {
grid-template-columns: 1fr;
}
.weekly-bars {
gap: 5px;
overflow-x: auto;
}
.week-bar {
min-width: 34px;
}
.sport-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,140 @@
import {
Component,
Input,
OnChanges,
SimpleChanges,
computed,
inject,
signal,
} from '@angular/core';
import { AnalyticsDashboard } from './dashboard.types';
import { DashboardService } from './dashboard.service';
@Component({
selector: 'app-dashboard',
standalone: true,
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.scss',
})
export class DashboardComponent implements OnChanges {
@Input({ required: true }) apiBaseUrl = '';
@Input() connected = false;
@Input() refreshKey = 0;
private readonly dashboardService = inject(DashboardService);
protected readonly dashboard = signal<AnalyticsDashboard | null>(null);
protected readonly dashboardLoading = signal(false);
protected readonly dashboardError = signal<string | null>(null);
protected readonly selectedSportType = signal<string | null>(null);
protected readonly maxWeeklyDistance = computed(() =>
Math.max(
1,
...(this.dashboard()?.weekly.map((week) => week.distanceMeters) ?? [0]),
),
);
protected readonly maxSportDistance = computed(() =>
Math.max(
1,
...(this.dashboard()?.sports.map((sport) => sport.distanceMeters) ?? [0]),
),
);
ngOnChanges(changes: SimpleChanges): void {
if (
this.connected &&
(changes['connected'] || changes['refreshKey'] || changes['apiBaseUrl'])
) {
this.loadDashboard();
}
}
protected loadDashboard(): void {
if (!this.connected) {
return;
}
this.dashboardLoading.set(true);
this.dashboardError.set(null);
this.dashboardService
.getDashboard(this.apiBaseUrl, 12, this.selectedSportType())
.subscribe({
next: (dashboard) => {
this.dashboard.set(dashboard);
this.dashboardLoading.set(false);
},
error: () => {
this.dashboardError.set('Dashboard konnte nicht geladen werden.');
this.dashboardLoading.set(false);
},
});
}
protected selectSportType(value: string): void {
this.selectedSportType.set(value === 'all' ? null : value);
this.loadDashboard();
}
protected distanceKm(meters: number | null | undefined): string {
return `${((meters ?? 0) / 1000).toLocaleString('de-DE', {
maximumFractionDigits: 1,
})} km`;
}
protected 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`;
}
protected elevation(meters: number | null | undefined): string {
return `${Math.round(meters ?? 0).toLocaleString('de-DE')} m`;
}
protected pace(secondsPerKm: number | null): string {
if (!secondsPerKm) {
return '-';
}
const minutes = Math.floor(secondsPerKm / 60);
const seconds = Math.round(secondsPerKm % 60)
.toString()
.padStart(2, '0');
return `${minutes}:${seconds} /km`;
}
protected speed(metersPerSecond: number | null): string {
if (!metersPerSecond) {
return '-';
}
return `${(metersPerSecond * 3.6).toLocaleString('de-DE', {
maximumFractionDigits: 1,
})} km/h`;
}
protected number(value: number | null | undefined, suffix = ''): string {
if (value === null || value === undefined) {
return '-';
}
return `${Math.round(value).toLocaleString('de-DE')}${suffix}`;
}
protected percent(value: number, max: number): number {
return Math.max(3, Math.round((value / max) * 100));
}
protected shortDate(value: string | null): string {
if (!value) {
return '-';
}
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
}).format(new Date(value));
}
}

View File

@@ -0,0 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { DashboardComponent } from './dashboard.component';
import { DashboardService } from './dashboard.service';
@NgModule({
imports: [CommonModule, DashboardComponent],
exports: [DashboardComponent],
providers: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,26 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { AnalyticsDashboard } from './dashboard.types';
@Injectable()
export class DashboardService {
private readonly http = inject(HttpClient);
getDashboard(
apiBaseUrl: string,
weeks: number,
sportType: string | null,
): Observable<AnalyticsDashboard> {
let params = new HttpParams().set('weeks', weeks);
if (sportType) {
params = params.set('sportType', sportType);
}
return this.http.get<AnalyticsDashboard>(
`${apiBaseUrl}/analytics/dashboard`,
{ params },
);
}
}

View File

@@ -0,0 +1,51 @@
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 AnalyticsWeeklyBucket extends AnalyticsTotals {
weekStart: string;
weekEnd: string;
}
export interface AnalyticsSportSummary extends AnalyticsTotals {
sportType: string;
}
export interface AnalyticsRecentActivity {
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 AnalyticsDashboard {
weeks: number;
selectedSportType: string | null;
availableSports: string[];
rangeStart: string;
rangeEnd: string;
totals: AnalyticsTotals;
averages: AnalyticsAverages;
weekly: AnalyticsWeeklyBucket[];
sports: AnalyticsSportSummary[];
recentActivities: AnalyticsRecentActivity[];
}

13
client/src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Client</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

6
client/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

1
client/src/styles.scss Normal file
View File

@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */

15
client/tsconfig.app.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

33
client/tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

15
client/tsconfig.spec.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}