git init
This commit is contained in:
5
client/.dockerignore
Normal file
5
client/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.angular
|
||||
.env
|
||||
.git
|
||||
17
client/.editorconfig
Normal file
17
client/.editorconfig
Normal 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
44
client/.gitignore
vendored
Normal 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
12
client/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
client/.vscode/extensions.json
vendored
Normal file
4
client/.vscode/extensions.json
vendored
Normal 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
20
client/.vscode/launch.json
vendored
Normal 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
9
client/.vscode/mcp.json
vendored
Normal 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
42
client/.vscode/tasks.json
vendored
Normal 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
14
client/Dockerfile
Normal 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
59
client/README.md
Normal 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
78
client/angular.json
Normal 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
11
client/nginx.conf
Normal 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
8709
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
client/package.json
Normal file
32
client/package.json
Normal 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
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
13
client/src/app/app.config.ts
Normal file
13
client/src/app/app.config.ts
Normal 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
137
client/src/app/app.html
Normal 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>
|
||||
3
client/src/app/app.routes.ts
Normal file
3
client/src/app/app.routes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [];
|
||||
279
client/src/app/app.scss
Normal file
279
client/src/app/app.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
23
client/src/app/app.spec.ts
Normal file
23
client/src/app/app.spec.ts
Normal 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
176
client/src/app/app.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
155
client/src/app/dashboard/dashboard.component.html
Normal file
155
client/src/app/dashboard/dashboard.component.html
Normal 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>
|
||||
293
client/src/app/dashboard/dashboard.component.scss
Normal file
293
client/src/app/dashboard/dashboard.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
140
client/src/app/dashboard/dashboard.component.ts
Normal file
140
client/src/app/dashboard/dashboard.component.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
11
client/src/app/dashboard/dashboard.module.ts
Normal file
11
client/src/app/dashboard/dashboard.module.ts
Normal 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 {}
|
||||
26
client/src/app/dashboard/dashboard.service.ts
Normal file
26
client/src/app/dashboard/dashboard.service.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
51
client/src/app/dashboard/dashboard.types.ts
Normal file
51
client/src/app/dashboard/dashboard.types.ts
Normal 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
13
client/src/index.html
Normal 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
6
client/src/main.ts
Normal 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
1
client/src/styles.scss
Normal 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
15
client/tsconfig.app.json
Normal 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
33
client/tsconfig.json
Normal 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
15
client/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user