From c94a02e6d013ceae760f3d9fb7b72ee756f464b3 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 17 Jun 2026 10:45:09 +0200 Subject: [PATCH] Docker --- .dockerignore | 15 + .env.example | 2 + Dockerfile | 137 +++ api/.dockerignore | 8 + api/Dockerfile | 31 +- api/src/analytics/analytics.controller.ts | 31 +- api/src/analytics/analytics.module.ts | 14 +- api/src/analytics/analytics.service.spec.ts | 281 +++++- api/src/analytics/analytics.service.ts | 886 +++++++++++++++++- api/src/analytics/analytics.types.ts | 103 ++ api/src/strava/strava-client.service.ts | 23 +- .../strava/strava-stream-import.service.ts | 54 ++ api/src/strava/strava-sync.controller.ts | 5 + api/src/strava/strava-sync.service.spec.ts | 69 ++ api/src/strava/strava-sync.service.ts | 137 ++- api/src/strava/strava.module.ts | 4 +- api/src/strava/strava.types.ts | 4 + client/.dockerignore | 8 + client/Dockerfile | 15 +- client/nginx.conf | 26 + client/package-lock.json | 19 + client/package.json | 3 +- client/src/app/app.html | 141 +-- client/src/app/app.routes.ts | 15 +- client/src/app/app.scss | 263 +----- client/src/app/app.ts | 174 +--- .../app/dashboard/dashboard.component.html | 2 +- .../src/app/dashboard/dashboard.component.ts | 25 +- client/src/app/dashboard/dashboard.service.ts | 2 +- .../running-activity-detail.component.html | 96 ++ .../running-activity-detail.component.scss | 93 ++ .../running-activity-detail.component.ts | 57 ++ .../running/running-dashboard.component.html | 68 ++ .../running/running-dashboard.component.scss | 134 +++ .../running/running-dashboard.component.ts | 59 ++ client/src/app/running/running-format.ts | 51 + .../running-kpi-dashboard.component.html | 153 +++ .../running-kpi-dashboard.component.scss | 244 +++++ .../running-kpi-dashboard.component.ts | 337 +++++++ .../running-metric-chart.component.html | 16 + .../running-metric-chart.component.scss | 45 + .../running/running-metric-chart.component.ts | 209 +++++ client/src/app/running/running.module.ts | 11 + client/src/app/running/running.service.ts | 47 + client/src/app/running/running.types.ts | 137 +++ .../src/app/settings/settings.component.html | 116 +++ .../src/app/settings/settings.component.scss | 224 +++++ client/src/app/settings/settings.component.ts | 226 +++++ client/src/app/settings/settings.module.ts | 8 + client/src/app/shared/api-base-url.ts | 9 + docker-compose.yml | 11 +- 51 files changed, 4220 insertions(+), 628 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 api/src/strava/strava-stream-import.service.ts create mode 100644 api/src/strava/strava-sync.service.spec.ts create mode 100644 client/src/app/running/running-activity-detail.component.html create mode 100644 client/src/app/running/running-activity-detail.component.scss create mode 100644 client/src/app/running/running-activity-detail.component.ts create mode 100644 client/src/app/running/running-dashboard.component.html create mode 100644 client/src/app/running/running-dashboard.component.scss create mode 100644 client/src/app/running/running-dashboard.component.ts create mode 100644 client/src/app/running/running-format.ts create mode 100644 client/src/app/running/running-kpi-dashboard.component.html create mode 100644 client/src/app/running/running-kpi-dashboard.component.scss create mode 100644 client/src/app/running/running-kpi-dashboard.component.ts create mode 100644 client/src/app/running/running-metric-chart.component.html create mode 100644 client/src/app/running/running-metric-chart.component.scss create mode 100644 client/src/app/running/running-metric-chart.component.ts create mode 100644 client/src/app/running/running.module.ts create mode 100644 client/src/app/running/running.service.ts create mode 100644 client/src/app/running/running.types.ts create mode 100644 client/src/app/settings/settings.component.html create mode 100644 client/src/app/settings/settings.component.scss create mode 100644 client/src/app/settings/settings.component.ts create mode 100644 client/src/app/settings/settings.module.ts create mode 100644 client/src/app/shared/api-base-url.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b89918 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.env +.env.* + +**/node_modules +**/dist +**/coverage +**/.angular +**/.cache +**/npm-debug.log* + +api/.dockerignore +api/Dockerfile +client/.dockerignore +client/Dockerfile diff --git a/.env.example b/.env.example index a41ab2d..efd3194 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ PORT=3000 +API_PORT=3000 +CLIENT_PORT=8080 CORS_ORIGIN=http://localhost:8080 CLIENT_URL=http://localhost:8080 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3cc42f4 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/api/.dockerignore b/api/.dockerignore index 6cefa15..9e1231c 100644 --- a/api/.dockerignore +++ b/api/.dockerignore @@ -1,5 +1,13 @@ node_modules dist coverage +.angular +.cache .env +.env.* +npm-debug.log* +Dockerfile +.dockerignore .git +.gitignore +README.md diff --git a/api/Dockerfile b/api/Dockerfile index 3a807c4..3860bf7 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -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"] diff --git a/api/src/analytics/analytics.controller.ts b/api/src/analytics/analytics.controller.ts index f334bcf..13f6359 100644 --- a/api/src/analytics/analytics.controller.ts +++ b/api/src/analytics/analytics.controller.ts @@ -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 { return this.analyticsService.getDashboard(Number(weeks ?? 12), sportType); } + + @Get('running/summary') + runningSummary(@Query('weeks') weeks?: string): Promise { + return this.analyticsService.getRunningSummary(Number(weeks ?? 12)); + } + + @Get('running/kpis') + runningKpis(@Query('weeks') weeks?: string): Promise { + return this.analyticsService.getRunningKpis(Number(weeks ?? 12)); + } + + @Get('running/activities') + runningActivities( + @Query('weeks') weeks?: string, + ): Promise { + return this.analyticsService.getRunningActivities(Number(weeks ?? 12)); + } + + @Get('running/activities/:id') + runningActivity(@Param('id') id: string): Promise { + return this.analyticsService.getRunningActivityDetail(id); + } } diff --git a/api/src/analytics/analytics.module.ts b/api/src/analytics/analytics.module.ts index 446489e..2bfd3e4 100644 --- a/api/src/analytics/analytics.module.ts +++ b/api/src/analytics/analytics.module.ts @@ -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], }) diff --git a/api/src/analytics/analytics.service.spec.ts b/api/src/analytics/analytics.service.spec.ts index 175486c..2de22fd 100644 --- a/api/src/analytics/analytics.service.spec.ts +++ b/api/src/analytics/analytics.service.spec.ts @@ -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; + const streamPointFind = jest.fn().mockResolvedValue(streamPoints); + const importStreamsForActivity = jest + .fn() + .mockResolvedValue(streamPoints.length); + const streamPointRepository = { + find: streamPointFind, + } as unknown as Repository; + 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; }); diff --git a/api/src/analytics/analytics.service.ts b/api/src/analytics/analytics.service.ts index 1bf57ff..c38579d 100644 --- a/api/src/analytics/analytics.service.ts +++ b/api/src/analytics/analytics.service.ts @@ -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, + @InjectRepository(StravaActivityStreamPointEntity) + private readonly streamPointRepository: Repository, + private readonly stravaStreamImportService: StravaStreamImportService, ) {} async getDashboard( @@ -92,12 +123,793 @@ export class AnalyticsService { }; } + async getRunningSummary(weeksInput = 12): Promise { + 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((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 { + 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 { + 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 { + 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 { + return this.streamPointRepository.find({ + where: { activityId }, + order: { pointIndex: 'ASC' }, + }); + } + + private async findStreamPointsByActivity( + activityIds: string[], + ): Promise> { + 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 { + 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, + ): 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(); + + 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 = { + 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, + ): 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, + ): 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( + (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, + ), + ); + } } diff --git a/api/src/analytics/analytics.types.ts b/api/src/analytics/analytics.types.ts index b5018c7..dc87043 100644 --- a/api/src/analytics/analytics.types.ts +++ b/api/src/analytics/analytics.types.ts @@ -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[]; +} diff --git a/api/src/strava/strava-client.service.ts b/api/src/strava/strava-client.service.ts index c78d8b7..e802544 100644 --- a/api/src/strava/strava-client.service.ts +++ b/api/src/strava/strava-client.service.ts @@ -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 { - return this.request({ + const streams = await this.request({ 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(key); if (!value) { diff --git a/api/src/strava/strava-stream-import.service.ts b/api/src/strava/strava-stream-import.service.ts new file mode 100644 index 0000000..cd89581 --- /dev/null +++ b/api/src/strava/strava-stream-import.service.ts @@ -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, + private readonly stravaTokenService: StravaTokenService, + private readonly stravaClientService: StravaClientService, + private readonly streamNormalizer: StravaStreamNormalizerService, + ) {} + + async importStreamsForActivity( + activity: StravaActivityEntity, + ): Promise { + 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[], + ): Promise { + const chunkSize = 1000; + for (let index = 0; index < points.length; index += chunkSize) { + await this.streamPointRepository.insert( + points.slice( + index, + index + chunkSize, + ) as QueryDeepPartialEntity[], + ); + } + } +} diff --git a/api/src/strava/strava-sync.controller.ts b/api/src/strava/strava-sync.controller.ts index 4ded36a..ab5e2f6 100644 --- a/api/src/strava/strava-sync.controller.ts +++ b/api/src/strava/strava-sync.controller.ts @@ -11,6 +11,11 @@ export class StravaSyncController { return this.stravaSyncService.startSync(); } + @Get('jobs/latest') + getLatestJob(): Promise { + return this.stravaSyncService.getLatestJob(); + } + @Get('jobs/:id') getJob(@Param('id') id: string): Promise { return this.stravaSyncService.getJob(id); diff --git a/api/src/strava/strava-sync.service.spec.ts b/api/src/strava/strava-sync.service.spec.ts new file mode 100644 index 0000000..5452c66 --- /dev/null +++ b/api/src/strava/strava-sync.service.spec.ts @@ -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) => { + 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); + }); +}); diff --git a/api/src/strava/strava-sync.service.ts b/api/src/strava/strava-sync.service.ts index 439d821..df4da0c 100644 --- a/api/src/strava/strava-sync.service.ts +++ b/api/src/strava/strava-sync.service.ts @@ -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(); + constructor( @InjectRepository(StravaAthleteEntity) private readonly athleteRepository: Repository, @InjectRepository(StravaActivityEntity) private readonly activityRepository: Repository, - @InjectRepository(StravaActivityStreamPointEntity) - private readonly streamPointRepository: Repository, @InjectRepository(StravaSyncJobEntity) private readonly jobRepository: Repository, @InjectRepository(StravaSyncJobItemEntity) private readonly jobItemRepository: Repository, private readonly stravaTokenService: StravaTokenService, private readonly stravaClientService: StravaClientService, - private readonly streamNormalizer: StravaStreamNormalizerService, + private readonly stravaStreamImportService: StravaStreamImportService, ) {} + async onModuleInit(): Promise { + const waitingJobs = await this.jobRepository.find({ + where: { status: 'rate_limited' }, + }); + + waitingJobs.forEach((job) => this.scheduleRetry(job)); + } + async startSync(): Promise { 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 { + 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 { const job = await this.jobRepository.findOne({ where: { id: jobId }, @@ -61,9 +97,14 @@ export class StravaSyncService { } async runJob(jobId: string): Promise { + 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[], - ): Promise { - const chunkSize = 1000; - for (let index = 0; index < points.length; index += chunkSize) { - await this.streamPointRepository.insert( - points.slice( - index, - index + chunkSize, - ) as QueryDeepPartialEntity[], - ); - } - } - 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 { - 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); + } } diff --git a/api/src/strava/strava.module.ts b/api/src/strava/strava.module.ts index 197479a..ebb5889 100644 --- a/api/src/strava/strava.module.ts +++ b/api/src/strava/strava.module.ts @@ -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 {} diff --git a/api/src/strava/strava.types.ts b/api/src/strava/strava.types.ts index a17c816..76a4421 100644 --- a/api/src/strava/strava.types.ts +++ b/api/src/strava/strava.types.ts @@ -64,6 +64,10 @@ export interface StravaStreamPayload { resolution?: string; } +export type StravaStreamsResponse = + | StravaStreamPayload[] + | Record & { type?: string }>; + export interface StravaRateLimit { limit?: string; usage?: string; diff --git a/client/.dockerignore b/client/.dockerignore index 84d181f..9e1231c 100644 --- a/client/.dockerignore +++ b/client/.dockerignore @@ -1,5 +1,13 @@ node_modules dist +coverage .angular +.cache .env +.env.* +npm-debug.log* +Dockerfile +.dockerignore .git +.gitignore +README.md diff --git a/client/Dockerfile b/client/Dockerfile index cd14166..84b0ba6 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -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 diff --git a/client/nginx.conf b/client/nginx.conf index 79fd959..b62d867 100644 --- a/client/nginx.conf +++ b/client/nginx.conf @@ -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; } } diff --git a/client/package-lock.json b/client/package-lock.json index b314482..e414cc7 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 1029772..0fafbc9 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", + "chart.js": "^4.5.1", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -29,4 +30,4 @@ "typescript": "~5.9.2", "vitest": "^4.0.8" } -} \ No newline at end of file +} diff --git a/client/src/app/app.html b/client/src/app/app.html index 5831162..c755a74 100644 --- a/client/src/app/app.html +++ b/client/src/app/app.html @@ -1,137 +1,12 @@
-
-
-

