git init
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
PORT=3000
|
||||
CORS_ORIGIN=http://localhost:8080
|
||||
CLIENT_URL=http://localhost:8080
|
||||
|
||||
DATABASE_HOST=mysql.example.internal
|
||||
DATABASE_PORT=3306
|
||||
DATABASE_USER=strava_app
|
||||
DATABASE_PASSWORD=change-me
|
||||
DATABASE_NAME=strava_app
|
||||
TYPEORM_SYNCHRONIZE=true
|
||||
TYPEORM_LOGGING=false
|
||||
|
||||
STRAVA_CLIENT_ID=change-me
|
||||
STRAVA_CLIENT_SECRET=change-me
|
||||
STRAVA_REDIRECT_URI=http://localhost:3000/auth/strava/callback
|
||||
APP_ENCRYPTION_KEY=change-me-to-a-long-random-secret
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
5
api/.dockerignore
Normal file
5
api/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
.git
|
||||
56
api/.gitignore
vendored
Normal file
56
api/.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
4
api/.prettierrc
Normal file
4
api/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
20
api/Dockerfile
Normal file
20
api/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine AS production
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/main.js"]
|
||||
98
api/README.md
Normal file
98
api/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
35
api/eslint.config.mjs
Normal file
35
api/eslint.config.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
8
api/nest-cli.json
Normal file
8
api/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
10591
api/package-lock.json
generated
Normal file
10591
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
77
api/package.json
Normal file
77
api/package.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "api",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/typeorm": "^11.0.2",
|
||||
"axios": "^1.18.0",
|
||||
"mysql2": "^3.22.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
16
api/src/analytics/analytics.controller.ts
Normal file
16
api/src/analytics/analytics.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { AnalyticsDashboard } from './analytics.types';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||
|
||||
@Get('dashboard')
|
||||
dashboard(
|
||||
@Query('weeks') weeks?: string,
|
||||
@Query('sportType') sportType?: string,
|
||||
): Promise<AnalyticsDashboard> {
|
||||
return this.analyticsService.getDashboard(Number(weeks ?? 12), sportType);
|
||||
}
|
||||
}
|
||||
12
api/src/analytics/analytics.module.ts
Normal file
12
api/src/analytics/analytics.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { StravaActivityEntity } from '../database/entities';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([StravaActivityEntity])],
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
123
api/src/analytics/analytics.service.spec.ts
Normal file
123
api/src/analytics/analytics.service.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { StravaActivityEntity } from '../database/entities';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
describe('AnalyticsService', () => {
|
||||
const createService = (activities: StravaActivityEntity[]) => {
|
||||
const repository = {
|
||||
find: jest.fn().mockResolvedValue(activities),
|
||||
} as unknown as Repository<StravaActivityEntity>;
|
||||
|
||||
return {
|
||||
service: new AnalyticsService(repository),
|
||||
repository,
|
||||
};
|
||||
};
|
||||
|
||||
const activity = (
|
||||
input: Partial<StravaActivityEntity>,
|
||||
): StravaActivityEntity =>
|
||||
({
|
||||
id: input.id ?? `activity-${input.stravaActivityId ?? '1'}`,
|
||||
stravaActivityId: input.stravaActivityId ?? '1',
|
||||
name: input.name ?? 'Morning Ride',
|
||||
sportType: input.sportType ?? 'Ride',
|
||||
startDate: input.startDate ?? new Date(),
|
||||
distance: input.distance ?? 10000,
|
||||
movingTime: input.movingTime ?? 1800,
|
||||
totalElevationGain: input.totalElevationGain ?? 120,
|
||||
calories: input.calories ?? 400,
|
||||
averageSpeed: input.averageSpeed ?? 5.5,
|
||||
averageHeartrate: input.averageHeartrate ?? null,
|
||||
averageWatts: input.averageWatts ?? null,
|
||||
averageCadence: input.averageCadence ?? null,
|
||||
}) as StravaActivityEntity;
|
||||
|
||||
it('returns empty dashboard buckets when there are no activities', async () => {
|
||||
const { service } = createService([]);
|
||||
|
||||
const dashboard = await service.getDashboard(12);
|
||||
|
||||
expect(dashboard.totals.activityCount).toBe(0);
|
||||
expect(dashboard.availableSports).toEqual([]);
|
||||
expect(dashboard.weekly).toHaveLength(12);
|
||||
expect(dashboard.sports).toEqual([]);
|
||||
expect(dashboard.recentActivities).toEqual([]);
|
||||
});
|
||||
|
||||
it('aggregates totals, averages, and sport summaries', async () => {
|
||||
const { service } = createService([
|
||||
activity({
|
||||
stravaActivityId: '1',
|
||||
sportType: 'Ride',
|
||||
distance: 20000,
|
||||
movingTime: 3600,
|
||||
totalElevationGain: 200,
|
||||
calories: 700,
|
||||
averageHeartrate: 140,
|
||||
averageWatts: 210,
|
||||
}),
|
||||
activity({
|
||||
stravaActivityId: '2',
|
||||
sportType: 'Run',
|
||||
distance: 5000,
|
||||
movingTime: 1500,
|
||||
totalElevationGain: 40,
|
||||
calories: 350,
|
||||
averageHeartrate: 150,
|
||||
}),
|
||||
]);
|
||||
|
||||
const dashboard = await service.getDashboard(12);
|
||||
|
||||
expect(dashboard.totals).toEqual({
|
||||
activityCount: 2,
|
||||
distanceMeters: 25000,
|
||||
movingTimeSeconds: 5100,
|
||||
elevationGainMeters: 240,
|
||||
calories: 1050,
|
||||
});
|
||||
expect(dashboard.averages.heartRate).toBe(145);
|
||||
expect(dashboard.averages.watts).toBe(210);
|
||||
expect(dashboard.sports.map((sport) => sport.sportType)).toEqual([
|
||||
'Ride',
|
||||
'Run',
|
||||
]);
|
||||
});
|
||||
|
||||
it('clamps the requested number of weeks', async () => {
|
||||
const { service } = createService([]);
|
||||
|
||||
await expect(service.getDashboard(999)).resolves.toMatchObject({
|
||||
weeks: 104,
|
||||
});
|
||||
await expect(service.getDashboard(0)).resolves.toMatchObject({
|
||||
weeks: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters dashboard values by sport type while keeping available sports', async () => {
|
||||
const { service } = createService([
|
||||
activity({
|
||||
stravaActivityId: '1',
|
||||
sportType: 'Ride',
|
||||
distance: 20000,
|
||||
movingTime: 3600,
|
||||
}),
|
||||
activity({
|
||||
stravaActivityId: '2',
|
||||
sportType: 'Run',
|
||||
distance: 5000,
|
||||
movingTime: 1500,
|
||||
}),
|
||||
]);
|
||||
|
||||
const dashboard = await service.getDashboard(12, 'Run');
|
||||
|
||||
expect(dashboard.selectedSportType).toBe('Run');
|
||||
expect(dashboard.availableSports).toEqual(['Ride', 'Run']);
|
||||
expect(dashboard.totals.activityCount).toBe(1);
|
||||
expect(dashboard.totals.distanceMeters).toBe(5000);
|
||||
expect(dashboard.sports.map((sport) => sport.sportType)).toEqual(['Run']);
|
||||
});
|
||||
});
|
||||
248
api/src/analytics/analytics.service.ts
Normal file
248
api/src/analytics/analytics.service.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { StravaActivityEntity } from '../database/entities';
|
||||
import {
|
||||
AnalyticsAverages,
|
||||
AnalyticsDashboard,
|
||||
AnalyticsRecentActivity,
|
||||
AnalyticsSportSummary,
|
||||
AnalyticsTotals,
|
||||
AnalyticsWeeklyBucket,
|
||||
} from './analytics.types';
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(
|
||||
@InjectRepository(StravaActivityEntity)
|
||||
private readonly activityRepository: Repository<StravaActivityEntity>,
|
||||
) {}
|
||||
|
||||
async getDashboard(
|
||||
weeksInput = 12,
|
||||
sportTypeInput?: string,
|
||||
): Promise<AnalyticsDashboard> {
|
||||
const weeks = this.clampWeeks(weeksInput);
|
||||
const selectedSportType = this.normalizeSportType(sportTypeInput);
|
||||
const now = new Date();
|
||||
const rangeStart = this.startOfWeek(this.addDays(now, -(weeks - 1) * 7));
|
||||
const rangeEnd = this.endOfDay(now);
|
||||
|
||||
const activities = await this.activityRepository.find({
|
||||
where: {
|
||||
startDate: MoreThanOrEqual(rangeStart),
|
||||
},
|
||||
order: {
|
||||
startDate: 'DESC',
|
||||
},
|
||||
});
|
||||
const availableSports = this.availableSports(activities);
|
||||
const filteredActivities = selectedSportType
|
||||
? activities.filter(
|
||||
(activity) =>
|
||||
(activity.sportType ?? 'Unbekannt') === selectedSportType,
|
||||
)
|
||||
: activities;
|
||||
|
||||
const totals = this.createTotals();
|
||||
const weekly = this.createWeeklyBuckets(rangeStart, weeks);
|
||||
const sports = new Map<string, AnalyticsSportSummary>();
|
||||
const heartRates: number[] = [];
|
||||
const watts: number[] = [];
|
||||
const cadences: number[] = [];
|
||||
|
||||
for (const activity of filteredActivities) {
|
||||
this.addActivity(totals, activity);
|
||||
|
||||
const week = weekly.get(
|
||||
this.dateKey(this.startOfWeek(activity.startDate)),
|
||||
);
|
||||
if (week) {
|
||||
this.addActivity(week, activity);
|
||||
}
|
||||
|
||||
const sportType = activity.sportType ?? 'Unbekannt';
|
||||
const sport = sports.get(sportType) ?? {
|
||||
...this.createTotals(),
|
||||
sportType,
|
||||
};
|
||||
this.addActivity(sport, activity);
|
||||
sports.set(sportType, sport);
|
||||
|
||||
this.pushIfNumber(heartRates, activity.averageHeartrate);
|
||||
this.pushIfNumber(watts, activity.averageWatts);
|
||||
this.pushIfNumber(cadences, activity.averageCadence);
|
||||
}
|
||||
|
||||
return {
|
||||
weeks,
|
||||
selectedSportType,
|
||||
availableSports,
|
||||
rangeStart: this.dateKey(rangeStart),
|
||||
rangeEnd: this.dateKey(rangeEnd),
|
||||
totals,
|
||||
averages: this.createAverages(totals, heartRates, watts, cadences),
|
||||
weekly: Array.from(weekly.values()),
|
||||
sports: Array.from(sports.values()).sort(
|
||||
(left, right) => right.distanceMeters - left.distanceMeters,
|
||||
),
|
||||
recentActivities: filteredActivities
|
||||
.slice(0, 8)
|
||||
.map((activity) => this.toRecentActivity(activity)),
|
||||
};
|
||||
}
|
||||
|
||||
private availableSports(activities: StravaActivityEntity[]): string[] {
|
||||
return Array.from(
|
||||
new Set(activities.map((activity) => activity.sportType ?? 'Unbekannt')),
|
||||
).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
private addActivity(
|
||||
totals: AnalyticsTotals,
|
||||
activity: StravaActivityEntity,
|
||||
): void {
|
||||
totals.activityCount += 1;
|
||||
totals.distanceMeters += activity.distance ?? 0;
|
||||
totals.movingTimeSeconds += activity.movingTime ?? 0;
|
||||
totals.elevationGainMeters += activity.totalElevationGain ?? 0;
|
||||
totals.calories += activity.calories ?? 0;
|
||||
}
|
||||
|
||||
private createAverages(
|
||||
totals: AnalyticsTotals,
|
||||
heartRates: number[],
|
||||
watts: number[],
|
||||
cadences: number[],
|
||||
): AnalyticsAverages {
|
||||
const speed =
|
||||
totals.movingTimeSeconds > 0
|
||||
? totals.distanceMeters / totals.movingTimeSeconds
|
||||
: null;
|
||||
const pace =
|
||||
totals.distanceMeters > 0
|
||||
? totals.movingTimeSeconds / (totals.distanceMeters / 1000)
|
||||
: null;
|
||||
|
||||
return {
|
||||
speedMetersPerSecond: this.roundNullable(speed, 2),
|
||||
paceSecondsPerKm: this.roundNullable(pace, 0),
|
||||
heartRate: this.average(heartRates),
|
||||
watts: this.average(watts),
|
||||
cadence: this.average(cadences),
|
||||
};
|
||||
}
|
||||
|
||||
private createWeeklyBuckets(
|
||||
rangeStart: Date,
|
||||
weeks: number,
|
||||
): Map<string, AnalyticsWeeklyBucket> {
|
||||
const buckets = new Map<string, AnalyticsWeeklyBucket>();
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
private toRecentActivity(
|
||||
activity: StravaActivityEntity,
|
||||
): AnalyticsRecentActivity {
|
||||
return {
|
||||
id: activity.id,
|
||||
stravaActivityId: activity.stravaActivityId,
|
||||
name: activity.name,
|
||||
sportType: activity.sportType,
|
||||
startDate: activity.startDate ? activity.startDate.toISOString() : null,
|
||||
distanceMeters: activity.distance,
|
||||
movingTimeSeconds: activity.movingTime,
|
||||
elevationGainMeters: activity.totalElevationGain,
|
||||
averageSpeedMetersPerSecond: activity.averageSpeed,
|
||||
averageHeartrate: activity.averageHeartrate,
|
||||
averageWatts: activity.averageWatts,
|
||||
};
|
||||
}
|
||||
|
||||
private createTotals(): AnalyticsTotals {
|
||||
return {
|
||||
activityCount: 0,
|
||||
distanceMeters: 0,
|
||||
movingTimeSeconds: 0,
|
||||
elevationGainMeters: 0,
|
||||
calories: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private average(values: number[]): number | null {
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round(
|
||||
values.reduce((total, value) => total + value, 0) / values.length,
|
||||
);
|
||||
}
|
||||
|
||||
private pushIfNumber(values: number[], value: number | null): void {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
private roundNullable(value: number | null, digits: number): number | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
private clampWeeks(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 12;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(Math.trunc(value), 1), 104);
|
||||
}
|
||||
|
||||
private normalizeSportType(value: string | undefined): string | null {
|
||||
if (!value || value === 'all') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.trim() || null;
|
||||
}
|
||||
|
||||
private startOfWeek(value: Date | null): Date {
|
||||
const date = value ? new Date(value) : new Date();
|
||||
const day = date.getDay();
|
||||
const mondayOffset = day === 0 ? -6 : 1 - day;
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() + mondayOffset);
|
||||
return date;
|
||||
}
|
||||
|
||||
private endOfDay(value: Date): Date {
|
||||
const date = new Date(value);
|
||||
date.setHours(23, 59, 59, 999);
|
||||
return date;
|
||||
}
|
||||
|
||||
private addDays(value: Date, days: number): Date {
|
||||
const date = new Date(value);
|
||||
date.setDate(date.getDate() + days);
|
||||
return date;
|
||||
}
|
||||
|
||||
private dateKey(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
51
api/src/analytics/analytics.types.ts
Normal file
51
api/src/analytics/analytics.types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface AnalyticsTotals {
|
||||
activityCount: number;
|
||||
distanceMeters: number;
|
||||
movingTimeSeconds: number;
|
||||
elevationGainMeters: number;
|
||||
calories: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsAverages {
|
||||
speedMetersPerSecond: number | null;
|
||||
paceSecondsPerKm: number | null;
|
||||
heartRate: number | null;
|
||||
watts: number | null;
|
||||
cadence: number | null;
|
||||
}
|
||||
|
||||
export interface AnalyticsWeeklyBucket extends AnalyticsTotals {
|
||||
weekStart: string;
|
||||
weekEnd: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsSportSummary extends AnalyticsTotals {
|
||||
sportType: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsRecentActivity {
|
||||
id: string;
|
||||
stravaActivityId: string;
|
||||
name: string;
|
||||
sportType: string | null;
|
||||
startDate: string | null;
|
||||
distanceMeters: number | null;
|
||||
movingTimeSeconds: number | null;
|
||||
elevationGainMeters: number | null;
|
||||
averageSpeedMetersPerSecond: number | null;
|
||||
averageHeartrate: number | null;
|
||||
averageWatts: number | null;
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboard {
|
||||
weeks: number;
|
||||
selectedSportType: string | null;
|
||||
availableSports: string[];
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
totals: AnalyticsTotals;
|
||||
averages: AnalyticsAverages;
|
||||
weekly: AnalyticsWeeklyBucket[];
|
||||
sports: AnalyticsSportSummary[];
|
||||
recentActivities: AnalyticsRecentActivity[];
|
||||
}
|
||||
22
api/src/app.controller.spec.ts
Normal file
22
api/src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return health status', () => {
|
||||
expect(appController.getHealth()).toEqual({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
});
|
||||
12
api/src/app.controller.ts
Normal file
12
api/src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHealth(): { status: string } {
|
||||
return this.appService.getHealth();
|
||||
}
|
||||
}
|
||||
26
api/src/app.module.ts
Normal file
26
api/src/app.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { AnalyticsModule } from './analytics/analytics.module';
|
||||
import { createTypeOrmOptions } from './database/typeorm.options';
|
||||
import { StravaModule } from './strava/strava.module';
|
||||
|
||||
const featureImports =
|
||||
process.env.NODE_ENV === 'test'
|
||||
? []
|
||||
: [
|
||||
TypeOrmModule.forRootAsync({
|
||||
useFactory: () => createTypeOrmOptions(),
|
||||
}),
|
||||
StravaModule,
|
||||
AnalyticsModule,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true }), ...featureImports],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
api/src/app.service.ts
Normal file
8
api/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHealth(): { status: string } {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
6
api/src/database/entities/index.ts
Normal file
6
api/src/database/entities/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { StravaActivityEntity } from './strava-activity.entity';
|
||||
export { StravaActivityStreamPointEntity } from './strava-activity-stream-point.entity';
|
||||
export { StravaAthleteEntity } from './strava-athlete.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,68 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { StravaActivityEntity } from './strava-activity.entity';
|
||||
|
||||
@Entity({ name: 'strava_activity_stream_points' })
|
||||
@Index(['activityId', 'pointIndex'], { unique: true })
|
||||
export class StravaActivityStreamPointEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'activity_id', type: 'varchar', length: 36 })
|
||||
activityId!: string;
|
||||
|
||||
@ManyToOne(() => StravaActivityEntity, (activity) => activity.streamPoints, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'activity_id' })
|
||||
activity!: StravaActivityEntity;
|
||||
|
||||
@Column({ name: 'point_index', type: 'int' })
|
||||
pointIndex!: number;
|
||||
|
||||
@Column({ name: 'time_seconds', type: 'int', nullable: true })
|
||||
timeSeconds!: number | null;
|
||||
|
||||
@Column({ type: 'double', nullable: true })
|
||||
distance!: number | null;
|
||||
|
||||
@Column({ type: 'double', nullable: true })
|
||||
latitude!: number | null;
|
||||
|
||||
@Column({ type: 'double', nullable: true })
|
||||
longitude!: number | null;
|
||||
|
||||
@Column({ type: 'double', nullable: true })
|
||||
altitude!: number | null;
|
||||
|
||||
@Column({ name: 'velocity_smooth', type: 'double', nullable: true })
|
||||
velocitySmooth!: number | null;
|
||||
|
||||
@Column({ name: 'heart_rate', type: 'int', nullable: true })
|
||||
heartRate!: number | null;
|
||||
|
||||
@Column({ type: 'double', nullable: true })
|
||||
cadence!: number | null;
|
||||
|
||||
@Column({ type: 'double', nullable: true })
|
||||
watts!: number | null;
|
||||
|
||||
@Column({ type: 'double', nullable: true })
|
||||
temperature!: number | null;
|
||||
|
||||
@Column({ type: 'boolean', nullable: true })
|
||||
moving!: boolean | null;
|
||||
|
||||
@Column({ name: 'grade_smooth', type: 'double', nullable: true })
|
||||
gradeSmooth!: number | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
129
api/src/database/entities/strava-activity.entity.ts
Normal file
129
api/src/database/entities/strava-activity.entity.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { StravaActivityStreamPointEntity } from './strava-activity-stream-point.entity';
|
||||
import { StravaAthleteEntity } from './strava-athlete.entity';
|
||||
|
||||
@Entity({ name: 'strava_activities' })
|
||||
@Index(['stravaAthleteId', 'stravaActivityId'], { unique: true })
|
||||
@Index(['stravaAthleteId', 'startDate'])
|
||||
export class StravaActivityEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'strava_athlete_id', type: 'varchar', length: 36 })
|
||||
stravaAthleteId!: string;
|
||||
|
||||
@ManyToOne(() => StravaAthleteEntity, (athlete) => athlete.activities, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'strava_athlete_id' })
|
||||
athlete!: StravaAthleteEntity;
|
||||
|
||||
@Column({ name: 'strava_activity_id', type: 'varchar', length: 32 })
|
||||
stravaActivityId!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'sport_type', type: 'varchar', length: 100, nullable: true })
|
||||
sportType!: string | 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: 'total_elevation_gain', type: 'double', nullable: true })
|
||||
totalElevationGain!: number | null;
|
||||
|
||||
@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: 'varchar', length: 255, nullable: true })
|
||||
timezone!: string | null;
|
||||
|
||||
@Column({ name: 'utc_offset', type: 'int', nullable: true })
|
||||
utcOffset!: 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({ name: 'average_heartrate', type: 'double', nullable: true })
|
||||
averageHeartrate!: number | null;
|
||||
|
||||
@Column({ name: 'max_heartrate', type: 'double', nullable: true })
|
||||
maxHeartrate!: number | null;
|
||||
|
||||
@Column({ name: 'average_watts', type: 'double', nullable: true })
|
||||
averageWatts!: number | null;
|
||||
|
||||
@Column({ name: 'max_watts', type: 'double', nullable: true })
|
||||
maxWatts!: number | null;
|
||||
|
||||
@Column({ name: 'weighted_average_watts', type: 'double', nullable: true })
|
||||
weightedAverageWatts!: number | null;
|
||||
|
||||
@Column({ name: 'average_cadence', type: 'double', nullable: true })
|
||||
averageCadence!: number | null;
|
||||
|
||||
@Column({ type: 'double', nullable: true })
|
||||
calories!: number | null;
|
||||
|
||||
@Column({ name: 'gear_id', type: 'varchar', length: 64, nullable: true })
|
||||
gearId!: string | null;
|
||||
|
||||
@Column({ name: 'is_trainer', default: false })
|
||||
trainer!: boolean;
|
||||
|
||||
@Column({ name: 'is_commute', default: false })
|
||||
commute!: boolean;
|
||||
|
||||
@Column({ name: 'is_manual', default: false })
|
||||
manual!: boolean;
|
||||
|
||||
@Column({ name: 'is_private', default: false })
|
||||
private!: boolean;
|
||||
|
||||
@Column({ name: 'visibility', type: 'varchar', length: 64, nullable: true })
|
||||
visibility!: string | null;
|
||||
|
||||
@Column({ name: 'map_id', type: 'varchar', length: 64, nullable: true })
|
||||
mapId!: string | null;
|
||||
|
||||
@Column({ name: 'summary_polyline', type: 'text', nullable: true })
|
||||
summaryPolyline!: string | null;
|
||||
|
||||
@Column({ name: 'resource_state', type: 'int', nullable: true })
|
||||
resourceState!: number | null;
|
||||
|
||||
@Column({ name: 'raw_payload', type: 'json', nullable: true })
|
||||
rawPayload!: Record<string, unknown> | null;
|
||||
|
||||
@OneToMany(() => StravaActivityStreamPointEntity, (point) => point.activity)
|
||||
streamPoints!: StravaActivityStreamPointEntity[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
73
api/src/database/entities/strava-athlete.entity.ts
Normal file
73
api/src/database/entities/strava-athlete.entity.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { StravaActivityEntity } from './strava-activity.entity';
|
||||
import { StravaTokenEntity } from './strava-token.entity';
|
||||
|
||||
@Entity({ name: 'strava_athletes' })
|
||||
@Index(['stravaAthleteId'], { unique: true })
|
||||
@Index(['accountKey'], { unique: true })
|
||||
export class StravaAthleteEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({
|
||||
name: 'account_key',
|
||||
type: 'varchar',
|
||||
length: 32,
|
||||
default: 'primary',
|
||||
})
|
||||
accountKey!: string;
|
||||
|
||||
@Column({ name: 'strava_athlete_id', type: 'varchar', length: 32 })
|
||||
stravaAthleteId!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
username!: string | null;
|
||||
|
||||
@Column({ name: 'first_name', type: 'varchar', length: 255, nullable: true })
|
||||
firstName!: string | null;
|
||||
|
||||
@Column({ name: 'last_name', type: 'varchar', length: 255, nullable: true })
|
||||
lastName!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
city!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
state!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
country!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||
sex!: string | null;
|
||||
|
||||
@Column({ name: 'profile_medium', type: 'text', nullable: true })
|
||||
profileMedium!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
profile!: string | null;
|
||||
|
||||
@Column({ name: 'raw_payload', type: 'json', nullable: true })
|
||||
rawPayload!: Record<string, unknown> | null;
|
||||
|
||||
@OneToOne(() => StravaTokenEntity, (token) => token.athlete)
|
||||
token!: StravaTokenEntity;
|
||||
|
||||
@OneToMany(() => StravaActivityEntity, (activity) => activity.athlete)
|
||||
activities!: StravaActivityEntity[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
44
api/src/database/entities/strava-sync-job-item.entity.ts
Normal file
44
api/src/database/entities/strava-sync-job-item.entity.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { StravaSyncJobEntity } from './strava-sync-job.entity';
|
||||
|
||||
export type StravaSyncJobItemStatus = 'pending' | 'completed' | 'failed';
|
||||
|
||||
@Entity({ name: 'strava_sync_job_items' })
|
||||
@Index(['jobId', 'stravaActivityId'], { unique: true })
|
||||
export class StravaSyncJobItemEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'job_id', type: 'varchar', length: 36 })
|
||||
jobId!: string;
|
||||
|
||||
@ManyToOne(() => StravaSyncJobEntity, (job) => job.items, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'job_id' })
|
||||
job!: StravaSyncJobEntity;
|
||||
|
||||
@Column({ name: 'strava_activity_id', type: 'varchar', length: 32 })
|
||||
stravaActivityId!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, default: 'pending' })
|
||||
status!: StravaSyncJobItemStatus;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
errorMessage!: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
67
api/src/database/entities/strava-sync-job.entity.ts
Normal file
67
api/src/database/entities/strava-sync-job.entity.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { StravaAthleteEntity } from './strava-athlete.entity';
|
||||
import { StravaSyncJobItemEntity } from './strava-sync-job-item.entity';
|
||||
|
||||
export type StravaSyncJobStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'rate_limited';
|
||||
|
||||
@Entity({ name: 'strava_sync_jobs' })
|
||||
@Index(['stravaAthleteId', 'createdAt'])
|
||||
export class StravaSyncJobEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'strava_athlete_id', type: 'varchar', length: 36 })
|
||||
stravaAthleteId!: string;
|
||||
|
||||
@ManyToOne(() => StravaAthleteEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'strava_athlete_id' })
|
||||
athlete!: StravaAthleteEntity;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, default: 'queued' })
|
||||
status!: StravaSyncJobStatus;
|
||||
|
||||
@Column({ name: 'activity_count', type: 'int', default: 0 })
|
||||
activityCount!: number;
|
||||
|
||||
@Column({ name: 'detail_count', type: 'int', default: 0 })
|
||||
detailCount!: number;
|
||||
|
||||
@Column({ name: 'stream_point_count', type: 'int', default: 0 })
|
||||
streamPointCount!: number;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
errorMessage!: string | null;
|
||||
|
||||
@Column({ name: 'retry_after', type: 'datetime', nullable: true })
|
||||
retryAfter!: Date | null;
|
||||
|
||||
@Column({ name: 'started_at', type: 'datetime', nullable: true })
|
||||
startedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'finished_at', type: 'datetime', nullable: true })
|
||||
finishedAt!: Date | null;
|
||||
|
||||
@OneToMany(() => StravaSyncJobItemEntity, (item) => item.job)
|
||||
items!: StravaSyncJobItemEntity[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
53
api/src/database/entities/strava-token.entity.ts
Normal file
53
api/src/database/entities/strava-token.entity.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { StravaAthleteEntity } from './strava-athlete.entity';
|
||||
|
||||
@Entity({ name: 'strava_tokens' })
|
||||
@Index(['stravaAthleteId'], { unique: true })
|
||||
export class StravaTokenEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'strava_athlete_id', type: 'varchar', length: 36 })
|
||||
stravaAthleteId!: string;
|
||||
|
||||
@OneToOne(() => StravaAthleteEntity, (athlete) => athlete.token, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'strava_athlete_id' })
|
||||
athlete!: StravaAthleteEntity;
|
||||
|
||||
@Column({ name: 'access_token_ciphertext', type: 'text' })
|
||||
accessTokenCiphertext!: string;
|
||||
|
||||
@Column({ name: 'refresh_token_ciphertext', type: 'text' })
|
||||
refreshTokenCiphertext!: string;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'datetime' })
|
||||
expiresAt!: Date;
|
||||
|
||||
@Column({ name: 'scope', type: 'text', nullable: true })
|
||||
scope!: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'token_type',
|
||||
type: 'varchar',
|
||||
length: 64,
|
||||
default: 'Bearer',
|
||||
})
|
||||
tokenType!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
36
api/src/database/typeorm.options.ts
Normal file
36
api/src/database/typeorm.options.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { DataSourceOptions } from 'typeorm';
|
||||
import {
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
StravaAthleteEntity,
|
||||
StravaSyncJobEntity,
|
||||
StravaSyncJobItemEntity,
|
||||
StravaTokenEntity,
|
||||
} from './entities';
|
||||
|
||||
const required = (key: string): string => {
|
||||
const value = process.env[key];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const createTypeOrmOptions = (): DataSourceOptions => ({
|
||||
type: 'mysql',
|
||||
host: required('DATABASE_HOST'),
|
||||
port: Number(process.env.DATABASE_PORT ?? 3306),
|
||||
username: required('DATABASE_USER'),
|
||||
password: required('DATABASE_PASSWORD'),
|
||||
database: required('DATABASE_NAME'),
|
||||
entities: [
|
||||
StravaAthleteEntity,
|
||||
StravaTokenEntity,
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
StravaSyncJobEntity,
|
||||
StravaSyncJobItemEntity,
|
||||
],
|
||||
synchronize: process.env.TYPEORM_SYNCHRONIZE !== 'false',
|
||||
logging: process.env.TYPEORM_LOGGING === 'true',
|
||||
});
|
||||
11
api/src/main.ts
Normal file
11
api/src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGIN?.split(',') ?? true,
|
||||
});
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
void bootstrap();
|
||||
49
api/src/strava/strava-activity.mapper.ts
Normal file
49
api/src/strava/strava-activity.mapper.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { StravaActivityEntity } from '../database/entities';
|
||||
import { StravaActivityPayload } from './strava.types';
|
||||
|
||||
export const mapStravaActivity = (
|
||||
stravaAthleteId: string,
|
||||
payload: StravaActivityPayload,
|
||||
existing?: StravaActivityEntity | null,
|
||||
): StravaActivityEntity => {
|
||||
const activity = existing ?? new StravaActivityEntity();
|
||||
activity.stravaAthleteId = stravaAthleteId;
|
||||
activity.stravaActivityId = String(payload.id);
|
||||
activity.name = payload.name ?? `Activity ${payload.id}`;
|
||||
activity.sportType = payload.sport_type ?? payload.type ?? null;
|
||||
activity.distance = numberOrNull(payload.distance);
|
||||
activity.movingTime = numberOrNull(payload.moving_time);
|
||||
activity.elapsedTime = numberOrNull(payload.elapsed_time);
|
||||
activity.totalElevationGain = numberOrNull(payload.total_elevation_gain);
|
||||
activity.startDate = dateOrNull(payload.start_date);
|
||||
activity.startDateLocal = dateOrNull(payload.start_date_local);
|
||||
activity.timezone = payload.timezone ?? null;
|
||||
activity.utcOffset = numberOrNull(payload.utc_offset);
|
||||
activity.averageSpeed = numberOrNull(payload.average_speed);
|
||||
activity.maxSpeed = numberOrNull(payload.max_speed);
|
||||
activity.averageHeartrate = numberOrNull(payload.average_heartrate);
|
||||
activity.maxHeartrate = numberOrNull(payload.max_heartrate);
|
||||
activity.averageWatts = numberOrNull(payload.average_watts);
|
||||
activity.maxWatts = numberOrNull(payload.max_watts);
|
||||
activity.weightedAverageWatts = numberOrNull(payload.weighted_average_watts);
|
||||
activity.averageCadence = numberOrNull(payload.average_cadence);
|
||||
activity.calories = numberOrNull(payload.calories);
|
||||
activity.gearId = payload.gear_id ?? null;
|
||||
activity.trainer = Boolean(payload.trainer);
|
||||
activity.commute = Boolean(payload.commute);
|
||||
activity.manual = Boolean(payload.manual);
|
||||
activity.private = Boolean(payload.private);
|
||||
activity.visibility = payload.visibility ?? null;
|
||||
activity.mapId = payload.map?.id ?? null;
|
||||
activity.summaryPolyline = payload.map?.summary_polyline ?? null;
|
||||
activity.resourceState = numberOrNull(payload.resource_state);
|
||||
activity.rawPayload = payload;
|
||||
|
||||
return activity;
|
||||
};
|
||||
|
||||
const numberOrNull = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const dateOrNull = (value: unknown): Date | null =>
|
||||
typeof value === 'string' ? new Date(value) : null;
|
||||
60
api/src/strava/strava-auth.controller.ts
Normal file
60
api/src/strava/strava-auth.controller.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Controller, Get, Query, Redirect } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { StravaAuthService } from './strava-auth.service';
|
||||
|
||||
@Controller('auth/strava')
|
||||
export class StravaAuthController {
|
||||
constructor(
|
||||
private readonly stravaAuthService: StravaAuthService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Get('connect')
|
||||
@Redirect()
|
||||
connect(): { url: string } {
|
||||
return { url: this.stravaAuthService.buildAuthorizationUrl() };
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
@Redirect()
|
||||
async callback(
|
||||
@Query('code') code?: string,
|
||||
@Query('scope') scope?: string,
|
||||
@Query('error') error?: string,
|
||||
): Promise<{ url: string }> {
|
||||
if (error || !code) {
|
||||
return {
|
||||
url: `${this.clientUrl()}?strava=error`,
|
||||
};
|
||||
}
|
||||
|
||||
await this.stravaAuthService.handleCallback(code, scope);
|
||||
return {
|
||||
url: `${this.clientUrl()}?strava=connected`,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
status(): Promise<{
|
||||
connected: boolean;
|
||||
athlete: {
|
||||
id: string;
|
||||
stravaAthleteId: string;
|
||||
username: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
profile: string | null;
|
||||
updatedAt: Date;
|
||||
} | null;
|
||||
}> {
|
||||
return this.stravaAuthService.getStatus();
|
||||
}
|
||||
|
||||
private clientUrl(): string {
|
||||
return (
|
||||
this.configService.get<string>('CLIENT_URL') ??
|
||||
this.configService.get<string>('CORS_ORIGIN')?.split(',')[0] ??
|
||||
'http://localhost:8080'
|
||||
);
|
||||
}
|
||||
}
|
||||
112
api/src/strava/strava-auth.service.ts
Normal file
112
api/src/strava/strava-auth.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { StravaAthleteEntity } from '../database/entities';
|
||||
import { StravaClientService } from './strava-client.service';
|
||||
import { StravaTokenService } from './strava-token.service';
|
||||
import { StravaAthletePayload } from './strava.types';
|
||||
|
||||
@Injectable()
|
||||
export class StravaAuthService {
|
||||
constructor(
|
||||
@InjectRepository(StravaAthleteEntity)
|
||||
private readonly athleteRepository: Repository<StravaAthleteEntity>,
|
||||
private readonly stravaClientService: StravaClientService,
|
||||
private readonly stravaTokenService: StravaTokenService,
|
||||
) {}
|
||||
|
||||
buildAuthorizationUrl(): string {
|
||||
return this.stravaClientService.buildAuthorizeUrl(
|
||||
Buffer.from(
|
||||
JSON.stringify({ issuedAt: new Date().toISOString() }),
|
||||
'utf8',
|
||||
).toString('base64url'),
|
||||
);
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
code: string,
|
||||
scope?: string,
|
||||
): Promise<{
|
||||
stravaAthleteId: string;
|
||||
scope: string | null;
|
||||
}> {
|
||||
const tokenPayload = await this.stravaClientService.exchangeCode(code);
|
||||
if (!tokenPayload.athlete) {
|
||||
throw new Error('Strava OAuth response did not include athlete payload');
|
||||
}
|
||||
|
||||
const athlete = await this.saveAthlete(tokenPayload.athlete);
|
||||
|
||||
await this.stravaTokenService.saveTokens({
|
||||
stravaAthleteId: athlete.id,
|
||||
accessToken: tokenPayload.access_token,
|
||||
refreshToken: tokenPayload.refresh_token,
|
||||
expiresAt: new Date(tokenPayload.expires_at * 1000),
|
||||
scope: scope ?? null,
|
||||
tokenType: tokenPayload.token_type ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
stravaAthleteId: athlete.id,
|
||||
scope: scope ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(): Promise<{
|
||||
connected: boolean;
|
||||
athlete: {
|
||||
id: string;
|
||||
stravaAthleteId: string;
|
||||
username: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
profile: string | null;
|
||||
updatedAt: Date;
|
||||
} | null;
|
||||
}> {
|
||||
const athlete = await this.athleteRepository.findOne({
|
||||
where: { accountKey: 'primary' },
|
||||
});
|
||||
|
||||
return {
|
||||
connected: Boolean(athlete),
|
||||
athlete: athlete
|
||||
? {
|
||||
id: athlete.id,
|
||||
stravaAthleteId: athlete.stravaAthleteId,
|
||||
username: athlete.username,
|
||||
firstName: athlete.firstName,
|
||||
lastName: athlete.lastName,
|
||||
profile: athlete.profile,
|
||||
updatedAt: athlete.updatedAt,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async saveAthlete(
|
||||
payload: StravaAthletePayload,
|
||||
): Promise<StravaAthleteEntity> {
|
||||
const stravaAthleteId = String(payload.id);
|
||||
const existing = await this.athleteRepository.findOne({
|
||||
where: [{ accountKey: 'primary' }, { stravaAthleteId }],
|
||||
});
|
||||
|
||||
const athlete = existing ?? this.athleteRepository.create();
|
||||
athlete.accountKey = 'primary';
|
||||
athlete.stravaAthleteId = stravaAthleteId;
|
||||
athlete.username = payload.username ?? null;
|
||||
athlete.firstName = payload.firstname ?? null;
|
||||
athlete.lastName = payload.lastname ?? null;
|
||||
athlete.city = payload.city ?? null;
|
||||
athlete.state = payload.state ?? null;
|
||||
athlete.country = payload.country ?? null;
|
||||
athlete.sex = payload.sex ?? null;
|
||||
athlete.profileMedium = payload.profile_medium ?? null;
|
||||
athlete.profile = payload.profile ?? null;
|
||||
athlete.rawPayload = payload as unknown as Record<string, unknown>;
|
||||
|
||||
return this.athleteRepository.save(athlete);
|
||||
}
|
||||
}
|
||||
165
api/src/strava/strava-client.service.ts
Normal file
165
api/src/strava/strava-client.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { StravaRateLimitError } from './strava-rate-limit.error';
|
||||
import {
|
||||
StravaActivityPayload,
|
||||
StravaStreamPayload,
|
||||
StravaTokenPayload,
|
||||
} from './strava.types';
|
||||
|
||||
@Injectable()
|
||||
export class StravaClientService {
|
||||
private readonly clientId: string;
|
||||
private readonly clientSecret: string;
|
||||
private readonly redirectUri: string;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
configService: ConfigService,
|
||||
) {
|
||||
this.clientId = this.required(configService, 'STRAVA_CLIENT_ID');
|
||||
this.clientSecret = this.required(configService, 'STRAVA_CLIENT_SECRET');
|
||||
this.redirectUri = this.required(configService, 'STRAVA_REDIRECT_URI');
|
||||
}
|
||||
|
||||
buildAuthorizeUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
redirect_uri: this.redirectUri,
|
||||
response_type: 'code',
|
||||
approval_prompt: 'auto',
|
||||
scope: 'read,activity:read_all',
|
||||
state,
|
||||
});
|
||||
|
||||
return `https://www.strava.com/oauth/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
async exchangeCode(code: string): Promise<StravaTokenPayload> {
|
||||
return this.postToken({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<StravaTokenPayload> {
|
||||
return this.postToken({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
async listActivities(
|
||||
accessToken: string,
|
||||
page: number,
|
||||
perPage = 100,
|
||||
): 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 },
|
||||
});
|
||||
}
|
||||
|
||||
async getActivity(
|
||||
accessToken: string,
|
||||
stravaActivityId: string,
|
||||
): Promise<StravaActivityPayload> {
|
||||
return this.request<StravaActivityPayload>({
|
||||
method: 'GET',
|
||||
url: `https://www.strava.com/api/v3/activities/${stravaActivityId}`,
|
||||
headers: this.authHeaders(accessToken),
|
||||
});
|
||||
}
|
||||
|
||||
async getActivityStreams(
|
||||
accessToken: string,
|
||||
stravaActivityId: string,
|
||||
): Promise<StravaStreamPayload[]> {
|
||||
return this.request<StravaStreamPayload[]>({
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async postToken(
|
||||
body: Record<string, string>,
|
||||
): Promise<StravaTokenPayload> {
|
||||
return this.request<StravaTokenPayload>({
|
||||
method: 'POST',
|
||||
url: 'https://www.strava.com/oauth/token',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.request<T>(config),
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (this.isAxiosError(error) && error.response?.status === 429) {
|
||||
throw new StravaRateLimitError(
|
||||
'Strava API rate limit exceeded',
|
||||
this.parseRetryAfter(error),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private parseRetryAfter(error: AxiosError): Date | null {
|
||||
const headers = error.response?.headers as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const value = this.headerValue(headers?.['retry-after']);
|
||||
const seconds = Number(value);
|
||||
|
||||
return Number.isFinite(seconds)
|
||||
? new Date(Date.now() + seconds * 1000)
|
||||
: null;
|
||||
}
|
||||
|
||||
private headerValue(value: unknown): string | number | null {
|
||||
if (Array.isArray(value)) {
|
||||
const values = value as unknown[];
|
||||
return this.headerValue(values[0]);
|
||||
}
|
||||
|
||||
return typeof value === 'string' || typeof value === 'number'
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
private authHeaders(accessToken: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${accessToken}` };
|
||||
}
|
||||
|
||||
private required(configService: ConfigService, key: string): string {
|
||||
const value = configService.get<string>(key);
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private isAxiosError(error: unknown): error is AxiosError {
|
||||
return (
|
||||
typeof error === 'object' && error !== null && 'isAxiosError' in error
|
||||
);
|
||||
}
|
||||
}
|
||||
9
api/src/strava/strava-rate-limit.error.ts
Normal file
9
api/src/strava/strava-rate-limit.error.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export class StravaRateLimitError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly retryAfter: Date | null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'StravaRateLimitError';
|
||||
}
|
||||
}
|
||||
60
api/src/strava/strava-stream-normalizer.service.spec.ts
Normal file
60
api/src/strava/strava-stream-normalizer.service.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
|
||||
|
||||
describe('StravaStreamNormalizerService', () => {
|
||||
const service = new StravaStreamNormalizerService();
|
||||
|
||||
it('normalizes known Strava streams into point rows', () => {
|
||||
const points = service.normalize('activity-1', [
|
||||
{ type: 'time', data: [0, 10] },
|
||||
{ type: 'distance', data: [0, 50] },
|
||||
{
|
||||
type: 'latlng',
|
||||
data: [
|
||||
[48.1, 16.1],
|
||||
[48.2, 16.2],
|
||||
],
|
||||
},
|
||||
{ type: 'heartrate', data: [140, 145] },
|
||||
{ type: 'moving', data: [true, false] },
|
||||
]);
|
||||
|
||||
expect(points).toEqual([
|
||||
expect.objectContaining({
|
||||
activityId: 'activity-1',
|
||||
pointIndex: 0,
|
||||
timeSeconds: 0,
|
||||
distance: 0,
|
||||
latitude: 48.1,
|
||||
longitude: 16.1,
|
||||
heartRate: 140,
|
||||
moving: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pointIndex: 1,
|
||||
timeSeconds: 10,
|
||||
distance: 50,
|
||||
latitude: 48.2,
|
||||
longitude: 16.2,
|
||||
heartRate: 145,
|
||||
moving: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills missing stream values with null', () => {
|
||||
const points = service.normalize('activity-1', [
|
||||
{ type: 'time', data: [0, 10] },
|
||||
{ type: 'watts', data: [250] },
|
||||
]);
|
||||
|
||||
expect(points).toHaveLength(2);
|
||||
expect(points[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
timeSeconds: 10,
|
||||
watts: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
61
api/src/strava/strava-stream-normalizer.service.ts
Normal file
61
api/src/strava/strava-stream-normalizer.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { StravaActivityStreamPointEntity } from '../database/entities';
|
||||
import { StravaStreamPayload } from './strava.types';
|
||||
|
||||
type StreamDataByType = Record<string, unknown[]>;
|
||||
|
||||
@Injectable()
|
||||
export class StravaStreamNormalizerService {
|
||||
normalize(
|
||||
activityId: string,
|
||||
streams: StravaStreamPayload[],
|
||||
): Partial<StravaActivityStreamPointEntity>[] {
|
||||
const streamData = this.byType(streams);
|
||||
const pointCount = Math.max(
|
||||
0,
|
||||
...Object.values(streamData).map((values) => values.length),
|
||||
);
|
||||
|
||||
return Array.from({ length: pointCount }, (_, pointIndex) => {
|
||||
const latlng = this.tuple(streamData.latlng?.[pointIndex]);
|
||||
|
||||
return {
|
||||
activityId,
|
||||
pointIndex,
|
||||
timeSeconds: this.numberOrNull(streamData.time?.[pointIndex]),
|
||||
distance: this.numberOrNull(streamData.distance?.[pointIndex]),
|
||||
latitude: this.numberOrNull(latlng?.[0]),
|
||||
longitude: this.numberOrNull(latlng?.[1]),
|
||||
altitude: this.numberOrNull(streamData.altitude?.[pointIndex]),
|
||||
velocitySmooth: this.numberOrNull(
|
||||
streamData.velocity_smooth?.[pointIndex],
|
||||
),
|
||||
heartRate: this.numberOrNull(streamData.heartrate?.[pointIndex]),
|
||||
cadence: this.numberOrNull(streamData.cadence?.[pointIndex]),
|
||||
watts: this.numberOrNull(streamData.watts?.[pointIndex]),
|
||||
temperature: this.numberOrNull(streamData.temp?.[pointIndex]),
|
||||
moving: this.booleanOrNull(streamData.moving?.[pointIndex]),
|
||||
gradeSmooth: this.numberOrNull(streamData.grade_smooth?.[pointIndex]),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private byType(streams: StravaStreamPayload[]): StreamDataByType {
|
||||
return streams.reduce<StreamDataByType>((result, stream) => {
|
||||
result[stream.type] = Array.isArray(stream.data) ? stream.data : [];
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private tuple(value: unknown): unknown[] | null {
|
||||
return Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
private numberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
private booleanOrNull(value: unknown): boolean | null {
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
}
|
||||
18
api/src/strava/strava-sync.controller.ts
Normal file
18
api/src/strava/strava-sync.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { StravaSyncJobEntity } from '../database/entities';
|
||||
import { StravaSyncService } from './strava-sync.service';
|
||||
|
||||
@Controller('strava/sync')
|
||||
export class StravaSyncController {
|
||||
constructor(private readonly stravaSyncService: StravaSyncService) {}
|
||||
|
||||
@Post()
|
||||
start(): Promise<StravaSyncJobEntity> {
|
||||
return this.stravaSyncService.startSync();
|
||||
}
|
||||
|
||||
@Get('jobs/:id')
|
||||
getJob(@Param('id') id: string): Promise<StravaSyncJobEntity> {
|
||||
return this.stravaSyncService.getJob(id);
|
||||
}
|
||||
}
|
||||
218
api/src/strava/strava-sync.service.ts
Normal file
218
api/src/strava/strava-sync.service.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
import {
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
StravaAthleteEntity,
|
||||
StravaSyncJobEntity,
|
||||
StravaSyncJobItemEntity,
|
||||
} from '../database/entities';
|
||||
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 { StravaTokenService } from './strava-token.service';
|
||||
import { StravaActivityPayload } from './strava.types';
|
||||
|
||||
@Injectable()
|
||||
export class StravaSyncService {
|
||||
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,
|
||||
) {}
|
||||
|
||||
async startSync(): Promise<StravaSyncJobEntity> {
|
||||
const athlete = await this.resolveAthlete();
|
||||
const job = await this.jobRepository.save(
|
||||
this.jobRepository.create({
|
||||
stravaAthleteId: athlete.id,
|
||||
status: 'queued',
|
||||
}),
|
||||
);
|
||||
|
||||
void this.runJob(job.id);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
async getJob(jobId: string): Promise<StravaSyncJobEntity> {
|
||||
const job = await this.jobRepository.findOne({
|
||||
where: { id: jobId },
|
||||
relations: { items: true },
|
||||
});
|
||||
if (!job) {
|
||||
throw new NotFoundException('Sync job not found');
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
async runJob(jobId: string): Promise<void> {
|
||||
const job = await this.getJob(jobId);
|
||||
job.status = 'running';
|
||||
job.startedAt = new Date();
|
||||
await this.jobRepository.save(job);
|
||||
|
||||
try {
|
||||
const accessToken = await this.stravaTokenService.getValidAccessToken(
|
||||
job.stravaAthleteId,
|
||||
);
|
||||
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
let summaries: StravaActivityPayload[] = [];
|
||||
|
||||
do {
|
||||
summaries = await this.stravaClientService.listActivities(
|
||||
accessToken,
|
||||
page,
|
||||
perPage,
|
||||
);
|
||||
|
||||
for (const summary of summaries) {
|
||||
await this.importActivity(job, accessToken, summary);
|
||||
}
|
||||
|
||||
page += 1;
|
||||
} while (summaries.length === perPage);
|
||||
|
||||
job.status = 'completed';
|
||||
job.finishedAt = new Date();
|
||||
await this.jobRepository.save(job);
|
||||
} catch (error) {
|
||||
await this.failJob(job, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async importActivity(
|
||||
job: StravaSyncJobEntity,
|
||||
accessToken: string,
|
||||
summary: StravaActivityPayload,
|
||||
): Promise<void> {
|
||||
const stravaActivityId = String(summary.id);
|
||||
const item = await this.getOrCreateJobItem(job.id, stravaActivityId);
|
||||
|
||||
try {
|
||||
const existingSummary = await this.activityRepository.findOne({
|
||||
where: {
|
||||
stravaAthleteId: job.stravaAthleteId,
|
||||
stravaActivityId,
|
||||
},
|
||||
});
|
||||
let activity = await this.activityRepository.save(
|
||||
mapStravaActivity(job.stravaAthleteId, summary, existingSummary),
|
||||
);
|
||||
job.activityCount += 1;
|
||||
|
||||
const detail = await this.stravaClientService.getActivity(
|
||||
accessToken,
|
||||
stravaActivityId,
|
||||
);
|
||||
activity = await this.activityRepository.save(
|
||||
mapStravaActivity(job.stravaAthleteId, detail, activity),
|
||||
);
|
||||
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;
|
||||
|
||||
item.status = 'completed';
|
||||
item.errorMessage = null;
|
||||
await this.jobItemRepository.save(item);
|
||||
await this.jobRepository.save(job);
|
||||
} catch (error) {
|
||||
if (error instanceof StravaRateLimitError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
item.status = 'failed';
|
||||
item.errorMessage = this.errorMessage(error);
|
||||
await this.jobItemRepository.save(item);
|
||||
await this.jobRepository.save(job);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
): Promise<StravaSyncJobItemEntity> {
|
||||
const existing = await this.jobItemRepository.findOne({
|
||||
where: { jobId, stravaActivityId },
|
||||
});
|
||||
if (existing) {
|
||||
existing.status = 'pending';
|
||||
existing.errorMessage = null;
|
||||
return this.jobItemRepository.save(existing);
|
||||
}
|
||||
|
||||
return this.jobItemRepository.save(
|
||||
this.jobItemRepository.create({
|
||||
jobId,
|
||||
stravaActivityId,
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveAthlete(): Promise<StravaAthleteEntity> {
|
||||
const athlete = await this.athleteRepository.findOne({
|
||||
where: { accountKey: 'primary' },
|
||||
});
|
||||
|
||||
if (!athlete) {
|
||||
throw new NotFoundException('No Strava athlete connected');
|
||||
}
|
||||
|
||||
return athlete;
|
||||
}
|
||||
|
||||
private async failJob(
|
||||
job: StravaSyncJobEntity,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
job.status =
|
||||
error instanceof StravaRateLimitError ? 'rate_limited' : 'failed';
|
||||
job.errorMessage = this.errorMessage(error);
|
||||
job.retryAfter =
|
||||
error instanceof StravaRateLimitError ? error.retryAfter : null;
|
||||
job.finishedAt = new Date();
|
||||
await this.jobRepository.save(job);
|
||||
}
|
||||
|
||||
private errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Unknown sync error';
|
||||
}
|
||||
}
|
||||
73
api/src/strava/strava-token.service.ts
Normal file
73
api/src/strava/strava-token.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { StravaTokenEntity } from '../database/entities';
|
||||
import { StravaClientService } from './strava-client.service';
|
||||
import { TokenCryptoService } from './token-crypto.service';
|
||||
|
||||
@Injectable()
|
||||
export class StravaTokenService {
|
||||
constructor(
|
||||
@InjectRepository(StravaTokenEntity)
|
||||
private readonly tokenRepository: Repository<StravaTokenEntity>,
|
||||
private readonly tokenCryptoService: TokenCryptoService,
|
||||
private readonly stravaClientService: StravaClientService,
|
||||
) {}
|
||||
|
||||
async saveTokens(input: {
|
||||
stravaAthleteId: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: Date;
|
||||
scope: string | null;
|
||||
tokenType: string | null;
|
||||
}): Promise<StravaTokenEntity> {
|
||||
const existing = await this.tokenRepository.findOne({
|
||||
where: { stravaAthleteId: input.stravaAthleteId },
|
||||
});
|
||||
|
||||
const token = existing ?? this.tokenRepository.create();
|
||||
token.stravaAthleteId = input.stravaAthleteId;
|
||||
token.accessTokenCiphertext = this.tokenCryptoService.encrypt(
|
||||
input.accessToken,
|
||||
);
|
||||
token.refreshTokenCiphertext = this.tokenCryptoService.encrypt(
|
||||
input.refreshToken,
|
||||
);
|
||||
token.expiresAt = input.expiresAt;
|
||||
token.scope = input.scope;
|
||||
token.tokenType = input.tokenType ?? 'Bearer';
|
||||
|
||||
return this.tokenRepository.save(token);
|
||||
}
|
||||
|
||||
async getValidAccessToken(stravaAthleteId: string): Promise<string> {
|
||||
const token = await this.tokenRepository.findOne({
|
||||
where: { stravaAthleteId },
|
||||
});
|
||||
if (!token) {
|
||||
throw new NotFoundException('No Strava token found for athlete');
|
||||
}
|
||||
|
||||
const refreshBufferMs = 60_000;
|
||||
if (token.expiresAt.getTime() > Date.now() + refreshBufferMs) {
|
||||
return this.tokenCryptoService.decrypt(token.accessTokenCiphertext);
|
||||
}
|
||||
|
||||
const refreshed = await this.stravaClientService.refreshToken(
|
||||
this.tokenCryptoService.decrypt(token.refreshTokenCiphertext),
|
||||
);
|
||||
|
||||
token.accessTokenCiphertext = this.tokenCryptoService.encrypt(
|
||||
refreshed.access_token,
|
||||
);
|
||||
token.refreshTokenCiphertext = this.tokenCryptoService.encrypt(
|
||||
refreshed.refresh_token,
|
||||
);
|
||||
token.expiresAt = new Date(refreshed.expires_at * 1000);
|
||||
token.tokenType = refreshed.token_type ?? token.tokenType;
|
||||
await this.tokenRepository.save(token);
|
||||
|
||||
return refreshed.access_token;
|
||||
}
|
||||
}
|
||||
44
api/src/strava/strava.module.ts
Normal file
44
api/src/strava/strava.module.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import {
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
StravaAthleteEntity,
|
||||
StravaSyncJobEntity,
|
||||
StravaSyncJobItemEntity,
|
||||
StravaTokenEntity,
|
||||
} from '../database/entities';
|
||||
import { StravaAuthController } from './strava-auth.controller';
|
||||
import { StravaAuthService } from './strava-auth.service';
|
||||
import { StravaClientService } from './strava-client.service';
|
||||
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
|
||||
import { StravaSyncController } from './strava-sync.controller';
|
||||
import { StravaSyncService } from './strava-sync.service';
|
||||
import { StravaTokenService } from './strava-token.service';
|
||||
import { TokenCryptoService } from './token-crypto.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule,
|
||||
TypeOrmModule.forFeature([
|
||||
StravaAthleteEntity,
|
||||
StravaTokenEntity,
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
StravaSyncJobEntity,
|
||||
StravaSyncJobItemEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [StravaAuthController, StravaSyncController],
|
||||
providers: [
|
||||
StravaAuthService,
|
||||
StravaClientService,
|
||||
StravaTokenService,
|
||||
TokenCryptoService,
|
||||
StravaStreamNormalizerService,
|
||||
StravaSyncService,
|
||||
],
|
||||
exports: [StravaStreamNormalizerService],
|
||||
})
|
||||
export class StravaModule {}
|
||||
70
api/src/strava/strava.types.ts
Normal file
70
api/src/strava/strava.types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export interface StravaAthletePayload {
|
||||
id: number | string;
|
||||
username?: string | null;
|
||||
firstname?: string | null;
|
||||
lastname?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
country?: string | null;
|
||||
sex?: string | null;
|
||||
profile_medium?: string | null;
|
||||
profile?: string | null;
|
||||
}
|
||||
|
||||
export interface StravaTokenPayload {
|
||||
token_type?: string;
|
||||
expires_at: number;
|
||||
expires_in?: number;
|
||||
refresh_token: string;
|
||||
access_token: string;
|
||||
athlete?: StravaAthletePayload;
|
||||
}
|
||||
|
||||
export interface StravaActivityPayload {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
sport_type?: string;
|
||||
type?: string;
|
||||
distance?: number;
|
||||
moving_time?: number;
|
||||
elapsed_time?: number;
|
||||
total_elevation_gain?: number;
|
||||
start_date?: string;
|
||||
start_date_local?: string;
|
||||
timezone?: string;
|
||||
utc_offset?: number;
|
||||
average_speed?: number;
|
||||
max_speed?: number;
|
||||
average_heartrate?: number;
|
||||
max_heartrate?: number;
|
||||
average_watts?: number;
|
||||
max_watts?: number;
|
||||
weighted_average_watts?: number;
|
||||
average_cadence?: number;
|
||||
calories?: number;
|
||||
gear_id?: string | null;
|
||||
trainer?: boolean;
|
||||
commute?: boolean;
|
||||
manual?: boolean;
|
||||
private?: boolean;
|
||||
visibility?: string | null;
|
||||
resource_state?: number;
|
||||
map?: {
|
||||
id?: string | null;
|
||||
summary_polyline?: string | null;
|
||||
} | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface StravaStreamPayload {
|
||||
type: string;
|
||||
data: unknown[];
|
||||
series_type?: string;
|
||||
original_size?: number;
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
export interface StravaRateLimit {
|
||||
limit?: string;
|
||||
usage?: string;
|
||||
}
|
||||
58
api/src/strava/token-crypto.service.ts
Normal file
58
api/src/strava/token-crypto.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class TokenCryptoService {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(configService: ConfigService) {
|
||||
const configuredKey = configService.get<string>('APP_ENCRYPTION_KEY');
|
||||
if (!configuredKey) {
|
||||
throw new Error(
|
||||
'Missing required environment variable: APP_ENCRYPTION_KEY',
|
||||
);
|
||||
}
|
||||
this.key = createHash('sha256').update(configuredKey).digest();
|
||||
}
|
||||
|
||||
encrypt(value: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', this.key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(value, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return [
|
||||
iv.toString('base64url'),
|
||||
authTag.toString('base64url'),
|
||||
encrypted.toString('base64url'),
|
||||
].join('.');
|
||||
}
|
||||
|
||||
decrypt(value: string): string {
|
||||
const [ivValue, authTagValue, encryptedValue] = value.split('.');
|
||||
if (!ivValue || !authTagValue || !encryptedValue) {
|
||||
throw new Error('Invalid encrypted token format');
|
||||
}
|
||||
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
this.key,
|
||||
Buffer.from(ivValue, 'base64url'),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(authTagValue, 'base64url'));
|
||||
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedValue, 'base64url')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
}
|
||||
29
api/test/app.e2e-spec.ts
Normal file
29
api/test/app.e2e-spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect({ status: 'ok' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
9
api/test/jest-e2e.json
Normal file
9
api/test/jest-e2e.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
4
api/tsconfig.build.json
Normal file
4
api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
25
api/tsconfig.json
Normal file
25
api/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
5
client/.dockerignore
Normal file
5
client/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.angular
|
||||
.env
|
||||
.git
|
||||
17
client/.editorconfig
Normal file
17
client/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
44
client/.gitignore
vendored
Normal file
44
client/.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
12
client/.prettierrc
Normal file
12
client/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
client/.vscode/extensions.json
vendored
Normal file
4
client/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
client/.vscode/launch.json
vendored
Normal file
20
client/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
client/.vscode/mcp.json
vendored
Normal file
9
client/.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
// For more information, visit: https://angular.dev/ai/mcp
|
||||
"servers": {
|
||||
"angular-cli": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@angular/cli", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
42
client/.vscode/tasks.json
vendored
Normal file
42
client/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
14
client/Dockerfile
Normal file
14
client/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine AS production
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist/client/browser /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
59
client/README.md
Normal file
59
client/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Client
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.6.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
78
client/angular.json
Normal file
78
client/angular.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"client": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "8kB",
|
||||
"maximumError": "12kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "client:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "client:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
client/nginx.conf
Normal file
11
client/nginx.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
8709
client/package-lock.json
generated
Normal file
8709
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
client/package.json
Normal file
32
client/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.12.1",
|
||||
"dependencies": {
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.2.6",
|
||||
"@angular/cli": "^21.2.6",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
13
client/src/app/app.config.ts
Normal file
13
client/src/app/app.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideHttpClient(),
|
||||
provideRouter(routes),
|
||||
],
|
||||
};
|
||||
137
client/src/app/app.html
Normal file
137
client/src/app/app.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<main class="shell">
|
||||
<section class="panel" aria-labelledby="page-title">
|
||||
<div class="heading">
|
||||
<p class="eyebrow">Strava Datenimport</p>
|
||||
<h1 id="page-title">Verbindung zu Strava</h1>
|
||||
<p class="intro">
|
||||
Verbinde deinen Strava Account einmalig, damit die API Aktivitaeten und
|
||||
Streams serverseitig abrufen kann.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (justConnected()) {
|
||||
<div class="notice success" role="status">
|
||||
Strava wurde verbunden. Der Token wurde im Backend gespeichert.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (connectionCanceled()) {
|
||||
<div class="notice error" role="alert">
|
||||
Strava wurde nicht verbunden. Starte den Verbindungsprozess erneut.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="notice error" role="alert">
|
||||
{{ error() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (syncError()) {
|
||||
<div class="notice error" role="alert">
|
||||
{{ syncError() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="status-row">
|
||||
<div>
|
||||
<span class="label">Status</span>
|
||||
@if (loading()) {
|
||||
<strong>Pruefe Verbindung...</strong>
|
||||
} @else if (status()?.connected) {
|
||||
<strong class="connected">Verbunden</strong>
|
||||
} @else {
|
||||
<strong class="disconnected">Nicht verbunden</strong>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
(click)="loadStatus()"
|
||||
[disabled]="loading()"
|
||||
title="Status aktualisieren"
|
||||
>
|
||||
<span aria-hidden="true">R</span>
|
||||
<span class="sr-only">Status aktualisieren</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (status()?.connected && status()?.athlete; as athlete) {
|
||||
<div class="athlete">
|
||||
@if (athlete.profile) {
|
||||
<img [src]="athlete.profile" alt="" />
|
||||
} @else {
|
||||
<div class="avatar" aria-hidden="true">
|
||||
{{ athleteName().slice(0, 1) }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div>
|
||||
<span class="label">Account</span>
|
||||
<strong>{{ athleteName() }}</strong>
|
||||
<span class="meta">Strava ID {{ athlete.stravaAthleteId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="sync-panel">
|
||||
<div class="sync-header">
|
||||
<div>
|
||||
<span class="label">Activities Sync</span>
|
||||
<strong>{{ syncStatusLabel() }}</strong>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
(click)="startSync()"
|
||||
[disabled]="!canStartSync()"
|
||||
>
|
||||
@if (syncLoading() || isSyncActive()) {
|
||||
Sync laeuft
|
||||
} @else {
|
||||
Activities synchronisieren
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (syncJob(); as job) {
|
||||
<div class="sync-stats">
|
||||
<div>
|
||||
<span class="label">Activities</span>
|
||||
<strong>{{ job.activityCount }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Details</span>
|
||||
<strong>{{ job.detailCount }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Stream Punkte</span>
|
||||
<strong>{{ job.streamPointCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (job.errorMessage) {
|
||||
<p class="job-error">{{ job.errorMessage }}</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="sync-empty">
|
||||
Noch kein Sync gestartet.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="primary" (click)="connectStrava()">
|
||||
Mit Strava verbinden
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<app-dashboard
|
||||
[apiBaseUrl]="apiBaseUrl"
|
||||
[connected]="status()?.connected ?? false"
|
||||
[refreshKey]="dashboardRefreshKey()"
|
||||
/>
|
||||
</main>
|
||||
3
client/src/app/app.routes.ts
Normal file
3
client/src/app/app.routes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [];
|
||||
279
client/src/app/app.scss
Normal file
279
client/src/app/app.scss
Normal file
@@ -0,0 +1,279 @@
|
||||
:host {
|
||||
color: #18212f;
|
||||
display: block;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.shell {
|
||||
align-items: start;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(252, 76, 2, 0.1), transparent 34%),
|
||||
#f7f8fb;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: minmax(360px, 520px) minmax(0, 1fr);
|
||||
min-height: 100dvh;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d9dee7;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 16px 40px rgba(24, 33, 47, 0.08);
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.label,
|
||||
.meta {
|
||||
color: #687386;
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.intro {
|
||||
color: #4e5a6b;
|
||||
line-height: 1.6;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.notice {
|
||||
border-radius: 6px;
|
||||
margin-bottom: 18px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: #ecf8ef;
|
||||
color: #17612a;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fff0ed;
|
||||
color: #9b2915;
|
||||
}
|
||||
|
||||
.status-row,
|
||||
.athlete {
|
||||
align-items: center;
|
||||
border: 1px solid #dfe4ec;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
min-height: 72px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.connected {
|
||||
color: #17612a;
|
||||
}
|
||||
|
||||
.disconnected {
|
||||
color: #9b2915;
|
||||
}
|
||||
|
||||
.icon-button,
|
||||
.primary,
|
||||
.secondary {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #c8d0dc;
|
||||
border-radius: 6px;
|
||||
color: #18212f;
|
||||
display: inline-flex;
|
||||
font-size: 1.15rem;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background: #f1f4f8;
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.athlete {
|
||||
gap: 14px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.athlete img,
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
align-items: center;
|
||||
background: #fc4c02;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
font-weight: 800;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #fc4c02;
|
||||
border: 1px solid #fc4c02;
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
background: #d64002;
|
||||
border-color: #d64002;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: #18212f;
|
||||
border: 1px solid #18212f;
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.secondary:hover:not(:disabled) {
|
||||
background: #2d394c;
|
||||
border-color: #2d394c;
|
||||
}
|
||||
|
||||
.secondary:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.sync-panel {
|
||||
border: 1px solid #dfe4ec;
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sync-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sync-stats {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.sync-stats > div {
|
||||
background: #f6f8fb;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sync-empty,
|
||||
.job-error {
|
||||
color: #687386;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.job-error {
|
||||
color: #9b2915;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sync-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sync-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
23
client/src/app/app.spec.ts
Normal file
23
client/src/app/app.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render title', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, client');
|
||||
});
|
||||
});
|
||||
176
client/src/app/app.ts
Normal file
176
client/src/app/app.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
|
||||
interface StravaAuthStatus {
|
||||
connected: boolean;
|
||||
athlete: {
|
||||
id: string;
|
||||
stravaAthleteId: string;
|
||||
username: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
profile: string | null;
|
||||
updatedAt: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface StravaSyncJob {
|
||||
id: string;
|
||||
status: 'queued' | 'running' | 'completed' | 'failed' | 'rate_limited';
|
||||
activityCount: number;
|
||||
detailCount: number;
|
||||
streamPointCount: number;
|
||||
errorMessage: string | null;
|
||||
retryAfter: string | null;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [DashboardModule],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
export class App {
|
||||
private readonly http = inject(HttpClient);
|
||||
protected readonly status = signal<StravaAuthStatus | null>(null);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly syncError = signal<string | null>(null);
|
||||
protected readonly syncJob = signal<StravaSyncJob | null>(null);
|
||||
protected readonly syncLoading = signal(false);
|
||||
protected readonly dashboardRefreshKey = signal(0);
|
||||
protected readonly justConnected = signal(false);
|
||||
protected readonly connectionCanceled = signal(false);
|
||||
protected readonly apiBaseUrl = this.resolveApiBaseUrl();
|
||||
protected readonly athleteName = computed(() => {
|
||||
const athlete = this.status()?.athlete;
|
||||
const fullName = [athlete?.firstName, athlete?.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
return fullName || athlete?.username || 'Strava Account';
|
||||
});
|
||||
protected readonly canStartSync = computed(
|
||||
() =>
|
||||
Boolean(this.status()?.connected) &&
|
||||
!this.loading() &&
|
||||
!this.syncLoading() &&
|
||||
!this.isSyncActive(),
|
||||
);
|
||||
protected readonly isSyncActive = computed(() => {
|
||||
const status = this.syncJob()?.status;
|
||||
return status === 'queued' || status === 'running';
|
||||
});
|
||||
protected readonly syncStatusLabel = computed(() => {
|
||||
switch (this.syncJob()?.status) {
|
||||
case 'queued':
|
||||
return 'Wartet';
|
||||
case 'running':
|
||||
return 'Laeuft';
|
||||
case 'completed':
|
||||
return 'Abgeschlossen';
|
||||
case 'failed':
|
||||
return 'Fehlgeschlagen';
|
||||
case 'rate_limited':
|
||||
return 'Rate Limit erreicht';
|
||||
default:
|
||||
return 'Noch nicht gestartet';
|
||||
}
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const authResult = new URLSearchParams(window.location.search).get('strava');
|
||||
this.justConnected.set(authResult === 'connected');
|
||||
this.connectionCanceled.set(authResult === 'error');
|
||||
this.loadStatus();
|
||||
}
|
||||
|
||||
protected connectStrava(): void {
|
||||
window.location.href = `${this.apiBaseUrl}/auth/strava/connect`;
|
||||
}
|
||||
|
||||
protected loadStatus(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.http
|
||||
.get<StravaAuthStatus>(`${this.apiBaseUrl}/auth/strava/status`)
|
||||
.subscribe({
|
||||
next: (status) => {
|
||||
this.status.set(status);
|
||||
this.loading.set(false);
|
||||
if (status.connected) {
|
||||
this.refreshDashboard();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.error.set('API nicht erreichbar oder Strava-Status konnte nicht geladen werden.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected startSync(): void {
|
||||
if (!this.canStartSync()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncLoading.set(true);
|
||||
this.syncError.set(null);
|
||||
|
||||
this.http
|
||||
.post<StravaSyncJob>(`${this.apiBaseUrl}/strava/sync`, {})
|
||||
.subscribe({
|
||||
next: (job) => {
|
||||
this.syncJob.set(job);
|
||||
this.syncLoading.set(false);
|
||||
this.pollSyncJob(job.id);
|
||||
},
|
||||
error: () => {
|
||||
this.syncError.set('Sync konnte nicht gestartet werden.');
|
||||
this.syncLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private pollSyncJob(jobId: string): void {
|
||||
window.setTimeout(() => {
|
||||
this.http
|
||||
.get<StravaSyncJob>(`${this.apiBaseUrl}/strava/sync/jobs/${jobId}`)
|
||||
.subscribe({
|
||||
next: (job) => {
|
||||
this.syncJob.set(job);
|
||||
|
||||
if (job.status === 'queued' || job.status === 'running') {
|
||||
this.pollSyncJob(job.id);
|
||||
} else if (job.status === 'completed') {
|
||||
this.refreshDashboard();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.syncError.set('Sync-Status konnte nicht geladen werden.');
|
||||
},
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private refreshDashboard(): void {
|
||||
this.dashboardRefreshKey.update((value) => value + 1);
|
||||
}
|
||||
|
||||
private resolveApiBaseUrl(): string {
|
||||
const { protocol, hostname, port, origin } = window.location;
|
||||
|
||||
if (port === '4200' || port === '8080') {
|
||||
return `${protocol}//${hostname}:3000`;
|
||||
}
|
||||
|
||||
return origin;
|
||||
}
|
||||
}
|
||||
155
client/src/app/dashboard/dashboard.component.html
Normal file
155
client/src/app/dashboard/dashboard.component.html
Normal file
@@ -0,0 +1,155 @@
|
||||
<section class="dashboard" aria-labelledby="dashboard-title">
|
||||
<div class="dashboard-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Auswertung</p>
|
||||
<h2 id="dashboard-title">Letzte 12 Wochen</h2>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-tools">
|
||||
<label>
|
||||
<span class="sr-only">Sportart filtern</span>
|
||||
<select
|
||||
[value]="selectedSportType() ?? 'all'"
|
||||
(change)="selectSportType($any($event.target).value)"
|
||||
[disabled]="dashboardLoading()"
|
||||
>
|
||||
<option value="all">Alle Sportarten</option>
|
||||
@for (sportType of dashboard()?.availableSports ?? []; track sportType) {
|
||||
<option [value]="sportType">{{ sportType }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
(click)="loadDashboard()"
|
||||
[disabled]="dashboardLoading() || !connected"
|
||||
title="Dashboard aktualisieren"
|
||||
>
|
||||
<span aria-hidden="true">R</span>
|
||||
<span class="sr-only">Dashboard aktualisieren</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (dashboardError()) {
|
||||
<div class="notice error" role="alert">
|
||||
{{ dashboardError() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (dashboardLoading()) {
|
||||
<div class="empty-state">Dashboard wird geladen...</div>
|
||||
} @else if (dashboard(); as data) {
|
||||
@if (data.totals.activityCount === 0) {
|
||||
<div class="empty-state">
|
||||
Keine Aktivitaeten fuer diesen Filter im Zeitraum.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="kpis">
|
||||
<div>
|
||||
<span class="label">Aktivitaeten</span>
|
||||
<strong>{{ data.totals.activityCount }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Distanz</span>
|
||||
<strong>{{ distanceKm(data.totals.distanceMeters) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Zeit</span>
|
||||
<strong>{{ duration(data.totals.movingTimeSeconds) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Hoehenmeter</span>
|
||||
<strong>{{ elevation(data.totals.elevationGainMeters) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="insights">
|
||||
<div>
|
||||
<span class="label">Pace</span>
|
||||
<strong>{{ pace(data.averages.paceSecondsPerKm) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Speed</span>
|
||||
<strong>{{ speed(data.averages.speedMetersPerSecond) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Herzfrequenz</span>
|
||||
<strong>{{ number(data.averages.heartRate, ' bpm') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Leistung</span>
|
||||
<strong>{{ number(data.averages.watts, ' W') }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-panel">
|
||||
<div class="section-title">
|
||||
<h3>Wochenverlauf</h3>
|
||||
<span>{{ data.rangeStart }} bis {{ data.rangeEnd }}</span>
|
||||
</div>
|
||||
|
||||
<div class="weekly-bars">
|
||||
@for (week of data.weekly; track week.weekStart) {
|
||||
<div class="week-bar">
|
||||
<div class="bar-track">
|
||||
<div
|
||||
class="bar-fill"
|
||||
[style.height.%]="percent(week.distanceMeters, maxWeeklyDistance())"
|
||||
></div>
|
||||
</div>
|
||||
<span>{{ shortDate(week.weekStart) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="chart-panel">
|
||||
<div class="section-title">
|
||||
<h3>Sportarten</h3>
|
||||
</div>
|
||||
|
||||
<div class="sport-list">
|
||||
@for (sport of data.sports; track sport.sportType) {
|
||||
<div class="sport-row">
|
||||
<div>
|
||||
<strong>{{ sport.sportType }}</strong>
|
||||
<span>{{ sport.activityCount }} Activities</span>
|
||||
</div>
|
||||
<div class="sport-meter">
|
||||
<span [style.width.%]="percent(sport.distanceMeters, maxSportDistance())"></span>
|
||||
</div>
|
||||
<span>{{ distanceKm(sport.distanceMeters) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-panel">
|
||||
<div class="section-title">
|
||||
<h3>Letzte Activities</h3>
|
||||
</div>
|
||||
|
||||
<div class="recent-list">
|
||||
@for (activity of data.recentActivities; track activity.id) {
|
||||
<div class="recent-row">
|
||||
<div>
|
||||
<strong>{{ activity.name }}</strong>
|
||||
<span>{{ activity.sportType ?? 'Unbekannt' }} | {{ shortDate(activity.startDate) }}</span>
|
||||
</div>
|
||||
<span>{{ distanceKm(activity.distanceMeters) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
Verbinde Strava und starte den Sync, um Auswertungen zu sehen.
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
293
client/src/app/dashboard/dashboard.component.scss
Normal file
293
client/src/app/dashboard/dashboard.component.scss
Normal file
@@ -0,0 +1,293 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d9dee7;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 16px 40px rgba(24, 33, 47, 0.08);
|
||||
min-width: 0;
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-heading,
|
||||
.section-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dashboard-heading {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.dashboard-tools {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-tools select {
|
||||
background: #ffffff;
|
||||
border: 1px solid #c8d0dc;
|
||||
border-radius: 6px;
|
||||
color: #18212f;
|
||||
font: inherit;
|
||||
min-height: 40px;
|
||||
padding: 0 34px 0 12px;
|
||||
}
|
||||
|
||||
.dashboard-tools select:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.label {
|
||||
color: #687386;
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #c8d0dc;
|
||||
border-radius: 6px;
|
||||
color: #18212f;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: 1.15rem;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background: #f1f4f8;
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.notice {
|
||||
border-radius: 6px;
|
||||
margin-bottom: 18px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fff0ed;
|
||||
color: #9b2915;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title span {
|
||||
color: #687386;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.kpis,
|
||||
.insights {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.kpis > div,
|
||||
.insights > div,
|
||||
.chart-panel,
|
||||
.empty-state {
|
||||
border: 1px solid #dfe4ec;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.kpis > div,
|
||||
.insights > div {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.kpis strong {
|
||||
display: block;
|
||||
font-size: 1.45rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.insights {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.chart-panel {
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.weekly-bars {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.week-bar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
align-items: end;
|
||||
background: #eef2f7;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
height: 144px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
background: #fc4c02;
|
||||
border-radius: 5px 5px 0 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.week-bar span {
|
||||
color: #687386;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||
}
|
||||
|
||||
.sport-list,
|
||||
.recent-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sport-row,
|
||||
.recent-row {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sport-row {
|
||||
grid-template-columns: minmax(120px, 1fr) minmax(120px, 1fr) auto;
|
||||
}
|
||||
|
||||
.recent-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.sport-row span,
|
||||
.recent-row span {
|
||||
color: #687386;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.sport-meter {
|
||||
background: #eef2f7;
|
||||
border-radius: 999px;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sport-meter span {
|
||||
background: #18212f;
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: #687386;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dashboard {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.dashboard-heading,
|
||||
.section-title {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard-tools,
|
||||
.dashboard-tools label,
|
||||
.dashboard-tools select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kpis,
|
||||
.insights,
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.weekly-bars {
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.week-bar {
|
||||
min-width: 34px;
|
||||
}
|
||||
|
||||
.sport-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
140
client/src/app/dashboard/dashboard.component.ts
Normal file
140
client/src/app/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AnalyticsDashboard } from './dashboard.types';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrl: './dashboard.component.scss',
|
||||
})
|
||||
export class DashboardComponent implements OnChanges {
|
||||
@Input({ required: true }) apiBaseUrl = '';
|
||||
@Input() connected = false;
|
||||
@Input() refreshKey = 0;
|
||||
|
||||
private readonly dashboardService = inject(DashboardService);
|
||||
protected readonly dashboard = signal<AnalyticsDashboard | null>(null);
|
||||
protected readonly dashboardLoading = signal(false);
|
||||
protected readonly dashboardError = signal<string | null>(null);
|
||||
protected readonly selectedSportType = signal<string | null>(null);
|
||||
protected readonly maxWeeklyDistance = computed(() =>
|
||||
Math.max(
|
||||
1,
|
||||
...(this.dashboard()?.weekly.map((week) => week.distanceMeters) ?? [0]),
|
||||
),
|
||||
);
|
||||
protected readonly maxSportDistance = computed(() =>
|
||||
Math.max(
|
||||
1,
|
||||
...(this.dashboard()?.sports.map((sport) => sport.distanceMeters) ?? [0]),
|
||||
),
|
||||
);
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (
|
||||
this.connected &&
|
||||
(changes['connected'] || changes['refreshKey'] || changes['apiBaseUrl'])
|
||||
) {
|
||||
this.loadDashboard();
|
||||
}
|
||||
}
|
||||
|
||||
protected loadDashboard(): void {
|
||||
if (!this.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dashboardLoading.set(true);
|
||||
this.dashboardError.set(null);
|
||||
|
||||
this.dashboardService
|
||||
.getDashboard(this.apiBaseUrl, 12, this.selectedSportType())
|
||||
.subscribe({
|
||||
next: (dashboard) => {
|
||||
this.dashboard.set(dashboard);
|
||||
this.dashboardLoading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.dashboardError.set('Dashboard konnte nicht geladen werden.');
|
||||
this.dashboardLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected selectSportType(value: string): void {
|
||||
this.selectedSportType.set(value === 'all' ? null : value);
|
||||
this.loadDashboard();
|
||||
}
|
||||
|
||||
protected distanceKm(meters: number | null | undefined): string {
|
||||
return `${((meters ?? 0) / 1000).toLocaleString('de-DE', {
|
||||
maximumFractionDigits: 1,
|
||||
})} km`;
|
||||
}
|
||||
|
||||
protected duration(seconds: number | null | undefined): string {
|
||||
const totalSeconds = seconds ?? 0;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.round((totalSeconds % 3600) / 60);
|
||||
|
||||
return hours > 0 ? `${hours} h ${minutes} min` : `${minutes} min`;
|
||||
}
|
||||
|
||||
protected elevation(meters: number | null | undefined): string {
|
||||
return `${Math.round(meters ?? 0).toLocaleString('de-DE')} m`;
|
||||
}
|
||||
|
||||
protected pace(secondsPerKm: number | null): string {
|
||||
if (!secondsPerKm) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const minutes = Math.floor(secondsPerKm / 60);
|
||||
const seconds = Math.round(secondsPerKm % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
return `${minutes}:${seconds} /km`;
|
||||
}
|
||||
|
||||
protected speed(metersPerSecond: number | null): string {
|
||||
if (!metersPerSecond) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return `${(metersPerSecond * 3.6).toLocaleString('de-DE', {
|
||||
maximumFractionDigits: 1,
|
||||
})} km/h`;
|
||||
}
|
||||
|
||||
protected number(value: number | null | undefined, suffix = ''): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return `${Math.round(value).toLocaleString('de-DE')}${suffix}`;
|
||||
}
|
||||
|
||||
protected percent(value: number, max: number): number {
|
||||
return Math.max(3, Math.round((value / max) * 100));
|
||||
}
|
||||
|
||||
protected shortDate(value: string | null): string {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
}
|
||||
11
client/src/app/dashboard/dashboard.module.ts
Normal file
11
client/src/app/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { DashboardComponent } from './dashboard.component';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, DashboardComponent],
|
||||
exports: [DashboardComponent],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
26
client/src/app/dashboard/dashboard.service.ts
Normal file
26
client/src/app/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AnalyticsDashboard } from './dashboard.types';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getDashboard(
|
||||
apiBaseUrl: string,
|
||||
weeks: number,
|
||||
sportType: string | null,
|
||||
): Observable<AnalyticsDashboard> {
|
||||
let params = new HttpParams().set('weeks', weeks);
|
||||
|
||||
if (sportType) {
|
||||
params = params.set('sportType', sportType);
|
||||
}
|
||||
|
||||
return this.http.get<AnalyticsDashboard>(
|
||||
`${apiBaseUrl}/analytics/dashboard`,
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
}
|
||||
51
client/src/app/dashboard/dashboard.types.ts
Normal file
51
client/src/app/dashboard/dashboard.types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface AnalyticsTotals {
|
||||
activityCount: number;
|
||||
distanceMeters: number;
|
||||
movingTimeSeconds: number;
|
||||
elevationGainMeters: number;
|
||||
calories: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsAverages {
|
||||
speedMetersPerSecond: number | null;
|
||||
paceSecondsPerKm: number | null;
|
||||
heartRate: number | null;
|
||||
watts: number | null;
|
||||
cadence: number | null;
|
||||
}
|
||||
|
||||
export interface AnalyticsWeeklyBucket extends AnalyticsTotals {
|
||||
weekStart: string;
|
||||
weekEnd: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsSportSummary extends AnalyticsTotals {
|
||||
sportType: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsRecentActivity {
|
||||
id: string;
|
||||
stravaActivityId: string;
|
||||
name: string;
|
||||
sportType: string | null;
|
||||
startDate: string | null;
|
||||
distanceMeters: number | null;
|
||||
movingTimeSeconds: number | null;
|
||||
elevationGainMeters: number | null;
|
||||
averageSpeedMetersPerSecond: number | null;
|
||||
averageHeartrate: number | null;
|
||||
averageWatts: number | null;
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboard {
|
||||
weeks: number;
|
||||
selectedSportType: string | null;
|
||||
availableSports: string[];
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
totals: AnalyticsTotals;
|
||||
averages: AnalyticsAverages;
|
||||
weekly: AnalyticsWeeklyBucket[];
|
||||
sports: AnalyticsSportSummary[];
|
||||
recentActivities: AnalyticsRecentActivity[];
|
||||
}
|
||||
13
client/src/index.html
Normal file
13
client/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Client</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
client/src/main.ts
Normal file
6
client/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
1
client/src/styles.scss
Normal file
1
client/src/styles.scss
Normal file
@@ -0,0 +1 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
15
client/tsconfig.app.json
Normal file
15
client/tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
33
client/tsconfig.json
Normal file
33
client/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
client/tsconfig.spec.json
Normal file
15
client/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: ./api
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- api
|
||||
restart: unless-stopped
|
||||
Reference in New Issue
Block a user