details
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -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<string, unknown>[] | null;
|
||||
|
||||
@Column({ name: 'laps_error', type: 'text', nullable: true })
|
||||
lapsError!: string | null;
|
||||
|
||||
@Column({ name: 'raw_payload', type: 'json', nullable: true })
|
||||
rawPayload!: Record<string, unknown> | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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<StravaActivityEntity>,
|
||||
@InjectRepository(StravaActivityStreamPointEntity)
|
||||
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
|
||||
@InjectRepository(StravaStrengthTrainingDetailEntity)
|
||||
private readonly strengthTrainingDetailRepository: Repository<StravaStrengthTrainingDetailEntity>,
|
||||
@InjectRepository(StravaSwimActivityDetailEntity)
|
||||
private readonly swimActivityDetailRepository: Repository<StravaSwimActivityDetailEntity>,
|
||||
) {}
|
||||
|
||||
async getAthleteProfile(): Promise<Record<string, unknown>> {
|
||||
@@ -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<string, unknown> = {
|
||||
...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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<StravaActivityPayload[]> {
|
||||
return this.request<StravaActivityPayload[]>({
|
||||
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<StravaLapPayload[]> {
|
||||
return this.request<StravaLapPayload[]>({
|
||||
method: 'GET',
|
||||
url: `https://www.strava.com/api/v3/activities/${stravaActivityId}/laps`,
|
||||
headers: this.authHeaders(accessToken),
|
||||
});
|
||||
}
|
||||
|
||||
private async postToken(
|
||||
body: Record<string, string>,
|
||||
): Promise<StravaTokenPayload> {
|
||||
|
||||
48
api/src/strava/strava-strength-training-detail.mapper.ts
Normal file
48
api/src/strava/strava-strength-training-detail.mapper.ts
Normal file
@@ -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;
|
||||
43
api/src/strava/strava-swim-activity-detail.mapper.ts
Normal file
43
api/src/strava/strava-swim-activity-detail.mapper.ts
Normal file
@@ -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;
|
||||
@@ -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<StravaSyncJobEntity>) => {
|
||||
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<StravaSyncJobEntity>) => {
|
||||
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<StravaSyncJobEntity>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, NodeJS.Timeout>();
|
||||
private readonly runningJobIds = new Set<string>();
|
||||
private autoSyncTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(StravaAthleteEntity)
|
||||
private readonly athleteRepository: Repository<StravaAthleteEntity>,
|
||||
@InjectRepository(StravaActivityEntity)
|
||||
private readonly activityRepository: Repository<StravaActivityEntity>,
|
||||
@InjectRepository(StravaStrengthTrainingDetailEntity)
|
||||
private readonly strengthTrainingDetailRepository: Repository<StravaStrengthTrainingDetailEntity>,
|
||||
@InjectRepository(StravaSwimActivityDetailEntity)
|
||||
private readonly swimActivityDetailRepository: Repository<StravaSwimActivityDetailEntity>,
|
||||
@InjectRepository(StravaSyncJobEntity)
|
||||
private readonly jobRepository: Repository<StravaSyncJobEntity>,
|
||||
@InjectRepository(StravaSyncJobItemEntity)
|
||||
@@ -34,11 +57,30 @@ export class StravaSyncService implements OnModuleInit {
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
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<StravaSyncJobEntity> {
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number | undefined> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
|
||||
@@ -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<string, Omit<StravaStreamPayload, 'type'> & { type?: string }>;
|
||||
|
||||
Reference in New Issue
Block a user