Strava Datenimport

-

Verbindung zu Strava

-

- Verbinde deinen Strava Account einmalig, damit die API Aktivitaeten und - Streams serverseitig abrufen kann. -

-
+ - @if (justConnected()) { -
- Strava wurde verbunden. Der Token wurde im Backend gespeichert. -
- } - - @if (connectionCanceled()) { - - } - - @if (error()) { - - } - - @if (syncError()) { - - } - -
-
- Status - @if (loading()) { - Pruefe Verbindung... - } @else if (status()?.connected) { - Verbunden - } @else { - Nicht verbunden - } -
- - -
- - @if (status()?.connected && status()?.athlete; as athlete) { -
- @if (athlete.profile) { - - } @else { - - } - -
- Account - {{ athleteName() }} - Strava ID {{ athlete.stravaAthleteId }} -
-
- } - -
-
-
- Activities Sync - {{ syncStatusLabel() }} -
- - -
- - @if (syncJob(); as job) { -
-
- Activities - {{ job.activityCount }} -
-
- Details - {{ job.detailCount }} -
-
- Stream Punkte - {{ job.streamPointCount }} -
-
- - @if (job.errorMessage) { -

{{ job.errorMessage }}

- } - } @else { -

- Noch kein Sync gestartet. -

- } -
- -
- -
+
+
- -
diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts index dc39edb..525fe17 100644 --- a/client/src/app/app.routes.ts +++ b/client/src/app/app.routes.ts @@ -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' }, +]; diff --git a/client/src/app/app.scss b/client/src/app/app.scss index e49486a..6dc3954 100644 --- a/client/src/app/app.scss +++ b/client/src/app/app.scss @@ -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; - } } diff --git a/client/src/app/app.ts b/client/src/app/app.ts index 40998a5..12e1a93 100644 --- a/client/src/app/app.ts +++ b/client/src/app/app.ts @@ -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(null); - protected readonly loading = signal(true); - protected readonly error = signal(null); - protected readonly syncError = signal(null); - protected readonly syncJob = signal(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(`${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(`${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(`${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 {} diff --git a/client/src/app/dashboard/dashboard.component.html b/client/src/app/dashboard/dashboard.component.html index 82decab..27f6c47 100644 --- a/client/src/app/dashboard/dashboard.component.html +++ b/client/src/app/dashboard/dashboard.component.html @@ -24,7 +24,7 @@ type="button" class="icon-button" (click)="loadDashboard()" - [disabled]="dashboardLoading() || !connected" + [disabled]="dashboardLoading()" title="Dashboard aktualisieren" > diff --git a/client/src/app/dashboard/dashboard.component.ts b/client/src/app/dashboard/dashboard.component.ts index 8d77941..bc69163 100644 --- a/client/src/app/dashboard/dashboard.component.ts +++ b/client/src/app/dashboard/dashboard.component.ts @@ -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(null); protected readonly dashboardLoading = signal(false); protected readonly dashboardError = signal(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); diff --git a/client/src/app/dashboard/dashboard.service.ts b/client/src/app/dashboard/dashboard.service.ts index 4d5d4bd..3ca0aea 100644 --- a/client/src/app/dashboard/dashboard.service.ts +++ b/client/src/app/dashboard/dashboard.service.ts @@ -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); diff --git a/client/src/app/running/running-activity-detail.component.html b/client/src/app/running/running-activity-detail.component.html new file mode 100644 index 0000000..a7b04c7 --- /dev/null +++ b/client/src/app/running/running-activity-detail.component.html @@ -0,0 +1,96 @@ +
+ Zurueck zur Laufanalyse + + @if (error()) { +
{{ error() }}
+ } + + @if (loading()) { +
Laufdetails werden geladen...
+ } @else if (detail(); as data) { +
+

{{ data.activity.sportType ?? 'Run' }} | {{ shortDate(data.activity.startDate) }}

+

{{ data.activity.name }}

+
+
Distanz{{ distanceKm(data.activity.distanceMeters) }}
+
Zeit{{ duration(data.activity.movingTimeSeconds) }}
+
Pace{{ pace(data.activity.averageSpeedMetersPerSecond ? 1000 / data.activity.averageSpeedMetersPerSecond : null) }}
+
Hoehenmeter{{ elevation(data.activity.elevationGainMeters) }}
+
+
+ +
+

Kilometer Splits

+ @if (data.splits.length === 0) { +

Keine Stream-Daten fuer Splits vorhanden.

+ } @else { + + + + + + + + + + + + + @for (split of data.splits; track split.kilometer) { + + + + + + + + + } + +
KmPaceZeitHFKadenzHM+
{{ split.kilometer }}{{ pace(split.paceSecondsPerKm) }}{{ duration(split.movingTimeSeconds) }}{{ number(split.averageHeartRate, ' bpm') }}{{ number(split.averageCadence) }}{{ elevation(split.elevationGainMeters) }}
+ } +
+ +
+ + + + +
+ } +
diff --git a/client/src/app/running/running-activity-detail.component.scss b/client/src/app/running/running-activity-detail.component.scss new file mode 100644 index 0000000..fd4f707 --- /dev/null +++ b/client/src/app/running/running-activity-detail.component.scss @@ -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; + } +} diff --git a/client/src/app/running/running-activity-detail.component.ts b/client/src/app/running/running-activity-detail.component.ts new file mode 100644 index 0000000..cbb2b2d --- /dev/null +++ b/client/src/app/running/running-activity-detail.component.ts @@ -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(null); + protected readonly loading = signal(true); + protected readonly error = signal(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; +} diff --git a/client/src/app/running/running-dashboard.component.html b/client/src/app/running/running-dashboard.component.html new file mode 100644 index 0000000..e4c801b --- /dev/null +++ b/client/src/app/running/running-dashboard.component.html @@ -0,0 +1,68 @@ +
+
+
+

