This commit is contained in:
Bastian Wagner
2026-06-16 12:15:29 +02:00
commit 38141c0358
80 changed files with 23444 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import { Controller, Get, Query } from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
import { AnalyticsDashboard } from './analytics.types';
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Get('dashboard')
dashboard(
@Query('weeks') weeks?: string,
@Query('sportType') sportType?: string,
): Promise<AnalyticsDashboard> {
return this.analyticsService.getDashboard(Number(weeks ?? 12), sportType);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StravaActivityEntity } from '../database/entities';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [TypeOrmModule.forFeature([StravaActivityEntity])],
controllers: [AnalyticsController],
providers: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,123 @@
import { Repository } from 'typeorm';
import { StravaActivityEntity } from '../database/entities';
import { AnalyticsService } from './analytics.service';
describe('AnalyticsService', () => {
const createService = (activities: StravaActivityEntity[]) => {
const repository = {
find: jest.fn().mockResolvedValue(activities),
} as unknown as Repository<StravaActivityEntity>;
return {
service: new AnalyticsService(repository),
repository,
};
};
const activity = (
input: Partial<StravaActivityEntity>,
): StravaActivityEntity =>
({
id: input.id ?? `activity-${input.stravaActivityId ?? '1'}`,
stravaActivityId: input.stravaActivityId ?? '1',
name: input.name ?? 'Morning Ride',
sportType: input.sportType ?? 'Ride',
startDate: input.startDate ?? new Date(),
distance: input.distance ?? 10000,
movingTime: input.movingTime ?? 1800,
totalElevationGain: input.totalElevationGain ?? 120,
calories: input.calories ?? 400,
averageSpeed: input.averageSpeed ?? 5.5,
averageHeartrate: input.averageHeartrate ?? null,
averageWatts: input.averageWatts ?? null,
averageCadence: input.averageCadence ?? null,
}) as StravaActivityEntity;
it('returns empty dashboard buckets when there are no activities', async () => {
const { service } = createService([]);
const dashboard = await service.getDashboard(12);
expect(dashboard.totals.activityCount).toBe(0);
expect(dashboard.availableSports).toEqual([]);
expect(dashboard.weekly).toHaveLength(12);
expect(dashboard.sports).toEqual([]);
expect(dashboard.recentActivities).toEqual([]);
});
it('aggregates totals, averages, and sport summaries', async () => {
const { service } = createService([
activity({
stravaActivityId: '1',
sportType: 'Ride',
distance: 20000,
movingTime: 3600,
totalElevationGain: 200,
calories: 700,
averageHeartrate: 140,
averageWatts: 210,
}),
activity({
stravaActivityId: '2',
sportType: 'Run',
distance: 5000,
movingTime: 1500,
totalElevationGain: 40,
calories: 350,
averageHeartrate: 150,
}),
]);
const dashboard = await service.getDashboard(12);
expect(dashboard.totals).toEqual({
activityCount: 2,
distanceMeters: 25000,
movingTimeSeconds: 5100,
elevationGainMeters: 240,
calories: 1050,
});
expect(dashboard.averages.heartRate).toBe(145);
expect(dashboard.averages.watts).toBe(210);
expect(dashboard.sports.map((sport) => sport.sportType)).toEqual([
'Ride',
'Run',
]);
});
it('clamps the requested number of weeks', async () => {
const { service } = createService([]);
await expect(service.getDashboard(999)).resolves.toMatchObject({
weeks: 104,
});
await expect(service.getDashboard(0)).resolves.toMatchObject({
weeks: 1,
});
});
it('filters dashboard values by sport type while keeping available sports', async () => {
const { service } = createService([
activity({
stravaActivityId: '1',
sportType: 'Ride',
distance: 20000,
movingTime: 3600,
}),
activity({
stravaActivityId: '2',
sportType: 'Run',
distance: 5000,
movingTime: 1500,
}),
]);
const dashboard = await service.getDashboard(12, 'Run');
expect(dashboard.selectedSportType).toBe('Run');
expect(dashboard.availableSports).toEqual(['Ride', 'Run']);
expect(dashboard.totals.activityCount).toBe(1);
expect(dashboard.totals.distanceMeters).toBe(5000);
expect(dashboard.sports.map((sport) => sport.sportType)).toEqual(['Run']);
});
});

