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