This commit is contained in:
Bastian Wagner
2026-06-17 15:41:49 +02:00
parent 8c7a5cbb63
commit 7423920f34
18 changed files with 1109 additions and 2 deletions

View File

@@ -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<AnalyticsRecentActivity[]> {
return this.analyticsService.getActivities(
Number(weeks ?? 52),
sportType,
Number(limit ?? 100),
);
}
@Get('activities/:id')
activity(@Param('id') id: string): Promise<Record<string, unknown>> {
return this.analyticsService.getActivityDetail(id);
}
@Get('running/activities/:id')
runningActivity(@Param('id') id: string): Promise<RunningActivityDetail> {
return this.analyticsService.getRunningActivityDetail(id);

View File

@@ -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],

View File

@@ -31,7 +31,14 @@ describe('AnalyticsService', () => {
.mockResolvedValue(streamPoints.length);
const streamPointRepository = {
find: streamPointFind,
count: jest.fn().mockResolvedValue(streamPoints.length),
} as unknown as Repository<StravaActivityStreamPointEntity>;
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,

View File

@@ -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<StravaActivityEntity>,
@InjectRepository(StravaActivityStreamPointEntity)
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
@InjectRepository(StravaStrengthTrainingDetailEntity)
private readonly strengthTrainingDetailRepository: Repository<StravaStrengthTrainingDetailEntity>,
@InjectRepository(StravaSwimActivityDetailEntity)
private readonly swimActivityDetailRepository: Repository<StravaSwimActivityDetailEntity>,
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<AnalyticsRecentActivity[]> {
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<Record<string, unknown>> {
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<RunningActivityDetail> {
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<string, unknown> {
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<string, unknown> {
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,

View File

@@ -0,0 +1,70 @@
<section class="activities" aria-labelledby="activities-title">
<div class="heading">
<div>
<p class="eyebrow">Aktivitaeten</p>
<h1 id="activities-title">Alle Sportarten</h1>
</div>
<div class="tools">
<label>
<span>Zeitraum</span>
<select [value]="weeks()" (change)="selectWeeks($any($event.target).value)" [disabled]="loading()">
<option value="12">12 Wochen</option>
<option value="26">26 Wochen</option>
<option value="52">52 Wochen</option>
<option value="104">104 Wochen</option>
</select>
</label>
<label>
<span>Sportart</span>
<select
[value]="selectedSportType() ?? 'all'"
(change)="selectSportType($any($event.target).value)"
[disabled]="loading()"
>
<option value="all">Alle</option>
@for (sportType of sportTypes(); track sportType) {
<option [value]="sportType">{{ sportType }}</option>
}
</select>
</label>
</div>
</div>
@if (error()) {
<div class="notice error">{{ error() }}</div>
}
@if (loading()) {
<div class="empty-state">Aktivitaeten werden geladen...</div>
} @else {
<div class="summary">
<div><span>Anzahl</span><strong>{{ activities().length }}</strong></div>
<div><span>Distanz</span><strong>{{ distanceKm(totalDistance()) }}</strong></div>
<div><span>Zeit</span><strong>{{ duration(totalTime()) }}</strong></div>
</div>
@if (activities().length === 0) {
<div class="empty-state">Keine Aktivitaeten fuer diese Auswahl.</div>
} @else {
<div class="activity-list">
@for (activity of activities(); track activity.id) {
<a class="activity-row" [routerLink]="['/activities', activity.id]">
<div class="activity-main">
<span class="sport">{{ activity.sportType ?? 'Unbekannt' }}</span>
<strong>{{ activity.name }}</strong>
<span>{{ shortDate(activity.startDate) }}</span>
</div>
<div class="activity-metrics">
<span>{{ distanceKm(activity.distanceMeters) }}</span>
<span>{{ duration(activity.movingTimeSeconds) }}</span>
<span>{{ activityPace(activity) }}</span>
<span>{{ number(activity.averageHeartrate, ' bpm') }}</span>
</div>
</a>
}
</div>
}
}
</section>

View File

@@ -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;
}
}

View File

@@ -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<ActivitySummary[]>([]);
protected readonly loading = signal(true);
protected readonly error = signal<string | null>(null);
protected readonly selectedSportType = signal<string | null>(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;
}

View File

@@ -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<ActivitySummary[]> {
let params = new HttpParams().set('weeks', weeks).set('limit', limit);
if (sportType) {
params = params.set('sportType', sportType);
}
return this.http.get<ActivitySummary[]>(`${apiBaseUrl}/analytics/activities`, {
params,
});
}
getActivityDetail(apiBaseUrl: string, id: string): Observable<ActivityDetail> {
return this.http.get<ActivityDetail>(`${apiBaseUrl}/analytics/activities/${id}`);
}
}

View File

@@ -0,0 +1,133 @@
<section class="detail-page">
<a routerLink="/activities" class="back">Zurueck zu Aktivitaeten</a>
@if (error()) {
<div class="notice error">{{ error() }}</div>
}
@if (loading()) {
<div class="empty-state">Aktivitaetsdetails werden geladen...</div>
} @else if (detail(); as activity) {
<header class="hero">
<div>
<p class="eyebrow">{{ activity.sportType ?? 'Aktivitaet' }} | {{ shortDate(activity.startDate) }}</p>
<h1>{{ activity.name }}</h1>
</div>
@if (isRun()) {
<a class="secondary-action" [routerLink]="['/running', activity.id]">Laufanalyse</a>
}
</header>
<div class="kpis">
<div><span>Distanz</span><strong>{{ distanceKm(activity.distanceMeters) }}</strong></div>
<div><span>Zeit</span><strong>{{ duration(activity.movingTimeSeconds) }}</strong></div>
<div><span>Pace</span><strong>{{ primaryPace() }}</strong></div>
<div><span>Herzfrequenz</span><strong>{{ number(activity.averageHeartrate, ' bpm') }}</strong></div>
</div>
<div class="grid">
<section class="panel">
<div class="section-title">
<h2>Leistungsdaten</h2>
</div>
<dl class="metric-list">
<div><dt>Elapsed Time</dt><dd>{{ duration(activity.elapsedTimeSeconds) }}</dd></div>
<div><dt>Speed</dt><dd>{{ speed(activity.averageSpeedMetersPerSecond) }}</dd></div>
<div><dt>Max Speed</dt><dd>{{ speed(activity.maxSpeedMetersPerSecond) }}</dd></div>
<div><dt>Hoehenmeter</dt><dd>{{ elevation(activity.elevationGainMeters) }}</dd></div>
<div><dt>Kalorien</dt><dd>{{ number(activity.calories, ' kcal') }}</dd></div>
<div><dt>Max HF</dt><dd>{{ number(activity.maxHeartrate, ' bpm') }}</dd></div>
<div><dt>Leistung</dt><dd>{{ number(activity.averageWatts, ' W') }}</dd></div>
<div><dt>Kadenz</dt><dd>{{ number(activity.averageCadence) }}</dd></div>
</dl>
</section>
<section class="panel">
<div class="section-title">
<h2>Kontext</h2>
</div>
<dl class="metric-list">
<div><dt>Start</dt><dd>{{ dateTime(activity.startDateLocal ?? activity.startDate) }}</dd></div>
<div><dt>Visibility</dt><dd>{{ activity.visibility ?? '-' }}</dd></div>
<div><dt>Stream-Punkte</dt><dd>{{ number(activity.streamPointCount) }}</dd></div>
<div><dt>Gear</dt><dd>{{ activity.gearId ?? '-' }}</dd></div>
<div><dt>Trainer</dt><dd>{{ activity.trainer ? 'Ja' : 'Nein' }}</dd></div>
<div><dt>Manuell</dt><dd>{{ activity.manual ? 'Ja' : 'Nein' }}</dd></div>
</dl>
</section>
</div>
@if (activity.swimActivityDetail; as swim) {
<section class="panel highlight">
<div class="section-title">
<div>
<h2>Schwimmdetails</h2>
<span>{{ number(swim.poolLength, ' m') }} Pool | {{ pace(swim.paceSecondsPer100m, '/100m') }}</span>
</div>
</div>
<div class="detail-strip">
<div><span>Distanz</span><strong>{{ distanceMeters(swim.distanceMeters) }}</strong></div>
<div><span>Zeit</span><strong>{{ duration(swim.movingTimeSeconds) }}</strong></div>
<div><span>HF</span><strong>{{ number(swim.averageHeartrate, ' bpm') }}</strong></div>
<div><span>Laps</span><strong>{{ swim.laps?.length ?? 0 }}</strong></div>
</div>
@if (swim.lapsError) {
<p class="empty-text">Laps konnten nicht geladen werden: {{ swim.lapsError }}</p>
} @else if (!swim.laps || swim.laps.length === 0) {
<p class="empty-text">Keine Lap-Daten vorhanden.</p>
} @else {
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Lap</th>
<th>Distanz</th>
<th>Zeit</th>
<th>Pace</th>
<th>HF</th>
</tr>
</thead>
<tbody>
@for (lap of swim.laps; track lap.id ?? $index) {
<tr>
<td>{{ lapIndex(lap, $index) }}</td>
<td>{{ distanceMeters(lap.distance) }}</td>
<td>{{ duration(lap.moving_time ?? lap.elapsed_time) }}</td>
<td>{{ lapPace(lap) }}</td>
<td>{{ number(lap.average_heartrate, ' bpm') }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@if (activity.strengthTrainingDetail; as strength) {
<section class="panel highlight">
<div class="section-title">
<h2>Krafttraining</h2>
</div>
<div class="detail-strip">
<div><span>Zeit</span><strong>{{ duration(strength.movingTimeSeconds ?? strength.elapsedTimeSeconds) }}</strong></div>
<div><span>Kalorien</span><strong>{{ number(strength.calories, ' kcal') }}</strong></div>
<div><span>HF</span><strong>{{ number(strength.averageHeartrate, ' bpm') }}</strong></div>
<div><span>RPE</span><strong>{{ number(strength.perceivedExertion, '', 1) }}</strong></div>
</div>
@if (strength.description) {
<div class="description">
{{ strength.description }}
</div>
} @else {
<p class="empty-text">Keine Beschreibung oder Satzdetails von Strava vorhanden.</p>
}
</section>
}
}
</section>

View File

@@ -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;
}
}

View File

@@ -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<ActivityDetail | null>(null);
protected readonly loading = signal(true);
protected readonly error = signal<string | null>(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;
}

View File

@@ -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));
};

View File

@@ -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<string, unknown> | 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<string, unknown> | 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;
}

View File

@@ -1,6 +1,7 @@
<main class="shell">
<nav class="nav" aria-label="Hauptnavigation">
<a routerLink="/dashboard" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Dashboard</a>
<a routerLink="/activities" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Aktivitaeten</a>
<a routerLink="/running" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Laufen</a>
<a routerLink="/running/kpis" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">KPI Dashboard</a>
<a routerLink="/settings" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Settings</a>

View File

@@ -1,4 +1,6 @@
import { Routes } from '@angular/router';
import { ActivitiesComponent } from './activities/activities.component';
import { ActivityDetailComponent } from './activities/activity-detail.component';
import { DashboardComponent } from './dashboard/dashboard.component';
import { RunningActivityDetailComponent } from './running/running-activity-detail.component';
import { RunningDashboardComponent } from './running/running-dashboard.component';
@@ -8,6 +10,8 @@ import { SettingsComponent } from './settings/settings.component';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'activities', component: ActivitiesComponent },
{ path: 'activities/:id', component: ActivityDetailComponent },
{ path: 'running', component: RunningDashboardComponent },
{ path: 'running/kpis', component: RunningKpiDashboardComponent },
{ path: 'running/:id', component: RunningActivityDetailComponent },

View File

@@ -135,13 +135,13 @@
<div class="recent-list">
@for (activity of data.recentActivities; track activity.id) {
<div class="recent-row">
<a class="recent-row" [routerLink]="['/activities', activity.id]">
<div>
<strong>{{ activity.name }}</strong>
<span>{{ activity.sportType ?? 'Unbekannt' }} | {{ shortDate(activity.startDate) }}</span>
</div>
<span>{{ distanceKm(activity.distanceMeters) }}</span>
</div>
</a>
}
</div>
</div>

View File

@@ -217,6 +217,12 @@ h3 {
.recent-row {
grid-template-columns: minmax(0, 1fr) auto;
color: inherit;
text-decoration: none;
}
.recent-row:hover strong {
color: #fc4c02;
}
.sport-row span,

View File

@@ -5,6 +5,7 @@ import {
inject,
signal,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { resolveApiBaseUrl } from '../shared/api-base-url';
import { AnalyticsDashboard } from './dashboard.types';
import { DashboardService } from './dashboard.service';
@@ -12,6 +13,7 @@ import { DashboardService } from './dashboard.service';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [RouterLink],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.scss',
})