View File

@@ -0,0 +1,248 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MoreThanOrEqual, Repository } from 'typeorm';
import { StravaActivityEntity } from '../database/entities';
import {
AnalyticsAverages,
AnalyticsDashboard,
AnalyticsRecentActivity,
AnalyticsSportSummary,
AnalyticsTotals,
AnalyticsWeeklyBucket,
} from './analytics.types';
@Injectable()
export class AnalyticsService {
constructor(
@InjectRepository(StravaActivityEntity)
private readonly activityRepository: Repository<StravaActivityEntity>,
) {}
async getDashboard(
weeksInput = 12,
sportTypeInput?: string,
): Promise<AnalyticsDashboard> {
const weeks = this.clampWeeks(weeksInput);
const selectedSportType = this.normalizeSportType(sportTypeInput);
const now = new Date();
const rangeStart = this.startOfWeek(this.addDays(now, -(weeks - 1) * 7));
const rangeEnd = this.endOfDay(now);
const activities = await this.activityRepository.find({
where: {
startDate: MoreThanOrEqual(rangeStart),
},
order: {
startDate: 'DESC',
},
});
const availableSports = this.availableSports(activities);
const filteredActivities = selectedSportType
? activities.filter(
(activity) =>
(activity.sportType ?? 'Unbekannt') === selectedSportType,
)
: activities;
const totals = this.createTotals();
const weekly = this.createWeeklyBuckets(rangeStart, weeks);
const sports = new Map<string, AnalyticsSportSummary>();
const heartRates: number[] = [];
const watts: number[] = [];
const cadences: number[] = [];
for (const activity of filteredActivities) {
this.addActivity(totals, activity);
const week = weekly.get(
this.dateKey(this.startOfWeek(activity.startDate)),
);
if (week) {
this.addActivity(week, activity);
}
const sportType = activity.sportType ?? 'Unbekannt';
const sport = sports.get(sportType) ?? {
...this.createTotals(),
sportType,
};
this.addActivity(sport, activity);
sports.set(sportType, sport);
this.pushIfNumber(heartRates, activity.averageHeartrate);
this.pushIfNumber(watts, activity.averageWatts);
this.pushIfNumber(cadences, activity.averageCadence);
}
return {
weeks,
selectedSportType,
availableSports,
rangeStart: this.dateKey(rangeStart),
rangeEnd: this.dateKey(rangeEnd),
totals,
averages: this.createAverages(totals, heartRates, watts, cadences),
weekly: Array.from(weekly.values()),
sports: Array.from(sports.values()).sort(
(left, right) => right.distanceMeters - left.distanceMeters,
),
recentActivities: filteredActivities
.slice(0, 8)
.map((activity) => this.toRecentActivity(activity)),
};
}
private availableSports(activities: StravaActivityEntity[]): string[] {
return Array.from(
new Set(activities.map((activity) => activity.sportType ?? 'Unbekannt')),
).sort((left, right) => left.localeCompare(right));
}
private addActivity(
totals: AnalyticsTotals,
activity: StravaActivityEntity,
): void {
totals.activityCount += 1;
totals.distanceMeters += activity.distance ?? 0;
totals.movingTimeSeconds += activity.movingTime ?? 0;
totals.elevationGainMeters += activity.totalElevationGain ?? 0;
totals.calories += activity.calories ?? 0;
}
private createAverages(
totals: AnalyticsTotals,
heartRates: number[],
watts: number[],
cadences: number[],
): AnalyticsAverages {
const speed =
totals.movingTimeSeconds > 0
? totals.distanceMeters / totals.movingTimeSeconds
: null;
const pace =
totals.distanceMeters > 0
? totals.movingTimeSeconds / (totals.distanceMeters / 1000)
: null;
return {
speedMetersPerSecond: this.roundNullable(speed, 2),
paceSecondsPerKm: this.roundNullable(pace, 0),
heartRate: this.average(heartRates),
watts: this.average(watts),
cadence: this.average(cadences),
};
}
private createWeeklyBuckets(
rangeStart: Date,
weeks: number,
): Map<string, AnalyticsWeeklyBucket> {
const buckets = new Map<string, AnalyticsWeeklyBucket>();
for (let index = 0; index < weeks; index += 1) {
const weekStart = this.addDays(rangeStart, index * 7);
const weekEnd = this.addDays(weekStart, 6);
buckets.set(this.dateKey(weekStart), {
...this.createTotals(),
weekStart: this.dateKey(weekStart),
weekEnd: this.dateKey(weekEnd),
});
}
return buckets;
}
private toRecentActivity(
activity: StravaActivityEntity,
): AnalyticsRecentActivity {
return {
id: activity.id,
stravaActivityId: activity.stravaActivityId,
name: activity.name,
sportType: activity.sportType,
startDate: activity.startDate ? activity.startDate.toISOString() : null,
distanceMeters: activity.distance,
movingTimeSeconds: activity.movingTime,
elevationGainMeters: activity.totalElevationGain,
averageSpeedMetersPerSecond: activity.averageSpeed,
averageHeartrate: activity.averageHeartrate,
averageWatts: activity.averageWatts,
};
}
private createTotals(): AnalyticsTotals {
return {
activityCount: 0,
distanceMeters: 0,
movingTimeSeconds: 0,
elevationGainMeters: 0,
calories: 0,
};
}
private average(values: number[]): number | null {
if (values.length === 0) {
return null;
}
return Math.round(
values.reduce((total, value) => total + value, 0) / values.length,
);
}
private pushIfNumber(values: number[], value: number | null): void {
if (typeof value === 'number' && Number.isFinite(value)) {
values.push(value);
}
}
private roundNullable(value: number | null, digits: number): number | null {
if (value === null) {
return null;
}
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
private clampWeeks(value: number): number {
if (!Number.isFinite(value)) {
return 12;
}
return Math.min(Math.max(Math.trunc(value), 1), 104);
}
private normalizeSportType(value: string | undefined): string | null {
if (!value || value === 'all') {
return null;
}
return value.trim() || null;
}
private startOfWeek(value: Date | null): Date {
const date = value ? new Date(value) : new Date();
const day = date.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + mondayOffset);
return date;
}
private endOfDay(value: Date): Date {
const date = new Date(value);
date.setHours(23, 59, 59, 999);
return date;
}
private addDays(value: Date, days: number): Date {
const date = new Date(value);
date.setDate(date.getDate() + days);
return date;
}
private dateKey(value: Date): string {
return value.toISOString().slice(0, 10);
}
}

View File

@@ -0,0 +1,51 @@
export interface AnalyticsTotals {
activityCount: number;
distanceMeters: number;
movingTimeSeconds: number;
elevationGainMeters: number;
calories: number;
}
export interface AnalyticsAverages {
speedMetersPerSecond: number | null;
paceSecondsPerKm: number | null;
heartRate: number | null;
watts: number | null;
cadence: number | null;
}
export interface AnalyticsWeeklyBucket extends AnalyticsTotals {
weekStart: string;
weekEnd: string;
}
export interface AnalyticsSportSummary extends AnalyticsTotals {
sportType: string;
}
export interface AnalyticsRecentActivity {
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 AnalyticsDashboard {
weeks: number;
selectedSportType: string | null;
availableSports: string[];
rangeStart: string;
rangeEnd: string;
totals: AnalyticsTotals;
averages: AnalyticsAverages;
weekly: AnalyticsWeeklyBucket[];
sports: AnalyticsSportSummary[];
recentActivities: AnalyticsRecentActivity[];
}