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

View File

@@ -1,5 +1,13 @@
node_modules
dist
coverage
.angular
.cache
.env
.env.*
npm-debug.log*
Dockerfile
.dockerignore
.git
.gitignore
README.md

View File

@@ -1,20 +1,41 @@
# syntax=docker/dockerfile:1.7
FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:24-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
RUN npm ci
COPY . .
COPY nest-cli.json tsconfig*.json ./
COPY src ./src
RUN npm run build
FROM node:24-alpine AS production
FROM node:24-alpine AS prod-deps
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --ignore-scripts
FROM node:24-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package*.json ./
USER node
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "const http=require('http');const req=http.get('http://127.0.0.1:3000/',res=>process.exit(res.statusCode===200?0:1));req.on('error',()=>process.exit(1));req.setTimeout(4000,()=>{req.destroy();process.exit(1);});"
EXPOSE 3000
CMD ["node", "dist/main.js"]

View File

@@ -1,6 +1,13 @@
import { Controller, Get, Query } from '@nestjs/common';
import { Param } from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
import { AnalyticsDashboard } from './analytics.types';
import {
AnalyticsDashboard,
AnalyticsRecentActivity,
RunningActivityDetail,
RunningKpiDashboard,
RunningSummary,
} from './analytics.types';
@Controller('analytics')
export class AnalyticsController {
@@ -13,4 +20,26 @@ export class AnalyticsController {
): Promise<AnalyticsDashboard> {
return this.analyticsService.getDashboard(Number(weeks ?? 12), sportType);
}
@Get('running/summary')
runningSummary(@Query('weeks') weeks?: string): Promise<RunningSummary> {
return this.analyticsService.getRunningSummary(Number(weeks ?? 12));
}
@Get('running/kpis')
runningKpis(@Query('weeks') weeks?: string): Promise<RunningKpiDashboard> {
return this.analyticsService.getRunningKpis(Number(weeks ?? 12));
}
@Get('running/activities')
runningActivities(
@Query('weeks') weeks?: string,
): Promise<AnalyticsRecentActivity[]> {
return this.analyticsService.getRunningActivities(Number(weeks ?? 12));
}
@Get('running/activities/:id')
runningActivity(@Param('id') id: string): Promise<RunningActivityDetail> {
return this.analyticsService.getRunningActivityDetail(id);
}
}

View File

@@ -1,11 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StravaActivityEntity } from '../database/entities';
import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
} from '../database/entities';
import { StravaModule } from '../strava/strava.module';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [TypeOrmModule.forFeature([StravaActivityEntity])],
imports: [
StravaModule,
TypeOrmModule.forFeature([
StravaActivityEntity,
StravaActivityStreamPointEntity,
]),
],
controllers: [AnalyticsController],
providers: [AnalyticsService],
})

View File

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

View File