Laufanalyse

+

Running Dashboard

+
+ +
+ + @if (error()) { +
{{ error() }}
+ } + + @if (loading()) { +
Laufanalyse wird geladen...
+ } @else if (summary(); as data) { + @if (data.totals.activityCount === 0) { +
Keine Laeufe im Zeitraum gefunden.
+ } @else { +
+
Laeufe{{ data.totals.activityCount }}
+
Distanz{{ distanceKm(data.totals.distanceMeters) }}
+
Zeit{{ duration(data.totals.movingTimeSeconds) }}
+
Pace{{ pace(data.averages.paceSecondsPerKm) }}
+
4W Schnitt{{ distanceKm(data.fourWeekAverageDistanceMeters) }}
+
Laengster Lauf{{ distanceKm(data.longestRun?.distanceMeters) }}
+
HM / km{{ number(data.elevationGainPerKm, ' m') }}
+
Longrun Anteil{{ number(data.longRunShare, ' %') }}
+
+ +
+
+

Wochenumfang

+ {{ data.rangeStart }} bis {{ data.rangeEnd }} +
+
+ @for (week of data.weekly; track week.weekStart) { +
+
+
+
+ {{ shortDate(week.weekStart) }} +
+ } +
+
+ + + } + } +
diff --git a/client/src/app/running/running-dashboard.component.scss b/client/src/app/running/running-dashboard.component.scss new file mode 100644 index 0000000..664a346 --- /dev/null +++ b/client/src/app/running/running-dashboard.component.scss @@ -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; + } +} diff --git a/client/src/app/running/running-dashboard.component.ts b/client/src/app/running/running-dashboard.component.ts new file mode 100644 index 0000000..f68237f --- /dev/null +++ b/client/src/app/running/running-dashboard.component.ts @@ -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(null); + protected readonly activities = signal([]); + protected readonly loading = signal(true); + protected readonly error = signal(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; +} diff --git a/client/src/app/running/running-format.ts b/client/src/app/running/running-format.ts new file mode 100644 index 0000000..70427bf --- /dev/null +++ b/client/src/app/running/running-format.ts @@ -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)); +}; + diff --git a/client/src/app/running/running-kpi-dashboard.component.html b/client/src/app/running/running-kpi-dashboard.component.html new file mode 100644 index 0000000..9e0f90f --- /dev/null +++ b/client/src/app/running/running-kpi-dashboard.component.html @@ -0,0 +1,153 @@ +
+
+
+

