diff --git a/client/vite.config.js b/client/vite.config.js index edceb407..85e8d6ac 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -81,7 +81,7 @@ export default defineConfig(({ mode }) => { server: { port: VITE_DEV_PORT, proxy: { - '^/api/': { target: `http://localhost:${SERVER_PORT}`, ws: true }, + '^/(api|calendar)': { target: `http://localhost:${SERVER_PORT}`, ws: true }, }, }, build: { diff --git a/server/app.ts b/server/app.ts index 62cacb87..a5713fce 100644 --- a/server/app.ts +++ b/server/app.ts @@ -6,6 +6,7 @@ import express, { type RequestHandler } from 'express'; import expressWs from 'express-ws'; import apiRouter from './api'; +import calendarRouter from './calendar'; const app = express(); const wsInstance = expressWs(app); @@ -25,6 +26,8 @@ app.use(corsConfig); app.use('/api', apiRouter(SERVER_DOMAIN, wsInstance)); +app.use('/calendar', calendarRouter(SERVER_DOMAIN)); + // Support history api app.use( history({ diff --git a/server/calendar.ts b/server/calendar.ts new file mode 100644 index 00000000..c06b3d1d --- /dev/null +++ b/server/calendar.ts @@ -0,0 +1,86 @@ +import 'moment-timezone'; + +import express from 'express'; +import ical from 'ical-generator'; +import moment from 'moment-timezone'; + +import eagerIterator from './eager-iterator'; +import { getSportIcon } from './sport-icons'; +import { Strava } from './strava'; + +export default function calendarRouter(domain: string): express.Router { + const router = express.Router(); + + const mkRequestLogin = (res: express.Response) => async () => { + res.status(403).send('Not logged in'); + return false; + }; + + router.get('/', async (req: express.Request, res: express.Response) => { + const token = req.cookies.token; + const requestLogin = mkRequestLogin(res); + if (token && (await new Strava(domain, token, requestLogin).hasToken())) { + const webcalDomain = domain.replace(/\w+(?=:\/\/)/, 'webcal'); + const pathWithSlash = req.originalUrl.replace(/\/?$/, '/'); + const fullPath = webcalDomain + pathWithSlash + token + '.ics'; + res.redirect(fullPath); + } else { + await requestLogin(); + } + }); + + router.get('/:token.ics', async (req: express.Request, res: express.Response) => { + const strava = new Strava(domain, req.params.token, mkRequestLogin(res)); + + res.type('text/calendar; charset=UTF-8'); + + const cal = ical({ name: 'Strava activities' }); + + for await (const activity of eagerIterator(strava.getStravaActivities())) { + const startTime = moment(activity.start_date).tz(activity.timezone.split(' ')[1]); + const location = activity.start_latlng.join(', '); + + const twoDigits = (val: number) => String(val.toFixed(0)).padStart(2, '0'); + + const toHms = (totalSeconds: number) => { + const hours = Math.floor(totalSeconds / 60 / 60), + minutes = Math.floor(totalSeconds / 60) % 60, + seconds = totalSeconds % 60; + return (hours ? [hours, twoDigits(minutes), twoDigits(seconds)] : [minutes, twoDigits(seconds)]).join(':'); + }; + + const descriptionFields = { + 'Elapsed time': activity.elapsed_time && `${toHms(activity.elapsed_time)}`, + 'Moving time': activity.moving_time && `${toHms(activity.moving_time)}`, + Distance: activity.distance && `${(activity.distance / 1000).toFixed(2)} km`, + 'Elevation gain': activity.total_elevation_gain && `${activity.total_elevation_gain.toFixed(0)} m`, + 'Average speed': + activity.average_speed && + activity.sport_type !== 'Run' && + `${(activity.average_speed * 3.6).toFixed(1)} km/h`, + 'Average pace': + activity.average_speed && activity.sport_type === 'Run' && `${toHms(1000 / activity.average_speed)}/km`, + 'Average heart rate': activity.has_heartrate && `${activity.average_heartrate?.toFixed(0)} bpm`, + 'Max heart rate': activity.has_heartrate && `${activity.max_heartrate?.toFixed(0)} bpm`, + }; + const descriptionData = Object.entries(descriptionFields) + .filter(([, value]) => value) + .map(([label, value]) => `${label}: ${value}`) + .join('\n'); + + cal.createEvent({ + summary: `${activity.name} ${getSportIcon(activity)}`, + location: location, + timezone: startTime.tz(), + start: startTime, + end: moment(startTime).add(activity.elapsed_time, 'seconds'), + url: `https://strava.com/activities/${activity.id}`, + description: descriptionData, + }); + } + + cal.serve(res); + }); + + return router; +} diff --git a/server/package.json b/server/package.json index 14b82068..27d43698 100644 --- a/server/package.json +++ b/server/package.json @@ -19,6 +19,9 @@ "cookie-parser": "^1.4.7", "express": "^5.2.1", "express-ws": "^5.0.2", + "ical-generator": "^3.4.2", + "moment": "^2.29.4", + "moment-timezone": "^0.5.40", "node-fetch": "^2.7.0", "proper-lockfile": "^4.1.2", "uuid": "^13.0.0" @@ -31,6 +34,7 @@ "@types/cookie-parser": "^1.4.7", "@types/express": "^5.0.6", "@types/express-ws": "^3.0.6", + "@types/luxon": "^2.3.2", "@types/node": "^20.19.33", "@types/node-fetch": "^2.6.13", "@typescript-eslint/eslint-plugin": "^8.56.0", diff --git a/server/sport-icons.ts b/server/sport-icons.ts new file mode 100644 index 00000000..a61f328e --- /dev/null +++ b/server/sport-icons.ts @@ -0,0 +1,80 @@ +import { type SummaryActivity } from './strava/model'; + +export function getSportIcon(source: SummaryActivity): string { + switch (source.sport_type) { + case 'Run': + case 'VirtualRun': + case 'TrailRun': + return 'πŸƒ'; + case 'Walk': + return '🚢'; + case 'Ride': + case 'EBikeRide': + case 'VirtualRide': + case 'Handcycle': + case 'Velomobile': + return '🚲'; + case 'Swim': + return '🏊'; + case 'AlpineSki': + case 'BackcountrySki': + case 'NordicSki': + return '⛷️'; + case 'Snowboard': + return 'πŸ‚'; + case 'IceSkate': + case 'Snowshoe': + return '⛸️'; + case 'Skateboard': + return 'πŸ›Ή'; + case 'RockClimbing': + return 'πŸ§—'; + case 'Surfing': + case 'Windsurf': + case 'Kitesurf': + case 'StandUpPaddling': + return 'πŸ„'; + case 'Canoeing': + case 'Kayaking': + case 'Rowing': + case 'VirtualRow': + return 'πŸ›Ά'; + case 'Sail': + return 'β›΅'; + case 'Golf': + return '🏌'; + case 'Soccer': + return '⚽'; + case 'Crossfit': + case 'Elliptical': + case 'WeightTraining': + case 'HighIntensityIntervalTraining': + case 'Pilates': + case 'StairStepper': + return 'πŸ‹'; + case 'Yoga': + return '🧘'; + case 'Wheelchair': + return 'πŸ§‘β€πŸ¦½'; + case 'Hike': + return 'πŸ₯Ύ'; + case 'Badminton': + return '🏸'; + case 'Tennis': + return '🎾'; + case 'EMountainBikeRide': + case 'MountainBikeRide': + case 'GravelRide': + return '🚡'; + case 'InlineSkate': + return 'πŸ›Ό'; + case 'TableTennis': + case 'Pickleball': + case 'Racquetball': + case 'Squash': + return 'πŸ“'; + case 'Workout': + default: + return ''; + } +} diff --git a/server/strava/index.ts b/server/strava/index.ts index 90673393..cbd08c58 100644 --- a/server/strava/index.ts +++ b/server/strava/index.ts @@ -58,7 +58,7 @@ export class Strava { /** * Fetches your data from the Strava API */ - async *getStravaActivities(start?: number, end?: number): AsyncGenerator { + async *getStravaActivities(start?: number, end?: number): AsyncGenerator { for await (const page of this.getStravaActivitiesPages(start, end)) { yield* page; } @@ -99,6 +99,10 @@ export class Strava { return await this.rawApi.get(`/activities/${id}`); } + hasToken(): Promise { + return this.rawApi.hasToken(); + } + async logout(global?: boolean); async logout(global: true, athlete: number); async logout(global = false, athlete?: number) { diff --git a/server/strava/raw-api.ts b/server/strava/raw-api.ts index 3bd84ff6..3b35e043 100644 --- a/server/strava/raw-api.ts +++ b/server/strava/raw-api.ts @@ -168,6 +168,15 @@ export default class RawStravaApi { }; } + async hasToken(): Promise { + try { + await this.loadCache(); + return true; + } catch (e) { + return false; + } + } + private async rawStravaAPI(endpoint: string, query: Record = {}) { const API_BASE = 'https://www.strava.com/api/v3'; const queryString = (Object.entries(query).filter(([, value]) => value) as [string, string | number][]) diff --git a/yarn.lock b/yarn.lock index 718d4781..6c29819b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1321,6 +1321,7 @@ __metadata: "@types/cookie-parser": "npm:^1.4.7" "@types/express": "npm:^5.0.6" "@types/express-ws": "npm:^3.0.6" + "@types/luxon": "npm:^2.3.2" "@types/node": "npm:^20.19.33" "@types/node-fetch": "npm:^2.6.13" "@types/proper-lockfile": "npm:^4.1.4" @@ -1337,6 +1338,9 @@ __metadata: express: "npm:^5.2.1" express-ws: "npm:^5.0.2" globals: "npm:^17.3.0" + ical-generator: "npm:^3.4.2" + moment: "npm:^2.29.4" + moment-timezone: "npm:^0.5.40" node-fetch: "npm:^2.7.0" nodemon: "npm:^3.1.7" prettier: "npm:^3.8.1" @@ -1547,6 +1551,13 @@ __metadata: languageName: node linkType: hard +"@types/luxon@npm:^2.3.2": + version: 2.4.0 + resolution: "@types/luxon@npm:2.4.0" + checksum: 10c0/2686d03827a3799f0374923323148f4d0d07f9e4321551389b2ae285e5085e178cfe908806041ab32bf66534497702b4ae8459d1b4190258af133818d4b846c9 + languageName: node + linkType: hard + "@types/mapbox__point-geometry@npm:^0.1.4": version: 0.1.4 resolution: "@types/mapbox__point-geometry@npm:0.1.4" @@ -4857,6 +4868,44 @@ __metadata: languageName: node linkType: hard +"ical-generator@npm:^3.4.2": + version: 3.6.1 + resolution: "ical-generator@npm:3.6.1" + dependencies: + uuid-random: "npm:^1.3.2" + peerDependencies: + "@touch4it/ical-timezones": ">=1.6.0" + "@types/luxon": ">= 1.26.0" + "@types/mocha": ">= 8.2.1" + "@types/node": ">= 15.0.0" + dayjs: ">= 1.10.0" + luxon: ">= 1.26.0" + moment: ">= 2.29.0" + moment-timezone: ">= 0.5.33" + rrule: ">= 2.6.8" + peerDependenciesMeta: + "@touch4it/ical-timezones": + optional: true + "@types/luxon": + optional: true + "@types/mocha": + optional: true + "@types/node": + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + moment-timezone: + optional: true + rrule: + optional: true + checksum: 10c0/969e1af967e1e84ba0bd5a723b56399009b89fbf9ee0bb9a27fdc03ac346162a4a4cd2c748ac3b13f0d37a60d6ec350c473fc81e48b32ccc8f446ad1db85658d + languageName: node + linkType: hard + "iconv-lite@npm:0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" @@ -5973,6 +6022,22 @@ __metadata: languageName: node linkType: hard +"moment-timezone@npm:^0.5.40": + version: 0.5.48 + resolution: "moment-timezone@npm:0.5.48" + dependencies: + moment: "npm:^2.29.4" + checksum: 10c0/ab14ec9d94bc33f29ac18e5417b7f8aca0b17130b952c5cc9697b8fea839e5ece9313af5fd3c9703a05db472b1560ddbfc7ad2aa24aac9afd047d6da6c3c6033 + languageName: node + linkType: hard + +"moment@npm:^2.29.4": + version: 2.30.1 + resolution: "moment@npm:2.30.1" + checksum: 10c0/865e4279418c6de666fca7786607705fd0189d8a7b7624e2e56be99290ac846f90878a6f602e34b4e0455c549b85385b1baf9966845962b313699e7cb847543a + languageName: node + linkType: hard + "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0" @@ -8359,6 +8424,13 @@ __metadata: languageName: node linkType: hard +"uuid-random@npm:^1.3.2": + version: 1.3.2 + resolution: "uuid-random@npm:1.3.2" + checksum: 10c0/14cb0fc8d52738b9f3b856df39e82f50650dad3dca254d3e62742148ba57292697c53d7bb171b84ad9d0f9d9231fd0f13d13eb65cb67b7d6c994f78d40e5e363 + languageName: node + linkType: hard + "uuid@npm:^13.0.0": version: 13.0.0 resolution: "uuid@npm:13.0.0"