git init
This commit is contained in:
16
api/src/analytics/analytics.controller.ts
Normal file
16
api/src/analytics/analytics.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
api/src/analytics/analytics.module.ts
Normal file
12
api/src/analytics/analytics.module.ts
Normal 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 {}
|
||||
123
api/src/analytics/analytics.service.spec.ts
Normal file
123
api/src/analytics/analytics.service.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
248
api/src/analytics/analytics.service.ts
Normal file
248
api/src/analytics/analytics.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
api/src/analytics/analytics.types.ts
Normal file
51
api/src/analytics/analytics.types.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user