This commit is contained in:
Bastian Wagner
2026-06-17 10:45:09 +02:00
parent 38141c0358
commit c94a02e6d0
51 changed files with 4220 additions and 628 deletions

View File

@@ -1,16 +1,52 @@
import { Repository } from 'typeorm';
import { StravaActivityEntity } from '../database/entities';
import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
} from '../database/entities';
import { StravaStreamImportService } from '../strava/strava-stream-import.service';
import { AnalyticsService } from './analytics.service';
describe('AnalyticsService', () => {
const createService = (activities: StravaActivityEntity[]) => {
const repository = {
afterEach(() => {
jest.useRealTimers();
});
const createService = (
activities: StravaActivityEntity[],
streamPoints: StravaActivityStreamPointEntity[] = [],
) => {
const activityRepository = {
find: jest.fn().mockResolvedValue(activities),
findOne: jest
.fn()
.mockImplementation(({ where }: { where: { id: string } }) =>
Promise.resolve(
activities.find((activity) => activity.id === where.id) ?? null,
),
),
} as unknown as Repository<StravaActivityEntity>;
const streamPointFind = jest.fn().mockResolvedValue(streamPoints);
const importStreamsForActivity = jest
.fn()
.mockResolvedValue(streamPoints.length);
const streamPointRepository = {
find: streamPointFind,
} as unknown as Repository<StravaActivityStreamPointEntity>;
const stravaStreamImportService = {
importStreamsForActivity,
} as unknown as StravaStreamImportService;
return {
service: new AnalyticsService(repository),
repository,
service: new AnalyticsService(
activityRepository,
streamPointRepository,
stravaStreamImportService,
),
activityRepository,
streamPointRepository,
stravaStreamImportService,
streamPointFind,
importStreamsForActivity,
};
};
@@ -29,6 +65,7 @@ describe('AnalyticsService', () => {
calories: input.calories ?? 400,
averageSpeed: input.averageSpeed ?? 5.5,
averageHeartrate: input.averageHeartrate ?? null,
maxHeartrate: input.maxHeartrate ?? null,
averageWatts: input.averageWatts ?? null,
averageCadence: input.averageCadence ?? null,
}) as StravaActivityEntity;
@@ -120,4 +157,238 @@ describe('AnalyticsService', () => {
expect(dashboard.totals.distanceMeters).toBe(5000);
expect(dashboard.sports.map((sport) => sport.sportType)).toEqual(['Run']);
});
it('returns running summary for run-like sport types only', async () => {
const { service } = createService([
activity({
stravaActivityId: '1',
sportType: 'Run',
distance: 10000,
movingTime: 3000,
}),
activity({
stravaActivityId: '2',
sportType: 'TrailRun',
distance: 5000,
movingTime: 1800,
}),
activity({
stravaActivityId: '3',
sportType: 'Ride',
distance: 50000,
movingTime: 7200,
}),
]);
const summary = await service.getRunningSummary(12);
expect(summary.totals.activityCount).toBe(2);
expect(summary.totals.distanceMeters).toBe(15000);
expect(summary.longestRun?.stravaActivityId).toBe('1');
});
it('returns running kpis with load, recovery, distribution, and progression', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-06-16T12:00:00.000Z'));
const { service } = createService([
activity({
id: 'run-1',
stravaActivityId: '1',
sportType: 'Run',
startDate: new Date('2026-06-15T10:00:00.000Z'),
distance: 10000,
movingTime: 3000,
averageHeartrate: 170,
maxHeartrate: 190,
}),
activity({
id: 'run-2',
stravaActivityId: '2',
sportType: 'Run',
startDate: new Date('2026-06-10T10:00:00.000Z'),
distance: 8000,
movingTime: 3200,
averageHeartrate: 130,
}),
activity({
id: 'run-3',
stravaActivityId: '3',
sportType: 'Run',
startDate: new Date('2026-05-20T10:00:00.000Z'),
distance: 5000,
movingTime: 1500,
averageHeartrate: 150,
}),
]);
const kpis = await service.getRunningKpis(12);
expect(kpis.load.acute).toBeGreaterThan(0);
expect(kpis.load.chronic).toBeGreaterThan(0);
expect(kpis.load.acuteChronicRatio).not.toBeNull();
expect(kpis.monotony.value).not.toBeNull();
expect(kpis.strain.value).not.toBeNull();
expect(kpis.recovery.daysSinceLastHardRun).toBe(1);
expect(
kpis.intensityDistribution.find((bucket) => bucket.zone === 'hard')
?.activityCount,
).toBe(1);
expect(
kpis.intensityDistribution.find((bucket) => bucket.zone === 'easy')
?.activityCount,
).toBe(1);
expect(kpis.progression.map((metric) => metric.key)).toEqual([
'distance',
'time',
'load',
'pace',
'runs',
]);
});
it('falls back to pace for intensity distribution when heart rate is missing', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-06-16T12:00:00.000Z'));
const { service } = createService([
activity({
id: 'fast-run',
stravaActivityId: '1',
sportType: 'Run',
startDate: new Date('2026-06-15T10:00:00.000Z'),
distance: 10000,
movingTime: 3000,
averageHeartrate: null,
maxHeartrate: null,
}),
activity({
id: 'easy-run',
stravaActivityId: '2',
sportType: 'Run',
startDate: new Date('2026-06-12T10:00:00.000Z'),
distance: 10000,
movingTime: 5000,
averageHeartrate: null,
maxHeartrate: null,
}),
]);
const kpis = await service.getRunningKpis(12);
expect(
kpis.intensityDistribution.find((bucket) => bucket.zone === 'hard')
?.activityCount,
).toBe(1);
expect(
kpis.intensityDistribution.find((bucket) => bucket.zone === 'easy')
?.activityCount,
).toBe(1);
});
it('estimates personal records from stream points', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-06-16T12:00:00.000Z'));
const run = activity({
id: 'run-1',
stravaActivityId: '1',
sportType: 'Run',
startDate: new Date('2026-06-15T10:00:00.000Z'),
distance: 10000,
movingTime: 4000,
});
const { service } = createService(
[run],
[
streamPoint(0, 0, 0, 10, 'run-1'),
streamPoint(1, 1000, 300, 10, 'run-1'),
streamPoint(2, 5000, 1800, 10, 'run-1'),
streamPoint(3, 10000, 4000, 10, 'run-1'),
],
);
const kpis = await service.getRunningKpis(12);
expect(kpis.personalRecords).toEqual([
expect.objectContaining({
distanceMeters: 1000,
timeSeconds: 300,
estimated: false,
}),
expect.objectContaining({
distanceMeters: 5000,
timeSeconds: 1800,
estimated: false,
}),
expect.objectContaining({
distanceMeters: 10000,
timeSeconds: 4000,
estimated: false,
}),
]);
});
it('builds kilometer splits and chart series for a running activity', async () => {
const run = activity({
id: 'run-1',
stravaActivityId: '1',
sportType: 'Run',
distance: 2000,
movingTime: 600,
});
const points = [
streamPoint(0, 0, 0, 10),
streamPoint(1, 1000, 300, 15),
streamPoint(2, 2000, 600, 25),
];
const { service } = createService([run], points);
const detail = await service.getRunningActivityDetail('run-1');
expect(detail.splits).toEqual([
expect.objectContaining({
kilometer: 1,
movingTimeSeconds: 300,
paceSecondsPerKm: 300,
elevationGainMeters: 5,
}),
expect.objectContaining({
kilometer: 2,
movingTimeSeconds: 300,
paceSecondsPerKm: 300,
elevationGainMeters: 10,
}),
]);
expect(detail.series).toHaveLength(3);
});
it('imports streams lazily when a running detail has no stream points', async () => {
const run = activity({
id: 'run-1',
stravaActivityId: '1',
sportType: 'Run',
});
const { service, streamPointFind, importStreamsForActivity } =
createService([run], []);
await service.getRunningActivityDetail('run-1');
expect(importStreamsForActivity).toHaveBeenCalledWith(run);
expect(streamPointFind).toHaveBeenCalledTimes(2);
});
const streamPoint = (
pointIndex: number,
distance: number,
timeSeconds: number,
altitude: number,
activityId = 'activity-1',
): StravaActivityStreamPointEntity =>
({
activityId,
pointIndex,
distance,
timeSeconds,
altitude,
heartRate: 140 + pointIndex,
cadence: 80 + pointIndex,
}) as StravaActivityStreamPointEntity;
});