Docker
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/coverage
|
||||
**/.angular
|
||||
**/.cache
|
||||
**/npm-debug.log*
|
||||
|
||||
api/.dockerignore
|
||||
api/Dockerfile
|
||||
client/.dockerignore
|
||||
client/Dockerfile
|
||||
@@ -1,4 +1,6 @@
|
||||
PORT=3000
|
||||
API_PORT=3000
|
||||
CLIENT_PORT=8080
|
||||
CORS_ORIGIN=http://localhost:8080
|
||||
CLIENT_URL=http://localhost:8080
|
||||
|
||||
|
||||
137
Dockerfile
Normal file
137
Dockerfile
Normal file
@@ -0,0 +1,137 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:24-alpine AS api-deps
|
||||
WORKDIR /build/api
|
||||
|
||||
COPY api/package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||
|
||||
FROM node:24-alpine AS api-build
|
||||
WORKDIR /build/api
|
||||
|
||||
COPY --from=api-deps /build/api/node_modules ./node_modules
|
||||
COPY api/package*.json ./
|
||||
COPY api/nest-cli.json api/tsconfig*.json ./
|
||||
COPY api/src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine AS api-prod-deps
|
||||
WORKDIR /build/api
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY api/package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --ignore-scripts
|
||||
|
||||
FROM node:24-alpine AS client-deps
|
||||
WORKDIR /build/client
|
||||
|
||||
COPY client/package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||
|
||||
FROM node:24-alpine AS client-build
|
||||
WORKDIR /build/client
|
||||
|
||||
COPY --from=client-deps /build/client/node_modules ./node_modules
|
||||
COPY client/package*.json ./
|
||||
COPY client/angular.json client/tsconfig*.json ./
|
||||
COPY client/public ./public
|
||||
COPY client/src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine AS production
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
RUN apk add --no-cache nginx supervisor wget \
|
||||
&& mkdir -p /run/nginx /var/log/supervisor /usr/share/nginx/html \
|
||||
&& chown -R node:node /app
|
||||
|
||||
COPY --from=api-prod-deps /build/api/node_modules ./api/node_modules
|
||||
COPY --from=api-build /build/api/dist ./api/dist
|
||||
COPY api/package*.json ./api/
|
||||
COPY --from=client-build /build/client/dist/client/browser /usr/share/nginx/html
|
||||
|
||||
RUN <<'EOF'
|
||||
cat > /etc/nginx/http.d/default.conf <<'NGINX'
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
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://127.0.0.1:3000;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
NGINX
|
||||
|
||||
cat > /etc/supervisord.conf <<'SUPERVISOR'
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
pidfile=/tmp/supervisord.pid
|
||||
|
||||
[program:api]
|
||||
directory=/app/api
|
||||
command=node dist/main.js
|
||||
user=node
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stdout_logfile=/dev/fd/1
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/fd/2
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:nginx]
|
||||
command=nginx -g "daemon off;"
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stdout_logfile=/dev/fd/1
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/fd/2
|
||||
stderr_logfile_maxbytes=0
|
||||
SUPERVISOR
|
||||
EOF
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["supervisord", "-c", "/etc/supervisord.conf"]
|
||||
@@ -1,5 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.angular
|
||||
.cache
|
||||
.env
|
||||
.env.*
|
||||
npm-debug.log*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:24-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
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 ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
COPY nest-cli.json tsconfig*.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine AS production
|
||||
FROM node:24-alpine AS prod-deps
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --ignore-scripts
|
||||
|
||||
FROM node:24-alpine AS production
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY package*.json ./
|
||||
|
||||
USER node
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "const http=require('http');const req=http.get('http://127.0.0.1:3000/',res=>process.exit(res.statusCode===200?0:1));req.on('error',()=>process.exit(1));req.setTimeout(4000,()=>{req.destroy();process.exit(1);});"
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { Param } from '@nestjs/common';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { AnalyticsDashboard } from './analytics.types';
|
||||
import {
|
||||
AnalyticsDashboard,
|
||||
AnalyticsRecentActivity,
|
||||
RunningActivityDetail,
|
||||
RunningKpiDashboard,
|
||||
RunningSummary,
|
||||
} from './analytics.types';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
@@ -13,4 +20,26 @@ export class AnalyticsController {
|
||||
): Promise<AnalyticsDashboard> {
|
||||
return this.analyticsService.getDashboard(Number(weeks ?? 12), sportType);
|
||||
}
|
||||
|
||||
@Get('running/summary')
|
||||
runningSummary(@Query('weeks') weeks?: string): Promise<RunningSummary> {
|
||||
return this.analyticsService.getRunningSummary(Number(weeks ?? 12));
|
||||
}
|
||||
|
||||
@Get('running/kpis')
|
||||
runningKpis(@Query('weeks') weeks?: string): Promise<RunningKpiDashboard> {
|
||||
return this.analyticsService.getRunningKpis(Number(weeks ?? 12));
|
||||
}
|
||||
|
||||
@Get('running/activities')
|
||||
runningActivities(
|
||||
@Query('weeks') weeks?: string,
|
||||
): Promise<AnalyticsRecentActivity[]> {
|
||||
return this.analyticsService.getRunningActivities(Number(weeks ?? 12));
|
||||
}
|
||||
|
||||
@Get('running/activities/:id')
|
||||
runningActivity(@Param('id') id: string): Promise<RunningActivityDetail> {
|
||||
return this.analyticsService.getRunningActivityDetail(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { StravaActivityEntity } from '../database/entities';
|
||||
import {
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
} from '../database/entities';
|
||||
import { StravaModule } from '../strava/strava.module';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([StravaActivityEntity])],
|
||||
imports: [
|
||||
StravaModule,
|
||||
TypeOrmModule.forFeature([
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
})
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { StravaActivityEntity } from '../database/entities';
|
||||
import {
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
} from '../database/entities';
|
||||
import { StravaStreamImportService } from '../strava/strava-stream-import.service';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
describe('AnalyticsService', () => {
|
||||
const createService = (activities: StravaActivityEntity[]) => {
|
||||
const repository = {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const createService = (
|
||||
activities: StravaActivityEntity[],
|
||||
streamPoints: StravaActivityStreamPointEntity[] = [],
|
||||
) => {
|
||||
const activityRepository = {
|
||||
find: jest.fn().mockResolvedValue(activities),
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockImplementation(({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(
|
||||
activities.find((activity) => activity.id === where.id) ?? null,
|
||||
),
|
||||
),
|
||||
} as unknown as Repository<StravaActivityEntity>;
|
||||
const streamPointFind = jest.fn().mockResolvedValue(streamPoints);
|
||||
const importStreamsForActivity = jest
|
||||
.fn()
|
||||
.mockResolvedValue(streamPoints.length);
|
||||
const streamPointRepository = {
|
||||
find: streamPointFind,
|
||||
} as unknown as Repository<StravaActivityStreamPointEntity>;
|
||||
const stravaStreamImportService = {
|
||||
importStreamsForActivity,
|
||||
} as unknown as StravaStreamImportService;
|
||||
|
||||
return {
|
||||
service: new AnalyticsService(repository),
|
||||
repository,
|
||||
service: new AnalyticsService(
|
||||
activityRepository,
|
||||
streamPointRepository,
|
||||
stravaStreamImportService,
|
||||
),
|
||||
activityRepository,
|
||||
streamPointRepository,
|
||||
stravaStreamImportService,
|
||||
streamPointFind,
|
||||
importStreamsForActivity,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,6 +65,7 @@ describe('AnalyticsService', () => {
|
||||
calories: input.calories ?? 400,
|
||||
averageSpeed: input.averageSpeed ?? 5.5,
|
||||
averageHeartrate: input.averageHeartrate ?? null,
|
||||
maxHeartrate: input.maxHeartrate ?? null,
|
||||
averageWatts: input.averageWatts ?? null,
|
||||
averageCadence: input.averageCadence ?? null,
|
||||
}) as StravaActivityEntity;
|
||||
@@ -120,4 +157,238 @@ describe('AnalyticsService', () => {
|
||||
expect(dashboard.totals.distanceMeters).toBe(5000);
|
||||
expect(dashboard.sports.map((sport) => sport.sportType)).toEqual(['Run']);
|
||||
});
|
||||
|
||||
it('returns running summary for run-like sport types only', async () => {
|
||||
const { service } = createService([
|
||||
activity({
|
||||
stravaActivityId: '1',
|
||||
sportType: 'Run',
|
||||
distance: 10000,
|
||||
movingTime: 3000,
|
||||
}),
|
||||
activity({
|
||||
stravaActivityId: '2',
|
||||
sportType: 'TrailRun',
|
||||
distance: 5000,
|
||||
movingTime: 1800,
|
||||
}),
|
||||
activity({
|
||||
stravaActivityId: '3',
|
||||
sportType: 'Ride',
|
||||
distance: 50000,
|
||||
movingTime: 7200,
|
||||
}),
|
||||
]);
|
||||
|
||||
const summary = await service.getRunningSummary(12);
|
||||
|
||||
expect(summary.totals.activityCount).toBe(2);
|
||||
expect(summary.totals.distanceMeters).toBe(15000);
|
||||
expect(summary.longestRun?.stravaActivityId).toBe('1');
|
||||
});
|
||||
|
||||
it('returns running kpis with load, recovery, distribution, and progression', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-06-16T12:00:00.000Z'));
|
||||
const { service } = createService([
|
||||
activity({
|
||||
id: 'run-1',
|
||||
stravaActivityId: '1',
|
||||
sportType: 'Run',
|
||||
startDate: new Date('2026-06-15T10:00:00.000Z'),
|
||||
distance: 10000,
|
||||
movingTime: 3000,
|
||||
averageHeartrate: 170,
|
||||
maxHeartrate: 190,
|
||||
}),
|
||||
activity({
|
||||
id: 'run-2',
|
||||
stravaActivityId: '2',
|
||||
sportType: 'Run',
|
||||
startDate: new Date('2026-06-10T10:00:00.000Z'),
|
||||
distance: 8000,
|
||||
movingTime: 3200,
|
||||
averageHeartrate: 130,
|
||||
}),
|
||||
activity({
|
||||
id: 'run-3',
|
||||
stravaActivityId: '3',
|
||||
sportType: 'Run',
|
||||
startDate: new Date('2026-05-20T10:00:00.000Z'),
|
||||
distance: 5000,
|
||||
movingTime: 1500,
|
||||
averageHeartrate: 150,
|
||||
}),
|
||||
]);
|
||||
|
||||
const kpis = await service.getRunningKpis(12);
|
||||
|
||||
expect(kpis.load.acute).toBeGreaterThan(0);
|
||||
expect(kpis.load.chronic).toBeGreaterThan(0);
|
||||
expect(kpis.load.acuteChronicRatio).not.toBeNull();
|
||||
expect(kpis.monotony.value).not.toBeNull();
|
||||
expect(kpis.strain.value).not.toBeNull();
|
||||
expect(kpis.recovery.daysSinceLastHardRun).toBe(1);
|
||||
expect(
|
||||
kpis.intensityDistribution.find((bucket) => bucket.zone === 'hard')
|
||||
?.activityCount,
|
||||
).toBe(1);
|
||||
expect(
|
||||
kpis.intensityDistribution.find((bucket) => bucket.zone === 'easy')
|
||||
?.activityCount,
|
||||
).toBe(1);
|
||||
expect(kpis.progression.map((metric) => metric.key)).toEqual([
|
||||
'distance',
|
||||
'time',
|
||||
'load',
|
||||
'pace',
|
||||
'runs',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to pace for intensity distribution when heart rate is missing', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-06-16T12:00:00.000Z'));
|
||||
const { service } = createService([
|
||||
activity({
|
||||
id: 'fast-run',
|
||||
stravaActivityId: '1',
|
||||
sportType: 'Run',
|
||||
startDate: new Date('2026-06-15T10:00:00.000Z'),
|
||||
distance: 10000,
|
||||
movingTime: 3000,
|
||||
averageHeartrate: null,
|
||||
maxHeartrate: null,
|
||||
}),
|
||||
activity({
|
||||
id: 'easy-run',
|
||||
stravaActivityId: '2',
|
||||
sportType: 'Run',
|
||||
startDate: new Date('2026-06-12T10:00:00.000Z'),
|
||||
distance: 10000,
|
||||
movingTime: 5000,
|
||||
averageHeartrate: null,
|
||||
maxHeartrate: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
const kpis = await service.getRunningKpis(12);
|
||||
|
||||
expect(
|
||||
kpis.intensityDistribution.find((bucket) => bucket.zone === 'hard')
|
||||
?.activityCount,
|
||||
).toBe(1);
|
||||
expect(
|
||||
kpis.intensityDistribution.find((bucket) => bucket.zone === 'easy')
|
||||
?.activityCount,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('estimates personal records from stream points', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-06-16T12:00:00.000Z'));
|
||||
const run = activity({
|
||||
id: 'run-1',
|
||||
stravaActivityId: '1',
|
||||
sportType: 'Run',
|
||||
startDate: new Date('2026-06-15T10:00:00.000Z'),
|
||||
distance: 10000,
|
||||
movingTime: 4000,
|
||||
});
|
||||
const { service } = createService(
|
||||
[run],
|
||||
[
|
||||
streamPoint(0, 0, 0, 10, 'run-1'),
|
||||
streamPoint(1, 1000, 300, 10, 'run-1'),
|
||||
streamPoint(2, 5000, 1800, 10, 'run-1'),
|
||||
streamPoint(3, 10000, 4000, 10, 'run-1'),
|
||||
],
|
||||
);
|
||||
|
||||
const kpis = await service.getRunningKpis(12);
|
||||
|
||||
expect(kpis.personalRecords).toEqual([
|
||||
expect.objectContaining({
|
||||
distanceMeters: 1000,
|
||||
timeSeconds: 300,
|
||||
estimated: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
distanceMeters: 5000,
|
||||
timeSeconds: 1800,
|
||||
estimated: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
distanceMeters: 10000,
|
||||
timeSeconds: 4000,
|
||||
estimated: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds kilometer splits and chart series for a running activity', async () => {
|
||||
const run = activity({
|
||||
id: 'run-1',
|
||||
stravaActivityId: '1',
|
||||
sportType: 'Run',
|
||||
distance: 2000,
|
||||
movingTime: 600,
|
||||
});
|
||||
const points = [
|
||||
streamPoint(0, 0, 0, 10),
|
||||
streamPoint(1, 1000, 300, 15),
|
||||
streamPoint(2, 2000, 600, 25),
|
||||
];
|
||||
const { service } = createService([run], points);
|
||||
|
||||
const detail = await service.getRunningActivityDetail('run-1');
|
||||
|
||||
expect(detail.splits).toEqual([
|
||||
expect.objectContaining({
|
||||
kilometer: 1,
|
||||
movingTimeSeconds: 300,
|
||||
paceSecondsPerKm: 300,
|
||||
elevationGainMeters: 5,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kilometer: 2,
|
||||
movingTimeSeconds: 300,
|
||||
paceSecondsPerKm: 300,
|
||||
elevationGainMeters: 10,
|
||||
}),
|
||||
]);
|
||||
expect(detail.series).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('imports streams lazily when a running detail has no stream points', async () => {
|
||||
const run = activity({
|
||||
id: 'run-1',
|
||||
stravaActivityId: '1',
|
||||
sportType: 'Run',
|
||||
});
|
||||
const { service, streamPointFind, importStreamsForActivity } =
|
||||
createService([run], []);
|
||||
|
||||
await service.getRunningActivityDetail('run-1');
|
||||
|
||||
expect(importStreamsForActivity).toHaveBeenCalledWith(run);
|
||||
expect(streamPointFind).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
const streamPoint = (
|
||||
pointIndex: number,
|
||||
distance: number,
|
||||
timeSeconds: number,
|
||||
altitude: number,
|
||||
activityId = 'activity-1',
|
||||
): StravaActivityStreamPointEntity =>
|
||||
({
|
||||
activityId,
|
||||
pointIndex,
|
||||
distance,
|
||||
timeSeconds,
|
||||
altitude,
|
||||
heartRate: 140 + pointIndex,
|
||||
cadence: 80 + pointIndex,
|
||||
}) as StravaActivityStreamPointEntity;
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { StravaActivityEntity } from '../database/entities';
|
||||
import { In, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import {
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
} from '../database/entities';
|
||||
import { StravaStreamImportService } from '../strava/strava-stream-import.service';
|
||||
import {
|
||||
AnalyticsAverages,
|
||||
AnalyticsDashboard,
|
||||
@@ -9,13 +13,40 @@ import {
|
||||
AnalyticsSportSummary,
|
||||
AnalyticsTotals,
|
||||
AnalyticsWeeklyBucket,
|
||||
RunningActivityDetail,
|
||||
RunningChartPoint,
|
||||
RunningIntensityDistributionBucket,
|
||||
RunningIntensityZone,
|
||||
RunningKpiDashboard,
|
||||
RunningLoadBucket,
|
||||
RunningPersonalRecordEstimate,
|
||||
RunningProgressionMetric,
|
||||
RunningSplit,
|
||||
RunningSummary,
|
||||
} from './analytics.types';
|
||||
|
||||
type ActivityLoad = {
|
||||
activity: StravaActivityEntity;
|
||||
intensity: number;
|
||||
intensityBasis: 'heart_rate' | 'pace' | 'none';
|
||||
zone: RunningIntensityZone;
|
||||
load: number;
|
||||
paceSecondsPerKm: number | null;
|
||||
};
|
||||
|
||||
type PeriodTotals = AnalyticsTotals & {
|
||||
load: number;
|
||||
paceSecondsPerKm: number | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(
|
||||
@InjectRepository(StravaActivityEntity)
|
||||
private readonly activityRepository: Repository<StravaActivityEntity>,
|
||||
@InjectRepository(StravaActivityStreamPointEntity)
|
||||
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
|
||||
private readonly stravaStreamImportService: StravaStreamImportService,
|
||||
) {}
|
||||
|
||||
async getDashboard(
|
||||
@@ -92,12 +123,793 @@ export class AnalyticsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getRunningSummary(weeksInput = 12): Promise<RunningSummary> {
|
||||
const weeks = this.clampWeeks(weeksInput);
|
||||
const now = new Date();
|
||||
const rangeStart = this.startOfWeek(this.addDays(now, -(weeks - 1) * 7));
|
||||
const rangeEnd = this.endOfDay(now);
|
||||
const activities = await this.findActivitiesInRange(rangeStart);
|
||||
const runs = activities.filter((activity) =>
|
||||
this.isRunningActivity(activity),
|
||||
);
|
||||
const totals = this.createTotals();
|
||||
const weekly = this.createWeeklyBuckets(rangeStart, weeks);
|
||||
const heartRates: number[] = [];
|
||||
const cadences: number[] = [];
|
||||
|
||||
for (const run of runs) {
|
||||
this.addActivity(totals, run);
|
||||
const week = weekly.get(this.dateKey(this.startOfWeek(run.startDate)));
|
||||
if (week) {
|
||||
this.addActivity(week, run);
|
||||
}
|
||||
this.pushIfNumber(heartRates, run.averageHeartrate);
|
||||
this.pushIfNumber(cadences, run.averageCadence);
|
||||
}
|
||||
|
||||
const longestRun =
|
||||
runs.reduce<StravaActivityEntity | null>((longest, run) => {
|
||||
if (!longest || (run.distance ?? 0) > (longest.distance ?? 0)) {
|
||||
return run;
|
||||
}
|
||||
return longest;
|
||||
}, null) ?? null;
|
||||
const lastFourWeeks = Array.from(weekly.values()).slice(-4);
|
||||
const fourWeekAverageDistanceMeters =
|
||||
lastFourWeeks.reduce((total, week) => total + week.distanceMeters, 0) /
|
||||
Math.max(1, lastFourWeeks.length);
|
||||
const currentWeekDistance =
|
||||
Array.from(weekly.values()).at(-1)?.distanceMeters ?? 0;
|
||||
|
||||
return {
|
||||
weeks,
|
||||
rangeStart: this.dateKey(rangeStart),
|
||||
rangeEnd: this.dateKey(rangeEnd),
|
||||
totals,
|
||||
averages: {
|
||||
...this.createAverages(totals, heartRates, [], cadences),
|
||||
watts: null,
|
||||
},
|
||||
fourWeekAverageDistanceMeters: Math.round(fourWeekAverageDistanceMeters),
|
||||
longestRun: longestRun ? this.toRecentActivity(longestRun) : null,
|
||||
elevationGainPerKm:
|
||||
totals.distanceMeters > 0
|
||||
? Math.round(
|
||||
(totals.elevationGainMeters / (totals.distanceMeters / 1000)) *
|
||||
10,
|
||||
) / 10
|
||||
: null,
|
||||
longRunShare:
|
||||
currentWeekDistance > 0 && longestRun
|
||||
? Math.round(((longestRun.distance ?? 0) / currentWeekDistance) * 100)
|
||||
: null,
|
||||
weekly: Array.from(weekly.values()),
|
||||
recentRuns: runs.slice(0, 8).map((run) => this.toRecentActivity(run)),
|
||||
};
|
||||
}
|
||||
|
||||
async getRunningKpis(weeksInput = 12): Promise<RunningKpiDashboard> {
|
||||
const weeks = this.clampWeeks(weeksInput);
|
||||
const now = new Date();
|
||||
const rangeStart = this.startOfWeek(this.addDays(now, -(weeks - 1) * 7));
|
||||
const rangeEnd = this.endOfDay(now);
|
||||
const activities = await this.findActivitiesInRange(rangeStart);
|
||||
const runs = activities.filter((activity) =>
|
||||
this.isRunningActivity(activity),
|
||||
);
|
||||
const streamPointsByActivity = await this.findStreamPointsByActivity(
|
||||
runs.map((run) => run.id),
|
||||
);
|
||||
const observedMaxHeartRate = this.observedMaxHeartRate(
|
||||
runs,
|
||||
streamPointsByActivity,
|
||||
);
|
||||
const medianPace = this.median(
|
||||
runs
|
||||
.map((run) => this.activityPace(run))
|
||||
.filter((value): value is number => value !== null),
|
||||
);
|
||||
const activityLoads = runs.map((run) =>
|
||||
this.createActivityLoad(run, observedMaxHeartRate, medianPace),
|
||||
);
|
||||
const dailyLoads = this.createDailyLoads(now, activityLoads);
|
||||
const acute = this.sum(dailyLoads.slice(-7));
|
||||
const chronic = Math.round(this.sum(dailyLoads.slice(-28)) / 4);
|
||||
const acuteChronicRatio =
|
||||
chronic > 0 ? this.roundNullable(acute / chronic, 2) : null;
|
||||
const dailyAverage = this.averageRaw(dailyLoads.slice(-7));
|
||||
const dailyStandardDeviation = this.standardDeviation(dailyLoads.slice(-7));
|
||||
const monotony =
|
||||
dailyStandardDeviation > 0
|
||||
? this.roundNullable(dailyAverage / dailyStandardDeviation, 2)
|
||||
: null;
|
||||
const strain = monotony !== null ? Math.round(acute * monotony) : null;
|
||||
|
||||
return {
|
||||
weeks,
|
||||
rangeStart: this.dateKey(rangeStart),
|
||||
rangeEnd: this.dateKey(rangeEnd),
|
||||
load: {
|
||||
acute,
|
||||
chronic,
|
||||
acuteChronicRatio,
|
||||
weekly: this.createLoadBuckets(rangeStart, weeks, activityLoads),
|
||||
},
|
||||
monotony: {
|
||||
value: monotony,
|
||||
dailyAverage: Math.round(dailyAverage),
|
||||
dailyStandardDeviation: Math.round(dailyStandardDeviation),
|
||||
},
|
||||
strain: { value: strain },
|
||||
recovery: this.createRecovery(now, activityLoads, acuteChronicRatio),
|
||||
intensityDistribution: this.createIntensityDistribution(activityLoads),
|
||||
progression: this.createProgression(now, activityLoads),
|
||||
personalRecords: this.createPersonalRecords(runs, streamPointsByActivity),
|
||||
};
|
||||
}
|
||||
|
||||
async getRunningActivities(
|
||||
weeksInput = 12,
|
||||
): Promise<AnalyticsRecentActivity[]> {
|
||||
const weeks = this.clampWeeks(weeksInput);
|
||||
const rangeStart = this.startOfWeek(
|
||||
this.addDays(new Date(), -(weeks - 1) * 7),
|
||||
);
|
||||
const activities = await this.findActivitiesInRange(rangeStart);
|
||||
|
||||
return activities
|
||||
.filter((activity) => this.isRunningActivity(activity))
|
||||
.map((activity) => this.toRecentActivity(activity));
|
||||
}
|
||||
|
||||
async getRunningActivityDetail(id: string): Promise<RunningActivityDetail> {
|
||||
const activity = await this.activityRepository.findOne({ where: { id } });
|
||||
if (!activity || !this.isRunningActivity(activity)) {
|
||||
throw new NotFoundException('Running activity not found');
|
||||
}
|
||||
|
||||
let points = await this.findStreamPoints(activity.id);
|
||||
|
||||
if (points.length === 0) {
|
||||
await this.stravaStreamImportService.importStreamsForActivity(activity);
|
||||
points = await this.findStreamPoints(activity.id);
|
||||
}
|
||||
|
||||
return {
|
||||
activity: this.toRecentActivity(activity),
|
||||
splits: this.createRunningSplits(points),
|
||||
series: this.createRunningSeries(points),
|
||||
};
|
||||
}
|
||||
|
||||
private findStreamPoints(
|
||||
activityId: string,
|
||||
): Promise<StravaActivityStreamPointEntity[]> {
|
||||
return this.streamPointRepository.find({
|
||||
where: { activityId },
|
||||
order: { pointIndex: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
private async findStreamPointsByActivity(
|
||||
activityIds: string[],
|
||||
): Promise<Map<string, StravaActivityStreamPointEntity[]>> {
|
||||
const pointsByActivity = new Map<
|
||||
string,
|
||||
StravaActivityStreamPointEntity[]
|
||||
>();
|
||||
if (activityIds.length === 0) {
|
||||
return pointsByActivity;
|
||||
}
|
||||
|
||||
const points = await this.streamPointRepository.find({
|
||||
where: { activityId: In(activityIds) },
|
||||
order: { activityId: 'ASC', pointIndex: 'ASC' },
|
||||
});
|
||||
|
||||
for (const point of points) {
|
||||
const activityPoints = pointsByActivity.get(point.activityId) ?? [];
|
||||
activityPoints.push(point);
|
||||
pointsByActivity.set(point.activityId, activityPoints);
|
||||
}
|
||||
|
||||
return pointsByActivity;
|
||||
}
|
||||
|
||||
private async findActivitiesInRange(
|
||||
rangeStart: Date,
|
||||
): Promise<StravaActivityEntity[]> {
|
||||
return this.activityRepository.find({
|
||||
where: {
|
||||
startDate: MoreThanOrEqual(rangeStart),
|
||||
},
|
||||
order: {
|
||||
startDate: 'DESC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private availableSports(activities: StravaActivityEntity[]): string[] {
|
||||
return Array.from(
|
||||
new Set(activities.map((activity) => activity.sportType ?? 'Unbekannt')),
|
||||
).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
private isRunningActivity(activity: StravaActivityEntity): boolean {
|
||||
const sportType = activity.sportType?.toLowerCase() ?? '';
|
||||
return sportType.includes('run');
|
||||
}
|
||||
|
||||
private observedMaxHeartRate(
|
||||
runs: StravaActivityEntity[],
|
||||
streamPointsByActivity: Map<string, StravaActivityStreamPointEntity[]>,
|
||||
): number | null {
|
||||
const values: number[] = [];
|
||||
|
||||
for (const run of runs) {
|
||||
this.pushIfNumber(values, run.maxHeartrate);
|
||||
this.pushIfNumber(values, run.averageHeartrate);
|
||||
for (const point of streamPointsByActivity.get(run.id) ?? []) {
|
||||
this.pushIfNumber(values, point.heartRate);
|
||||
}
|
||||
}
|
||||
|
||||
return values.length > 0 ? Math.max(...values) : null;
|
||||
}
|
||||
|
||||
private createActivityLoad(
|
||||
activity: StravaActivityEntity,
|
||||
observedMaxHeartRate: number | null,
|
||||
medianPace: number | null,
|
||||
): ActivityLoad {
|
||||
const paceSecondsPerKm = this.activityPace(activity);
|
||||
const heartRateIntensity =
|
||||
observedMaxHeartRate && activity.averageHeartrate
|
||||
? activity.averageHeartrate / observedMaxHeartRate
|
||||
: null;
|
||||
const paceIntensity =
|
||||
medianPace && paceSecondsPerKm ? medianPace / paceSecondsPerKm : null;
|
||||
const rawIntensity = heartRateIntensity ?? paceIntensity ?? 0;
|
||||
const intensity =
|
||||
rawIntensity > 0 ? this.clamp(rawIntensity, 0.55, 1.3) : 0;
|
||||
const movingMinutes = (activity.movingTime ?? 0) / 60;
|
||||
|
||||
return {
|
||||
activity,
|
||||
intensity,
|
||||
intensityBasis: heartRateIntensity
|
||||
? 'heart_rate'
|
||||
: paceIntensity
|
||||
? 'pace'
|
||||
: 'none',
|
||||
zone: this.intensityZone(heartRateIntensity, paceIntensity),
|
||||
load: Math.round(movingMinutes * intensity * intensity),
|
||||
paceSecondsPerKm,
|
||||
};
|
||||
}
|
||||
|
||||
private intensityZone(
|
||||
heartRateIntensity: number | null,
|
||||
paceIntensity: number | null,
|
||||
): RunningIntensityZone {
|
||||
const intensity = heartRateIntensity ?? paceIntensity;
|
||||
if (intensity === null || intensity === undefined) {
|
||||
return 'moderate';
|
||||
}
|
||||
|
||||
if (heartRateIntensity !== null) {
|
||||
if (heartRateIntensity >= 0.85) {
|
||||
return 'hard';
|
||||
}
|
||||
if (heartRateIntensity < 0.75) {
|
||||
return 'easy';
|
||||
}
|
||||
return 'moderate';
|
||||
}
|
||||
|
||||
if ((paceIntensity ?? 0) >= 1.08) {
|
||||
return 'hard';
|
||||
}
|
||||
if ((paceIntensity ?? 0) < 0.95) {
|
||||
return 'easy';
|
||||
}
|
||||
return 'moderate';
|
||||
}
|
||||
|
||||
private createDailyLoads(now: Date, activityLoads: ActivityLoad[]): number[] {
|
||||
const start = this.startOfDay(this.addDays(now, -27));
|
||||
const dailyLoads = Array.from({ length: 28 }, () => 0);
|
||||
|
||||
for (const activityLoad of activityLoads) {
|
||||
if (!activityLoad.activity.startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dayIndex = Math.floor(
|
||||
(this.startOfDay(activityLoad.activity.startDate).getTime() -
|
||||
start.getTime()) /
|
||||
86400000,
|
||||
);
|
||||
if (dayIndex >= 0 && dayIndex < dailyLoads.length) {
|
||||
dailyLoads[dayIndex] += activityLoad.load;
|
||||
}
|
||||
}
|
||||
|
||||
return dailyLoads.map((value) => Math.round(value));
|
||||
}
|
||||
|
||||
private createLoadBuckets(
|
||||
rangeStart: Date,
|
||||
weeks: number,
|
||||
activityLoads: ActivityLoad[],
|
||||
): RunningLoadBucket[] {
|
||||
const buckets = new Map<string, RunningLoadBucket>();
|
||||
|
||||
for (let index = 0; index < weeks; index += 1) {
|
||||
const weekStart = this.addDays(rangeStart, index * 7);
|
||||
const weekEnd = this.addDays(weekStart, 6);
|
||||
buckets.set(this.dateKey(weekStart), {
|
||||
...this.createTotals(),
|
||||
weekStart: this.dateKey(weekStart),
|
||||
weekEnd: this.dateKey(weekEnd),
|
||||
load: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const activityLoad of activityLoads) {
|
||||
const week = buckets.get(
|
||||
this.dateKey(this.startOfWeek(activityLoad.activity.startDate)),
|
||||
);
|
||||
if (!week) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.addActivity(week, activityLoad.activity);
|
||||
week.load += activityLoad.load;
|
||||
}
|
||||
|
||||
return Array.from(buckets.values()).map((bucket) => ({
|
||||
...bucket,
|
||||
load: Math.round(bucket.load),
|
||||
}));
|
||||
}
|
||||
|
||||
private createRecovery(
|
||||
now: Date,
|
||||
activityLoads: ActivityLoad[],
|
||||
acuteChronicRatio: number | null,
|
||||
): RunningKpiDashboard['recovery'] {
|
||||
const sortedLoads = [...activityLoads]
|
||||
.filter((load) => load.activity.startDate)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(right.activity.startDate?.getTime() ?? 0) -
|
||||
(left.activity.startDate?.getTime() ?? 0),
|
||||
);
|
||||
const lastRun = sortedLoads[0] ?? null;
|
||||
const lastHardRun =
|
||||
sortedLoads.find((load) => load.zone === 'hard') ?? null;
|
||||
const daysSinceLastRun = lastRun
|
||||
? this.daysBetween(lastRun.activity.startDate, now)
|
||||
: null;
|
||||
const daysSinceLastHardRun = lastHardRun
|
||||
? this.daysBetween(lastHardRun.activity.startDate, now)
|
||||
: null;
|
||||
|
||||
let score = 85;
|
||||
if (acuteChronicRatio !== null) {
|
||||
score -= Math.max(0, acuteChronicRatio - 1) * 45;
|
||||
score += Math.max(0, 1 - acuteChronicRatio) * 10;
|
||||
}
|
||||
if (daysSinceLastHardRun !== null && daysSinceLastHardRun < 2) {
|
||||
score -= 20;
|
||||
}
|
||||
if (daysSinceLastRun === 0) {
|
||||
score -= 10;
|
||||
}
|
||||
score = Math.round(this.clamp(score, 0, 100));
|
||||
|
||||
const status =
|
||||
score < 45 || (acuteChronicRatio ?? 0) > 1.5
|
||||
? 'red'
|
||||
: score < 70 || (acuteChronicRatio ?? 0) > 1.25
|
||||
? 'yellow'
|
||||
: 'green';
|
||||
|
||||
return {
|
||||
status,
|
||||
score,
|
||||
daysSinceLastRun,
|
||||
daysSinceLastHardRun,
|
||||
message:
|
||||
status === 'green'
|
||||
? 'Belastung wirkt stabil.'
|
||||
: status === 'yellow'
|
||||
? 'Belastung steigt, Erholung im Blick behalten.'
|
||||
: 'Hohe Belastung, lockere Tage einplanen.',
|
||||
};
|
||||
}
|
||||
|
||||
private createIntensityDistribution(
|
||||
activityLoads: ActivityLoad[],
|
||||
): RunningIntensityDistributionBucket[] {
|
||||
const labels: Record<RunningIntensityZone, string> = {
|
||||
easy: 'Easy',
|
||||
moderate: 'Moderate',
|
||||
hard: 'Hard',
|
||||
};
|
||||
const distribution = new Map<
|
||||
RunningIntensityZone,
|
||||
RunningIntensityDistributionBucket
|
||||
>(
|
||||
(['easy', 'moderate', 'hard'] as RunningIntensityZone[]).map((zone) => [
|
||||
zone,
|
||||
{
|
||||
...this.createTotals(),
|
||||
zone,
|
||||
label: labels[zone],
|
||||
load: 0,
|
||||
share: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
for (const activityLoad of activityLoads) {
|
||||
const bucket = distribution.get(activityLoad.zone);
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.addActivity(bucket, activityLoad.activity);
|
||||
bucket.load += activityLoad.load;
|
||||
}
|
||||
|
||||
const totalTime = Array.from(distribution.values()).reduce(
|
||||
(total, bucket) => total + bucket.movingTimeSeconds,
|
||||
0,
|
||||
);
|
||||
|
||||
return Array.from(distribution.values()).map((bucket) => ({
|
||||
...bucket,
|
||||
load: Math.round(bucket.load),
|
||||
share:
|
||||
totalTime > 0
|
||||
? Math.round((bucket.movingTimeSeconds / totalTime) * 100)
|
||||
: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
private createProgression(
|
||||
now: Date,
|
||||
activityLoads: ActivityLoad[],
|
||||
): RunningProgressionMetric[] {
|
||||
const currentStart = this.startOfDay(this.addDays(now, -27));
|
||||
const previousStart = this.startOfDay(this.addDays(now, -55));
|
||||
const previousEnd = this.addDays(currentStart, -1);
|
||||
const current = this.periodTotals(activityLoads, currentStart, now);
|
||||
const previous = this.periodTotals(
|
||||
activityLoads,
|
||||
previousStart,
|
||||
previousEnd,
|
||||
);
|
||||
|
||||
return [
|
||||
this.progressionMetric(
|
||||
'distance',
|
||||
'Distanz',
|
||||
'meters',
|
||||
current.distanceMeters,
|
||||
previous.distanceMeters,
|
||||
),
|
||||
this.progressionMetric(
|
||||
'time',
|
||||
'Zeit',
|
||||
'seconds',
|
||||
current.movingTimeSeconds,
|
||||
previous.movingTimeSeconds,
|
||||
),
|
||||
this.progressionMetric(
|
||||
'load',
|
||||
'Load',
|
||||
'load',
|
||||
current.load,
|
||||
previous.load,
|
||||
),
|
||||
this.progressionMetric(
|
||||
'pace',
|
||||
'Pace',
|
||||
'pace',
|
||||
current.paceSecondsPerKm,
|
||||
previous.paceSecondsPerKm,
|
||||
true,
|
||||
),
|
||||
this.progressionMetric(
|
||||
'runs',
|
||||
'Laeufe',
|
||||
'count',
|
||||
current.activityCount,
|
||||
previous.activityCount,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private periodTotals(
|
||||
activityLoads: ActivityLoad[],
|
||||
start: Date,
|
||||
end: Date,
|
||||
): PeriodTotals {
|
||||
const totals: PeriodTotals = {
|
||||
...this.createTotals(),
|
||||
load: 0,
|
||||
paceSecondsPerKm: null,
|
||||
};
|
||||
|
||||
for (const activityLoad of activityLoads) {
|
||||
const startDate = activityLoad.activity.startDate;
|
||||
if (!startDate || startDate < start || startDate > end) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.addActivity(totals, activityLoad.activity);
|
||||
totals.load += activityLoad.load;
|
||||
}
|
||||
|
||||
totals.load = Math.round(totals.load);
|
||||
totals.paceSecondsPerKm =
|
||||
totals.distanceMeters > 0
|
||||
? Math.round(totals.movingTimeSeconds / (totals.distanceMeters / 1000))
|
||||
: null;
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
private progressionMetric(
|
||||
key: RunningProgressionMetric['key'],
|
||||
label: string,
|
||||
unit: RunningProgressionMetric['unit'],
|
||||
current: number | null,
|
||||
previous: number | null,
|
||||
lowerIsBetter = false,
|
||||
): RunningProgressionMetric {
|
||||
const change =
|
||||
current !== null && previous !== null && previous > 0
|
||||
? ((current - previous) / previous) * 100
|
||||
: null;
|
||||
const changePercent =
|
||||
change === null
|
||||
? null
|
||||
: Math.round((lowerIsBetter ? -change : change) * 10) / 10;
|
||||
|
||||
return { key, label, unit, current, previous, changePercent };
|
||||
}
|
||||
|
||||
private createPersonalRecords(
|
||||
runs: StravaActivityEntity[],
|
||||
streamPointsByActivity: Map<string, StravaActivityStreamPointEntity[]>,
|
||||
): RunningPersonalRecordEstimate[] {
|
||||
return [1000, 5000, 10000].map((distanceMeters) => {
|
||||
const streamBest = this.fastestStreamSegment(
|
||||
distanceMeters,
|
||||
runs,
|
||||
streamPointsByActivity,
|
||||
);
|
||||
|
||||
return streamBest ?? this.estimatedActivityPr(distanceMeters, runs);
|
||||
});
|
||||
}
|
||||
|
||||
private fastestStreamSegment(
|
||||
targetDistance: number,
|
||||
runs: StravaActivityEntity[],
|
||||
streamPointsByActivity: Map<string, StravaActivityStreamPointEntity[]>,
|
||||
): RunningPersonalRecordEstimate | null {
|
||||
let best: RunningPersonalRecordEstimate | null = null;
|
||||
|
||||
for (const run of runs) {
|
||||
const points = (streamPointsByActivity.get(run.id) ?? []).filter(
|
||||
(point) => point.distance !== null && point.timeSeconds !== null,
|
||||
);
|
||||
let left = 0;
|
||||
|
||||
for (let right = 1; right < points.length; right += 1) {
|
||||
while (
|
||||
left < right &&
|
||||
(points[right].distance ?? 0) - (points[left].distance ?? 0) >=
|
||||
targetDistance
|
||||
) {
|
||||
const timeSeconds =
|
||||
(points[right].timeSeconds ?? 0) - (points[left].timeSeconds ?? 0);
|
||||
if (
|
||||
timeSeconds > 0 &&
|
||||
(!best ||
|
||||
best.timeSeconds === null ||
|
||||
timeSeconds < best.timeSeconds)
|
||||
) {
|
||||
best = this.toPersonalRecord(
|
||||
targetDistance,
|
||||
timeSeconds,
|
||||
run,
|
||||
false,
|
||||
);
|
||||
}
|
||||
left += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private estimatedActivityPr(
|
||||
targetDistance: number,
|
||||
runs: StravaActivityEntity[],
|
||||
): RunningPersonalRecordEstimate {
|
||||
const candidates = runs.filter(
|
||||
(run) => (run.distance ?? 0) >= targetDistance && this.activityPace(run),
|
||||
);
|
||||
const best = candidates.reduce<RunningPersonalRecordEstimate | null>(
|
||||
(currentBest, run) => {
|
||||
const paceSecondsPerKm = this.activityPace(run);
|
||||
if (!paceSecondsPerKm) {
|
||||
return currentBest;
|
||||
}
|
||||
|
||||
const timeSeconds = Math.round(
|
||||
paceSecondsPerKm * (targetDistance / 1000),
|
||||
);
|
||||
if (
|
||||
!currentBest ||
|
||||
currentBest.timeSeconds === null ||
|
||||
timeSeconds < currentBest.timeSeconds
|
||||
) {
|
||||
return this.toPersonalRecord(targetDistance, timeSeconds, run, true);
|
||||
}
|
||||
return currentBest;
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
best ?? {
|
||||
distanceMeters: targetDistance,
|
||||
timeSeconds: null,
|
||||
paceSecondsPerKm: null,
|
||||
activityId: null,
|
||||
activityName: null,
|
||||
startDate: null,
|
||||
estimated: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private toPersonalRecord(
|
||||
distanceMeters: number,
|
||||
timeSeconds: number,
|
||||
activity: StravaActivityEntity,
|
||||
estimated: boolean,
|
||||
): RunningPersonalRecordEstimate {
|
||||
return {
|
||||
distanceMeters,
|
||||
timeSeconds,
|
||||
paceSecondsPerKm: Math.round(timeSeconds / (distanceMeters / 1000)),
|
||||
activityId: activity.id,
|
||||
activityName: activity.name,
|
||||
startDate: activity.startDate ? activity.startDate.toISOString() : null,
|
||||
estimated,
|
||||
};
|
||||
}
|
||||
|
||||
private createRunningSplits(
|
||||
points: StravaActivityStreamPointEntity[],
|
||||
): RunningSplit[] {
|
||||
const usablePoints = points.filter(
|
||||
(point) => point.distance !== null && point.timeSeconds !== null,
|
||||
);
|
||||
const splits: RunningSplit[] = [];
|
||||
|
||||
if (usablePoints.length < 2) {
|
||||
return splits;
|
||||
}
|
||||
|
||||
let splitStart = usablePoints[0];
|
||||
let splitStartIndex = 0;
|
||||
let nextKm = 1000;
|
||||
|
||||
for (let index = 1; index < usablePoints.length; index += 1) {
|
||||
const point = usablePoints[index];
|
||||
if ((point.distance ?? 0) < nextKm) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const splitPoints = usablePoints.slice(splitStartIndex, index + 1);
|
||||
const distanceMeters = Math.max(
|
||||
1,
|
||||
(point.distance ?? 0) - (splitStart.distance ?? 0),
|
||||
);
|
||||
const movingTimeSeconds = Math.max(
|
||||
0,
|
||||
(point.timeSeconds ?? 0) - (splitStart.timeSeconds ?? 0),
|
||||
);
|
||||
|
||||
splits.push({
|
||||
kilometer: splits.length + 1,
|
||||
distanceMeters,
|
||||
movingTimeSeconds,
|
||||
paceSecondsPerKm:
|
||||
distanceMeters > 0
|
||||
? Math.round(movingTimeSeconds / (distanceMeters / 1000))
|
||||
: null,
|
||||
averageHeartRate: this.average(
|
||||
splitPoints
|
||||
.map((splitPoint) => splitPoint.heartRate)
|
||||
.filter((value): value is number => value !== null),
|
||||
),
|
||||
averageCadence: this.average(
|
||||
splitPoints
|
||||
.map((splitPoint) => splitPoint.cadence)
|
||||
.filter((value): value is number => value !== null),
|
||||
),
|
||||
elevationGainMeters: this.positiveElevationGain(splitPoints),
|
||||
});
|
||||
|
||||
splitStart = point;
|
||||
splitStartIndex = index;
|
||||
nextKm += 1000;
|
||||
}
|
||||
|
||||
return splits;
|
||||
}
|
||||
|
||||
private createRunningSeries(
|
||||
points: StravaActivityStreamPointEntity[],
|
||||
): RunningChartPoint[] {
|
||||
return points
|
||||
.filter((point) => point.distance !== null)
|
||||
.map((point, index, values) => {
|
||||
const previous = index > 0 ? values[index - 1] : null;
|
||||
const distanceDelta =
|
||||
previous?.distance !== null && previous?.distance !== undefined
|
||||
? (point.distance ?? 0) - previous.distance
|
||||
: 0;
|
||||
const timeDelta =
|
||||
previous?.timeSeconds !== null && previous?.timeSeconds !== undefined
|
||||
? (point.timeSeconds ?? 0) - previous.timeSeconds
|
||||
: 0;
|
||||
|
||||
return {
|
||||
distanceMeters: point.distance ?? 0,
|
||||
timeSeconds: point.timeSeconds,
|
||||
paceSecondsPerKm:
|
||||
point.velocitySmooth && point.velocitySmooth > 0
|
||||
? Math.round(1000 / point.velocitySmooth)
|
||||
: distanceDelta > 0 && timeDelta > 0
|
||||
? Math.round(timeDelta / (distanceDelta / 1000))
|
||||
: null,
|
||||
heartRate: point.heartRate,
|
||||
altitude: point.altitude,
|
||||
cadence: point.cadence,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private positiveElevationGain(
|
||||
points: StravaActivityStreamPointEntity[],
|
||||
): number {
|
||||
let gain = 0;
|
||||
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const previous = points[index - 1].altitude;
|
||||
const current = points[index].altitude;
|
||||
if (previous === null || current === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
gain += Math.max(0, current - previous);
|
||||
}
|
||||
|
||||
return Math.round(gain);
|
||||
}
|
||||
|
||||
private addActivity(
|
||||
totals: AnalyticsTotals,
|
||||
activity: StravaActivityEntity,
|
||||
@@ -180,6 +992,38 @@ export class AnalyticsService {
|
||||
};
|
||||
}
|
||||
|
||||
private activityPace(activity: StravaActivityEntity): number | null {
|
||||
if (!activity.distance || activity.distance <= 0 || !activity.movingTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round(activity.movingTime / (activity.distance / 1000));
|
||||
}
|
||||
|
||||
private sum(values: number[]): number {
|
||||
return Math.round(values.reduce((total, value) => total + value, 0));
|
||||
}
|
||||
|
||||
private averageRaw(values: number[]): number {
|
||||
if (values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return values.reduce((total, value) => total + value, 0) / values.length;
|
||||
}
|
||||
|
||||
private standardDeviation(values: number[]): number {
|
||||
if (values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const mean = this.averageRaw(values);
|
||||
const variance =
|
||||
values.reduce((total, value) => total + (value - mean) ** 2, 0) /
|
||||
values.length;
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
|
||||
private average(values: number[]): number | null {
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
@@ -190,6 +1034,18 @@ export class AnalyticsService {
|
||||
);
|
||||
}
|
||||
|
||||
private median(values: number[]): number | null {
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[middle - 1] + sorted[middle]) / 2
|
||||
: sorted[middle];
|
||||
}
|
||||
|
||||
private pushIfNumber(values: number[], value: number | null): void {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
values.push(value);
|
||||
@@ -205,6 +1061,10 @@ export class AnalyticsService {
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
private clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
private clampWeeks(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 12;
|
||||
@@ -236,6 +1096,12 @@ export class AnalyticsService {
|
||||
return date;
|
||||
}
|
||||
|
||||
private startOfDay(value: Date | null): Date {
|
||||
const date = value ? new Date(value) : new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
private addDays(value: Date, days: number): Date {
|
||||
const date = new Date(value);
|
||||
date.setDate(date.getDate() + days);
|
||||
@@ -245,4 +1111,18 @@ export class AnalyticsService {
|
||||
private dateKey(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private daysBetween(start: Date | null, end: Date): number | null {
|
||||
if (!start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(this.startOfDay(end).getTime() - this.startOfDay(start).getTime()) /
|
||||
86400000,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,3 +49,106 @@ export interface AnalyticsDashboard {
|
||||
sports: AnalyticsSportSummary[];
|
||||
recentActivities: AnalyticsRecentActivity[];
|
||||
}
|
||||
|
||||
export interface RunningSummary {
|
||||
weeks: number;
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
totals: AnalyticsTotals;
|
||||
averages: AnalyticsAverages;
|
||||
fourWeekAverageDistanceMeters: number;
|
||||
longestRun: AnalyticsRecentActivity | null;
|
||||
elevationGainPerKm: number | null;
|
||||
longRunShare: number | null;
|
||||
weekly: AnalyticsWeeklyBucket[];
|
||||
recentRuns: AnalyticsRecentActivity[];
|
||||
}
|
||||
|
||||
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: AnalyticsRecentActivity;
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StravaRateLimitError } from './strava-rate-limit.error';
|
||||
import {
|
||||
StravaActivityPayload,
|
||||
StravaStreamPayload,
|
||||
StravaStreamsResponse,
|
||||
StravaTokenPayload,
|
||||
} from './strava.types';
|
||||
|
||||
@@ -84,15 +85,17 @@ export class StravaClientService {
|
||||
accessToken: string,
|
||||
stravaActivityId: string,
|
||||
): Promise<StravaStreamPayload[]> {
|
||||
return this.request<StravaStreamPayload[]>({
|
||||
const streams = await this.request<StravaStreamsResponse>({
|
||||
method: 'GET',
|
||||
url: `https://www.strava.com/api/v3/activities/${stravaActivityId}/streams`,
|
||||
headers: this.authHeaders(accessToken),
|
||||
params: {
|
||||
keys: 'time,distance,latlng,altitude,velocity_smooth,heartrate,cadence,watts,temp,moving,grade_smooth',
|
||||
key_by_type: false,
|
||||
key_by_type: true,
|
||||
},
|
||||
});
|
||||
|
||||
return this.normalizeStreamsResponse(streams);
|
||||
}
|
||||
|
||||
private async postToken(
|
||||
@@ -149,6 +152,22 @@ export class StravaClientService {
|
||||
return { Authorization: `Bearer ${accessToken}` };
|
||||
}
|
||||
|
||||
private normalizeStreamsResponse(
|
||||
streams: StravaStreamsResponse,
|
||||
): StravaStreamPayload[] {
|
||||
if (Array.isArray(streams)) {
|
||||
return streams;
|
||||
}
|
||||
|
||||
return Object.entries(streams).map(([type, stream]) => ({
|
||||
type: stream.type ?? type,
|
||||
data: Array.isArray(stream.data) ? stream.data : [],
|
||||
series_type: stream.series_type,
|
||||
original_size: stream.original_size,
|
||||
resolution: stream.resolution,
|
||||
}));
|
||||
}
|
||||
|
||||
private required(configService: ConfigService, key: string): string {
|
||||
const value = configService.get<string>(key);
|
||||
if (!value) {
|
||||
|
||||
54
api/src/strava/strava-stream-import.service.ts
Normal file
54
api/src/strava/strava-stream-import.service.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
} from '../database/entities';
|
||||
import { StravaClientService } from './strava-client.service';
|
||||
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
|
||||
import { StravaTokenService } from './strava-token.service';
|
||||
|
||||
@Injectable()
|
||||
export class StravaStreamImportService {
|
||||
constructor(
|
||||
@InjectRepository(StravaActivityStreamPointEntity)
|
||||
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
|
||||
private readonly stravaTokenService: StravaTokenService,
|
||||
private readonly stravaClientService: StravaClientService,
|
||||
private readonly streamNormalizer: StravaStreamNormalizerService,
|
||||
) {}
|
||||
|
||||
async importStreamsForActivity(
|
||||
activity: StravaActivityEntity,
|
||||
): Promise<number> {
|
||||
const accessToken = await this.stravaTokenService.getValidAccessToken(
|
||||
activity.stravaAthleteId,
|
||||
);
|
||||
const streams = await this.stravaClientService.getActivityStreams(
|
||||
accessToken,
|
||||
activity.stravaActivityId,
|
||||
);
|
||||
const points = this.streamNormalizer.normalize(activity.id, streams);
|
||||
|
||||
await this.streamPointRepository.delete({ activityId: activity.id });
|
||||
await this.insertStreamPoints(points);
|
||||
|
||||
return points.length;
|
||||
}
|
||||
|
||||
private async insertStreamPoints(
|
||||
points: Partial<StravaActivityStreamPointEntity>[],
|
||||
): Promise<void> {
|
||||
const chunkSize = 1000;
|
||||
for (let index = 0; index < points.length; index += chunkSize) {
|
||||
await this.streamPointRepository.insert(
|
||||
points.slice(
|
||||
index,
|
||||
index + chunkSize,
|
||||
) as QueryDeepPartialEntity<StravaActivityStreamPointEntity>[],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export class StravaSyncController {
|
||||
return this.stravaSyncService.startSync();
|
||||
}
|
||||
|
||||
@Get('jobs/latest')
|
||||
getLatestJob(): Promise<StravaSyncJobEntity | null> {
|
||||
return this.stravaSyncService.getLatestJob();
|
||||
}
|
||||
|
||||
@Get('jobs/:id')
|
||||
getJob(@Param('id') id: string): Promise<StravaSyncJobEntity> {
|
||||
return this.stravaSyncService.getJob(id);
|
||||
|
||||
69
api/src/strava/strava-sync.service.spec.ts
Normal file
69
api/src/strava/strava-sync.service.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { StravaSyncJobEntity } from '../database/entities';
|
||||
import { StravaRateLimitError } from './strava-rate-limit.error';
|
||||
import { StravaSyncService } from './strava-sync.service';
|
||||
|
||||
describe('StravaSyncService', () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('keeps a rate limited job visible and retries it after 15 minutes', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-06-16T12:00:00.000Z'));
|
||||
|
||||
const job = {
|
||||
id: 'job-1',
|
||||
stravaAthleteId: 'athlete-1',
|
||||
status: 'queued',
|
||||
activityCount: 0,
|
||||
detailCount: 0,
|
||||
streamPointCount: 0,
|
||||
errorMessage: null,
|
||||
retryAfter: null,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
items: [],
|
||||
} as StravaSyncJobEntity;
|
||||
|
||||
const jobRepository = {
|
||||
findOne: jest.fn().mockResolvedValue(job),
|
||||
save: jest.fn((entity: Partial<StravaSyncJobEntity>) => {
|
||||
Object.assign(job, entity);
|
||||
return Promise.resolve(job);
|
||||
}),
|
||||
};
|
||||
const stravaTokenService = {
|
||||
getValidAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||
};
|
||||
const stravaClientService = {
|
||||
listActivities: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new StravaRateLimitError('limit', null))
|
||||
.mockResolvedValueOnce([]),
|
||||
};
|
||||
|
||||
const service = new StravaSyncService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
jobRepository as never,
|
||||
{} as never,
|
||||
stravaTokenService as never,
|
||||
stravaClientService as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.runJob(job.id);
|
||||
|
||||
expect(job.status).toBe('rate_limited');
|
||||
expect(job.retryAfter?.toISOString()).toBe('2026-06-16T12:15:00.000Z');
|
||||
expect(job.finishedAt).toBeNull();
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(15 * 60 * 1000);
|
||||
|
||||
expect(stravaClientService.listActivities).toHaveBeenCalledTimes(2);
|
||||
expect(job.status).toBe('completed');
|
||||
expect(job.finishedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import {
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
StravaAthleteEntity,
|
||||
StravaSyncJobEntity,
|
||||
StravaSyncJobItemEntity,
|
||||
@@ -12,30 +10,52 @@ import {
|
||||
import { mapStravaActivity } from './strava-activity.mapper';
|
||||
import { StravaClientService } from './strava-client.service';
|
||||
import { StravaRateLimitError } from './strava-rate-limit.error';
|
||||
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
|
||||
import { StravaStreamImportService } from './strava-stream-import.service';
|
||||
import { StravaTokenService } from './strava-token.service';
|
||||
import { StravaActivityPayload } from './strava.types';
|
||||
|
||||
@Injectable()
|
||||
export class StravaSyncService {
|
||||
export class StravaSyncService implements OnModuleInit {
|
||||
private readonly retryDelayMs = 15 * 60 * 1000;
|
||||
private readonly retryTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(StravaAthleteEntity)
|
||||
private readonly athleteRepository: Repository<StravaAthleteEntity>,
|
||||
@InjectRepository(StravaActivityEntity)
|
||||
private readonly activityRepository: Repository<StravaActivityEntity>,
|
||||
@InjectRepository(StravaActivityStreamPointEntity)
|
||||
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
|
||||
@InjectRepository(StravaSyncJobEntity)
|
||||
private readonly jobRepository: Repository<StravaSyncJobEntity>,
|
||||
@InjectRepository(StravaSyncJobItemEntity)
|
||||
private readonly jobItemRepository: Repository<StravaSyncJobItemEntity>,
|
||||
private readonly stravaTokenService: StravaTokenService,
|
||||
private readonly stravaClientService: StravaClientService,
|
||||
private readonly streamNormalizer: StravaStreamNormalizerService,
|
||||
private readonly stravaStreamImportService: StravaStreamImportService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const waitingJobs = await this.jobRepository.find({
|
||||
where: { status: 'rate_limited' },
|
||||
});
|
||||
|
||||
waitingJobs.forEach((job) => this.scheduleRetry(job));
|
||||
}
|
||||
|
||||
async startSync(): Promise<StravaSyncJobEntity> {
|
||||
const athlete = await this.resolveAthlete();
|
||||
const activeJob = await this.jobRepository.findOne({
|
||||
where: {
|
||||
stravaAthleteId: athlete.id,
|
||||
status: In(['queued', 'running', 'rate_limited']),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (activeJob) {
|
||||
this.scheduleRetry(activeJob);
|
||||
return activeJob;
|
||||
}
|
||||
|
||||
const job = await this.jobRepository.save(
|
||||
this.jobRepository.create({
|
||||
stravaAthleteId: athlete.id,
|
||||
@@ -48,6 +68,22 @@ export class StravaSyncService {
|
||||
return job;
|
||||
}
|
||||
|
||||
async getLatestJob(): Promise<StravaSyncJobEntity | null> {
|
||||
const athlete = await this.resolveAthlete();
|
||||
|
||||
const job = await this.jobRepository.findOne({
|
||||
where: { stravaAthleteId: athlete.id },
|
||||
relations: { items: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (job?.status === 'rate_limited') {
|
||||
this.scheduleRetry(job);
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
async getJob(jobId: string): Promise<StravaSyncJobEntity> {
|
||||
const job = await this.jobRepository.findOne({
|
||||
where: { id: jobId },
|
||||
@@ -61,9 +97,14 @@ export class StravaSyncService {
|
||||
}
|
||||
|
||||
async runJob(jobId: string): Promise<void> {
|
||||
this.clearRetry(jobId);
|
||||
|
||||
const job = await this.getJob(jobId);
|
||||
job.status = 'running';
|
||||
job.startedAt = new Date();
|
||||
job.finishedAt = null;
|
||||
job.retryAfter = null;
|
||||
job.errorMessage = null;
|
||||
await this.jobRepository.save(job);
|
||||
|
||||
try {
|
||||
@@ -91,6 +132,7 @@ export class StravaSyncService {
|
||||
|
||||
job.status = 'completed';
|
||||
job.finishedAt = new Date();
|
||||
job.retryAfter = null;
|
||||
await this.jobRepository.save(job);
|
||||
} catch (error) {
|
||||
await this.failJob(job, error);
|
||||
@@ -105,6 +147,10 @@ export class StravaSyncService {
|
||||
const stravaActivityId = String(summary.id);
|
||||
const item = await this.getOrCreateJobItem(job.id, stravaActivityId);
|
||||
|
||||
if (item.status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingSummary = await this.activityRepository.findOne({
|
||||
where: {
|
||||
@@ -126,14 +172,8 @@ export class StravaSyncService {
|
||||
);
|
||||
job.detailCount += 1;
|
||||
|
||||
const streams = await this.stravaClientService.getActivityStreams(
|
||||
accessToken,
|
||||
stravaActivityId,
|
||||
);
|
||||
await this.streamPointRepository.delete({ activityId: activity.id });
|
||||
const points = this.streamNormalizer.normalize(activity.id, streams);
|
||||
await this.insertStreamPoints(points);
|
||||
job.streamPointCount += points.length;
|
||||
job.streamPointCount +=
|
||||
await this.stravaStreamImportService.importStreamsForActivity(activity);
|
||||
|
||||
item.status = 'completed';
|
||||
item.errorMessage = null;
|
||||
@@ -151,20 +191,6 @@ export class StravaSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
private async insertStreamPoints(
|
||||
points: Partial<StravaActivityStreamPointEntity>[],
|
||||
): Promise<void> {
|
||||
const chunkSize = 1000;
|
||||
for (let index = 0; index < points.length; index += chunkSize) {
|
||||
await this.streamPointRepository.insert(
|
||||
points.slice(
|
||||
index,
|
||||
index + chunkSize,
|
||||
) as QueryDeepPartialEntity<StravaActivityStreamPointEntity>[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateJobItem(
|
||||
jobId: string,
|
||||
stravaActivityId: string,
|
||||
@@ -173,6 +199,10 @@ export class StravaSyncService {
|
||||
where: { jobId, stravaActivityId },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.status === 'completed') {
|
||||
return existing;
|
||||
}
|
||||
|
||||
existing.status = 'pending';
|
||||
existing.errorMessage = null;
|
||||
return this.jobItemRepository.save(existing);
|
||||
@@ -203,16 +233,51 @@ export class StravaSyncService {
|
||||
job: StravaSyncJobEntity,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
job.status =
|
||||
error instanceof StravaRateLimitError ? 'rate_limited' : 'failed';
|
||||
const isRateLimit = error instanceof StravaRateLimitError;
|
||||
|
||||
job.status = isRateLimit ? 'rate_limited' : 'failed';
|
||||
job.errorMessage = this.errorMessage(error);
|
||||
job.retryAfter =
|
||||
error instanceof StravaRateLimitError ? error.retryAfter : null;
|
||||
job.finishedAt = new Date();
|
||||
job.retryAfter = isRateLimit
|
||||
? new Date(Date.now() + this.retryDelayMs)
|
||||
: null;
|
||||
job.finishedAt = isRateLimit ? null : new Date();
|
||||
await this.jobRepository.save(job);
|
||||
|
||||
if (isRateLimit) {
|
||||
this.scheduleRetry(job);
|
||||
}
|
||||
}
|
||||
|
||||
private errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Unknown sync error';
|
||||
}
|
||||
|
||||
private scheduleRetry(job: StravaSyncJobEntity): void {
|
||||
if (job.status !== 'rate_limited' || this.retryTimers.has(job.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const retryAfter =
|
||||
job.retryAfter instanceof Date
|
||||
? job.retryAfter
|
||||
: new Date(Date.now() + this.retryDelayMs);
|
||||
const delay = Math.max(retryAfter.getTime() - Date.now(), 0);
|
||||
const timer = setTimeout(() => {
|
||||
this.retryTimers.delete(job.id);
|
||||
void this.runJob(job.id);
|
||||
}, delay);
|
||||
|
||||
timer.unref?.();
|
||||
this.retryTimers.set(job.id, timer);
|
||||
}
|
||||
|
||||
private clearRetry(jobId: string): void {
|
||||
const timer = this.retryTimers.get(jobId);
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
this.retryTimers.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { StravaAuthController } from './strava-auth.controller';
|
||||
import { StravaAuthService } from './strava-auth.service';
|
||||
import { StravaClientService } from './strava-client.service';
|
||||
import { StravaStreamImportService } from './strava-stream-import.service';
|
||||
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
|
||||
import { StravaSyncController } from './strava-sync.controller';
|
||||
import { StravaSyncService } from './strava-sync.service';
|
||||
@@ -37,8 +38,9 @@ import { TokenCryptoService } from './token-crypto.service';
|
||||
StravaTokenService,
|
||||
TokenCryptoService,
|
||||
StravaStreamNormalizerService,
|
||||
StravaStreamImportService,
|
||||
StravaSyncService,
|
||||
],
|
||||
exports: [StravaStreamNormalizerService],
|
||||
exports: [StravaStreamNormalizerService, StravaStreamImportService],
|
||||
})
|
||||
export class StravaModule {}
|
||||
|
||||
@@ -64,6 +64,10 @@ export interface StravaStreamPayload {
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
export type StravaStreamsResponse =
|
||||
| StravaStreamPayload[]
|
||||
| Record<string, Omit<StravaStreamPayload, 'type'> & { type?: string }>;
|
||||
|
||||
export interface StravaRateLimit {
|
||||
limit?: string;
|
||||
usage?: string;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.angular
|
||||
.cache
|
||||
.env
|
||||
.env.*
|
||||
npm-debug.log*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
19
client/package-lock.json
generated
19
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
57
client/src/app/running/running-activity-detail.component.ts
Normal file
57
client/src/app/running/running-activity-detail.component.ts
Normal 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;
|
||||
}
|
||||
68
client/src/app/running/running-dashboard.component.html
Normal file
68
client/src/app/running/running-dashboard.component.html
Normal 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>
|
||||
134
client/src/app/running/running-dashboard.component.scss
Normal file
134
client/src/app/running/running-dashboard.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
59
client/src/app/running/running-dashboard.component.ts
Normal file
59
client/src/app/running/running-dashboard.component.ts
Normal 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;
|
||||
}
|
||||
51
client/src/app/running/running-format.ts
Normal file
51
client/src/app/running/running-format.ts
Normal 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));
|
||||
};
|
||||
|
||||
153
client/src/app/running/running-kpi-dashboard.component.html
Normal file
153
client/src/app/running/running-kpi-dashboard.component.html
Normal 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>
|
||||
244
client/src/app/running/running-kpi-dashboard.component.scss
Normal file
244
client/src/app/running/running-kpi-dashboard.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
337
client/src/app/running/running-kpi-dashboard.component.ts
Normal file
337
client/src/app/running/running-kpi-dashboard.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
client/src/app/running/running-metric-chart.component.html
Normal file
16
client/src/app/running/running-metric-chart.component.html
Normal 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>
|
||||
45
client/src/app/running/running-metric-chart.component.scss
Normal file
45
client/src/app/running/running-metric-chart.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
209
client/src/app/running/running-metric-chart.component.ts
Normal file
209
client/src/app/running/running-metric-chart.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
client/src/app/running/running.module.ts
Normal file
11
client/src/app/running/running.module.ts
Normal 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 {}
|
||||
47
client/src/app/running/running.service.ts
Normal file
47
client/src/app/running/running.service.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
137
client/src/app/running/running.types.ts
Normal file
137
client/src/app/running/running.types.ts
Normal 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[];
|
||||
}
|
||||
116
client/src/app/settings/settings.component.html
Normal file
116
client/src/app/settings/settings.component.html
Normal 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>
|
||||
224
client/src/app/settings/settings.component.scss
Normal file
224
client/src/app/settings/settings.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
226
client/src/app/settings/settings.component.ts
Normal file
226
client/src/app/settings/settings.component.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
8
client/src/app/settings/settings.module.ts
Normal file
8
client/src/app/settings/settings.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { SettingsComponent } from './settings.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [SettingsComponent],
|
||||
exports: [SettingsComponent],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
9
client/src/app/shared/api-base-url.ts
Normal file
9
client/src/app/shared/api-base-url.ts
Normal 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;
|
||||
};
|
||||
@@ -1,18 +1,21 @@
|
||||
services:
|
||||
api:
|
||||
image: strava-mcp-api:latest
|
||||
build:
|
||||
context: ./api
|
||||
env_file:
|
||||
- .env
|
||||
target: production
|
||||
env_file: .env
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
- "127.0.0.1:${API_PORT:-3000}:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
client:
|
||||
image: strava-mcp-client:latest
|
||||
build:
|
||||
context: ./client
|
||||
target: production
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "${CLIENT_PORT:-8080}:80"
|
||||
depends_on:
|
||||
- api
|
||||
restart: unless-stopped
|
||||
|
||||
Reference in New Issue
Block a user