From 7423920f34b01292f3e8c29bfb58def9b16f3ad8 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 17 Jun 2026 15:41:49 +0200 Subject: [PATCH] details --- api/src/analytics/analytics.controller.ts | 18 ++ api/src/analytics/analytics.module.ts | 4 + api/src/analytics/analytics.service.spec.ts | 9 + api/src/analytics/analytics.service.ts | 120 +++++++++++ .../app/activities/activities.component.html | 70 +++++++ .../app/activities/activities.component.scss | 159 +++++++++++++++ .../app/activities/activities.component.ts | 100 +++++++++ .../src/app/activities/activities.service.ts | 30 +++ .../activities/activity-detail.component.html | 133 ++++++++++++ .../activities/activity-detail.component.scss | 191 ++++++++++++++++++ .../activities/activity-detail.component.ts | 91 +++++++++ client/src/app/activities/activity-format.ts | 88 ++++++++ client/src/app/activities/activity.types.ts | 81 ++++++++ client/src/app/app.html | 1 + client/src/app/app.routes.ts | 4 + .../app/dashboard/dashboard.component.html | 4 +- .../app/dashboard/dashboard.component.scss | 6 + .../src/app/dashboard/dashboard.component.ts | 2 + 18 files changed, 1109 insertions(+), 2 deletions(-) create mode 100644 client/src/app/activities/activities.component.html create mode 100644 client/src/app/activities/activities.component.scss create mode 100644 client/src/app/activities/activities.component.ts create mode 100644 client/src/app/activities/activities.service.ts create mode 100644 client/src/app/activities/activity-detail.component.html create mode 100644 client/src/app/activities/activity-detail.component.scss create mode 100644 client/src/app/activities/activity-detail.component.ts create mode 100644 client/src/app/activities/activity-format.ts create mode 100644 client/src/app/activities/activity.types.ts diff --git a/api/src/analytics/analytics.controller.ts b/api/src/analytics/analytics.controller.ts index 13f6359..703f6ee 100644 --- a/api/src/analytics/analytics.controller.ts +++ b/api/src/analytics/analytics.controller.ts @@ -38,6 +38,24 @@ export class AnalyticsController { return this.analyticsService.getRunningActivities(Number(weeks ?? 12)); } + @Get('activities') + activities( + @Query('weeks') weeks?: string, + @Query('sportType') sportType?: string, + @Query('limit') limit?: string, + ): Promise { + return this.analyticsService.getActivities( + Number(weeks ?? 52), + sportType, + Number(limit ?? 100), + ); + } + + @Get('activities/:id') + activity(@Param('id') id: string): Promise> { + return this.analyticsService.getActivityDetail(id); + } + @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 9b8eca5..159c54d 100644 --- a/api/src/analytics/analytics.module.ts +++ b/api/src/analytics/analytics.module.ts @@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { StravaActivityEntity, StravaActivityStreamPointEntity, + StravaStrengthTrainingDetailEntity, + StravaSwimActivityDetailEntity, } from '../database/entities'; import { StravaModule } from '../strava/strava.module'; import { AnalyticsController } from './analytics.controller'; @@ -14,6 +16,8 @@ import { AnalyticsService } from './analytics.service'; TypeOrmModule.forFeature([ StravaActivityEntity, StravaActivityStreamPointEntity, + StravaStrengthTrainingDetailEntity, + StravaSwimActivityDetailEntity, ]), ], controllers: [AnalyticsController], diff --git a/api/src/analytics/analytics.service.spec.ts b/api/src/analytics/analytics.service.spec.ts index 2de22fd..7294e8d 100644 --- a/api/src/analytics/analytics.service.spec.ts +++ b/api/src/analytics/analytics.service.spec.ts @@ -31,7 +31,14 @@ describe('AnalyticsService', () => { .mockResolvedValue(streamPoints.length); const streamPointRepository = { find: streamPointFind, + count: jest.fn().mockResolvedValue(streamPoints.length), } as unknown as Repository; + const strengthTrainingDetailRepository = { + findOne: jest.fn().mockResolvedValue(null), + }; + const swimActivityDetailRepository = { + findOne: jest.fn().mockResolvedValue(null), + }; const stravaStreamImportService = { importStreamsForActivity, } as unknown as StravaStreamImportService; @@ -40,6 +47,8 @@ describe('AnalyticsService', () => { service: new AnalyticsService( activityRepository, streamPointRepository, + strengthTrainingDetailRepository as never, + swimActivityDetailRepository as never, stravaStreamImportService, ), activityRepository, diff --git a/api/src/analytics/analytics.service.ts b/api/src/analytics/analytics.service.ts index c38579d..40e115b 100644 --- a/api/src/analytics/analytics.service.ts +++ b/api/src/analytics/analytics.service.ts @@ -4,6 +4,8 @@ import { In, MoreThanOrEqual, Repository } from 'typeorm'; import { StravaActivityEntity, StravaActivityStreamPointEntity, + StravaStrengthTrainingDetailEntity, + StravaSwimActivityDetailEntity, } from '../database/entities'; import { StravaStreamImportService } from '../strava/strava-stream-import.service'; import { @@ -46,6 +48,10 @@ export class AnalyticsService { private readonly activityRepository: Repository, @InjectRepository(StravaActivityStreamPointEntity) private readonly streamPointRepository: Repository, + @InjectRepository(StravaStrengthTrainingDetailEntity) + private readonly strengthTrainingDetailRepository: Repository, + @InjectRepository(StravaSwimActivityDetailEntity) + private readonly swimActivityDetailRepository: Repository, private readonly stravaStreamImportService: StravaStreamImportService, ) {} @@ -262,6 +268,80 @@ export class AnalyticsService { .map((activity) => this.toRecentActivity(activity)); } + async getActivities( + weeksInput = 52, + sportTypeInput?: string, + limitInput = 100, + ): Promise { + const weeks = this.clampWeeks(weeksInput); + const limit = this.clamp(limitInput, 1, 200); + const selectedSportType = this.normalizeSportType(sportTypeInput); + const rangeStart = this.startOfWeek( + this.addDays(new Date(), -(weeks - 1) * 7), + ); + const activities = await this.findActivitiesInRange(rangeStart); + + return activities + .filter((activity) => + selectedSportType + ? (activity.sportType ?? 'Unbekannt') === selectedSportType + : true, + ) + .slice(0, limit) + .map((activity) => this.toRecentActivity(activity)); + } + + async getActivityDetail(id: string): Promise> { + const activity = await this.activityRepository.findOne({ + where: [{ id }, { stravaActivityId: id }], + }); + if (!activity) { + throw new NotFoundException('Activity not found'); + } + + const [streamPointCount, strengthTrainingDetail, swimActivityDetail] = + await Promise.all([ + this.streamPointRepository.count({ where: { activityId: activity.id } }), + this.strengthTrainingDetailRepository.findOne({ + where: { activityId: activity.id }, + }), + this.swimActivityDetailRepository.findOne({ + where: { activityId: activity.id }, + }), + ]); + + return { + ...this.toRecentActivity(activity), + startDateLocal: activity.startDateLocal + ? activity.startDateLocal.toISOString() + : null, + elapsedTimeSeconds: activity.elapsedTime, + totalElevationGainMeters: activity.totalElevationGain, + maxSpeedMetersPerSecond: activity.maxSpeed, + maxHeartrate: activity.maxHeartrate, + averageWatts: activity.averageWatts, + maxWatts: activity.maxWatts, + weightedAverageWatts: activity.weightedAverageWatts, + averageCadence: activity.averageCadence, + calories: activity.calories, + gearId: activity.gearId, + trainer: activity.trainer, + commute: activity.commute, + manual: activity.manual, + private: activity.private, + visibility: activity.visibility, + mapId: activity.mapId, + summaryPolyline: activity.summaryPolyline, + streamPointCount, + strengthTrainingDetail: strengthTrainingDetail + ? this.toStrengthTrainingDetail(strengthTrainingDetail) + : null, + swimActivityDetail: swimActivityDetail + ? this.toSwimActivityDetail(swimActivityDetail) + : null, + }; + } + async getRunningActivityDetail(id: string): Promise { const activity = await this.activityRepository.findOne({ where: { id } }); if (!activity || !this.isRunningActivity(activity)) { @@ -982,6 +1062,46 @@ export class AnalyticsService { }; } + private toStrengthTrainingDetail( + detail: StravaStrengthTrainingDetailEntity, + ): Record { + return { + movingTimeSeconds: detail.movingTime, + elapsedTimeSeconds: detail.elapsedTime, + calories: detail.calories, + averageHeartrate: detail.averageHeartrate, + maxHeartrate: detail.maxHeartrate, + perceivedExertion: detail.perceivedExertion, + workoutType: detail.workoutType, + description: detail.description, + rawPayload: detail.rawPayload, + }; + } + + private toSwimActivityDetail( + detail: StravaSwimActivityDetailEntity, + ): Record { + return { + distanceMeters: detail.distance, + movingTimeSeconds: detail.movingTime, + elapsedTimeSeconds: detail.elapsedTime, + averageSpeedMetersPerSecond: detail.averageSpeed, + maxSpeedMetersPerSecond: detail.maxSpeed, + paceSecondsPer100m: + detail.distance && detail.distance > 0 && detail.movingTime + ? Math.round(detail.movingTime / (detail.distance / 100)) + : null, + calories: detail.calories, + averageHeartrate: detail.averageHeartrate, + maxHeartrate: detail.maxHeartrate, + averageCadence: detail.averageCadence, + poolLength: detail.poolLength, + laps: detail.laps, + lapsError: detail.lapsError, + rawPayload: detail.rawPayload, + }; + } + private createTotals(): AnalyticsTotals { return { activityCount: 0, diff --git a/client/src/app/activities/activities.component.html b/client/src/app/activities/activities.component.html new file mode 100644 index 0000000..3742cc5 --- /dev/null +++ b/client/src/app/activities/activities.component.html @@ -0,0 +1,70 @@ +
+
+
+

Aktivitaeten

+

Alle Sportarten

+
+ +
+ + + +
+
+ + @if (error()) { +
{{ error() }}
+ } + + @if (loading()) { +
Aktivitaeten werden geladen...
+ } @else { +
+
Anzahl{{ activities().length }}
+
Distanz{{ distanceKm(totalDistance()) }}
+
Zeit{{ duration(totalTime()) }}
+
+ + @if (activities().length === 0) { +
Keine Aktivitaeten fuer diese Auswahl.
+ } @else { + + } + } +
diff --git a/client/src/app/activities/activities.component.scss b/client/src/app/activities/activities.component.scss new file mode 100644 index 0000000..388e827 --- /dev/null +++ b/client/src/app/activities/activities.component.scss @@ -0,0 +1,159 @@ +:host { + display: block; +} + +.activities { + display: grid; + gap: 18px; +} + +.heading, +.summary, +.activity-row, +.notice, +.empty-state { + background: #ffffff; + border: 1px solid #dfe4ec; + border-radius: 8px; +} + +.heading { + align-items: center; + display: flex; + justify-content: space-between; + padding: 24px; +} + +.eyebrow { + color: #687386; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + margin: 0 0 8px; + text-transform: uppercase; +} + +h1 { + font-size: 1.55rem; + margin: 0; +} + +.tools { + display: flex; + gap: 12px; +} + +.tools label { + color: #687386; + display: grid; + font-size: 0.78rem; + font-weight: 700; + gap: 6px; +} + +.tools select { + background: #ffffff; + border: 1px solid #c8d0dc; + border-radius: 6px; + color: #18212f; + font: inherit; + min-height: 40px; + min-width: 150px; + padding: 0 34px 0 12px; +} + +.summary { + display: grid; + gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding: 18px; +} + +.summary span, +.activity-main span, +.activity-metrics span { + color: #687386; + font-size: 0.82rem; +} + +.summary strong { + display: block; + font-size: 1.35rem; + margin-top: 4px; +} + +.activity-list { + display: grid; + gap: 10px; +} + +.activity-row { + align-items: center; + color: inherit; + display: grid; + gap: 16px; + grid-template-columns: minmax(0, 1.4fr) minmax(360px, 1fr); + padding: 16px; + text-decoration: none; + transition: + border-color 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; +} + +.activity-row:hover { + border-color: #fc4c02; + box-shadow: 0 10px 28px rgba(24, 33, 47, 0.1); + transform: translateY(-1px); +} + +.activity-main { + display: grid; + gap: 4px; + min-width: 0; +} + +.activity-main strong { + overflow-wrap: anywhere; +} + +.sport { + color: #fc4c02; + font-weight: 800; +} + +.activity-metrics { + display: grid; + gap: 10px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + text-align: right; +} + +.notice, +.empty-state { + color: #687386; + padding: 18px; +} + +.error { + background: #fff0ed; + color: #9b2915; +} + +@media (max-width: 760px) { + .heading, + .tools { + align-items: stretch; + flex-direction: column; + } + + .summary, + .activity-row, + .activity-metrics { + grid-template-columns: 1fr; + } + + .activity-metrics { + text-align: left; + } +} diff --git a/client/src/app/activities/activities.component.ts b/client/src/app/activities/activities.component.ts new file mode 100644 index 0000000..38609b8 --- /dev/null +++ b/client/src/app/activities/activities.component.ts @@ -0,0 +1,100 @@ +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { resolveApiBaseUrl } from '../shared/api-base-url'; +import { ActivitiesService } from './activities.service'; +import { ActivitySummary } from './activity.types'; +import { + distanceKm, + duration, + number, + pace, + shortDate, + speed, +} from './activity-format'; + +@Component({ + selector: 'app-activities', + standalone: true, + imports: [RouterLink], + templateUrl: './activities.component.html', + styleUrl: './activities.component.scss', +}) +export class ActivitiesComponent implements OnInit { + private readonly activitiesService = inject(ActivitiesService); + private readonly apiBaseUrl = resolveApiBaseUrl(); + protected readonly activities = signal([]); + protected readonly loading = signal(true); + protected readonly error = signal(null); + protected readonly selectedSportType = signal(null); + protected readonly weeks = signal(52); + protected readonly sportTypes = computed(() => + Array.from( + new Set( + this.activities().map((activity) => activity.sportType ?? 'Unbekannt'), + ), + ).sort((left, right) => left.localeCompare(right)), + ); + protected readonly totalDistance = computed(() => + this.activities().reduce( + (total, activity) => total + (activity.distanceMeters ?? 0), + 0, + ), + ); + protected readonly totalTime = computed(() => + this.activities().reduce( + (total, activity) => total + (activity.movingTimeSeconds ?? 0), + 0, + ), + ); + + ngOnInit(): void { + this.loadActivities(); + } + + protected loadActivities(): void { + this.loading.set(true); + this.error.set(null); + + this.activitiesService + .listActivities( + this.apiBaseUrl, + this.weeks(), + this.selectedSportType(), + 200, + ) + .subscribe({ + next: (activities) => { + this.activities.set(activities); + this.loading.set(false); + }, + error: () => { + this.error.set('Aktivitaeten konnten nicht geladen werden.'); + this.loading.set(false); + }, + }); + } + + protected selectSportType(value: string): void { + this.selectedSportType.set(value === 'all' ? null : value); + this.loadActivities(); + } + + protected selectWeeks(value: string): void { + this.weeks.set(Number(value)); + this.loadActivities(); + } + + protected activityPace(activity: ActivitySummary): string { + if (!activity.distanceMeters || !activity.movingTimeSeconds) { + return '-'; + } + + return pace(activity.movingTimeSeconds / (activity.distanceMeters / 1000)); + } + + protected distanceKm = distanceKm; + protected duration = duration; + protected number = number; + protected shortDate = shortDate; + protected speed = speed; +} diff --git a/client/src/app/activities/activities.service.ts b/client/src/app/activities/activities.service.ts new file mode 100644 index 0000000..6effe82 --- /dev/null +++ b/client/src/app/activities/activities.service.ts @@ -0,0 +1,30 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ActivityDetail, ActivitySummary } from './activity.types'; + +@Injectable({ providedIn: 'root' }) +export class ActivitiesService { + private readonly http = inject(HttpClient); + + listActivities( + apiBaseUrl: string, + weeks: number, + sportType: string | null, + limit = 200, + ): Observable { + let params = new HttpParams().set('weeks', weeks).set('limit', limit); + + if (sportType) { + params = params.set('sportType', sportType); + } + + return this.http.get(`${apiBaseUrl}/analytics/activities`, { + params, + }); + } + + getActivityDetail(apiBaseUrl: string, id: string): Observable { + return this.http.get(`${apiBaseUrl}/analytics/activities/${id}`); + } +} diff --git a/client/src/app/activities/activity-detail.component.html b/client/src/app/activities/activity-detail.component.html new file mode 100644 index 0000000..05ff5d7 --- /dev/null +++ b/client/src/app/activities/activity-detail.component.html @@ -0,0 +1,133 @@ +
+ Zurueck zu Aktivitaeten + + @if (error()) { +
{{ error() }}
+ } + + @if (loading()) { +
Aktivitaetsdetails werden geladen...
+ } @else if (detail(); as activity) { +
+
+

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

+

{{ activity.name }}

+
+ + @if (isRun()) { + Laufanalyse + } +
+ +
+
Distanz{{ distanceKm(activity.distanceMeters) }}
+
Zeit{{ duration(activity.movingTimeSeconds) }}
+
Pace{{ primaryPace() }}
+
Herzfrequenz{{ number(activity.averageHeartrate, ' bpm') }}
+
+ +
+
+
+

Leistungsdaten

+
+
+
Elapsed Time
{{ duration(activity.elapsedTimeSeconds) }}
+
Speed
{{ speed(activity.averageSpeedMetersPerSecond) }}
+
Max Speed
{{ speed(activity.maxSpeedMetersPerSecond) }}
+
Hoehenmeter
{{ elevation(activity.elevationGainMeters) }}
+
Kalorien
{{ number(activity.calories, ' kcal') }}
+
Max HF
{{ number(activity.maxHeartrate, ' bpm') }}
+
Leistung
{{ number(activity.averageWatts, ' W') }}
+
Kadenz
{{ number(activity.averageCadence) }}
+
+
+ +
+
+

Kontext

+
+
+
Start
{{ dateTime(activity.startDateLocal ?? activity.startDate) }}
+
Visibility
{{ activity.visibility ?? '-' }}
+
Stream-Punkte
{{ number(activity.streamPointCount) }}
+
Gear
{{ activity.gearId ?? '-' }}
+
Trainer
{{ activity.trainer ? 'Ja' : 'Nein' }}
+
Manuell
{{ activity.manual ? 'Ja' : 'Nein' }}
+
+
+
+ + @if (activity.swimActivityDetail; as swim) { +
+
+
+

Schwimmdetails

+ {{ number(swim.poolLength, ' m') }} Pool | {{ pace(swim.paceSecondsPer100m, '/100m') }} +
+
+ +
+
Distanz{{ distanceMeters(swim.distanceMeters) }}
+
Zeit{{ duration(swim.movingTimeSeconds) }}
+
HF{{ number(swim.averageHeartrate, ' bpm') }}
+
Laps{{ swim.laps?.length ?? 0 }}
+
+ + @if (swim.lapsError) { +

Laps konnten nicht geladen werden: {{ swim.lapsError }}

+ } @else if (!swim.laps || swim.laps.length === 0) { +

Keine Lap-Daten vorhanden.

+ } @else { +
+ + + + + + + + + + + + @for (lap of swim.laps; track lap.id ?? $index) { + + + + + + + + } + +
LapDistanzZeitPaceHF
{{ lapIndex(lap, $index) }}{{ distanceMeters(lap.distance) }}{{ duration(lap.moving_time ?? lap.elapsed_time) }}{{ lapPace(lap) }}{{ number(lap.average_heartrate, ' bpm') }}
+
+ } +
+ } + + @if (activity.strengthTrainingDetail; as strength) { +
+
+

Krafttraining

+
+ +
+
Zeit{{ duration(strength.movingTimeSeconds ?? strength.elapsedTimeSeconds) }}
+
Kalorien{{ number(strength.calories, ' kcal') }}
+
HF{{ number(strength.averageHeartrate, ' bpm') }}
+
RPE{{ number(strength.perceivedExertion, '', 1) }}
+
+ + @if (strength.description) { +
+ {{ strength.description }} +
+ } @else { +

Keine Beschreibung oder Satzdetails von Strava vorhanden.

+ } +
+ } + } +
diff --git a/client/src/app/activities/activity-detail.component.scss b/client/src/app/activities/activity-detail.component.scss new file mode 100644 index 0000000..7a3b6f2 --- /dev/null +++ b/client/src/app/activities/activity-detail.component.scss @@ -0,0 +1,191 @@ +:host { + display: block; +} + +.detail-page { + display: grid; + gap: 18px; +} + +.back, +.secondary-action { + color: #4e5a6b; + font-weight: 800; + text-decoration: none; +} + +.secondary-action { + border: 1px solid #c8d0dc; + border-radius: 6px; + padding: 10px 12px; +} + +.hero, +.panel, +.kpis > div, +.notice, +.empty-state { + background: #ffffff; + border: 1px solid #dfe4ec; + border-radius: 8px; +} + +.hero { + align-items: center; + display: flex; + justify-content: space-between; + padding: 24px; +} + +.eyebrow, +.kpis span, +.detail-strip span, +.empty-text, +.section-title span { + color: #687386; + font-size: 0.82rem; +} + +.eyebrow { + font-weight: 700; + letter-spacing: 0.08em; + margin: 0 0 8px; + text-transform: uppercase; +} + +h1, +h2 { + margin: 0; +} + +h1 { + font-size: 1.55rem; + overflow-wrap: anywhere; +} + +h2 { + font-size: 1rem; +} + +.kpis, +.detail-strip { + display: grid; + gap: 12px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.kpis > div, +.detail-strip > div { + padding: 16px; +} + +.detail-strip > div { + border: 1px solid #eef2f7; + border-radius: 6px; +} + +.kpis strong, +.detail-strip strong { + display: block; + margin-top: 4px; +} + +.grid { + display: grid; + gap: 18px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.panel { + padding: 18px; +} + +.highlight { + border-color: #ffd0bd; +} + +.section-title { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 16px; +} + +.metric-list { + display: grid; + gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; +} + +.metric-list div { + border-bottom: 1px solid #eef2f7; + padding-bottom: 10px; +} + +dt { + color: #687386; + font-size: 0.78rem; +} + +dd { + font-weight: 800; + margin: 4px 0 0; +} + +.description { + background: #f7f8fb; + border-radius: 6px; + line-height: 1.6; + margin-top: 14px; + padding: 14px; + white-space: pre-wrap; +} + +.table-wrap { + overflow-x: auto; +} + +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; +} + +.notice, +.empty-state { + color: #687386; + padding: 18px; +} + +.error { + background: #fff0ed; + color: #9b2915; +} + +@media (max-width: 760px) { + .hero { + align-items: flex-start; + flex-direction: column; + gap: 14px; + } + + .kpis, + .detail-strip, + .grid, + .metric-list { + grid-template-columns: 1fr; + } +} diff --git a/client/src/app/activities/activity-detail.component.ts b/client/src/app/activities/activity-detail.component.ts new file mode 100644 index 0000000..0250aa0 --- /dev/null +++ b/client/src/app/activities/activity-detail.component.ts @@ -0,0 +1,91 @@ +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { resolveApiBaseUrl } from '../shared/api-base-url'; +import { ActivitiesService } from './activities.service'; +import { ActivityDetail, SwimLap } from './activity.types'; +import { + dateTime, + distanceKm, + distanceMeters, + duration, + elevation, + number, + pace, + shortDate, + speed, +} from './activity-format'; + +@Component({ + selector: 'app-activity-detail', + standalone: true, + imports: [RouterLink], + templateUrl: './activity-detail.component.html', + styleUrl: './activity-detail.component.scss', +}) +export class ActivityDetailComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly activitiesService = inject(ActivitiesService); + private readonly apiBaseUrl = resolveApiBaseUrl(); + protected readonly detail = signal(null); + protected readonly loading = signal(true); + protected readonly error = signal(null); + protected readonly isRun = computed(() => + (this.detail()?.sportType ?? '').toLowerCase().includes('run'), + ); + protected readonly primaryPace = computed(() => { + const activity = this.detail(); + if (!activity?.distanceMeters || !activity.movingTimeSeconds) { + return '-'; + } + + const suffix = + activity.sportType?.toLowerCase() === 'swim' ? '/100m' : '/km'; + const divisor = + activity.sportType?.toLowerCase() === 'swim' + ? activity.distanceMeters / 100 + : activity.distanceMeters / 1000; + return pace(activity.movingTimeSeconds / divisor, suffix); + }); + + ngOnInit(): void { + const id = this.route.snapshot.paramMap.get('id'); + if (!id) { + this.error.set('Aktivitaet wurde nicht gefunden.'); + this.loading.set(false); + return; + } + + this.activitiesService.getActivityDetail(this.apiBaseUrl, id).subscribe({ + next: (detail) => { + this.detail.set(detail); + this.loading.set(false); + }, + error: () => { + this.error.set('Aktivitaetsdetails konnten nicht geladen werden.'); + this.loading.set(false); + }, + }); + } + + protected lapPace(lap: SwimLap): string { + if (!lap.distance || !lap.moving_time) { + return '-'; + } + + return pace(lap.moving_time / (lap.distance / 100), '/100m'); + } + + protected lapIndex(lap: SwimLap, index: number): number { + return lap.lap_index ?? lap.split ?? index + 1; + } + + protected dateTime = dateTime; + protected distanceKm = distanceKm; + protected distanceMeters = distanceMeters; + protected duration = duration; + protected elevation = elevation; + protected number = number; + protected pace = pace; + protected shortDate = shortDate; + protected speed = speed; +} diff --git a/client/src/app/activities/activity-format.ts b/client/src/app/activities/activity-format.ts new file mode 100644 index 0000000..0b69697 --- /dev/null +++ b/client/src/app/activities/activity-format.ts @@ -0,0 +1,88 @@ +export const distanceKm = (meters: number | null | undefined): string => + meters === null || meters === undefined + ? '-' + : `${(meters / 1000).toLocaleString('de-DE', { + maximumFractionDigits: 2, + })} km`; + +export const distanceMeters = (meters: number | null | undefined): string => + meters === null || meters === undefined + ? '-' + : `${Math.round(meters).toLocaleString('de-DE')} m`; + +export const duration = (seconds: number | null | undefined): string => { + if (seconds === null || seconds === undefined) { + return '-'; + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = Math.round(seconds % 60); + + if (hours > 0) { + return `${hours} h ${minutes.toString().padStart(2, '0')} min`; + } + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')} min`; +}; + +export const elevation = (meters: number | null | undefined): string => + meters === null || meters === undefined + ? '-' + : `${Math.round(meters).toLocaleString('de-DE')} m`; + +export const speed = (metersPerSecond: number | null | undefined): string => + metersPerSecond + ? `${(metersPerSecond * 3.6).toLocaleString('de-DE', { + maximumFractionDigits: 1, + })} km/h` + : '-'; + +export const pace = ( + seconds: number | null | undefined, + suffix = '/km', +): string => { + if (!seconds) { + return '-'; + } + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60) + .toString() + .padStart(2, '0'); + return `${minutes}:${remainingSeconds} ${suffix}`; +}; + +export const number = ( + value: number | null | undefined, + suffix = '', + maximumFractionDigits = 0, +): string => + value === null || value === undefined + ? '-' + : `${value.toLocaleString('de-DE', { maximumFractionDigits })}${suffix}`; + +export const shortDate = (value: string | null | undefined): string => { + if (!value) { + return '-'; + } + + return new Intl.DateTimeFormat('de-DE', { + day: '2-digit', + month: '2-digit', + year: '2-digit', + }).format(new Date(value)); +}; + +export const dateTime = (value: string | null | undefined): string => { + if (!value) { + return '-'; + } + + return new Intl.DateTimeFormat('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)); +}; diff --git a/client/src/app/activities/activity.types.ts b/client/src/app/activities/activity.types.ts new file mode 100644 index 0000000..ffd057f --- /dev/null +++ b/client/src/app/activities/activity.types.ts @@ -0,0 +1,81 @@ +export interface ActivitySummary { + 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 StrengthTrainingDetail { + movingTimeSeconds: number | null; + elapsedTimeSeconds: number | null; + calories: number | null; + averageHeartrate: number | null; + maxHeartrate: number | null; + perceivedExertion: number | null; + workoutType: number | null; + description: string | null; + rawPayload: Record | null; +} + +export interface SwimLap { + id?: number | string; + name?: string; + lap_index?: number; + split?: number; + distance?: number; + moving_time?: number; + elapsed_time?: number; + average_speed?: number; + max_speed?: number; + average_heartrate?: number; + max_heartrate?: number; + average_cadence?: number; + [key: string]: unknown; +} + +export interface SwimActivityDetail { + distanceMeters: number | null; + movingTimeSeconds: number | null; + elapsedTimeSeconds: number | null; + averageSpeedMetersPerSecond: number | null; + maxSpeedMetersPerSecond: number | null; + paceSecondsPer100m: number | null; + calories: number | null; + averageHeartrate: number | null; + maxHeartrate: number | null; + averageCadence: number | null; + poolLength: number | null; + laps: SwimLap[] | null; + lapsError: string | null; + rawPayload: Record | null; +} + +export interface ActivityDetail extends ActivitySummary { + startDateLocal: string | null; + elapsedTimeSeconds: number | null; + totalElevationGainMeters: number | null; + maxSpeedMetersPerSecond: number | null; + maxHeartrate: number | null; + maxWatts: number | null; + weightedAverageWatts: number | null; + averageCadence: number | null; + calories: number | null; + gearId: string | null; + trainer: boolean; + commute: boolean; + manual: boolean; + private: boolean; + visibility: string | null; + mapId: string | null; + summaryPolyline: string | null; + streamPointCount: number; + strengthTrainingDetail: StrengthTrainingDetail | null; + swimActivityDetail: SwimActivityDetail | null; +} diff --git a/client/src/app/app.html b/client/src/app/app.html index c755a74..1ab8f1b 100644 --- a/client/src/app/app.html +++ b/client/src/app/app.html @@ -1,6 +1,7 @@