details
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user