details
This commit is contained in:
@@ -38,6 +38,24 @@ export class AnalyticsController {
|
|||||||
return this.analyticsService.getRunningActivities(Number(weeks ?? 12));
|
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')
|
@Get('running/activities/:id')
|
||||||
runningActivity(@Param('id') id: string): Promise<RunningActivityDetail> {
|
runningActivity(@Param('id') id: string): Promise<RunningActivityDetail> {
|
||||||
return this.analyticsService.getRunningActivityDetail(id);
|
return this.analyticsService.getRunningActivityDetail(id);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import {
|
import {
|
||||||
StravaActivityEntity,
|
StravaActivityEntity,
|
||||||
StravaActivityStreamPointEntity,
|
StravaActivityStreamPointEntity,
|
||||||
|
StravaStrengthTrainingDetailEntity,
|
||||||
|
StravaSwimActivityDetailEntity,
|
||||||
} from '../database/entities';
|
} from '../database/entities';
|
||||||
import { StravaModule } from '../strava/strava.module';
|
import { StravaModule } from '../strava/strava.module';
|
||||||
import { AnalyticsController } from './analytics.controller';
|
import { AnalyticsController } from './analytics.controller';
|
||||||
@@ -14,6 +16,8 @@ import { AnalyticsService } from './analytics.service';
|
|||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
StravaActivityEntity,
|
StravaActivityEntity,
|
||||||
StravaActivityStreamPointEntity,
|
StravaActivityStreamPointEntity,
|
||||||
|
StravaStrengthTrainingDetailEntity,
|
||||||
|
StravaSwimActivityDetailEntity,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [AnalyticsController],
|
controllers: [AnalyticsController],
|
||||||
|
|||||||
@@ -31,7 +31,14 @@ describe('AnalyticsService', () => {
|
|||||||
.mockResolvedValue(streamPoints.length);
|
.mockResolvedValue(streamPoints.length);
|
||||||
const streamPointRepository = {
|
const streamPointRepository = {
|
||||||
find: streamPointFind,
|
find: streamPointFind,
|
||||||
|
count: jest.fn().mockResolvedValue(streamPoints.length),
|
||||||
} as unknown as Repository<StravaActivityStreamPointEntity>;
|
} as unknown as Repository<StravaActivityStreamPointEntity>;
|
||||||
|
const strengthTrainingDetailRepository = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
const swimActivityDetailRepository = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
const stravaStreamImportService = {
|
const stravaStreamImportService = {
|
||||||
importStreamsForActivity,
|
importStreamsForActivity,
|
||||||
} as unknown as StravaStreamImportService;
|
} as unknown as StravaStreamImportService;
|
||||||
@@ -40,6 +47,8 @@ describe('AnalyticsService', () => {
|
|||||||
service: new AnalyticsService(
|
service: new AnalyticsService(
|
||||||
activityRepository,
|
activityRepository,
|
||||||
streamPointRepository,
|
streamPointRepository,
|
||||||
|
strengthTrainingDetailRepository as never,
|
||||||
|
swimActivityDetailRepository as never,
|
||||||
stravaStreamImportService,
|
stravaStreamImportService,
|
||||||
),
|
),
|
||||||
activityRepository,
|
activityRepository,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { In, MoreThanOrEqual, Repository } from 'typeorm';
|
|||||||
import {
|
import {
|
||||||
StravaActivityEntity,
|
StravaActivityEntity,
|
||||||
StravaActivityStreamPointEntity,
|
StravaActivityStreamPointEntity,
|
||||||
|
StravaStrengthTrainingDetailEntity,
|
||||||
|
StravaSwimActivityDetailEntity,
|
||||||
} from '../database/entities';
|
} from '../database/entities';
|
||||||
import { StravaStreamImportService } from '../strava/strava-stream-import.service';
|
import { StravaStreamImportService } from '../strava/strava-stream-import.service';
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +48,10 @@ export class AnalyticsService {
|
|||||||
private readonly activityRepository: Repository<StravaActivityEntity>,
|
private readonly activityRepository: Repository<StravaActivityEntity>,
|
||||||
@InjectRepository(StravaActivityStreamPointEntity)
|
@InjectRepository(StravaActivityStreamPointEntity)
|
||||||
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
|
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
|
||||||
|
@InjectRepository(StravaStrengthTrainingDetailEntity)
|
||||||
|
private readonly strengthTrainingDetailRepository: Repository<StravaStrengthTrainingDetailEntity>,
|
||||||
|
@InjectRepository(StravaSwimActivityDetailEntity)
|
||||||
|
private readonly swimActivityDetailRepository: Repository<StravaSwimActivityDetailEntity>,
|
||||||
private readonly stravaStreamImportService: StravaStreamImportService,
|
private readonly stravaStreamImportService: StravaStreamImportService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -262,6 +268,80 @@ export class AnalyticsService {
|
|||||||
.map((activity) => this.toRecentActivity(activity));
|
.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> {
|
async getRunningActivityDetail(id: string): Promise<RunningActivityDetail> {
|
||||||
const activity = await this.activityRepository.findOne({ where: { id } });
|
const activity = await this.activityRepository.findOne({ where: { id } });
|
||||||
if (!activity || !this.isRunningActivity(activity)) {
|
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 {
|
private createTotals(): AnalyticsTotals {
|
||||||
return {
|
return {
|
||||||
activityCount: 0,
|
activityCount: 0,
|
||||||
|
|||||||
70
client/src/app/activities/activities.component.html
Normal file
70
client/src/app/activities/activities.component.html
Normal 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>
|
||||||
159
client/src/app/activities/activities.component.scss
Normal file
159
client/src/app/activities/activities.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
client/src/app/activities/activities.component.ts
Normal file
100
client/src/app/activities/activities.component.ts
Normal 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;
|
||||||
|
}
|
||||||
30
client/src/app/activities/activities.service.ts
Normal file
30
client/src/app/activities/activities.service.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
client/src/app/activities/activity-detail.component.html
Normal file
133
client/src/app/activities/activity-detail.component.html
Normal 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>
|
||||||
191
client/src/app/activities/activity-detail.component.scss
Normal file
191
client/src/app/activities/activity-detail.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
client/src/app/activities/activity-detail.component.ts
Normal file
91
client/src/app/activities/activity-detail.component.ts
Normal 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;
|
||||||
|
}
|
||||||
88
client/src/app/activities/activity-format.ts
Normal file
88
client/src/app/activities/activity-format.ts
Normal 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));
|
||||||
|
};
|
||||||
81
client/src/app/activities/activity.types.ts
Normal file
81
client/src/app/activities/activity.types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<main class="shell">
|
<main class="shell">
|
||||||
<nav class="nav" aria-label="Hauptnavigation">
|
<nav class="nav" aria-label="Hauptnavigation">
|
||||||
<a routerLink="/dashboard" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Dashboard</a>
|
<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" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Laufen</a>
|
||||||
<a routerLink="/running/kpis" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">KPI Dashboard</a>
|
<a routerLink="/running/kpis" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">KPI Dashboard</a>
|
||||||
<a routerLink="/settings" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Settings</a>
|
<a routerLink="/settings" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Settings</a>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Routes } from '@angular/router';
|
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 { DashboardComponent } from './dashboard/dashboard.component';
|
||||||
import { RunningActivityDetailComponent } from './running/running-activity-detail.component';
|
import { RunningActivityDetailComponent } from './running/running-activity-detail.component';
|
||||||
import { RunningDashboardComponent } from './running/running-dashboard.component';
|
import { RunningDashboardComponent } from './running/running-dashboard.component';
|
||||||
@@ -8,6 +10,8 @@ import { SettingsComponent } from './settings/settings.component';
|
|||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
|
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
|
||||||
{ path: 'dashboard', component: DashboardComponent },
|
{ path: 'dashboard', component: DashboardComponent },
|
||||||
|
{ path: 'activities', component: ActivitiesComponent },
|
||||||
|
{ path: 'activities/:id', component: ActivityDetailComponent },
|
||||||
{ path: 'running', component: RunningDashboardComponent },
|
{ path: 'running', component: RunningDashboardComponent },
|
||||||
{ path: 'running/kpis', component: RunningKpiDashboardComponent },
|
{ path: 'running/kpis', component: RunningKpiDashboardComponent },
|
||||||
{ path: 'running/:id', component: RunningActivityDetailComponent },
|
{ path: 'running/:id', component: RunningActivityDetailComponent },
|
||||||
|
|||||||
@@ -135,13 +135,13 @@
|
|||||||
|
|
||||||
<div class="recent-list">
|
<div class="recent-list">
|
||||||
@for (activity of data.recentActivities; track activity.id) {
|
@for (activity of data.recentActivities; track activity.id) {
|
||||||
<div class="recent-row">
|
<a class="recent-row" [routerLink]="['/activities', activity.id]">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ activity.name }}</strong>
|
<strong>{{ activity.name }}</strong>
|
||||||
<span>{{ activity.sportType ?? 'Unbekannt' }} | {{ shortDate(activity.startDate) }}</span>
|
<span>{{ activity.sportType ?? 'Unbekannt' }} | {{ shortDate(activity.startDate) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ distanceKm(activity.distanceMeters) }}</span>
|
<span>{{ distanceKm(activity.distanceMeters) }}</span>
|
||||||
</div>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,6 +217,12 @@ h3 {
|
|||||||
|
|
||||||
.recent-row {
|
.recent-row {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-row:hover strong {
|
||||||
|
color: #fc4c02;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sport-row span,
|
.sport-row span,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
import { resolveApiBaseUrl } from '../shared/api-base-url';
|
import { resolveApiBaseUrl } from '../shared/api-base-url';
|
||||||
import { AnalyticsDashboard } from './dashboard.types';
|
import { AnalyticsDashboard } from './dashboard.types';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
@@ -12,6 +13,7 @@ import { DashboardService } from './dashboard.service';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dashboard',
|
selector: 'app-dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
imports: [RouterLink],
|
||||||
templateUrl: './dashboard.component.html',
|
templateUrl: './dashboard.component.html',
|
||||||
styleUrl: './dashboard.component.scss',
|
styleUrl: './dashboard.component.scss',
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user