Laufanalyse

+

KPI Dashboard

+
+
+ Laufen + +
+
+ + @if (error()) { +
{{ error() }}
+ } + + @if (loading()) { +
KPI Dashboard wird geladen...
+ } @else if (kpis(); as data) { + @if (totalRuns(data) === 0) { +
Keine Laeufe im Zeitraum gefunden.
+ } @else { +
+
+ Akute Belastung + {{ formatLoad(data.load.acute) }} +
+
+ Chronische Belastung + {{ formatLoad(data.load.chronic) }} +
+
+ A/C Ratio + {{ formatRatio(data.load.acuteChronicRatio) }} +
+
+ Erholung + {{ data.recovery.score }} +
+
+ Monotony + {{ formatRatio(data.monotony.value) }} +
+
+ Strain + {{ formatLoad(data.strain.value) }} +
+
+ Letzter harter Lauf + {{ formatLoad(data.recovery.daysSinceLastHardRun) }} d +
+
+ Status + {{ recoveryLabel(data.recovery.status) }} +
+
+ +
+
+

Erholungsindikator

+

{{ data.recovery.message }}

+
+
+
+
+
+ {{ data.recovery.score }} / 100 +
+
+ +
+
+
+

Weekly Load

+ {{ data.rangeStart }} bis {{ data.rangeEnd }} +
+
+
+ +
+
+

Akut vs. chronisch

+ Load Trend +
+
+
+ +
+
+

Easy / Moderate / Hard

+ Zeitanteil +
+
+
+ +
+
+

Progression

+ 4 Wochen vs. vorherige 4 Wochen +
+
+
+
+ +
+
+

Progression

+ Positive Werte sind besser +
+
+ @for (metric of data.progression; track metric.key) { +
+ {{ metric.label }} + {{ metricValue(metric) }} + Vorher: {{ previousMetricValue(metric) }} + {{ changeLabel(metric.changePercent) }} +
+ } +
+
+ +
+
+

PR Schaetzungen

+ Aus Streams, sonst Pace-Fallback +
+
+ @for (record of data.personalRecords; track record.distanceMeters) { + @if (record.activityId) { + + {{ distanceKm(record.distanceMeters) }} + {{ duration(record.timeSeconds) }} + {{ pace(record.paceSecondsPerKm) }} + {{ record.activityName }} + {{ shortDate(record.startDate) }} + {{ record.estimated ? 'geschaetzt' : 'exakt' }} + + } @else { +
+ {{ distanceKm(record.distanceMeters) }} + - + - + Keine Daten + - + geschaetzt +
+ } + } +
+
+ } + } +
diff --git a/client/src/app/running/running-kpi-dashboard.component.scss b/client/src/app/running/running-kpi-dashboard.component.scss new file mode 100644 index 0000000..c2887f7 --- /dev/null +++ b/client/src/app/running/running-kpi-dashboard.component.scss @@ -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; + } +} diff --git a/client/src/app/running/running-kpi-dashboard.component.ts b/client/src/app/running/running-kpi-dashboard.component.ts new file mode 100644 index 0000000..f8f453b --- /dev/null +++ b/client/src/app/running/running-kpi-dashboard.component.ts @@ -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; + @ViewChild('loadRatioCanvas') private loadRatioCanvas?: ElementRef; + @ViewChild('distributionCanvas') private distributionCanvas?: ElementRef; + @ViewChild('progressionCanvas') private progressionCanvas?: ElementRef; + + private readonly runningService = inject(RunningService); + private readonly apiBaseUrl = resolveApiBaseUrl(); + private readonly charts: Chart[] = []; + + protected readonly kpis = signal(null); + protected readonly loading = signal(true); + protected readonly error = signal(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; + } +} diff --git a/client/src/app/running/running-metric-chart.component.html b/client/src/app/running/running-metric-chart.component.html new file mode 100644 index 0000000..1a395f8 --- /dev/null +++ b/client/src/app/running/running-metric-chart.component.html @@ -0,0 +1,16 @@ +
+
+

