395 lines
11 KiB
TypeScript
395 lines
11 KiB
TypeScript
import { Repository } from 'typeorm';
|
|
import {
|
|
StravaActivityEntity,
|
|
StravaActivityStreamPointEntity,
|
|
} from '../database/entities';
|
|
import { StravaStreamImportService } from '../strava/strava-stream-import.service';
|
|
import { AnalyticsService } from './analytics.service';
|
|
|
|
describe('AnalyticsService', () => {
|
|
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(
|
|
activityRepository,
|
|
streamPointRepository,
|
|
stravaStreamImportService,
|
|
),
|
|
activityRepository,
|
|
streamPointRepository,
|
|
stravaStreamImportService,
|
|
streamPointFind,
|
|
importStreamsForActivity,
|
|
};
|
|
};
|
|
|
|
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,
|
|
maxHeartrate: input.maxHeartrate ?? 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']);
|
|
});
|
|
|
|
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;
|
|
});
|