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; const streamPointFind = jest.fn().mockResolvedValue(streamPoints); const importStreamsForActivity = jest .fn() .mockResolvedValue(streamPoints.length); const streamPointRepository = { find: streamPointFind, } as unknown as Repository; const stravaStreamImportService = { importStreamsForActivity, } as unknown as StravaStreamImportService; return { service: new AnalyticsService( activityRepository, streamPointRepository, stravaStreamImportService, ), activityRepository, streamPointRepository, stravaStreamImportService, streamPointFind, importStreamsForActivity, }; }; const activity = ( input: Partial, ): 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; });