{{ title }}

+ @if (summaryLabel()) { + {{ summaryLabel() }} + } +
+ +
+ +
+ + @if (!hasData()) { +

{{ emptyText }}

+ } +
diff --git a/client/src/app/running/running-metric-chart.component.scss b/client/src/app/running/running-metric-chart.component.scss new file mode 100644 index 0000000..31f8869 --- /dev/null +++ b/client/src/app/running/running-metric-chart.component.scss @@ -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; + } +} diff --git a/client/src/app/running/running-metric-chart.component.ts b/client/src/app/running/running-metric-chart.component.ts new file mode 100644 index 0000000..26d0144 --- /dev/null +++ b/client/src/app/running/running-metric-chart.component.ts @@ -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; + + @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; + } +} diff --git a/client/src/app/running/running.module.ts b/client/src/app/running/running.module.ts new file mode 100644 index 0000000..e9e4cab --- /dev/null +++ b/client/src/app/running/running.module.ts @@ -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 {} diff --git a/client/src/app/running/running.service.ts b/client/src/app/running/running.service.ts new file mode 100644 index 0000000..fd1c2c3 --- /dev/null +++ b/client/src/app/running/running.service.ts @@ -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 { + const params = new HttpParams().set('weeks', weeks); + return this.http.get( + `${apiBaseUrl}/analytics/running/summary`, + { params }, + ); + } + + getActivities(apiBaseUrl: string, weeks = 12): Observable { + const params = new HttpParams().set('weeks', weeks); + return this.http.get( + `${apiBaseUrl}/analytics/running/activities`, + { params }, + ); + } + + getKpis(apiBaseUrl: string, weeks = 12): Observable { + const params = new HttpParams().set('weeks', weeks); + return this.http.get( + `${apiBaseUrl}/analytics/running/kpis`, + { params }, + ); + } + + getActivityDetail( + apiBaseUrl: string, + id: string, + ): Observable { + return this.http.get( + `${apiBaseUrl}/analytics/running/activities/${id}`, + ); + } +} diff --git a/client/src/app/running/running.types.ts b/client/src/app/running/running.types.ts new file mode 100644 index 0000000..260d233 --- /dev/null +++ b/client/src/app/running/running.types.ts @@ -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[]; +} diff --git a/client/src/app/settings/settings.component.html b/client/src/app/settings/settings.component.html new file mode 100644 index 0000000..ec54aab --- /dev/null +++ b/client/src/app/settings/settings.component.html @@ -0,0 +1,116 @@ +
+
+

