From 8c7a5cbb6366ca1942147a415a5c90690359ae8e Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 17 Jun 2026 15:10:26 +0200 Subject: [PATCH] details --- .env.example | 4 + api/src/database/entities/index.ts | 2 + .../strava-strength-training-detail.entity.ts | 74 ++++ .../strava-swim-activity-detail.entity.ts | 86 +++++ api/src/database/typeorm.options.ts | 4 + api/src/mcp/mcp-data.service.ts | 77 ++++ api/src/mcp/mcp.module.ts | 4 + api/src/strava/strava-client.service.ts | 19 +- .../strava-strength-training-detail.mapper.ts | 48 +++ .../strava-swim-activity-detail.mapper.ts | 43 +++ api/src/strava/strava-sync.service.spec.ts | 352 ++++++++++++++++++ api/src/strava/strava-sync.service.ts | 250 +++++++++++-- api/src/strava/strava.module.ts | 4 + api/src/strava/strava.types.ts | 25 ++ 14 files changed, 953 insertions(+), 39 deletions(-) create mode 100644 api/src/database/entities/strava-strength-training-detail.entity.ts create mode 100644 api/src/database/entities/strava-swim-activity-detail.entity.ts create mode 100644 api/src/strava/strava-strength-training-detail.mapper.ts create mode 100644 api/src/strava/strava-swim-activity-detail.mapper.ts diff --git a/.env.example b/.env.example index a4f4cde..e064f3f 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ DATABASE_NAME=strava_app TYPEORM_SYNCHRONIZE=true TYPEORM_LOGGING=false +STRAVA_SYNC_ON_START=true +STRAVA_SYNC_INTERVAL_MINUTES=60 +STRAVA_SYNC_LOOKBACK_DAYS=14 + STRAVA_CLIENT_ID=change-me STRAVA_CLIENT_SECRET=change-me STRAVA_REDIRECT_URI=http://localhost:3000/auth/strava/callback diff --git a/api/src/database/entities/index.ts b/api/src/database/entities/index.ts index 8d15828..0a2896b 100644 --- a/api/src/database/entities/index.ts +++ b/api/src/database/entities/index.ts @@ -1,6 +1,8 @@ export { StravaActivityEntity } from './strava-activity.entity'; export { StravaActivityStreamPointEntity } from './strava-activity-stream-point.entity'; export { StravaAthleteEntity } from './strava-athlete.entity'; +export { StravaStrengthTrainingDetailEntity } from './strava-strength-training-detail.entity'; +export { StravaSwimActivityDetailEntity } from './strava-swim-activity-detail.entity'; export { StravaSyncJobEntity } from './strava-sync-job.entity'; export { StravaSyncJobItemEntity } from './strava-sync-job-item.entity'; export { StravaTokenEntity } from './strava-token.entity'; diff --git a/api/src/database/entities/strava-strength-training-detail.entity.ts b/api/src/database/entities/strava-strength-training-detail.entity.ts new file mode 100644 index 0000000..aa84ebb --- /dev/null +++ b/api/src/database/entities/strava-strength-training-detail.entity.ts @@ -0,0 +1,74 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { StravaActivityEntity } from './strava-activity.entity'; + +@Entity({ name: 'strava_strength_training_details' }) +@Index(['activityId'], { unique: true }) +@Index(['stravaActivityId'], { unique: true }) +export class StravaStrengthTrainingDetailEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'activity_id', type: 'varchar', length: 36 }) + activityId!: string; + + @OneToOne(() => StravaActivityEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'activity_id' }) + activity!: StravaActivityEntity; + + @Column({ name: 'strava_activity_id', type: 'varchar', length: 32 }) + stravaActivityId!: string; + + @Column({ name: 'sport_type', type: 'varchar', length: 100, nullable: true }) + sportType!: string | null; + + @Column({ type: 'varchar', length: 500 }) + name!: string; + + @Column({ name: 'start_date', type: 'datetime', nullable: true }) + startDate!: Date | null; + + @Column({ name: 'start_date_local', type: 'datetime', nullable: true }) + startDateLocal!: Date | null; + + @Column({ name: 'moving_time', type: 'int', nullable: true }) + movingTime!: number | null; + + @Column({ name: 'elapsed_time', type: 'int', nullable: true }) + elapsedTime!: number | null; + + @Column({ type: 'double', nullable: true }) + calories!: number | null; + + @Column({ name: 'average_heartrate', type: 'double', nullable: true }) + averageHeartrate!: number | null; + + @Column({ name: 'max_heartrate', type: 'double', nullable: true }) + maxHeartrate!: number | null; + + @Column({ name: 'perceived_exertion', type: 'double', nullable: true }) + perceivedExertion!: number | null; + + @Column({ name: 'workout_type', type: 'int', nullable: true }) + workoutType!: number | null; + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ name: 'raw_payload', type: 'json', nullable: true }) + rawPayload!: Record | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/api/src/database/entities/strava-swim-activity-detail.entity.ts b/api/src/database/entities/strava-swim-activity-detail.entity.ts new file mode 100644 index 0000000..968ab07 --- /dev/null +++ b/api/src/database/entities/strava-swim-activity-detail.entity.ts @@ -0,0 +1,86 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { StravaActivityEntity } from './strava-activity.entity'; + +@Entity({ name: 'strava_swim_activity_details' }) +@Index(['activityId'], { unique: true }) +@Index(['stravaActivityId'], { unique: true }) +export class StravaSwimActivityDetailEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'activity_id', type: 'varchar', length: 36 }) + activityId!: string; + + @OneToOne(() => StravaActivityEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'activity_id' }) + activity!: StravaActivityEntity; + + @Column({ name: 'strava_activity_id', type: 'varchar', length: 32 }) + stravaActivityId!: string; + + @Column({ name: 'sport_type', type: 'varchar', length: 100, nullable: true }) + sportType!: string | null; + + @Column({ type: 'varchar', length: 500 }) + name!: string; + + @Column({ name: 'start_date', type: 'datetime', nullable: true }) + startDate!: Date | null; + + @Column({ name: 'start_date_local', type: 'datetime', nullable: true }) + startDateLocal!: Date | null; + + @Column({ type: 'double', nullable: true }) + distance!: number | null; + + @Column({ name: 'moving_time', type: 'int', nullable: true }) + movingTime!: number | null; + + @Column({ name: 'elapsed_time', type: 'int', nullable: true }) + elapsedTime!: number | null; + + @Column({ name: 'average_speed', type: 'double', nullable: true }) + averageSpeed!: number | null; + + @Column({ name: 'max_speed', type: 'double', nullable: true }) + maxSpeed!: number | null; + + @Column({ type: 'double', nullable: true }) + calories!: number | null; + + @Column({ name: 'average_heartrate', type: 'double', nullable: true }) + averageHeartrate!: number | null; + + @Column({ name: 'max_heartrate', type: 'double', nullable: true }) + maxHeartrate!: number | null; + + @Column({ name: 'average_cadence', type: 'double', nullable: true }) + averageCadence!: number | null; + + @Column({ name: 'pool_length', type: 'double', nullable: true }) + poolLength!: number | null; + + @Column({ name: 'laps', type: 'json', nullable: true }) + laps!: Record[] | null; + + @Column({ name: 'laps_error', type: 'text', nullable: true }) + lapsError!: string | null; + + @Column({ name: 'raw_payload', type: 'json', nullable: true }) + rawPayload!: Record | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/api/src/database/typeorm.options.ts b/api/src/database/typeorm.options.ts index ce4d7f3..12e5537 100644 --- a/api/src/database/typeorm.options.ts +++ b/api/src/database/typeorm.options.ts @@ -3,6 +3,8 @@ import { StravaActivityEntity, StravaActivityStreamPointEntity, StravaAthleteEntity, + StravaStrengthTrainingDetailEntity, + StravaSwimActivityDetailEntity, StravaSyncJobEntity, StravaSyncJobItemEntity, StravaTokenEntity, @@ -28,6 +30,8 @@ export const createTypeOrmOptions = (): DataSourceOptions => ({ StravaTokenEntity, StravaActivityEntity, StravaActivityStreamPointEntity, + StravaStrengthTrainingDetailEntity, + StravaSwimActivityDetailEntity, StravaSyncJobEntity, StravaSyncJobItemEntity, ], diff --git a/api/src/mcp/mcp-data.service.ts b/api/src/mcp/mcp-data.service.ts index 45cde79..48c55a0 100644 --- a/api/src/mcp/mcp-data.service.ts +++ b/api/src/mcp/mcp-data.service.ts @@ -5,6 +5,8 @@ import { StravaActivityEntity, StravaActivityStreamPointEntity, StravaAthleteEntity, + StravaStrengthTrainingDetailEntity, + StravaSwimActivityDetailEntity, } from '../database/entities'; export interface ListActivitiesInput { @@ -32,6 +34,10 @@ export class McpDataService { private readonly activityRepository: Repository, @InjectRepository(StravaActivityStreamPointEntity) private readonly streamPointRepository: Repository, + @InjectRepository(StravaStrengthTrainingDetailEntity) + private readonly strengthTrainingDetailRepository: Repository, + @InjectRepository(StravaSwimActivityDetailEntity) + private readonly swimActivityDetailRepository: Repository, ) {} async getAthleteProfile(): Promise> { @@ -150,6 +156,13 @@ export class McpDataService { const streamPointCount = await this.streamPointRepository.count({ where: { activityId: activity.id }, }); + const strengthTrainingDetail = + await this.strengthTrainingDetailRepository.findOne({ + where: { activityId: activity.id }, + }); + const swimActivityDetail = await this.swimActivityDetailRepository.findOne({ + where: { activityId: activity.id }, + }); const detail: Record = { ...this.toActivitySummary(activity), elapsedTimeSeconds: activity.elapsedTime, @@ -168,6 +181,12 @@ export class McpDataService { mapId: activity.mapId, summaryPolyline: activity.summaryPolyline, streamPointCount, + strengthTrainingDetail: strengthTrainingDetail + ? this.toStrengthTrainingDetail(strengthTrainingDetail) + : null, + swimActivityDetail: swimActivityDetail + ? this.toSwimActivityDetail(swimActivityDetail) + : null, createdAt: this.toIso(activity.createdAt), updatedAt: this.toIso(activity.updatedAt), }; @@ -213,6 +232,64 @@ export class McpDataService { return detail; } + private toStrengthTrainingDetail( + detail: StravaStrengthTrainingDetailEntity, + ): Record { + return { + id: detail.id, + activityId: detail.activityId, + stravaActivityId: detail.stravaActivityId, + sportType: detail.sportType, + name: detail.name, + startDate: this.toIso(detail.startDate), + startDateLocal: this.toIso(detail.startDateLocal), + 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, + createdAt: this.toIso(detail.createdAt), + updatedAt: this.toIso(detail.updatedAt), + }; + } + + private toSwimActivityDetail( + detail: StravaSwimActivityDetailEntity, + ): Record { + return { + id: detail.id, + activityId: detail.activityId, + stravaActivityId: detail.stravaActivityId, + sportType: detail.sportType, + name: detail.name, + startDate: this.toIso(detail.startDate), + startDateLocal: this.toIso(detail.startDateLocal), + 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, + createdAt: this.toIso(detail.createdAt), + updatedAt: this.toIso(detail.updatedAt), + }; + } + private toActivitySummary( activity: StravaActivityEntity, ): Record { diff --git a/api/src/mcp/mcp.module.ts b/api/src/mcp/mcp.module.ts index f8bb06d..dd77e67 100644 --- a/api/src/mcp/mcp.module.ts +++ b/api/src/mcp/mcp.module.ts @@ -5,6 +5,8 @@ import { StravaActivityEntity, StravaActivityStreamPointEntity, StravaAthleteEntity, + StravaStrengthTrainingDetailEntity, + StravaSwimActivityDetailEntity, } from '../database/entities'; import { StravaModule } from '../strava/strava.module'; import { McpDataService } from './mcp-data.service'; @@ -18,6 +20,8 @@ import { McpServerService } from './mcp-server.service'; StravaAthleteEntity, StravaActivityEntity, StravaActivityStreamPointEntity, + StravaStrengthTrainingDetailEntity, + StravaSwimActivityDetailEntity, ]), ], providers: [McpDataService, McpServerService], diff --git a/api/src/strava/strava-client.service.ts b/api/src/strava/strava-client.service.ts index e802544..f0a7963 100644 --- a/api/src/strava/strava-client.service.ts +++ b/api/src/strava/strava-client.service.ts @@ -6,6 +6,7 @@ import { firstValueFrom } from 'rxjs'; import { StravaRateLimitError } from './strava-rate-limit.error'; import { StravaActivityPayload, + StravaLapPayload, StravaStreamPayload, StravaStreamsResponse, StravaTokenPayload, @@ -61,12 +62,17 @@ export class StravaClientService { accessToken: string, page: number, perPage = 100, + after?: number, ): Promise { return this.request({ method: 'GET', url: 'https://www.strava.com/api/v3/athlete/activities', headers: this.authHeaders(accessToken), - params: { page, per_page: perPage }, + params: { + page, + per_page: perPage, + ...(after ? { after } : {}), + }, }); } @@ -98,6 +104,17 @@ export class StravaClientService { return this.normalizeStreamsResponse(streams); } + async getActivityLaps( + accessToken: string, + stravaActivityId: string, + ): Promise { + return this.request({ + method: 'GET', + url: `https://www.strava.com/api/v3/activities/${stravaActivityId}/laps`, + headers: this.authHeaders(accessToken), + }); + } + private async postToken( body: Record, ): Promise { diff --git a/api/src/strava/strava-strength-training-detail.mapper.ts b/api/src/strava/strava-strength-training-detail.mapper.ts new file mode 100644 index 0000000..a5407a9 --- /dev/null +++ b/api/src/strava/strava-strength-training-detail.mapper.ts @@ -0,0 +1,48 @@ +import { + StravaActivityEntity, + StravaStrengthTrainingDetailEntity, +} from '../database/entities'; +import { StravaActivityPayload } from './strava.types'; + +const strengthSportTypes = new Set([ + 'crossfit', + 'highintensityintervaltraining', + 'weighttraining', + 'workout', +]); + +export const isStrengthTrainingActivity = ( + sportType: string | null | undefined, +): boolean => strengthSportTypes.has((sportType ?? '').toLowerCase()); + +export const mapStravaStrengthTrainingDetail = ( + activity: StravaActivityEntity, + payload: StravaActivityPayload, + existing?: StravaStrengthTrainingDetailEntity | null, +): StravaStrengthTrainingDetailEntity => { + const detail = existing ?? new StravaStrengthTrainingDetailEntity(); + + detail.activityId = activity.id; + detail.stravaActivityId = activity.stravaActivityId; + detail.sportType = activity.sportType; + detail.name = activity.name; + detail.startDate = activity.startDate; + detail.startDateLocal = activity.startDateLocal; + detail.movingTime = activity.movingTime; + detail.elapsedTime = activity.elapsedTime; + detail.calories = activity.calories; + detail.averageHeartrate = activity.averageHeartrate; + detail.maxHeartrate = activity.maxHeartrate; + detail.description = stringOrNull(payload.description); + detail.perceivedExertion = numberOrNull(payload.perceived_exertion); + detail.workoutType = numberOrNull(payload.workout_type); + detail.rawPayload = payload; + + return detail; +}; + +const numberOrNull = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +const stringOrNull = (value: unknown): string | null => + typeof value === 'string' && value.trim() ? value : null; diff --git a/api/src/strava/strava-swim-activity-detail.mapper.ts b/api/src/strava/strava-swim-activity-detail.mapper.ts new file mode 100644 index 0000000..4609c48 --- /dev/null +++ b/api/src/strava/strava-swim-activity-detail.mapper.ts @@ -0,0 +1,43 @@ +import { + StravaActivityEntity, + StravaSwimActivityDetailEntity, +} from '../database/entities'; +import { StravaActivityPayload, StravaLapPayload } from './strava.types'; + +export const isSwimActivity = (sportType: string | null | undefined): boolean => + (sportType ?? '').toLowerCase() === 'swim'; + +export const mapStravaSwimActivityDetail = ( + activity: StravaActivityEntity, + payload: StravaActivityPayload, + laps: StravaLapPayload[], + lapsError: string | null, + existing?: StravaSwimActivityDetailEntity | null, +): StravaSwimActivityDetailEntity => { + const detail = existing ?? new StravaSwimActivityDetailEntity(); + + detail.activityId = activity.id; + detail.stravaActivityId = activity.stravaActivityId; + detail.sportType = activity.sportType; + detail.name = activity.name; + detail.startDate = activity.startDate; + detail.startDateLocal = activity.startDateLocal; + detail.distance = activity.distance; + detail.movingTime = activity.movingTime; + detail.elapsedTime = activity.elapsedTime; + detail.averageSpeed = activity.averageSpeed; + detail.maxSpeed = activity.maxSpeed; + detail.calories = activity.calories; + detail.averageHeartrate = activity.averageHeartrate; + detail.maxHeartrate = activity.maxHeartrate; + detail.averageCadence = activity.averageCadence; + detail.poolLength = numberOrNull(payload.pool_length); + detail.laps = laps; + detail.lapsError = lapsError; + detail.rawPayload = payload; + + return detail; +}; + +const numberOrNull = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; diff --git a/api/src/strava/strava-sync.service.spec.ts b/api/src/strava/strava-sync.service.spec.ts index 5452c66..d1830ff 100644 --- a/api/src/strava/strava-sync.service.spec.ts +++ b/api/src/strava/strava-sync.service.spec.ts @@ -33,6 +33,9 @@ describe('StravaSyncService', () => { return Promise.resolve(job); }), }; + const activityRepository = { + findOne: jest.fn().mockResolvedValue(null), + }; const stravaTokenService = { getValidAccessToken: jest.fn().mockResolvedValue('access-token'), }; @@ -44,6 +47,8 @@ describe('StravaSyncService', () => { }; const service = new StravaSyncService( + {} as never, + activityRepository as never, {} as never, {} as never, jobRepository as never, @@ -66,4 +71,351 @@ describe('StravaSyncService', () => { expect(job.status).toBe('completed'); expect(job.finishedAt).toBeInstanceOf(Date); }); + + it('uses a lookback window from the latest imported activity for incremental syncs', async () => { + const latestActivity = { + startDate: new Date('2026-06-16T12:00:00.000Z'), + }; + const job = { + id: 'job-1', + stravaAthleteId: 'athlete-1', + status: 'queued', + activityCount: 0, + detailCount: 0, + streamPointCount: 0, + errorMessage: null, + retryAfter: null, + startedAt: null, + finishedAt: null, + items: [], + } as StravaSyncJobEntity; + const jobRepository = { + findOne: jest.fn().mockResolvedValue(job), + save: jest.fn((entity: Partial) => { + Object.assign(job, entity); + return Promise.resolve(job); + }), + }; + const activityRepository = { + findOne: jest.fn().mockResolvedValue(latestActivity), + }; + const stravaTokenService = { + getValidAccessToken: jest.fn().mockResolvedValue('access-token'), + }; + const stravaClientService = { + listActivities: jest.fn().mockResolvedValue([]), + }; + const service = new StravaSyncService( + {} as never, + activityRepository as never, + {} as never, + {} as never, + jobRepository as never, + {} as never, + stravaTokenService as never, + stravaClientService as never, + {} as never, + ); + + await service.runJob(job.id); + + expect(stravaClientService.listActivities).toHaveBeenCalledWith( + 'access-token', + 1, + 100, + 1780401600, + ); + }); + + it('stores strength training details in their own table', async () => { + const job = { + id: 'job-1', + stravaAthleteId: 'athlete-1', + status: 'queued', + activityCount: 0, + detailCount: 0, + streamPointCount: 0, + errorMessage: null, + retryAfter: null, + startedAt: null, + finishedAt: null, + items: [], + } as StravaSyncJobEntity; + const jobRepository = { + findOne: jest.fn().mockResolvedValue(job), + save: jest.fn((entity: Partial) => { + Object.assign(job, entity); + return Promise.resolve(job); + }), + }; + const activityRepository = { + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn((activity) => + Promise.resolve({ + ...activity, + id: 'activity-1', + }), + ), + }; + const strengthTrainingDetailRepository = { + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn((detail) => Promise.resolve(detail)), + }; + const jobItemRepository = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((item) => item), + save: jest.fn((item) => Promise.resolve(item)), + }; + const stravaTokenService = { + getValidAccessToken: jest.fn().mockResolvedValue('access-token'), + }; + const stravaClientService = { + listActivities: jest.fn().mockResolvedValue([ + { + id: 'strength-1', + name: 'Push Day', + sport_type: 'WeightTraining', + start_date: '2026-06-16T12:00:00.000Z', + elapsed_time: 3600, + }, + ]), + getActivity: jest.fn().mockResolvedValue({ + id: 'strength-1', + name: 'Push Day', + sport_type: 'WeightTraining', + start_date: '2026-06-16T12:00:00.000Z', + elapsed_time: 3600, + moving_time: 3400, + calories: 420, + average_heartrate: 118, + max_heartrate: 155, + perceived_exertion: 7, + description: 'Bench press 5x5', + }), + }; + const stravaStreamImportService = { + importStreamsForActivity: jest.fn().mockResolvedValue(0), + }; + const service = new StravaSyncService( + {} as never, + activityRepository as never, + strengthTrainingDetailRepository as never, + {} as never, + jobRepository as never, + jobItemRepository as never, + stravaTokenService as never, + stravaClientService as never, + stravaStreamImportService as never, + ); + + await service.runJob(job.id); + + expect(strengthTrainingDetailRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + activityId: 'activity-1', + stravaActivityId: 'strength-1', + sportType: 'WeightTraining', + name: 'Push Day', + elapsedTime: 3600, + calories: 420, + averageHeartrate: 118, + maxHeartrate: 155, + perceivedExertion: 7, + description: 'Bench press 5x5', + }), + ); + }); + + it('stores swim details and laps in their own table', async () => { + const job = { + id: 'job-1', + stravaAthleteId: 'athlete-1', + status: 'queued', + activityCount: 0, + detailCount: 0, + streamPointCount: 0, + errorMessage: null, + retryAfter: null, + startedAt: null, + finishedAt: null, + items: [], + } as StravaSyncJobEntity; + const jobRepository = { + findOne: jest.fn().mockResolvedValue(job), + save: jest.fn((entity: Partial) => { + Object.assign(job, entity); + return Promise.resolve(job); + }), + }; + const activityRepository = { + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn((activity) => + Promise.resolve({ + ...activity, + id: 'activity-1', + }), + ), + }; + const swimActivityDetailRepository = { + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn((detail) => Promise.resolve(detail)), + }; + const jobItemRepository = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((item) => item), + save: jest.fn((item) => Promise.resolve(item)), + }; + const stravaTokenService = { + getValidAccessToken: jest.fn().mockResolvedValue('access-token'), + }; + const laps = [ + { + id: 'lap-1', + lap_index: 1, + distance: 100, + elapsed_time: 95, + }, + ]; + const stravaClientService = { + listActivities: jest.fn().mockResolvedValue([ + { + id: 'swim-1', + name: 'Morning Swim', + sport_type: 'Swim', + start_date: '2026-06-16T12:00:00.000Z', + elapsed_time: 1800, + distance: 1000, + }, + ]), + getActivity: jest.fn().mockResolvedValue({ + id: 'swim-1', + name: 'Morning Swim', + sport_type: 'Swim', + start_date: '2026-06-16T12:00:00.000Z', + elapsed_time: 1800, + moving_time: 1700, + distance: 1000, + average_speed: 0.58, + calories: 350, + average_heartrate: 132, + pool_length: 25, + }), + getActivityLaps: jest.fn().mockResolvedValue(laps), + }; + const stravaStreamImportService = { + importStreamsForActivity: jest.fn().mockResolvedValue(0), + }; + const service = new StravaSyncService( + {} as never, + activityRepository as never, + {} as never, + swimActivityDetailRepository as never, + jobRepository as never, + jobItemRepository as never, + stravaTokenService as never, + stravaClientService as never, + stravaStreamImportService as never, + ); + + await service.runJob(job.id); + + expect(stravaClientService.getActivityLaps).toHaveBeenCalledWith( + 'access-token', + 'swim-1', + ); + expect(swimActivityDetailRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + activityId: 'activity-1', + stravaActivityId: 'swim-1', + sportType: 'Swim', + name: 'Morning Swim', + distance: 1000, + elapsedTime: 1800, + movingTime: 1700, + averageSpeed: 0.58, + calories: 350, + averageHeartrate: 132, + poolLength: 25, + laps, + lapsError: null, + }), + ); + }); + + it('resumes stale queued and running jobs on module init', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-16T12:00:00.000Z')); + + const queuedJob = { + id: 'queued-job', + status: 'queued', + retryAfter: null, + } as StravaSyncJobEntity; + const runningJob = { + id: 'running-job', + status: 'running', + retryAfter: null, + } as StravaSyncJobEntity; + const rateLimitedJob = { + id: 'rate-limited-job', + status: 'rate_limited', + retryAfter: new Date('2026-06-16T12:15:00.000Z'), + } as StravaSyncJobEntity; + const jobRepository = { + find: jest + .fn() + .mockResolvedValue([queuedJob, runningJob, rateLimitedJob]), + }; + const service = new StravaSyncService( + {} as never, + {} as never, + {} as never, + {} as never, + jobRepository as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + const runJob = jest.spyOn(service, 'runJob').mockResolvedValue(undefined); + + await service.onModuleInit(); + + expect(runJob).toHaveBeenCalledWith(queuedJob.id); + expect(runJob).toHaveBeenCalledWith(runningJob.id); + expect(runJob).not.toHaveBeenCalledWith(rateLimitedJob.id); + expect(jest.getTimerCount()).toBe(1); + }); + + it('restarts a stale active job when sync is requested again', async () => { + const athlete = { id: 'athlete-1', accountKey: 'primary' }; + const activeJob = { + id: 'job-1', + stravaAthleteId: athlete.id, + status: 'running', + } as StravaSyncJobEntity; + const athleteRepository = { + findOne: jest.fn().mockResolvedValue(athlete), + }; + const jobRepository = { + findOne: jest.fn().mockResolvedValue(activeJob), + }; + const service = new StravaSyncService( + athleteRepository as never, + {} as never, + {} as never, + {} as never, + jobRepository as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + const runJob = jest.spyOn(service, 'runJob').mockResolvedValue(undefined); + + const result = await service.startSync(); + + expect(result).toBe(activeJob); + expect(runJob).toHaveBeenCalledWith(activeJob.id); + }); }); diff --git a/api/src/strava/strava-sync.service.ts b/api/src/strava/strava-sync.service.ts index df4da0c..32c1952 100644 --- a/api/src/strava/strava-sync.service.ts +++ b/api/src/strava/strava-sync.service.ts @@ -1,29 +1,52 @@ -import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common'; +import { + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { StravaActivityEntity, StravaAthleteEntity, + StravaStrengthTrainingDetailEntity, + StravaSwimActivityDetailEntity, StravaSyncJobEntity, StravaSyncJobItemEntity, } from '../database/entities'; import { mapStravaActivity } from './strava-activity.mapper'; +import { + isStrengthTrainingActivity, + mapStravaStrengthTrainingDetail, +} from './strava-strength-training-detail.mapper'; +import { + isSwimActivity, + mapStravaSwimActivityDetail, +} from './strava-swim-activity-detail.mapper'; import { StravaClientService } from './strava-client.service'; import { StravaRateLimitError } from './strava-rate-limit.error'; import { StravaStreamImportService } from './strava-stream-import.service'; import { StravaTokenService } from './strava-token.service'; -import { StravaActivityPayload } from './strava.types'; +import { StravaActivityPayload, StravaLapPayload } from './strava.types'; @Injectable() -export class StravaSyncService implements OnModuleInit { +export class StravaSyncService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(StravaSyncService.name); private readonly retryDelayMs = 15 * 60 * 1000; private readonly retryTimers = new Map(); + private readonly runningJobIds = new Set(); + private autoSyncTimer: NodeJS.Timeout | null = null; constructor( @InjectRepository(StravaAthleteEntity) private readonly athleteRepository: Repository, @InjectRepository(StravaActivityEntity) private readonly activityRepository: Repository, + @InjectRepository(StravaStrengthTrainingDetailEntity) + private readonly strengthTrainingDetailRepository: Repository, + @InjectRepository(StravaSwimActivityDetailEntity) + private readonly swimActivityDetailRepository: Repository, @InjectRepository(StravaSyncJobEntity) private readonly jobRepository: Repository, @InjectRepository(StravaSyncJobItemEntity) @@ -34,11 +57,30 @@ export class StravaSyncService implements OnModuleInit { ) {} async onModuleInit(): Promise { - const waitingJobs = await this.jobRepository.find({ - where: { status: 'rate_limited' }, + const activeJobs = await this.jobRepository.find({ + where: { status: In(['queued', 'running', 'rate_limited']) }, }); - waitingJobs.forEach((job) => this.scheduleRetry(job)); + activeJobs.forEach((job) => { + if (job.status === 'rate_limited') { + this.scheduleRetry(job); + return; + } + + void this.runJob(job.id); + }); + + this.configureAutoSync(); + } + + onModuleDestroy(): void { + if (this.autoSyncTimer) { + clearInterval(this.autoSyncTimer); + this.autoSyncTimer = null; + } + + this.retryTimers.forEach((timer) => clearTimeout(timer)); + this.retryTimers.clear(); } async startSync(): Promise { @@ -52,7 +94,11 @@ export class StravaSyncService implements OnModuleInit { }); if (activeJob) { - this.scheduleRetry(activeJob); + if (activeJob.status === 'rate_limited') { + this.scheduleRetry(activeJob); + } else { + void this.runJob(activeJob.id); + } return activeJob; } @@ -97,45 +143,56 @@ export class StravaSyncService implements OnModuleInit { } async runJob(jobId: string): Promise { + if (this.runningJobIds.has(jobId)) { + return; + } + + this.runningJobIds.add(jobId); this.clearRetry(jobId); - const job = await this.getJob(jobId); - job.status = 'running'; - job.startedAt = new Date(); - job.finishedAt = null; - job.retryAfter = null; - job.errorMessage = null; - await this.jobRepository.save(job); - try { - const accessToken = await this.stravaTokenService.getValidAccessToken( - job.stravaAthleteId, - ); + const job = await this.getJob(jobId); + const after = await this.resolveSyncAfter(job.stravaAthleteId); + job.status = 'running'; + job.startedAt = new Date(); + job.finishedAt = null; + job.retryAfter = null; + job.errorMessage = null; + await this.jobRepository.save(job); - let page = 1; - const perPage = 100; - let summaries: StravaActivityPayload[] = []; - - do { - summaries = await this.stravaClientService.listActivities( - accessToken, - page, - perPage, + try { + const accessToken = await this.stravaTokenService.getValidAccessToken( + job.stravaAthleteId, ); - for (const summary of summaries) { - await this.importActivity(job, accessToken, summary); - } + let page = 1; + const perPage = 100; + let summaries: StravaActivityPayload[] = []; - page += 1; - } while (summaries.length === perPage); + do { + summaries = await this.stravaClientService.listActivities( + accessToken, + page, + perPage, + after, + ); - job.status = 'completed'; - job.finishedAt = new Date(); - job.retryAfter = null; - await this.jobRepository.save(job); - } catch (error) { - await this.failJob(job, error); + for (const summary of summaries) { + await this.importActivity(job, accessToken, summary); + } + + page += 1; + } while (summaries.length === perPage); + + job.status = 'completed'; + job.finishedAt = new Date(); + job.retryAfter = null; + await this.jobRepository.save(job); + } catch (error) { + await this.failJob(job, error); + } + } finally { + this.runningJobIds.delete(jobId); } } @@ -170,6 +227,8 @@ export class StravaSyncService implements OnModuleInit { activity = await this.activityRepository.save( mapStravaActivity(job.stravaAthleteId, detail, activity), ); + await this.upsertStrengthTrainingDetail(activity, detail); + await this.upsertSwimActivityDetail(activity, detail, accessToken); job.detailCount += 1; job.streamPointCount += @@ -229,6 +288,121 @@ export class StravaSyncService implements OnModuleInit { return athlete; } + private async upsertStrengthTrainingDetail( + activity: StravaActivityEntity, + detail: StravaActivityPayload, + ): Promise { + if (!isStrengthTrainingActivity(activity.sportType)) { + return; + } + + const existing = + await this.strengthTrainingDetailRepository.findOne({ + where: { activityId: activity.id }, + }); + + await this.strengthTrainingDetailRepository.save( + mapStravaStrengthTrainingDetail(activity, detail, existing), + ); + } + + private async upsertSwimActivityDetail( + activity: StravaActivityEntity, + detail: StravaActivityPayload, + accessToken: string, + ): Promise { + if (!isSwimActivity(activity.sportType)) { + return; + } + + let laps: StravaLapPayload[] = []; + let lapsError: string | null = null; + try { + laps = await this.stravaClientService.getActivityLaps( + accessToken, + activity.stravaActivityId, + ); + } catch (error) { + if (error instanceof StravaRateLimitError) { + throw error; + } + + lapsError = this.errorMessage(error); + this.logger.warn( + `Could not import swim laps for activity ${activity.stravaActivityId}: ${lapsError}`, + ); + } + + const existing = await this.swimActivityDetailRepository.findOne({ + where: { activityId: activity.id }, + }); + + await this.swimActivityDetailRepository.save( + mapStravaSwimActivityDetail(activity, detail, laps, lapsError, existing), + ); + } + + private async resolveSyncAfter( + stravaAthleteId: string, + ): Promise { + const latestActivity = await this.activityRepository.findOne({ + where: { stravaAthleteId }, + order: { startDate: 'DESC' }, + }); + + if (!latestActivity?.startDate) { + return undefined; + } + + const lookbackDays = this.numberFromEnv('STRAVA_SYNC_LOOKBACK_DAYS', 14); + const lookbackMs = Math.max(0, lookbackDays) * 24 * 60 * 60 * 1000; + + return Math.floor((latestActivity.startDate.getTime() - lookbackMs) / 1000); + } + + private configureAutoSync(): void { + if (process.env.NODE_ENV === 'test') { + return; + } + + if (process.env.STRAVA_SYNC_ON_START !== 'false') { + setTimeout(() => void this.startSyncSafely(), 0).unref?.(); + } + + const intervalMinutes = this.numberFromEnv( + 'STRAVA_SYNC_INTERVAL_MINUTES', + 60, + ); + if (intervalMinutes <= 0) { + return; + } + + this.autoSyncTimer = setInterval( + () => void this.startSyncSafely(), + intervalMinutes * 60 * 1000, + ); + this.autoSyncTimer.unref?.(); + } + + private async startSyncSafely(): Promise { + try { + await this.startSync(); + } catch (error) { + if (error instanceof NotFoundException) { + return; + } + + this.logger.warn( + `Automatic Strava sync could not be started: ${this.errorMessage(error)}`, + ); + } + } + + private numberFromEnv(key: string, fallback: number): number { + const value = Number(process.env[key]); + return Number.isFinite(value) ? value : fallback; + } + private async failJob( job: StravaSyncJobEntity, error: unknown, diff --git a/api/src/strava/strava.module.ts b/api/src/strava/strava.module.ts index 410c133..3be93ac 100644 --- a/api/src/strava/strava.module.ts +++ b/api/src/strava/strava.module.ts @@ -5,6 +5,8 @@ import { StravaActivityEntity, StravaActivityStreamPointEntity, StravaAthleteEntity, + StravaStrengthTrainingDetailEntity, + StravaSwimActivityDetailEntity, StravaSyncJobEntity, StravaSyncJobItemEntity, StravaTokenEntity, @@ -27,6 +29,8 @@ import { TokenCryptoService } from './token-crypto.service'; StravaTokenEntity, StravaActivityEntity, StravaActivityStreamPointEntity, + StravaStrengthTrainingDetailEntity, + StravaSwimActivityDetailEntity, StravaSyncJobEntity, StravaSyncJobItemEntity, ]), diff --git a/api/src/strava/strava.types.ts b/api/src/strava/strava.types.ts index 76a4421..4f6763c 100644 --- a/api/src/strava/strava.types.ts +++ b/api/src/strava/strava.types.ts @@ -64,6 +64,31 @@ export interface StravaStreamPayload { resolution?: string; } +export interface StravaLapPayload { + id?: number | string; + name?: string; + activity?: unknown; + athlete?: unknown; + elapsed_time?: number; + moving_time?: number; + start_date?: string; + start_date_local?: string; + distance?: number; + start_index?: number; + end_index?: number; + lap_index?: number; + split?: number; + average_speed?: number; + max_speed?: number; + average_cadence?: number; + average_watts?: number; + average_heartrate?: number; + max_heartrate?: number; + total_elevation_gain?: number; + pace_zone?: number; + [key: string]: unknown; +} + export type StravaStreamsResponse = | StravaStreamPayload[] | Record & { type?: string }>;