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

View File

@@ -38,6 +38,24 @@ export class AnalyticsController {
return this.analyticsService.getRunningActivities(Number(weeks ?? 12));
}
@Get('activities')
activities(
@Query('weeks') weeks?: string,
@Query('sportType') sportType?: string,
@Query('limit') limit?: string,
): Promise<AnalyticsRecentActivity[]> {
return this.analyticsService.getActivities(
Number(weeks ?? 52),
sportType,
Number(limit ?? 100),
);
}
@Get('activities/:id')
activity(@Param('id') id: string): Promise<Record<string, unknown>> {
return this.analyticsService.getActivityDetail(id);
}
@Get('running/activities/:id')
runningActivity(@Param('id') id: string): Promise<RunningActivityDetail> {
return this.analyticsService.getRunningActivityDetail(id);

View File

@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
} from '../database/entities';
import { StravaModule } from '../strava/strava.module';
import { AnalyticsController } from './analytics.controller';
@@ -14,6 +16,8 @@ import { AnalyticsService } from './analytics.service';
TypeOrmModule.forFeature([
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
]),
],
controllers: [AnalyticsController],

View File

@@ -31,7 +31,14 @@ describe('AnalyticsService', () => {
.mockResolvedValue(streamPoints.length);
const streamPointRepository = {
find: streamPointFind,
count: jest.fn().mockResolvedValue(streamPoints.length),
} as unknown as Repository<StravaActivityStreamPointEntity>;
const strengthTrainingDetailRepository = {
findOne: jest.fn().mockResolvedValue(null),
};
const swimActivityDetailRepository = {
findOne: jest.fn().mockResolvedValue(null),
};
const stravaStreamImportService = {
importStreamsForActivity,
} as unknown as StravaStreamImportService;
@@ -40,6 +47,8 @@ describe('AnalyticsService', () => {
service: new AnalyticsService(
activityRepository,
streamPointRepository,
strengthTrainingDetailRepository as never,
swimActivityDetailRepository as never,
stravaStreamImportService,
),
activityRepository,

View File

@@ -4,6 +4,8 @@ import { In, MoreThanOrEqual, Repository } from 'typeorm';
import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
} from '../database/entities';
import { StravaStreamImportService } from '../strava/strava-stream-import.service';
import {
@@ -46,6 +48,10 @@ export class AnalyticsService {
private readonly activityRepository: Repository<StravaActivityEntity>,
@InjectRepository(StravaActivityStreamPointEntity)
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
@InjectRepository(StravaStrengthTrainingDetailEntity)
private readonly strengthTrainingDetailRepository: Repository<StravaStrengthTrainingDetailEntity>,
@InjectRepository(StravaSwimActivityDetailEntity)
private readonly swimActivityDetailRepository: Repository<StravaSwimActivityDetailEntity>,
private readonly stravaStreamImportService: StravaStreamImportService,
) {}
@@ -262,6 +268,80 @@ export class AnalyticsService {
.map((activity) => this.toRecentActivity(activity));
}
async getActivities(
weeksInput = 52,
sportTypeInput?: string,
limitInput = 100,
): Promise<AnalyticsRecentActivity[]> {
const weeks = this.clampWeeks(weeksInput);
const limit = this.clamp(limitInput, 1, 200);
const selectedSportType = this.normalizeSportType(sportTypeInput);
const rangeStart = this.startOfWeek(
this.addDays(new Date(), -(weeks - 1) * 7),
);
const activities = await this.findActivitiesInRange(rangeStart);
return activities
.filter((activity) =>
selectedSportType
? (activity.sportType ?? 'Unbekannt') === selectedSportType
: true,
)
.slice(0, limit)
.map((activity) => this.toRecentActivity(activity));
}
async getActivityDetail(id: string): Promise<Record<string, unknown>> {
const activity = await this.activityRepository.findOne({
where: [{ id }, { stravaActivityId: id }],
});
if (!activity) {
throw new NotFoundException('Activity not found');
}
const [streamPointCount, strengthTrainingDetail, swimActivityDetail] =
await Promise.all([
this.streamPointRepository.count({ where: { activityId: activity.id } }),
this.strengthTrainingDetailRepository.findOne({
where: { activityId: activity.id },
}),
this.swimActivityDetailRepository.findOne({
where: { activityId: activity.id },
}),
]);
return {
...this.toRecentActivity(activity),
startDateLocal: activity.startDateLocal
? activity.startDateLocal.toISOString()
: null,
elapsedTimeSeconds: activity.elapsedTime,
totalElevationGainMeters: activity.totalElevationGain,
maxSpeedMetersPerSecond: activity.maxSpeed,
maxHeartrate: activity.maxHeartrate,
averageWatts: activity.averageWatts,
maxWatts: activity.maxWatts,
weightedAverageWatts: activity.weightedAverageWatts,
averageCadence: activity.averageCadence,
calories: activity.calories,
gearId: activity.gearId,
trainer: activity.trainer,
commute: activity.commute,
manual: activity.manual,
private: activity.private,
visibility: activity.visibility,
mapId: activity.mapId,
summaryPolyline: activity.summaryPolyline,
streamPointCount,
strengthTrainingDetail: strengthTrainingDetail
? this.toStrengthTrainingDetail(strengthTrainingDetail)
: null,
swimActivityDetail: swimActivityDetail
? this.toSwimActivityDetail(swimActivityDetail)
: null,
};
}
async getRunningActivityDetail(id: string): Promise<RunningActivityDetail> {
const activity = await this.activityRepository.findOne({ where: { id } });
if (!activity || !this.isRunningActivity(activity)) {
@@ -982,6 +1062,46 @@ export class AnalyticsService {
};
}
private toStrengthTrainingDetail(
detail: StravaStrengthTrainingDetailEntity,
): Record<string, unknown> {
return {
movingTimeSeconds: detail.movingTime,
elapsedTimeSeconds: detail.elapsedTime,
calories: detail.calories,
averageHeartrate: detail.averageHeartrate,
maxHeartrate: detail.maxHeartrate,
perceivedExertion: detail.perceivedExertion,
workoutType: detail.workoutType,
description: detail.description,
rawPayload: detail.rawPayload,
};
}
private toSwimActivityDetail(
detail: StravaSwimActivityDetailEntity,
): Record<string, unknown> {
return {
distanceMeters: detail.distance,
movingTimeSeconds: detail.movingTime,
elapsedTimeSeconds: detail.elapsedTime,
averageSpeedMetersPerSecond: detail.averageSpeed,
maxSpeedMetersPerSecond: detail.maxSpeed,
paceSecondsPer100m:
detail.distance && detail.distance > 0 && detail.movingTime
? Math.round(detail.movingTime / (detail.distance / 100))
: null,
calories: detail.calories,
averageHeartrate: detail.averageHeartrate,
maxHeartrate: detail.maxHeartrate,
averageCadence: detail.averageCadence,
poolLength: detail.poolLength,
laps: detail.laps,
lapsError: detail.lapsError,
rawPayload: detail.rawPayload,
};
}
private createTotals(): AnalyticsTotals {
return {
activityCount: 0,