Strava Datenimport

+

Verbindung zu Strava

+

+ Verbinde deinen Strava Account einmalig, damit die API Aktivitaeten und + Streams serverseitig abrufen kann. +

+
+ + @if (justConnected()) { +
+ Strava wurde verbunden. Der Token wurde im Backend gespeichert. +
+ } + + @if (connectionCanceled()) { + + } + + @if (error()) { + + } + + @if (syncError()) { + + } + +
+
+ Status + @if (loading()) { + Pruefe Verbindung... + } @else if (status()?.connected) { + Verbunden + } @else { + Nicht verbunden + } +
+ + +
+ + @if (status()?.connected && status()?.athlete; as athlete) { +
+ @if (athlete.profile) { + + } @else { + + } + +
+ Account + {{ athleteName() }} + Strava ID {{ athlete.stravaAthleteId }} +
+
+ } + +
+
+
+ Activities Sync + {{ syncStatusLabel() }} +
+ + +
+ + @if (syncJob(); as job) { +
+
+ Activities + {{ job.activityCount }} +
+
+ Details + {{ job.detailCount }} +
+
+ Stream Punkte + {{ job.streamPointCount }} +
+
+ + @if (job.errorMessage) { +

{{ job.errorMessage }}

+ } + + @if (retryLabel(); as retry) { +

{{ retry }}

+ } + } @else { +

Noch kein Sync gestartet.

+ } +
+ +
+ +
+
diff --git a/client/src/app/settings/settings.component.scss b/client/src/app/settings/settings.component.scss new file mode 100644 index 0000000..9888af8 --- /dev/null +++ b/client/src/app/settings/settings.component.scss @@ -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; + } +} diff --git a/client/src/app/settings/settings.component.ts b/client/src/app/settings/settings.component.ts new file mode 100644 index 0000000..8fd4a21 --- /dev/null +++ b/client/src/app/settings/settings.component.ts @@ -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(null); + protected readonly loading = signal(true); + protected readonly error = signal(null); + protected readonly syncError = signal(null); + protected readonly syncJob = signal(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(`${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(`${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(`${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(`${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' + ); + } +} diff --git a/client/src/app/settings/settings.module.ts b/client/src/app/settings/settings.module.ts new file mode 100644 index 0000000..0933d8d --- /dev/null +++ b/client/src/app/settings/settings.module.ts @@ -0,0 +1,8 @@ +import { NgModule } from '@angular/core'; +import { SettingsComponent } from './settings.component'; + +@NgModule({ + imports: [SettingsComponent], + exports: [SettingsComponent], +}) +export class SettingsModule {} diff --git a/client/src/app/shared/api-base-url.ts b/client/src/app/shared/api-base-url.ts new file mode 100644 index 0000000..6aadad9 --- /dev/null +++ b/client/src/app/shared/api-base-url.ts @@ -0,0 +1,9 @@ +export const resolveApiBaseUrl = (): string => { + const { protocol, hostname, port, origin } = window.location; + + if (port === '4200') { + return `${protocol}//${hostname}:3000`; + } + + return origin; +}; diff --git a/docker-compose.yml b/docker-compose.yml index 602d4b2..c9c5f8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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