@@ -1,7 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MoreThanOrEqual, Repository } from 'typeorm';
import { StravaActivityEntity } from '../database/entities';
import { In, MoreThanOrEqual, Repository } from 'typeorm';
import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
} from '../database/entities';
import { StravaStreamImportService } from '../strava/strava-stream-import.service';
import {
AnalyticsAverages,
AnalyticsDashboard,
@@ -9,13 +13,40 @@ import {
AnalyticsSportSummary,
AnalyticsTotals,
AnalyticsWeeklyBucket,
RunningActivityDetail,
RunningChartPoint,
RunningIntensityDistributionBucket,
RunningIntensityZone,
RunningKpiDashboard,
RunningLoadBucket,
RunningPersonalRecordEstimate,
RunningProgressionMetric,
RunningSplit,
RunningSummary,
} from './analytics.types';
type ActivityLoad = {
activity: StravaActivityEntity;
intensity: number;
intensityBasis: 'heart_rate' | 'pace' | 'none';
zone: RunningIntensityZone;
load: number;
paceSecondsPerKm: number | null;
};
type PeriodTotals = AnalyticsTotals & {
load: number;
paceSecondsPerKm: number | null;
};
@Injectable()
export class AnalyticsService {
constructor(
@InjectRepository(StravaActivityEntity)
private readonly activityRepository: Repository<StravaActivityEntity>,
@InjectRepository(StravaActivityStreamPointEntity)
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
private readonly stravaStreamImportService: StravaStreamImportService,
) {}
async getDashboard(
@@ -92,12 +123,793 @@ export class AnalyticsService {
};
}
async getRunningSummary(weeksInput = 12): Promise<RunningSummary> {
const weeks = this.clampWeeks(weeksInput);
const now = new Date();
const rangeStart = this.startOfWeek(this.addDays(now, -(weeks - 1) * 7));
const rangeEnd = this.endOfDay(now);
const activities = await this.findActivitiesInRange(rangeStart);
const runs = activities.filter((activity) =>
this.isRunningActivity(activity),
);
const totals = this.createTotals();
const weekly = this.createWeeklyBuckets(rangeStart, weeks);
const heartRates: number[] = [];
const cadences: number[] = [];
for (const run of runs) {
this.addActivity(totals, run);
const week = weekly.get(this.dateKey(this.startOfWeek(run.startDate)));
if (week) {
this.addActivity(week, run);
}
this.pushIfNumber(heartRates, run.averageHeartrate);
this.pushIfNumber(cadences, run.averageCadence);
}
const longestRun =
runs.reduce<StravaActivityEntity | null>((longest, run) => {
if (!longest || (run.distance ?? 0) > (longest.distance ?? 0)) {
return run;
}
return longest;
}, null) ?? null;
const lastFourWeeks = Array.from(weekly.values()).slice(-4);
const fourWeekAverageDistanceMeters =
lastFourWeeks.reduce((total, week) => total + week.distanceMeters, 0) /
Math.max(1, lastFourWeeks.length);
const currentWeekDistance =
Array.from(weekly.values()).at(-1)?.distanceMeters ?? 0;
return {
weeks,
rangeStart: this.dateKey(rangeStart),
rangeEnd: this.dateKey(rangeEnd),
totals,
averages: {
...this.createAverages(totals, heartRates, [], cadences),
watts: null,
},
fourWeekAverageDistanceMeters: Math.round(fourWeekAverageDistanceMeters),
longestRun: longestRun ? this.toRecentActivity(longestRun) : null,
elevationGainPerKm:
totals.distanceMeters > 0
? Math.round(
(totals.elevationGainMeters / (totals.distanceMeters / 1000)) *
10,
) / 10
: null,
longRunShare:
currentWeekDistance > 0 && longestRun
? Math.round(((longestRun.distance ?? 0) / currentWeekDistance) * 100)
: null,
weekly: Array.from(weekly.values()),
recentRuns: runs.slice(0, 8).map((run) => this.toRecentActivity(run)),
};
}
async getRunningKpis(weeksInput = 12): Promise<RunningKpiDashboard> {
const weeks = this.clampWeeks(weeksInput);
const now = new Date();
const rangeStart = this.startOfWeek(this.addDays(now, -(weeks - 1) * 7));
const rangeEnd = this.endOfDay(now);
const activities = await this.findActivitiesInRange(rangeStart);
const runs = activities.filter((activity) =>
this.isRunningActivity(activity),
);
const streamPointsByActivity = await this.findStreamPointsByActivity(
runs.map((run) => run.id),
);
const observedMaxHeartRate = this.observedMaxHeartRate(
runs,
streamPointsByActivity,
);
const medianPace = this.median(
runs
.map((run) => this.activityPace(run))
.filter((value): value is number => value !== null),
);
const activityLoads = runs.map((run) =>
this.createActivityLoad(run, observedMaxHeartRate, medianPace),
);
const dailyLoads = this.createDailyLoads(now, activityLoads);
const acute = this.sum(dailyLoads.slice(-7));
const chronic = Math.round(this.sum(dailyLoads.slice(-28)) / 4);
const acuteChronicRatio =
chronic > 0 ? this.roundNullable(acute / chronic, 2) : null;
const dailyAverage = this.averageRaw(dailyLoads.slice(-7));
const dailyStandardDeviation = this.standardDeviation(dailyLoads.slice(-7));
const monotony =
dailyStandardDeviation > 0
? this.roundNullable(dailyAverage / dailyStandardDeviation, 2)
: null;
const strain = monotony !== null ? Math.round(acute * monotony) : null;
return {
weeks,
rangeStart: this.dateKey(rangeStart),
rangeEnd: this.dateKey(rangeEnd),
load: {
acute,
chronic,
acuteChronicRatio,
weekly: this.createLoadBuckets(rangeStart, weeks, activityLoads),
},
monotony: {
value: monotony,
dailyAverage: Math.round(dailyAverage),
dailyStandardDeviation: Math.round(dailyStandardDeviation),
},
strain: { value: strain },
recovery: this.createRecovery(now, activityLoads, acuteChronicRatio),
intensityDistribution: this.createIntensityDistribution(activityLoads),
progression: this.createProgression(now, activityLoads),
personalRecords: this.createPersonalRecords(runs, streamPointsByActivity),
};
}
async getRunningActivities(
weeksInput = 12,
): Promise<AnalyticsRecentActivity[]> {
const weeks = this.clampWeeks(weeksInput);
const rangeStart = this.startOfWeek(
this.addDays(new Date(), -(weeks - 1) * 7),
);
const activities = await this.findActivitiesInRange(rangeStart);
return activities
.filter((activity) => this.isRunningActivity(activity))
.map((activity) => this.toRecentActivity(activity));
}
async getRunningActivityDetail(id: string): Promise<RunningActivityDetail> {
const activity = await this.activityRepository.findOne({ where: { id } });
if (!activity || !this.isRunningActivity(activity)) {
throw new NotFoundException('Running activity not found');
}
let points = await this.findStreamPoints(activity.id);
if (points.length === 0) {
await this.stravaStreamImportService.importStreamsForActivity(activity);
points = await this.findStreamPoints(activity.id);
}
return {
activity: this.toRecentActivity(activity),
splits: this.createRunningSplits(points),
series: this.createRunningSeries(points),
};
}
private findStreamPoints(
activityId: string,
): Promise<StravaActivityStreamPointEntity[]> {
return this.streamPointRepository.find({
where: { activityId },
order: { pointIndex: 'ASC' },
});
}
private async findStreamPointsByActivity(
activityIds: string[],
): Promise<Map<string, StravaActivityStreamPointEntity[]>> {
const pointsByActivity = new Map<
string,
StravaActivityStreamPointEntity[]
>();
if (activityIds.length === 0) {
return pointsByActivity;
}
const points = await this.streamPointRepository.find({
where: { activityId: In(activityIds) },
order: { activityId: 'ASC', pointIndex: 'ASC' },
});
for (const point of points) {
const activityPoints = pointsByActivity.get(point.activityId) ?? [];
activityPoints.push(point);
pointsByActivity.set(point.activityId, activityPoints);
}
return pointsByActivity;
}
private async findActivitiesInRange(
rangeStart: Date,
): Promise<StravaActivityEntity[]> {
return this.activityRepository.find({
where: {
startDate: MoreThanOrEqual(rangeStart),
},
order: {
startDate: 'DESC',
},
});
}
private availableSports(activities: StravaActivityEntity[]): string[] {
return Array.from(
new Set(activities.map((activity) => activity.sportType ?? 'Unbekannt')),
).sort((left, right) => left.localeCompare(right));
}
private isRunningActivity(activity: StravaActivityEntity): boolean {
const sportType = activity.sportType?.toLowerCase() ?? '';
return sportType.includes('run');
}
private observedMaxHeartRate(
runs: StravaActivityEntity[],
streamPointsByActivity: Map<string, StravaActivityStreamPointEntity[]>,
): number | null {
const values: number[] = [];
for (const run of runs) {
this.pushIfNumber(values, run.maxHeartrate);
this.pushIfNumber(values, run.averageHeartrate);
for (const point of streamPointsByActivity.get(run.id) ?? []) {
this.pushIfNumber(values, point.heartRate);
}
}
return values.length > 0 ? Math.max(...values) : null;
}
private createActivityLoad(
activity: StravaActivityEntity,
observedMaxHeartRate: number | null,
medianPace: number | null,
): ActivityLoad {
const paceSecondsPerKm = this.activityPace(activity);
const heartRateIntensity =
observedMaxHeartRate && activity.averageHeartrate
? activity.averageHeartrate / observedMaxHeartRate
: null;
const paceIntensity =
medianPace && paceSecondsPerKm ? medianPace / paceSecondsPerKm : null;
const rawIntensity = heartRateIntensity ?? paceIntensity ?? 0;
const intensity =
rawIntensity > 0 ? this.clamp(rawIntensity, 0.55, 1.3) : 0;
const movingMinutes = (activity.movingTime ?? 0) / 60;
return {
activity,
intensity,
intensityBasis: heartRateIntensity
? 'heart_rate'
: paceIntensity
? 'pace'
: 'none',
zone: this.intensityZone(heartRateIntensity, paceIntensity),
load: Math.round(movingMinutes * intensity * intensity),
paceSecondsPerKm,
};
}
private intensityZone(
heartRateIntensity: number | null,
paceIntensity: number | null,
): RunningIntensityZone {
const intensity = heartRateIntensity ?? paceIntensity;
if (intensity === null || intensity === undefined) {
return 'moderate';
}
if (heartRateIntensity !== null) {
if (heartRateIntensity >= 0.85) {
return 'hard';
}
if (heartRateIntensity < 0.75) {
return 'easy';
}
return 'moderate';
}
if ((paceIntensity ?? 0) >= 1.08) {
return 'hard';
}
if ((paceIntensity ?? 0) < 0.95) {
return 'easy';
}
return 'moderate';
}
private createDailyLoads(now: Date, activityLoads: ActivityLoad[]): number[] {
const start = this.startOfDay(this.addDays(now, -27));
const dailyLoads = Array.from({ length: 28 }, () => 0);
for (const activityLoad of activityLoads) {
if (!activityLoad.activity.startDate) {
continue;
}
const dayIndex = Math.floor(
(this.startOfDay(activityLoad.activity.startDate).getTime() -
start.getTime()) /
86400000,
);
if (dayIndex >= 0 && dayIndex < dailyLoads.length) {
dailyLoads[dayIndex] += activityLoad.load;
}
}
return dailyLoads.map((value) => Math.round(value));
}
private createLoadBuckets(
rangeStart: Date,
weeks: number,
activityLoads: ActivityLoad[],
): RunningLoadBucket[] {
const buckets = new Map<string, RunningLoadBucket>();
for (let index = 0; index < weeks; index += 1) {
const weekStart = this.addDays(rangeStart, index * 7);
const weekEnd = this.addDays(weekStart, 6);
buckets.set(this.dateKey(weekStart), {
...this.createTotals(),
weekStart: this.dateKey(weekStart),
weekEnd: this.dateKey(weekEnd),
load: 0,
});
}
for (const activityLoad of activityLoads) {
const week = buckets.get(
this.dateKey(this.startOfWeek(activityLoad.activity.startDate)),
);
if (!week) {
continue;
}
this.addActivity(week, activityLoad.activity);
week.load += activityLoad.load;
}
return Array.from(buckets.values()).map((bucket) => ({
...bucket,
load: Math.round(bucket.load),
}));
}
private createRecovery(
now: Date,
activityLoads: ActivityLoad[],
acuteChronicRatio: number | null,
): RunningKpiDashboard['recovery'] {
const sortedLoads = [...activityLoads]
.filter((load) => load.activity.startDate)
.sort(
(left, right) =>
(right.activity.startDate?.getTime() ?? 0) -
(left.activity.startDate?.getTime() ?? 0),
);
const lastRun = sortedLoads[0] ?? null;
const lastHardRun =
sortedLoads.find((load) => load.zone === 'hard') ?? null;
const daysSinceLastRun = lastRun
? this.daysBetween(lastRun.activity.startDate, now)
: null;
const daysSinceLastHardRun = lastHardRun
? this.daysBetween(lastHardRun.activity.startDate, now)
: null;
let score = 85;
if (acuteChronicRatio !== null) {
score -= Math.max(0, acuteChronicRatio - 1) * 45;
score += Math.max(0, 1 - acuteChronicRatio) * 10;
}
if (daysSinceLastHardRun !== null && daysSinceLastHardRun < 2) {
score -= 20;
}
if (daysSinceLastRun === 0) {
score -= 10;
}
score = Math.round(this.clamp(score, 0, 100));
const status =
score < 45 || (acuteChronicRatio ?? 0) > 1.5
? 'red'
: score < 70 || (acuteChronicRatio ?? 0) > 1.25
? 'yellow'
: 'green';
return {
status,
score,
daysSinceLastRun,
daysSinceLastHardRun,
message:
status === 'green'
? 'Belastung wirkt stabil.'
: status === 'yellow'
? 'Belastung steigt, Erholung im Blick behalten.'
: 'Hohe Belastung, lockere Tage einplanen.',
};
}
private createIntensityDistribution(
activityLoads: ActivityLoad[],
): RunningIntensityDistributionBucket[] {
const labels: Record<RunningIntensityZone, string> = {
easy: 'Easy',
moderate: 'Moderate',
hard: 'Hard',
};
const distribution = new Map<
RunningIntensityZone,
RunningIntensityDistributionBucket
>(
(['easy', 'moderate', 'hard'] as RunningIntensityZone[]).map((zone) => [
zone,
{
...this.createTotals(),
zone,
label: labels[zone],
load: 0,
share: 0,
},
]),
);
for (const activityLoad of activityLoads) {
const bucket = distribution.get(activityLoad.zone);
if (!bucket) {
continue;
}
this.addActivity(bucket, activityLoad.activity);
bucket.load += activityLoad.load;
}
const totalTime = Array.from(distribution.values()).reduce(
(total, bucket) => total + bucket.movingTimeSeconds,
0,
);
return Array.from(distribution.values()).map((bucket) => ({
...bucket,
load: Math.round(bucket.load),
share:
totalTime > 0
? Math.round((bucket.movingTimeSeconds / totalTime) * 100)
: 0,
}));
}
private createProgression(
now: Date,
activityLoads: ActivityLoad[],
): RunningProgressionMetric[] {
const currentStart = this.startOfDay(this.addDays(now, -27));
const previousStart = this.startOfDay(this.addDays(now, -55));
const previousEnd = this.addDays(currentStart, -1);
const current = this.periodTotals(activityLoads, currentStart, now);
const previous = this.periodTotals(
activityLoads,
previousStart,
previousEnd,
);
return [
this.progressionMetric(
'distance',
'Distanz',
'meters',
current.distanceMeters,
previous.distanceMeters,
),
this.progressionMetric(
'time',
'Zeit',
'seconds',
current.movingTimeSeconds,
previous.movingTimeSeconds,
),
this.progressionMetric(
'load',
'Load',
'load',
current.load,
previous.load,
),
this.progressionMetric(
'pace',
'Pace',
'pace',
current.paceSecondsPerKm,
previous.paceSecondsPerKm,
true,
),
this.progressionMetric(
'runs',
'Laeufe',
'count',
current.activityCount,
previous.activityCount,
),
];
}
private periodTotals(
activityLoads: ActivityLoad[],
start: Date,
end: Date,
): PeriodTotals {
const totals: PeriodTotals = {
...this.createTotals(),
load: 0,
paceSecondsPerKm: null,
};
for (const activityLoad of activityLoads) {
const startDate = activityLoad.activity.startDate;
if (!startDate || startDate < start || startDate > end) {
continue;
}
this.addActivity(totals, activityLoad.activity);
totals.load += activityLoad.load;
}
totals.load = Math.round(totals.load);
totals.paceSecondsPerKm =
totals.distanceMeters > 0
? Math.round(totals.movingTimeSeconds / (totals.distanceMeters / 1000))
: null;
return totals;
}
private progressionMetric(
key: RunningProgressionMetric['key'],
label: string,
unit: RunningProgressionMetric['unit'],
current: number | null,
previous: number | null,
lowerIsBetter = false,
): RunningProgressionMetric {
const change =
current !== null && previous !== null && previous > 0
? ((current - previous) / previous) * 100
: null;
const changePercent =
change === null
? null
: Math.round((lowerIsBetter ? -change : change) * 10) / 10;
return { key, label, unit, current, previous, changePercent };
}
private createPersonalRecords(
runs: StravaActivityEntity[],
streamPointsByActivity: Map<string, StravaActivityStreamPointEntity[]>,
): RunningPersonalRecordEstimate[] {
return [1000, 5000, 10000].map((distanceMeters) => {
const streamBest = this.fastestStreamSegment(
distanceMeters,
runs,
streamPointsByActivity,
);
return streamBest ?? this.estimatedActivityPr(distanceMeters, runs);
});
}
private fastestStreamSegment(
targetDistance: number,
runs: StravaActivityEntity[],
streamPointsByActivity: Map<string, StravaActivityStreamPointEntity[]>,
): RunningPersonalRecordEstimate | null {
let best: RunningPersonalRecordEstimate | null = null;
for (const run of runs) {
const points = (streamPointsByActivity.get(run.id) ?? []).filter(
(point) => point.distance !== null && point.timeSeconds !== null,
);
let left = 0;
for (let right = 1; right < points.length; right += 1) {
while (
left < right &&
(points[right].distance ?? 0) - (points[left].distance ?? 0) >=
targetDistance
) {
const timeSeconds =
(points[right].timeSeconds ?? 0) - (points[left].timeSeconds ?? 0);
if (
timeSeconds > 0 &&
(!best ||
best.timeSeconds === null ||
timeSeconds < best.timeSeconds)
) {
best = this.toPersonalRecord(
targetDistance,
timeSeconds,
run,
false,
);
}
left += 1;
}
}
}
return best;
}
private estimatedActivityPr(
targetDistance: number,
runs: StravaActivityEntity[],
): RunningPersonalRecordEstimate {
const candidates = runs.filter(
(run) => (run.distance ?? 0) >= targetDistance && this.activityPace(run),
);
const best = candidates.reduce<RunningPersonalRecordEstimate | null>(
(currentBest, run) => {
const paceSecondsPerKm = this.activityPace(run);
if (!paceSecondsPerKm) {
return currentBest;
}
const timeSeconds = Math.round(
paceSecondsPerKm * (targetDistance / 1000),
);
if (
!currentBest ||
currentBest.timeSeconds === null ||
timeSeconds < currentBest.timeSeconds
) {
return this.toPersonalRecord(targetDistance, timeSeconds, run, true);
}
return currentBest;
},
null,
);
return (
best ?? {
distanceMeters: targetDistance,
timeSeconds: null,
paceSecondsPerKm: null,
activityId: null,
activityName: null,
startDate: null,
estimated: true,
}
);
}
private toPersonalRecord(
distanceMeters: number,
timeSeconds: number,
activity: StravaActivityEntity,
estimated: boolean,
): RunningPersonalRecordEstimate {
return {
distanceMeters,
timeSeconds,
paceSecondsPerKm: Math.round(timeSeconds / (distanceMeters / 1000)),
activityId: activity.id,
activityName: activity.name,
startDate: activity.startDate ? activity.startDate.toISOString() : null,
estimated,
};
}
private createRunningSplits(
points: StravaActivityStreamPointEntity[],
): RunningSplit[] {
const usablePoints = points.filter(
(point) => point.distance !== null && point.timeSeconds !== null,
);
const splits: RunningSplit[] = [];
if (usablePoints.length < 2) {
return splits;
}
let splitStart = usablePoints[0];
let splitStartIndex = 0;
let nextKm = 1000;
for (let index = 1; index < usablePoints.length; index += 1) {
const point = usablePoints[index];
if ((point.distance ?? 0) < nextKm) {
continue;
}
const splitPoints = usablePoints.slice(splitStartIndex, index + 1);
const distanceMeters = Math.max(
1,
(point.distance ?? 0) - (splitStart.distance ?? 0),
);
const movingTimeSeconds = Math.max(
0,
(point.timeSeconds ?? 0) - (splitStart.timeSeconds ?? 0),
);
splits.push({
kilometer: splits.length + 1,
distanceMeters,
movingTimeSeconds,
paceSecondsPerKm:
distanceMeters > 0
? Math.round(movingTimeSeconds / (distanceMeters / 1000))
: null,
averageHeartRate: this.average(
splitPoints
.map((splitPoint) => splitPoint.heartRate)
.filter((value): value is number => value !== null),
),
averageCadence: this.average(
splitPoints
.map((splitPoint) => splitPoint.cadence)
.filter((value): value is number => value !== null),
),
elevationGainMeters: this.positiveElevationGain(splitPoints),
});
splitStart = point;
splitStartIndex = index;
nextKm += 1000;
}
return splits;
}
private createRunningSeries(
points: StravaActivityStreamPointEntity[],
): RunningChartPoint[] {
return points
.filter((point) => point.distance !== null)
.map((point, index, values) => {
const previous = index > 0 ? values[index - 1] : null;
const distanceDelta =
previous?.distance !== null && previous?.distance !== undefined
? (point.distance ?? 0) - previous.distance
: 0;
const timeDelta =
previous?.timeSeconds !== null && previous?.timeSeconds !== undefined
? (point.timeSeconds ?? 0) - previous.timeSeconds
: 0;
return {
distanceMeters: point.distance ?? 0,
timeSeconds: point.timeSeconds,
paceSecondsPerKm:
point.velocitySmooth && point.velocitySmooth > 0
? Math.round(1000 / point.velocitySmooth)
: distanceDelta > 0 && timeDelta > 0
? Math.round(timeDelta / (distanceDelta / 1000))
: null,
heartRate: point.heartRate,
altitude: point.altitude,
cadence: point.cadence,
};
});
}
private positiveElevationGain(
points: StravaActivityStreamPointEntity[],
): number {
let gain = 0;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1].altitude;
const current = points[index].altitude;
if (previous === null || current === null) {
continue;
}
gain += Math.max(0, current - previous);
}
return Math.round(gain);
}
private addActivity(
totals: AnalyticsTotals,
activity: StravaActivityEntity,
@@ -180,6 +992,38 @@ export class AnalyticsService {
};
}
private activityPace(activity: StravaActivityEntity): number | null {
if (!activity.distance || activity.distance <= 0 || !activity.movingTime) {
return null;
}
return Math.round(activity.movingTime / (activity.distance / 1000));
}
private sum(values: number[]): number {
return Math.round(values.reduce((total, value) => total + value, 0));
}
private averageRaw(values: number[]): number {
if (values.length === 0) {
return 0;
}
return values.reduce((total, value) => total + value, 0) / values.length;
}
private standardDeviation(values: number[]): number {
if (values.length === 0) {
return 0;
}
const mean = this.averageRaw(values);
const variance =
values.reduce((total, value) => total + (value - mean) ** 2, 0) /
values.length;
return Math.sqrt(variance);
}
private average(values: number[]): number | null {
if (values.length === 0) {
return null;
@@ -190,6 +1034,18 @@ export class AnalyticsService {
);
}
private median(values: number[]): number | null {
if (values.length === 0) {
return null;
}
const sorted = [...values].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle];
}
private pushIfNumber(values: number[], value: number | null): void {
if (typeof value === 'number' && Number.isFinite(value)) {
values.push(value);
@@ -205,6 +1061,10 @@ export class AnalyticsService {
return Math.round(value * factor) / factor;
}
private clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
private clampWeeks(value: number): number {
if (!Number.isFinite(value)) {
return 12;
@@ -236,6 +1096,12 @@ export class AnalyticsService {
return date;
}
private startOfDay(value: Date | null): Date {
const date = value ? new Date(value) : new Date();
date.setHours(0, 0, 0, 0);
return date;
}
private addDays(value: Date, days: number): Date {
const date = new Date(value);
date.setDate(date.getDate() + days);
@@ -245,4 +1111,18 @@ export class AnalyticsService {
private dateKey(value: Date): string {
return value.toISOString().slice(0, 10);
}
private daysBetween(start: Date | null, end: Date): number | null {
if (!start) {
return null;
}
return Math.max(
0,
Math.floor(
(this.startOfDay(end).getTime() - this.startOfDay(start).getTime()) /
86400000,
),
);
}
}

View File

@@ -49,3 +49,106 @@ export interface AnalyticsDashboard {
sports: AnalyticsSportSummary[];
recentActivities: AnalyticsRecentActivity[];
}
export interface RunningSummary {
weeks: number;
rangeStart: string;
rangeEnd: string;
totals: AnalyticsTotals;
averages: AnalyticsAverages;
fourWeekAverageDistanceMeters: number;
longestRun: AnalyticsRecentActivity | null;
elevationGainPerKm: number | null;
longRunShare: number | null;
weekly: AnalyticsWeeklyBucket[];
recentRuns: AnalyticsRecentActivity[];
}
export interface RunningSplit {
kilometer: number;
distanceMeters: number;
movingTimeSeconds: number;
paceSecondsPerKm: number | null;
averageHeartRate: number | null;
averageCadence: number | null;
elevationGainMeters: number;
}
export interface RunningChartPoint {
distanceMeters: number;
timeSeconds: number | null;
paceSecondsPerKm: number | null;
heartRate: number | null;
altitude: number | null;
cadence: number | null;
}
export interface RunningActivityDetail {
activity: AnalyticsRecentActivity;
splits: RunningSplit[];
series: RunningChartPoint[];
}
export type RunningIntensityZone = 'easy' | 'moderate' | 'hard';
export interface RunningLoadBucket extends AnalyticsTotals {
weekStart: string;
weekEnd: string;
load: number;
}
export interface RunningIntensityDistributionBucket extends AnalyticsTotals {
zone: RunningIntensityZone;
label: string;
load: number;
share: number;
}
export interface RunningProgressionMetric {
key: 'distance' | 'time' | 'load' | 'pace' | 'runs';
label: string;
unit: 'meters' | 'seconds' | 'load' | 'pace' | 'count';
current: number | null;
previous: number | null;
changePercent: number | null;
}
export interface RunningPersonalRecordEstimate {
distanceMeters: number;
timeSeconds: number | null;
paceSecondsPerKm: number | null;
activityId: string | null;
activityName: string | null;
startDate: string | null;
estimated: boolean;
}
export interface RunningKpiDashboard {
weeks: number;
rangeStart: string;
rangeEnd: string;
load: {
acute: number;
chronic: number;
acuteChronicRatio: number | null;
weekly: RunningLoadBucket[];
};
monotony: {
value: number | null;
dailyAverage: number;
dailyStandardDeviation: number;
};
strain: {
value: number | null;
};
recovery: {
status: 'green' | 'yellow' | 'red';
score: number;
daysSinceLastRun: number | null;
daysSinceLastHardRun: number | null;
message: string;
};
intensityDistribution: RunningIntensityDistributionBucket[];
progression: RunningProgressionMetric[];
personalRecords: RunningPersonalRecordEstimate[];
}

View File

@@ -7,6 +7,7 @@ import { StravaRateLimitError } from './strava-rate-limit.error';
import {
StravaActivityPayload,
StravaStreamPayload,
StravaStreamsResponse,
StravaTokenPayload,
} from './strava.types';
@@ -84,15 +85,17 @@ export class StravaClientService {
accessToken: string,
stravaActivityId: string,
): Promise<StravaStreamPayload[]> {
return this.request<StravaStreamPayload[]>({
const streams = await this.request<StravaStreamsResponse>({
method: 'GET',
url: `https://www.strava.com/api/v3/activities/${stravaActivityId}/streams`,
headers: this.authHeaders(accessToken),
params: {
keys: 'time,distance,latlng,altitude,velocity_smooth,heartrate,cadence,watts,temp,moving,grade_smooth',
key_by_type: false,
key_by_type: true,
},
});
return this.normalizeStreamsResponse(streams);
}
private async postToken(
@@ -149,6 +152,22 @@ export class StravaClientService {
return { Authorization: `Bearer ${accessToken}` };
}
private normalizeStreamsResponse(
streams: StravaStreamsResponse,
): StravaStreamPayload[] {
if (Array.isArray(streams)) {
return streams;
}
return Object.entries(streams).map(([type, stream]) => ({
type: stream.type ?? type,
data: Array.isArray(stream.data) ? stream.data : [],
series_type: stream.series_type,
original_size: stream.original_size,
resolution: stream.resolution,
}));
}
private required(configService: ConfigService, key: string): string {
const value = configService.get<string>(key);
if (!value) {

View File

@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { Repository } from 'typeorm';
import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
} from '../database/entities';
import { StravaClientService } from './strava-client.service';
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
import { StravaTokenService } from './strava-token.service';
@Injectable()
export class StravaStreamImportService {
constructor(
@InjectRepository(StravaActivityStreamPointEntity)
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
private readonly stravaTokenService: StravaTokenService,
private readonly stravaClientService: StravaClientService,
private readonly streamNormalizer: StravaStreamNormalizerService,
) {}
async importStreamsForActivity(
activity: StravaActivityEntity,
): Promise<number> {
const accessToken = await this.stravaTokenService.getValidAccessToken(
activity.stravaAthleteId,
);
const streams = await this.stravaClientService.getActivityStreams(
accessToken,
activity.stravaActivityId,
);
const points = this.streamNormalizer.normalize(activity.id, streams);
await this.streamPointRepository.delete({ activityId: activity.id });
await this.insertStreamPoints(points);
return points.length;
}
private async insertStreamPoints(
points: Partial<StravaActivityStreamPointEntity>[],
): Promise<void> {
const chunkSize = 1000;
for (let index = 0; index < points.length; index += chunkSize) {
await this.streamPointRepository.insert(
points.slice(
index,
index + chunkSize,
) as QueryDeepPartialEntity<StravaActivityStreamPointEntity>[],
);
}
}
}

View File

@@ -11,6 +11,11 @@ export class StravaSyncController {
return this.stravaSyncService.startSync();
}
@Get('jobs/latest')
getLatestJob(): Promise<StravaSyncJobEntity | null> {
return this.stravaSyncService.getLatestJob();
}
@Get('jobs/:id')
getJob(@Param('id') id: string): Promise<StravaSyncJobEntity> {
return this.stravaSyncService.getJob(id);

View File

@@ -0,0 +1,69 @@
import { StravaSyncJobEntity } from '../database/entities';
import { StravaRateLimitError } from './strava-rate-limit.error';
import { StravaSyncService } from './strava-sync.service';
describe('StravaSyncService', () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('keeps a rate limited job visible and retries it after 15 minutes', async () => {
jest.useFakeTimers();
jest.setSystemTime(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 stravaTokenService = {
getValidAccessToken: jest.fn().mockResolvedValue('access-token'),
};
const stravaClientService = {
listActivities: jest
.fn()
.mockRejectedValueOnce(new StravaRateLimitError('limit', null))
.mockResolvedValueOnce([]),
};
const service = new StravaSyncService(
{} as never,
{} as never,
jobRepository as never,
{} as never,
stravaTokenService as never,
stravaClientService as never,
{} as never,
);
await service.runJob(job.id);
expect(job.status).toBe('rate_limited');
expect(job.retryAfter?.toISOString()).toBe('2026-06-16T12:15:00.000Z');
expect(job.finishedAt).toBeNull();
expect(jest.getTimerCount()).toBe(1);
await jest.advanceTimersByTimeAsync(15 * 60 * 1000);
expect(stravaClientService.listActivities).toHaveBeenCalledTimes(2);
expect(job.status).toBe('completed');
expect(job.finishedAt).toBeInstanceOf(Date);
});
});

View File

@@ -1,10 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { In, Repository } from 'typeorm';
import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaAthleteEntity,
StravaSyncJobEntity,
StravaSyncJobItemEntity,
@@ -12,30 +10,52 @@ import {
import { mapStravaActivity } from './strava-activity.mapper';
import { StravaClientService } from './strava-client.service';
import { StravaRateLimitError } from './strava-rate-limit.error';
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
import { StravaStreamImportService } from './strava-stream-import.service';
import { StravaTokenService } from './strava-token.service';
import { StravaActivityPayload } from './strava.types';
@Injectable()
export class StravaSyncService {
export class StravaSyncService implements OnModuleInit {
private readonly retryDelayMs = 15 * 60 * 1000;
private readonly retryTimers = new Map<string, NodeJS.Timeout>();
constructor(
@InjectRepository(StravaAthleteEntity)
private readonly athleteRepository: Repository<StravaAthleteEntity>,
@InjectRepository(StravaActivityEntity)
private readonly activityRepository: Repository<StravaActivityEntity>,
@InjectRepository(StravaActivityStreamPointEntity)
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
@InjectRepository(StravaSyncJobEntity)
private readonly jobRepository: Repository<StravaSyncJobEntity>,
@InjectRepository(StravaSyncJobItemEntity)
private readonly jobItemRepository: Repository<StravaSyncJobItemEntity>,
private readonly stravaTokenService: StravaTokenService,
private readonly stravaClientService: StravaClientService,
private readonly streamNormalizer: StravaStreamNormalizerService,
private readonly stravaStreamImportService: StravaStreamImportService,
) {}
async onModuleInit(): Promise<void> {
const waitingJobs = await this.jobRepository.find({
where: { status: 'rate_limited' },
});
waitingJobs.forEach((job) => this.scheduleRetry(job));
}
async startSync(): Promise<StravaSyncJobEntity> {
const athlete = await this.resolveAthlete();
const activeJob = await this.jobRepository.findOne({
where: {
stravaAthleteId: athlete.id,
status: In(['queued', 'running', 'rate_limited']),
},
order: { createdAt: 'DESC' },
});
if (activeJob) {
this.scheduleRetry(activeJob);
return activeJob;
}
const job = await this.jobRepository.save(
this.jobRepository.create({
stravaAthleteId: athlete.id,
@@ -48,6 +68,22 @@ export class StravaSyncService {
return job;
}
async getLatestJob(): Promise<StravaSyncJobEntity | null> {
const athlete = await this.resolveAthlete();
const job = await this.jobRepository.findOne({
where: { stravaAthleteId: athlete.id },
relations: { items: true },
order: { createdAt: 'DESC' },
});
if (job?.status === 'rate_limited') {
this.scheduleRetry(job);
}
return job;
}
async getJob(jobId: string): Promise<StravaSyncJobEntity> {
const job = await this.jobRepository.findOne({
where: { id: jobId },
@@ -61,9 +97,14 @@ export class StravaSyncService {
}
async runJob(jobId: string): Promise<void> {
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 {
@@ -91,6 +132,7 @@ export class StravaSyncService {
job.status = 'completed';
job.finishedAt = new Date();
job.retryAfter = null;
await this.jobRepository.save(job);
} catch (error) {
await this.failJob(job, error);
@@ -105,6 +147,10 @@ export class StravaSyncService {
const stravaActivityId = String(summary.id);
const item = await this.getOrCreateJobItem(job.id, stravaActivityId);
if (item.status === 'completed') {
return;
}
try {
const existingSummary = await this.activityRepository.findOne({
where: {
@@ -126,14 +172,8 @@ export class StravaSyncService {
);
job.detailCount += 1;
const streams = await this.stravaClientService.getActivityStreams(
accessToken,
stravaActivityId,
);
await this.streamPointRepository.delete({ activityId: activity.id });
const points = this.streamNormalizer.normalize(activity.id, streams);
await this.insertStreamPoints(points);
job.streamPointCount += points.length;
job.streamPointCount +=
await this.stravaStreamImportService.importStreamsForActivity(activity);
item.status = 'completed';
item.errorMessage = null;
@@ -151,20 +191,6 @@ export class StravaSyncService {
}
}
private async insertStreamPoints(
points: Partial<StravaActivityStreamPointEntity>[],
): Promise<void> {
const chunkSize = 1000;
for (let index = 0; index < points.length; index += chunkSize) {
await this.streamPointRepository.insert(
points.slice(
index,
index + chunkSize,
) as QueryDeepPartialEntity<StravaActivityStreamPointEntity>[],
);
}
}
private async getOrCreateJobItem(
jobId: string,
stravaActivityId: string,
@@ -173,6 +199,10 @@ export class StravaSyncService {
where: { jobId, stravaActivityId },
});
if (existing) {
if (existing.status === 'completed') {
return existing;
}
existing.status = 'pending';
existing.errorMessage = null;
return this.jobItemRepository.save(existing);
@@ -203,16 +233,51 @@ export class StravaSyncService {
job: StravaSyncJobEntity,
error: unknown,
): Promise<void> {
job.status =
error instanceof StravaRateLimitError ? 'rate_limited' : 'failed';
const isRateLimit = error instanceof StravaRateLimitError;
job.status = isRateLimit ? 'rate_limited' : 'failed';
job.errorMessage = this.errorMessage(error);
job.retryAfter =
error instanceof StravaRateLimitError ? error.retryAfter : null;
job.finishedAt = new Date();
job.retryAfter = isRateLimit
? new Date(Date.now() + this.retryDelayMs)
: null;
job.finishedAt = isRateLimit ? null : new Date();
await this.jobRepository.save(job);
if (isRateLimit) {
this.scheduleRetry(job);
}
}
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown sync error';
}
private scheduleRetry(job: StravaSyncJobEntity): void {
if (job.status !== 'rate_limited' || this.retryTimers.has(job.id)) {
return;
}
const retryAfter =
job.retryAfter instanceof Date
? job.retryAfter
: new Date(Date.now() + this.retryDelayMs);
const delay = Math.max(retryAfter.getTime() - Date.now(), 0);
const timer = setTimeout(() => {
this.retryTimers.delete(job.id);
void this.runJob(job.id);
}, delay);
timer.unref?.();
this.retryTimers.set(job.id, timer);
}
private clearRetry(jobId: string): void {
const timer = this.retryTimers.get(jobId);
if (!timer) {
return;
}
clearTimeout(timer);
this.retryTimers.delete(jobId);
}
}

View File

@@ -12,6 +12,7 @@ import {
import { StravaAuthController } from './strava-auth.controller';
import { StravaAuthService } from './strava-auth.service';
import { StravaClientService } from './strava-client.service';
import { StravaStreamImportService } from './strava-stream-import.service';
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
import { StravaSyncController } from './strava-sync.controller';
import { StravaSyncService } from './strava-sync.service';
@@ -37,8 +38,9 @@ import { TokenCryptoService } from './token-crypto.service';
StravaTokenService,
TokenCryptoService,
StravaStreamNormalizerService,
StravaStreamImportService,
StravaSyncService,
],
exports: [StravaStreamNormalizerService],
exports: [StravaStreamNormalizerService, StravaStreamImportService],
})
export class StravaModule {}

View File

@@ -64,6 +64,10 @@ export interface StravaStreamPayload {
resolution?: string;
}
export type StravaStreamsResponse =
| StravaStreamPayload[]
| Record<string, Omit<StravaStreamPayload, 'type'> & { type?: string }>;
export interface StravaRateLimit {
limit?: string;
usage?: string;