diff --git a/.eslintrc.js b/.eslintrc.js index 2925873c0e4..86e239483c2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,6 +8,7 @@ module.exports = { extends: [ 'eslint:recommended', 'plugin:react/recommended', + 'plugin:react/jsx-runtime', 'plugin:prettier/recommended', ], overrides: [], diff --git a/.github/workflows/run_data_sync.yml b/.github/workflows/run_data_sync.yml index a81889d89be..91a7a4e0637 100644 --- a/.github/workflows/run_data_sync.yml +++ b/.github/workflows/run_data_sync.yml @@ -21,7 +21,7 @@ on: env: # please change to your own config. - RUN_TYPE: pass # support strava/nike/garmin/garmin_cn/keep/only_gpx/only_fit/nike_to_strava/strava_to_garmin/strava_to_garmin_cn/garmin_to_strava/garmin_to_strava_cn/codoon, Please change the 'pass' it to your own + RUN_TYPE: pass # support strava/nike/garmin/garmin_cn/garmin_sync_cn_global/keep/only_gpx/only_fit/nike_to_strava/strava_to_garmin/strava_to_garmin_cn/garmin_to_strava/garmin_to_strava_cn/codoon, Please change the 'pass' it to your own ATHLETE: yihong0618 TITLE: Yihong0618 Running MIN_GRID_DISTANCE: 10 # change min distance here @@ -125,6 +125,15 @@ jobs: # If you only want to sync `type running` add args --only-run, default script is to sync all data (rides and runs). # python run_page/garmin_sync.py ${{ secrets.GARMIN_SECRET_STRING_CN }} --only-run --is-cn + - name: Run sync Garmin CN to Garmin script + if: env.RUN_TYPE == 'garmin_sync_cn_global' + run: | + # make garimin secret string `python run_page/garmin_sync_cn_global.py ${email} ${password} --is-cn + python run_page/garmin_sync_cn_global.py ${{ secrets.GARMIN_SECRET_STRING_CN }} ${{ secrets.GARMIN_SECRET_STRING }} + # If you only want to sync `type running` add args --only-run, default script is to sync all data (rides and runs). + # python run_page/garmin_sync_cn_global.py ${{ secrets.GARMIN_SECRET_STRING_CN }} ${{ secrets.GARMIN_SECRET_STRING }} --only-run + + - name: Run sync Only GPX script if: env.RUN_TYPE == 'only_gpx' run: | diff --git a/Dockerfile b/Dockerfile index fc1db81cb7c..684004d4391 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,5 +59,5 @@ COPY --from=data /root/running_page /root/running_page RUN pnpm run build FROM nginx:alpine AS web -COPY --from=frontend-build /root/running_page/public /usr/share/nginx/html/ +COPY --from=frontend-build /root/running_page/dist /usr/share/nginx/html/ COPY --from=frontend-build /root/running_page/assets /usr/share/nginx/html/assets diff --git a/README-CN.md b/README-CN.md index cb687deac0e..c789e7d9eaf 100644 --- a/README-CN.md +++ b/README-CN.md @@ -95,7 +95,8 @@ R.I.P. 希望大家都能健康顺利的跑过终点,逝者安息。 | [deepinwine](https://github.com/deepinwine) | | Garmin-cn | | [Jeffggmm](https://github.com/Jeffggmm) | | Garmin | | [s1smart](https://github.com/s1smart) | | Strava | - +| [Ryan](https://github.com/85Ryan) | | Strava | +| [PPZ](https://github.com/8824PPZ) | | Strava | ## 它是怎么工作的 @@ -136,6 +137,7 @@ R.I.P. 希望大家都能健康顺利的跑过终点,逝者安息。 - **[GPX](#gpx)** - **[TCX](#tcx)** - **[FIT](#fit)** +- **[佳明国内同步国际](#Garmin-CN-to-Garmin)** - **[Tcx+Strava(upload all tcx data to strava)](#tcx_to_strava)** - **[Gpx+Strava(upload all tcx data to strava)](#gpx_to_strava)** - **[Nike+Strava(Using NRC Run, Strava backup data)](#nikestrava)** @@ -569,6 +571,43 @@ python3(python) run_page/garmin_sync.py xxxxxxxxxx --is-cn --only-run +### Garmin-CN to Garmin + +
+ 同步佳明 CN 数据到 佳明国际区 + +
+ +- 如果你只想同步 `type running` 使用参数 --only-run +**The Python version must be >=3.10** + +#### 获取佳明 CN 的密钥 + +在终端中输入以下命令 + +```bash +python3(python) run_page/get_garmin_secret.py ${your email} ${your password} --is-cn +``` + +#### 获取佳明全球的密钥 + +在终端中输入以下命令 + +```bash +python3(python) run_page/get_garmin_secret.py ${your email} ${your password} +``` + +#### 同步 佳明 CN 到 佳明全球 + +在终端中输入以下命令 + +```bash +python3(python) run_page/garmin_sync_cn_global.py ${garmin_cn_secret_string} ${garmin_secret_string} +``` + +
+ + ### Nike Run Club
diff --git a/README.md b/README.md index 740a1a1de26..faeeb702ca9 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,9 @@ English | [简体中文](https://github.com/yihong0618/running_page/blob/master/ | [Echo](https://github.com/donghao526) | | JoyRun | | [Jeffggmm](https://github.com/Jeffggmm) | | Garmin | | [s1smart](https://github.com/s1smart) | | Strava | - +| [XmchxUp](https://github.com/XmchxUp) | | Strava | +| [Ryan](https://github.com/85Ryan) | | Strava | +| [PPZ](https://github.com/8824PPZ) | | Strava |
## How it works @@ -119,6 +121,7 @@ English | [简体中文](https://github.com/yihong0618/running_page/blob/master/ - **[GPX](#gpx)** - **[TCX](#tcx)** - **[FIT](#fit)** +- **[Garmin-CN_to_Garmin(Sync Garmin-CN activities to Garmin Global)](#garmin-cn-to-garmin)** - **[Nike_to_Strava(Using NRC Run, Strava backup data)](#nike_to_strava)** - **[Tcx_to_Strava(upload all tcx data to strava)](#tcx_to_strava)** - **[Gpx_to_Strava(upload all gpx data to strava)](#gpx_to_strava)** @@ -214,6 +217,13 @@ const USE_DASH_LINE = true; const LINE_OPACITY = 0.4; ``` +- To use Google Analytics, you need to modify the configuration in the `src/utils/const.ts` file. + +```typescript +const USE_GOOGLE_ANALYTICS = false; +const GOOGLE_ANALYTICS_TRACKING_ID = ''; +``` + > privacy protection,setting flowing env: ```bash @@ -370,6 +380,45 @@ python3(python) run_page/garmin_sync.py xxxxxxxxxxxxxx(secret_string) --is-cn - +### Garmin-CN to Garmin + +
+ Sync your Garmin-CN data to Garmin + +
+ +- If you only want to sync `type running` add args --only-run +**The Python version must be >=3.10** + +#### Get Garmin CN Secret + +Enter the following command in the terminal + +```bash +# to get secret_string +python3(python) run_page/get_garmin_secret.py ${your email} ${your password} --is-cn +``` + +#### Get Garmin Secret + +Enter the following command in the terminal + +```bash +# to get secret_string +python3(python) run_page/get_garmin_secret.py ${your email} ${your password} +``` + +#### Sync Garmin CN to Garmin + +Enter the following command in the terminal + +```bash +# to sync garmin-cn to garmin-global +python3(python) run_page/garmin_sync_cn_global.py ${garmin_cn_secret_string} ${garmin_secret_string} +``` + +
+ ### Nike Run Club
@@ -721,8 +770,6 @@ For more display effects, see: 4. make sure you have write permissions in Workflow permissions settings. - - 5. If you want to deploy your running_page to xxx.github.io instead of xxx.github.io/running_page, you need to do three things: - Rename your forked running_page repository to `xxx.github.io`, where xxx is your GitHub username diff --git a/assets/index.tsx b/assets/index.tsx index 03ce70dcee2..8efea083b54 100644 --- a/assets/index.tsx +++ b/assets/index.tsx @@ -1 +1,2 @@ export const yearStats = import.meta.glob('./year_*.svg', { import: 'ReactComponent' }) +export const totalStat = import.meta.glob(['./github.svg', './grid.svg'], { import: 'ReactComponent' }) diff --git a/package.json b/package.json index d32ec67a5c9..054204c055a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dependencies": { "@mapbox/mapbox-gl-language": "^1.0.0", "@mapbox/polyline": "^1.1.1", + "@svgr/plugin-svgo": "^8.1.0", "@vercel/analytics": "^0.1.6", "@vitejs/plugin-react": "^4.0.0", "gcoord": "^0.3.2", @@ -14,6 +15,7 @@ "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-ga4": "^2.1.0", "react-helmet-async": "^1.3.0", "react-map-gl": "^7.1.6", "react-router-dom": "^6.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f85f54cedd8..6c5483dbf78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: '@mapbox/polyline': specifier: ^1.1.1 version: 1.2.0 + '@svgr/plugin-svgo': + specifier: ^8.1.0 + version: 8.1.0(@svgr/core@7.0.0) '@vercel/analytics': specifier: ^0.1.6 version: 0.1.11(react@18.2.0) @@ -35,6 +38,9 @@ dependencies: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-ga4: + specifier: ^2.1.0 + version: 2.1.0 react-helmet-async: specifier: ^1.3.0 version: 1.3.0(react-dom@18.2.0)(react@18.2.0) @@ -862,6 +868,23 @@ packages: - supports-color dev: false + /@svgr/plugin-svgo@8.1.0(@svgr/core@7.0.0): + resolution: {integrity: sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + dependencies: + '@svgr/core': 7.0.0 + cosmiconfig: 8.2.0 + deepmerge: 4.3.1 + svgo: 3.1.0 + dev: false + + /@trysound/sax@0.2.0: + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + dev: false + /@types/estree@1.0.1: resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} dev: false @@ -1152,6 +1175,10 @@ packages: engines: {node: '>=8'} dev: false + /boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + dev: false + /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -1276,6 +1303,11 @@ packages: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: true + /commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + dev: false + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true @@ -1303,10 +1335,48 @@ packages: which: 2.0.2 dev: true + /css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + dev: false + + /css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.0.2 + dev: false + + /css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.0.2 + dev: false + + /css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + dev: false + /csscolorparser@1.0.3: resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==} dev: false + /csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + dependencies: + css-tree: 2.2.1 + dev: false + /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} dev: true @@ -1339,6 +1409,11 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + dev: false + /define-properties@1.2.0: resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==} engines: {node: '>= 0.4'} @@ -1368,6 +1443,33 @@ packages: esutils: 2.0.3 dev: true + /dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + dev: false + + /domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + dev: false + + /domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: false + + /domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dev: false + /earcut@2.2.4: resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} dev: false @@ -2382,6 +2484,14 @@ packages: vt-pbf: 3.1.3 dev: false + /mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + dev: false + + /mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + dev: false + /meow@6.1.1: resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==} engines: {node: '>=8'} @@ -2471,6 +2581,12 @@ packages: engines: {node: '>=0.10.0'} dev: false + /nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + dependencies: + boolbase: 1.0.0 + dev: false + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2706,6 +2822,10 @@ packages: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} dev: false + /react-ga4@2.1.0: + resolution: {integrity: sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==} + dev: false + /react-helmet-async@1.3.0(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==} peerDependencies: @@ -3131,6 +3251,20 @@ packages: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} dev: false + /svgo@3.1.0: + resolution: {integrity: sha512-R5SnNA89w1dYgNv570591F66v34b3eQShpIBcQtZtM5trJwm1VvxbIoMpRYY3ybTAutcKTLEmTsdnaknOHbiQA==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 5.1.0 + css-tree: 2.3.1 + css-what: 6.1.0 + csso: 5.0.5 + picocolors: 1.0.0 + dev: false + /tachyons-custom@4.9.8: resolution: {integrity: sha512-MVnslsN5dFswmh2+Sw+OOA4/6pQfnJrA8/wrtaf+Px347wze3MIk3lp3Q3QmbUPqrJda79s0dZtY3dgTwVHdfg==} dev: false diff --git a/requirements.txt b/requirements.txt index da5d2237234..bd368b63a8e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,13 +16,14 @@ aiofiles cloudscraper==1.2.58 git+https://github.com/alenrajsp/tcxreader.git rich -lxml +lxml==4.9.4 eviltransform stravaweblib tenacity numpy tzlocal fit-tool +garmin-fit-sdk haversine==2.8.0 garth pycryptodome diff --git a/run_page/codoon_sync.py b/run_page/codoon_sync.py index aa0d198975e..84a19bf4650 100755 --- a/run_page/codoon_sync.py +++ b/run_page/codoon_sync.py @@ -129,6 +129,12 @@ def formated_input( def tcx_output(fit_array, run_data): # route ID fit_id = str(run_data["id"]) + # local time + fit_start_time_local = run_data["start_time"] + # zulu time + utc = adjust_time_to_utc(to_date(fit_start_time_local), str(get_localzone())) + fit_start_time = utc.strftime("%Y-%m-%dT%H:%M:%SZ") + # Root node training_center_database = ET.Element( "TrainingCenterDatabase", @@ -154,7 +160,7 @@ def tcx_output(fit_array, run_data): activities.append(activity) # Id activity_id = ET.Element("Id") - activity_id.text = fit_id + activity_id.text = fit_start_time # Codoon use start_time as ID activity.append(activity_id) # Creator activity_creator = ET.Element("Creator") @@ -164,13 +170,6 @@ def tcx_output(fit_array, run_data): activity_creator_name.text = "咕咚" activity_creator.append(activity_creator_name) # Lap - - # local time - fit_start_time_local = run_data["start_time"] - # zulu time - utc = adjust_time_to_utc(to_date(fit_start_time_local), str(get_localzone())) - fit_start_time = utc.strftime("%Y-%m-%dT%H:%M:%SZ") - activity_lap = ET.Element("Lap", {"StartTime": fit_start_time}) activity.append(activity_lap) # TotalTimeSeconds diff --git a/run_page/config.py b/run_page/config.py index bae7be41597..cbc2031302d 100644 --- a/run_page/config.py +++ b/run_page/config.py @@ -19,6 +19,7 @@ SQL_FILE = os.path.join(parent, "run_page", "data.db") JSON_FILE = os.path.join(parent, "src", "static", "activities.json") SYNCED_FILE = os.path.join(parent, "imported.json") +SYNCED_ACTIVITY_FILE = os.path.join(parent, "synced_activity.json") # TODO: Move into nike_sync NRC THINGS diff --git a/run_page/garmin_sync.py b/run_page/garmin_sync.py index 1c06cd21fe1..1defb34a4fa 100755 --- a/run_page/garmin_sync.py +++ b/run_page/garmin_sync.py @@ -117,7 +117,9 @@ async def download_activity(self, activity_id, file_type="gpx"): response.raise_for_status() return response.read() - async def upload_activities_original(self, datas, use_fake_garmin_device=False): + async def upload_activities_original_from_strava( + self, datas, use_fake_garmin_device=False + ): print( "start upload activities to garmin!, use_fake_garmin_device:", use_fake_garmin_device, @@ -152,6 +154,38 @@ async def upload_activities_original(self, datas, use_fake_garmin_device=False): print("garmin upload failed: ", e) await self.req.aclose() + async def upload_activity_from_file(self, file): + print("Uploading " + str(file)) + f = open(file, "rb") + + file_body = BytesIO(f.read()) + files = {"file": (file, file_body)} + + try: + res = await self.req.post( + self.upload_url, files=files, headers=self.headers + ) + f.close() + except Exception as e: + print(str(e)) + # just pass for now + return + try: + resp = res.json()["detailedImportResult"] + print("garmin upload success: ", resp) + except Exception as e: + print("garmin upload failed: ", e) + + async def upload_activities_files(self, files): + print("start upload activities to garmin!") + + await gather_with_concurrency( + 10, + [self.upload_activity_from_file(file=f) for f in files], + ) + + await self.req.aclose() + class GarminConnectHttpError(Exception): def __init__(self, status): @@ -201,10 +235,18 @@ async def download_garmin_data(client, activity_id, file_type="gpx"): zip_file = zipfile.ZipFile(file_path, "r") for file_info in zip_file.infolist(): zip_file.extract(file_info, folder) - os.rename( - os.path.join(folder, f"{activity_id}_ACTIVITY.fit"), - os.path.join(folder, f"{activity_id}.fit"), - ) + if file_info.filename.endswith(".fit"): + os.rename( + os.path.join(folder, f"{activity_id}_ACTIVITY.fit"), + os.path.join(folder, f"{activity_id}.fit"), + ) + elif file_info.filename.endswith(".gpx"): + os.rename( + os.path.join(folder, f"{activity_id}_ACTIVITY.gpx"), + os.path.join(FOLDER_DICT["gpx"], f"{activity_id}.gpx"), + ) + else: + os.remove(os.path.join(folder, file_info.filename)) os.remove(file_path) except Exception as e: print(f"Failed to download activity {activity_id}: {str(e)}") @@ -320,4 +362,7 @@ async def download_new_activities( ) ) loop.run_until_complete(future) + # fit may contain gpx(maybe upload by user) + if file_type == "fit": + make_activities_file(SQL_FILE, FOLDER_DICT["gpx"], JSON_FILE, file_suffix="gpx") make_activities_file(SQL_FILE, folder, JSON_FILE, file_suffix=file_type) diff --git a/run_page/garmin_sync_cn_global.py b/run_page/garmin_sync_cn_global.py new file mode 100644 index 00000000000..8d4ab0cd58b --- /dev/null +++ b/run_page/garmin_sync_cn_global.py @@ -0,0 +1,105 @@ +""" +Python 3 API wrapper for Garmin Connect to get your statistics. +Copy most code from https://github.com/cyberjunky/python-garminconnect +""" + +import argparse +import asyncio +import logging +import os +import sys +import time +import traceback +import zipfile +from io import BytesIO + +import aiofiles +import cloudscraper +import garth +import httpx +from config import FIT_FOLDER, GPX_FOLDER, JSON_FILE, SQL_FILE, config +from garmin_device_adaptor import wrap_device_info +from garmin_sync import Garmin, get_downloaded_ids +from garmin_sync import download_new_activities, gather_with_concurrency +from synced_data_file_logger import load_synced_activity_list, save_synced_activity_list +from utils import make_activities_file + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "cn_secret_string", nargs="?", help="secret_string fro get_garmin_secret.py" + ) + parser.add_argument( + "global_secret_string", nargs="?", help="secret_string fro get_garmin_secret.py" + ) + parser.add_argument( + "--only-run", + dest="only_run", + action="store_true", + help="if is only for running", + ) + + options = parser.parse_args() + secret_string_cn = options.cn_secret_string + secret_string_global = options.global_secret_string + auth_domain = "CN" + is_only_running = options.only_run + if secret_string_cn is None or secret_string_global is None: + print("Missing argument nor valid configuration file") + sys.exit(1) + + # Step 1: + # Sync all activities from Garmin CN to Garmin Global in FIT format + # If the activity is manually imported with a GPX, the GPX file will be synced + + # load synced activity list + synced_activity = load_synced_activity_list() + + folder = FIT_FOLDER + # make gpx or tcx dir + if not os.path.exists(folder): + os.mkdir(folder) + + loop = asyncio.get_event_loop() + future = asyncio.ensure_future( + download_new_activities( + secret_string_cn, + auth_domain, + synced_activity, + is_only_running, + folder, + "fit", + ) + ) + loop.run_until_complete(future) + new_ids = future.result() + + to_upload_files = [] + for i in new_ids: + if os.path.exists(os.path.join(FIT_FOLDER, f"{i}.fit")): + # upload fit files + to_upload_files.append(os.path.join(FIT_FOLDER, f"{i}.fit")) + elif os.path.exists(os.path.join(GPX_FOLDER, f"{i}.gpx")): + # upload gpx files which are manually uploaded to garmin connect + to_upload_files.append(os.path.join(GPX_FOLDER, f"{i}.gpx")) + + print("Files to sync:" + " ".join(to_upload_files)) + garmin_global_client = Garmin( + secret_string_global, + config("sync", "garmin", "authentication_domain"), + is_only_running, + ) + loop = asyncio.get_event_loop() + future = asyncio.ensure_future( + garmin_global_client.upload_activities_files(to_upload_files) + ) + loop.run_until_complete(future) + + # Save synced activity list for speeding up + synced_activity.extend(new_ids) + save_synced_activity_list(synced_activity) + + # Step 2: + # Generate track from fit/gpx file + make_activities_file(SQL_FILE, GPX_FOLDER, JSON_FILE, file_suffix="gpx") + make_activities_file(SQL_FILE, FIT_FOLDER, JSON_FILE, file_suffix="fit") diff --git a/run_page/gpxtrackposter/track.py b/run_page/gpxtrackposter/track.py index 3172db26a1c..3efbed2a447 100644 --- a/run_page/gpxtrackposter/track.py +++ b/run_page/gpxtrackposter/track.py @@ -12,14 +12,8 @@ import lxml import polyline import s2sphere as s2 -from fit_tool.fit_file import FitFile -from fit_tool.profile.messages.activity_message import ActivityMessage -from fit_tool.profile.messages.device_info_message import DeviceInfoMessage -from fit_tool.profile.messages.file_id_message import FileIdMessage -from fit_tool.profile.messages.record_message import RecordMessage -from fit_tool.profile.messages.session_message import SessionMessage -from fit_tool.profile.messages.software_message import SoftwareMessage -from fit_tool.profile.profile_type import Sport +from garmin_fit_sdk import Decoder, Stream +from garmin_fit_sdk.util import FIT_EPOCH_S from polyline_processor import filter_out from rich import print from tcxreader.tcxreader import TCXReader @@ -32,6 +26,13 @@ IGNORE_BEFORE_SAVING = os.getenv("IGNORE_BEFORE_SAVING", False) +# Garmin stores all latitude and longitude values as 32-bit integer values. +# This unit is called semicircle. +# So that gives 2^32 possible values. +# And to represent values up to 360° (or -180° to 180°), each 'degree' represents 2^32 / 360 = 11930465. +# So dividing latitude and longitude (int32) value by 11930465 will give the decimal value. +SEMICIRCLE = 11930465 + class Track: def __init__(self): @@ -91,9 +92,12 @@ def load_fit(self, file_name): # (for example, treadmill runs pulled via garmin-connect-export) if os.path.getsize(file_name) == 0: raise TrackLoadError("Empty FIT file") - - fit = FitFile.from_file(file_name) - self._load_fit_data(fit) + stream = Stream.from_file(file_name) + decoder = Decoder(stream) + messages, errors = decoder.read(convert_datetimes_to_dates=False) + if errors: + print(f"FIT file read fail: {errors}") + self._load_fit_data(messages) except Exception as e: print( f"Something went wrong when loading FIT. for file {self.file_names[0]}, we just ignore this file and continue" @@ -223,59 +227,55 @@ def _load_gpx_data(self, gpx): ) self.moving_dict = self._get_moving_data(gpx) - def _load_fit_data(self, fit: FitFile): + def _load_fit_data(self, fit: dict): _polylines = [] self.polyline_container = [] + message = fit["session_mesgs"][0] + self.start_time = datetime.datetime.utcfromtimestamp( + (message["start_time"] + FIT_EPOCH_S) + ) + self.run_id = self.__make_run_id(self.start_time) + self.end_time = datetime.datetime.utcfromtimestamp( + (message["start_time"] + FIT_EPOCH_S + message["total_elapsed_time"]) + ) + self.length = message["total_distance"] + self.average_heartrate = ( + message["avg_heart_rate"] if "avg_heart_rate" in message else None + ) + self.type = message["sport"].lower() - for record in fit.records: - message = record.message - - if isinstance(message, RecordMessage): - if message.position_lat and message.position_long: - _polylines.append( - s2.LatLng.from_degrees( - message.position_lat, message.position_long - ) - ) - self.polyline_container.append( - [message.position_lat, message.position_long] - ) - elif isinstance(message, SessionMessage): - self.start_time = datetime.datetime.utcfromtimestamp( - message.start_time / 1000 - ) - self.run_id = message.start_time - self.end_time = datetime.datetime.utcfromtimestamp( - (message.start_time + message.total_elapsed_time * 1000) / 1000 - ) - self.length = message.total_distance - self.average_heartrate = ( - message.avg_heart_rate if message.avg_heart_rate != 0 else None - ) - self.type = Sport(message.sport).name.lower() - - # moving_dict - self.moving_dict["distance"] = message.total_distance - self.moving_dict["moving_time"] = datetime.timedelta( - seconds=message.total_moving_time - if message.total_moving_time - else message.total_timer_time - ) - self.moving_dict["elapsed_time"] = datetime.timedelta( - seconds=message.total_elapsed_time - ) - self.moving_dict["average_speed"] = ( - message.enhanced_avg_speed - if message.enhanced_avg_speed - else message.avg_speed - ) - - self.start_time_local, self.end_time_local = parse_datetime_to_local( - self.start_time, self.end_time, self.polyline_container[0] + # moving_dict + self.moving_dict["distance"] = message["total_distance"] + self.moving_dict["moving_time"] = datetime.timedelta( + seconds=message["total_moving_time"] + if "total_moving_time" in message + else message["total_timer_time"] + ) + self.moving_dict["elapsed_time"] = datetime.timedelta( + seconds=message["total_elapsed_time"] ) - self.start_latlng = start_point(*self.polyline_container[0]) - self.polylines.append(_polylines) - self.polyline_str = polyline.encode(self.polyline_container) + self.moving_dict["average_speed"] = ( + message["enhanced_avg_speed"] + if message["enhanced_avg_speed"] + else message["avg_speed"] + ) + for record in fit["record_mesgs"]: + if "position_lat" in record and "position_long" in record: + lat = record["position_lat"] / SEMICIRCLE + lng = record["position_long"] / SEMICIRCLE + _polylines.append(s2.LatLng.from_degrees(lat, lng)) + self.polyline_container.append([lat, lng]) + if self.polyline_container: + self.start_time_local, self.end_time_local = parse_datetime_to_local( + self.start_time, self.end_time, self.polyline_container[0] + ) + self.start_latlng = start_point(*self.polyline_container[0]) + self.polylines.append(_polylines) + self.polyline_str = polyline.encode(self.polyline_container) + else: + self.start_time_local, self.end_time_local = parse_datetime_to_local( + self.start_time, self.end_time, None + ) def append(self, other): """Append other track to self.""" diff --git a/run_page/gpxtrackposter/utils.py b/run_page/gpxtrackposter/utils.py index b2234c97484..6b371ec1507 100644 --- a/run_page/gpxtrackposter/utils.py +++ b/run_page/gpxtrackposter/utils.py @@ -129,16 +129,19 @@ def format_float(f): def parse_datetime_to_local(start_time, end_time, point): - # just parse the start time, because start/end maybe different - offset = start_time.utcoffset() - if offset: - return start_time + offset, end_time + offset - lat, lng = point - try: - timezone = get_tz(lng=lng, lat=lat) - except: - # just a little trick when tzfpy support windows will delete this + if not point: + timezone = "Asia/Shanghai" + else: + # just parse the start time, because start/end maybe different + offset = start_time.utcoffset() + if offset: + return start_time + offset, end_time + offset lat, lng = point - timezone = tf.timezone_at(lng=lng, lat=lat) + try: + timezone = get_tz(lng=lng, lat=lat) + except: + # just a little trick when tzfpy support windows will delete this + lat, lng = point + timezone = tf.timezone_at(lng=lng, lat=lat) tc_offset = datetime.now(pytz.timezone(timezone)).utcoffset() return start_time + tc_offset, end_time + tc_offset diff --git a/run_page/strava_to_garmin_sync.py b/run_page/strava_to_garmin_sync.py index 34adba7d7a5..ca8890c2f23 100644 --- a/run_page/strava_to_garmin_sync.py +++ b/run_page/strava_to_garmin_sync.py @@ -98,7 +98,9 @@ async def upload_to_activities( files_list.append(data) except Exception as ex: print("get strava data error: ", ex) - await garmin_client.upload_activities_original(files_list, use_fake_garmin_device) + await garmin_client.upload_activities_original_from_strava( + files_list, use_fake_garmin_device + ) return files_list diff --git a/run_page/synced_data_file_logger.py b/run_page/synced_data_file_logger.py index de9583b59d5..5a7fb98efce 100644 --- a/run_page/synced_data_file_logger.py +++ b/run_page/synced_data_file_logger.py @@ -1,5 +1,5 @@ import os -from config import SYNCED_FILE +from config import SYNCED_FILE, SYNCED_ACTIVITY_FILE import json @@ -12,6 +12,11 @@ def save_synced_data_file_list(file_list: list): json.dump(file_list, f) +def save_synced_activity_list(activity_list: list): + with open(SYNCED_ACTIVITY_FILE, "w") as f: + json.dump(activity_list, f) + + def load_synced_file_list(): if os.path.exists(SYNCED_FILE): with open(SYNCED_FILE, "r") as f: @@ -22,3 +27,15 @@ def load_synced_file_list(): pass return [] + + +def load_synced_activity_list(): + if os.path.exists(SYNCED_ACTIVITY_FILE): + with open(SYNCED_ACTIVITY_FILE, "r") as f: + try: + return json.load(f) + except Exception as e: + print(f"json load {SYNCED_ACTIVITY_FILE} \nerror {e}") + pass + + return [] diff --git a/run_page/utils.py b/run_page/utils.py index b003707284a..de6945ee48a 100644 --- a/run_page/utils.py +++ b/run_page/utils.py @@ -40,7 +40,9 @@ def to_date(ts): # shouldn't be an issue since it's an offline cmdline tool return datetime.strptime(ts, ts_fmt) except ValueError: - print("Error: Can not execute strptime") + print( + f"Warning: Can not execute strptime {ts} with ts_fmt {ts_fmt}, try next one..." + ) pass raise ValueError(f"cannot parse timestamp {ts} into date with fmts: {ts_fmts}") diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 2d431b69ac3..a98cda8c369 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -1,5 +1,4 @@ import { Link } from 'react-router-dom'; -import React from 'react'; import useSiteMetadata from '@/hooks/useSiteMetadata'; const Header = () => { diff --git a/src/components/LocationStat/CitiesStat.tsx b/src/components/LocationStat/CitiesStat.tsx index 18f0d83a0a5..3dd5fcdae66 100644 --- a/src/components/LocationStat/CitiesStat.tsx +++ b/src/components/LocationStat/CitiesStat.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import Stat from '@/components/Stat'; import useActivities from '@/hooks/useActivities'; diff --git a/src/components/LocationStat/LocationSummary.tsx b/src/components/LocationStat/LocationSummary.tsx index 4c5b618ba44..03647609c86 100644 --- a/src/components/LocationStat/LocationSummary.tsx +++ b/src/components/LocationStat/LocationSummary.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import Stat from '@/components/Stat'; import useActivities from '@/hooks/useActivities'; diff --git a/src/components/LocationStat/PeriodStat.tsx b/src/components/LocationStat/PeriodStat.tsx index b733e97771b..14c7d7a690f 100644 --- a/src/components/LocationStat/PeriodStat.tsx +++ b/src/components/LocationStat/PeriodStat.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import Stat from '@/components/Stat'; import useActivities from '@/hooks/useActivities'; diff --git a/src/components/LocationStat/index.tsx b/src/components/LocationStat/index.tsx index d23548ad167..c0f58e2c6dd 100644 --- a/src/components/LocationStat/index.tsx +++ b/src/components/LocationStat/index.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import YearStat from '@/components/YearStat'; import { CHINESE_LOCATION_INFO_MESSAGE_FIRST, diff --git a/src/components/RunMap/RunMapButtons.tsx b/src/components/RunMap/RunMapButtons.tsx index 5153a63a288..7eec958a472 100644 --- a/src/components/RunMap/RunMapButtons.tsx +++ b/src/components/RunMap/RunMapButtons.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import useActivities from '@/hooks/useActivities'; import styles from './style.module.scss'; diff --git a/src/components/RunMap/RunMaker.tsx b/src/components/RunMap/RunMarker.tsx similarity index 97% rename from src/components/RunMap/RunMaker.tsx rename to src/components/RunMap/RunMarker.tsx index d57bf8f3cd8..c373d8a22d0 100644 --- a/src/components/RunMap/RunMaker.tsx +++ b/src/components/RunMap/RunMarker.tsx @@ -1,6 +1,5 @@ import { ReactComponent as EndSvg } from '@assets/end.svg'; import { ReactComponent as StartSvg } from '@assets/start.svg'; -import React from 'react'; import { Marker } from 'react-map-gl'; import styles from './style.module.scss'; diff --git a/src/components/RunMap/index.tsx b/src/components/RunMap/index.tsx index ebb5280ac6a..47f00bdf5e1 100644 --- a/src/components/RunMap/index.tsx +++ b/src/components/RunMap/index.tsx @@ -14,7 +14,7 @@ import { MAP_HEIGHT, } from '@/utils/const'; import { Coordinate, IViewState, geoJsonForMap } from '@/utils/utils'; -import RunMarker from './RunMaker'; +import RunMarker from './RunMarker'; import RunMapButtons from './RunMapButtons'; import styles from './style.module.scss'; import { FeatureCollection } from 'geojson'; @@ -46,10 +46,10 @@ const RunMap = ({ (ref: MapRef) => { if (ref !== null) { const map = ref.getMap(); - if (map) { + if (map && IS_CHINESE) { map.addControl(new MapboxLanguage({defaultLanguage: 'zh-Hans'})); - } - map.on('load', () => { + } + map.on('load', () => { if (!ROAD_LABEL_DISPLAY) { // todo delete layers MAP_LAYER_LIST.forEach((layerId) => { @@ -57,8 +57,8 @@ const RunMap = ({ }); } mapRef.current = ref; - }); - } + }); + } if (mapRef.current) { const map = mapRef.current.getMap(); if (map) { @@ -107,7 +107,7 @@ const RunMap = ({ [endLon, endLat] = points[points.length - 1]; } let dash = USE_DASH_LINE && !isSingleRun && !isBigMap ? [2, 2] : [2, 0]; - const onMove = React.useCallback(({ viewState }: {viewState: IViewState}) => { + const onMove = React.useCallback(({ viewState }: { viewState: IViewState }) => { setViewState(viewState); }, []); const style: React.CSSProperties = { @@ -123,7 +123,7 @@ const RunMap = ({ return ( { return sortFuncInfo === 'BPM' - ? a.average_heartrate ?? 0 - (b.average_heartrate ?? 0) - : b.average_heartrate ?? 0 - (a.average_heartrate ?? 0); + ? (a.average_heartrate ?? 0) - (b.average_heartrate ?? 0) + : (b.average_heartrate ?? 0) - (a.average_heartrate ?? 0); }; const sortRunTimeFunc: SortFunc = (a, b) => { const aTotalSeconds = convertMovingTime2Sec(a.moving_time); diff --git a/src/components/SVGStat/index.tsx b/src/components/SVGStat/index.tsx index 3d0f7bc5052..f80ce613523 100644 --- a/src/components/SVGStat/index.tsx +++ b/src/components/SVGStat/index.tsx @@ -1,12 +1,34 @@ -import React from 'react'; -import { ReactComponent as GitHubSvg } from '@assets/github.svg'; -import { ReactComponent as GridSvg } from '@assets/grid.svg'; +import { ComponentType, lazy, Suspense } from 'react' import styles from './style.module.scss'; +import { totalStat } from '@assets/index' + +// Lazy load both github.svg and grid.svg +const GithubSvg = lazy(() => + totalStat['./github.svg']() + .then((res) => { + return { default: res as ComponentType } + }) + .catch(() => { + return { default: () =>
Failed to load SVG
}; + }) +) + +const GridSvg = lazy(() => + totalStat['./grid.svg']() + .then((res) => { + return { default: res as ComponentType } + }) + .catch(() => { + return { default: () =>
Failed to load SVG
}; + }) +) const SVGStat = () => (
- - + Loading...
}> + + + ); diff --git a/src/components/SVGStat/style.module.scss b/src/components/SVGStat/style.module.scss index 985e0350ea5..e362dba1045 100644 --- a/src/components/SVGStat/style.module.scss +++ b/src/components/SVGStat/style.module.scss @@ -3,3 +3,7 @@ width: 100%; margin: 1rem 0 0; } + +.center { + text-align: center; +} diff --git a/src/components/Stat/index.tsx b/src/components/Stat/index.tsx index bff741a2eff..1f3c2f666b7 100644 --- a/src/components/Stat/index.tsx +++ b/src/components/Stat/index.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import { CSSProperties } from 'react'; import { intComma } from '@/utils/utils'; -const divStyle: React.CSSProperties = { +const divStyle: CSSProperties = { fontWeight: '700', }; diff --git a/src/components/YearStat/index.tsx b/src/components/YearStat/index.tsx index 5d53c525526..9d891883f01 100644 --- a/src/components/YearStat/index.tsx +++ b/src/components/YearStat/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { ComponentType, lazy, Suspense } from 'react'; import Stat from '@/components/Stat'; import useActivities from '@/hooks/useActivities'; import { formatPace } from '@/utils/utils'; @@ -11,12 +11,13 @@ const YearStat = ({ year, onClick }: { year: string, onClick: (_year: string) => // for hover const [hovered, eventHandlers] = useHover(); // lazy Component - const YearSVG = React.lazy(() => yearStats[`./year_${year}.svg`]() - .then((res) => ({ default: res })) - .catch((err) => { - console.error(err); - return { default: () =>
Failed to load SVG
}; - }) + const YearSVG = lazy(() => + yearStats[`./year_${year}.svg`]() + .then((res) => ({ default: res as ComponentType })) + .catch((err) => { + console.error(err); + return { default: () =>
Failed to load SVG
}; + }) ); if (years.includes(year)) { @@ -71,9 +72,9 @@ const YearStat = ({ year, onClick }: { year: string, onClick: (_year: string) => )} {year !== "Total" && hovered && ( - + - + )}
diff --git a/src/components/YearsStat/index.tsx b/src/components/YearsStat/index.tsx index 3d9d4a25a4c..156a0e19057 100644 --- a/src/components/YearsStat/index.tsx +++ b/src/components/YearsStat/index.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import YearStat from '@/components/YearStat'; import useActivities from '@/hooks/useActivities'; import { INFO_MESSAGE } from '@/utils/const'; @@ -25,11 +24,11 @@ const YearsStat = ({ year, onClick }: { year: string, onClick: (_year: string) = ))} {// eslint-disable-next-line no-prototype-builtins - yearsArrayUpdate.hasOwnProperty('Total') ? ( - - ) : ( -
- )} + yearsArrayUpdate.hasOwnProperty('Total') ? ( + + ) : ( +
+ )}
); }; diff --git a/src/hooks/usePageTracking.ts b/src/hooks/usePageTracking.ts new file mode 100644 index 00000000000..c81c367d343 --- /dev/null +++ b/src/hooks/usePageTracking.ts @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import ReactGA from 'react-ga4'; + +const usePageTracking = () => { + const location = useLocation(); + useEffect(() => { + ReactGA.send({ + hitType: 'pageview', + page: location.pathname + location.search, + }); + }, [location]); +}; + +export default usePageTracking; diff --git a/src/main.tsx b/src/main.tsx index 697d65d45b6..893e757b703 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,15 +1,31 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import { HelmetProvider } from 'react-helmet-async'; import Index from './pages'; import NotFound from './pages/404'; +import ReactGA from 'react-ga4'; +import { + GOOGLE_ANALYTICS_TRACKING_ID, + USE_GOOGLE_ANALYTICS, +} from './utils/const'; import '@/styles/index.scss'; +import { withOptionalGAPageTracking } from './utils/trackRoute'; + +if (USE_GOOGLE_ANALYTICS) { + ReactGA.initialize(GOOGLE_ANALYTICS_TRACKING_ID); +} const routes = createBrowserRouter( [ - { path: '/', element: }, - { path: '*', element: }, + { + path: '/', + element: withOptionalGAPageTracking(), + }, + { + path: '*', + element: withOptionalGAPageTracking(), + }, ], { basename: import.meta.env.BASE_URL } ); diff --git a/src/pages/404.tsx b/src/pages/404.tsx index 60aaf7be6e9..2cb0768e80a 100644 --- a/src/pages/404.tsx +++ b/src/pages/404.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import Layout from '@/components/Layout'; import useSiteMetadata from '@/hooks/useSiteMetadata'; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index d38e78ae460..3c3fdd4512d 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,5 +1,5 @@ +import { useEffect, useState } from 'react'; import { Analytics } from '@vercel/analytics/react'; -import React, { useEffect, useState } from 'react'; import Layout from '@/components/Layout'; import LocationStat from '@/components/LocationStat'; import RunMap from '@/components/RunMap'; @@ -48,6 +48,9 @@ const Index = () => { func: (_run: Activity, _value: string) => boolean ) => { scrollToMap(); + if (name != 'Year') { + setYear(thisYear) + } setActivity(filterAndSortRuns(activities, item, func, sortDateFunc)); setRunIndex(-1); setTitle(`${item} ${name} Running Heatmap`); @@ -129,19 +132,26 @@ const Index = () => { if (!svgStat) { return; } - svgStat.addEventListener('click', (e) => { + + const handleClick = (e: Event) => { const target = e.target as HTMLElement; - if (target) { - const tagName = target.tagName.toLowerCase(); - - // click the github-stat style svg - if ( - tagName === 'rect' && - parseFloat(target.getAttribute('width') || '0.0') === 2.6 && - parseFloat(target.getAttribute('height') || '0.0') === 2.6 && - target.getAttribute('fill') !== '#444444' - ) { - const [runDate] = target.innerHTML.match(/\d{4}-\d{1,2}-\d{1,2}/) || [ + if (target.tagName.toLowerCase() === 'path') { + // Use querySelector to get the element and the element. + const descEl = target.querySelector('desc'); + if (descEl) { + // If the runId exists in the <desc> element, it means that a running route has been clicked. + const runId = Number(descEl.innerHTML); + if (!runId) { + return; + } + locateActivity([runId]); + return; + } + + const titleEl = target.querySelector('title'); + if (titleEl) { + // If the runDate exists in the <title> element, it means that a date square has been clicked. + const [runDate] = titleEl.innerHTML.match(/\d{4}-\d{1,2}-\d{1,2}/) || [ `${+thisYear + 1}`, ]; const runIDsOnDate = runs @@ -151,20 +161,13 @@ const Index = () => { return; } locateActivity(runIDsOnDate); - } else if (tagName === 'polyline') { - // click the route grid svg - const desc = target.getElementsByTagName('desc')[0]; - if (!desc) { - return; - } - const run_id = Number(desc.innerHTML); - if (!run_id) { - return; - } - locateActivity([run_id]); } } - }); + } + svgStat.addEventListener('click', handleClick); + return () => { + svgStat && svgStat.removeEventListener('click', handleClick); + }; }, [year]); return ( diff --git a/src/utils/const.ts b/src/utils/const.ts index 75a5d36bdf1..1bd3fda4025 100644 --- a/src/utils/const.ts +++ b/src/utils/const.ts @@ -24,6 +24,9 @@ const MAP_LAYER_LIST = [ 'country-label', ]; +const USE_GOOGLE_ANALYTICS = false; +const GOOGLE_ANALYTICS_TRACKING_ID = ''; + // styling: set to `true` if you want dash-line route const USE_DASH_LINE = true; // styling: route line opacity: [0, 1] @@ -68,6 +71,8 @@ const RUN_TITLES = { }; export { + USE_GOOGLE_ANALYTICS, + GOOGLE_ANALYTICS_TRACKING_ID, CHINESE_LOCATION_INFO_MESSAGE_FIRST, CHINESE_LOCATION_INFO_MESSAGE_SECOND, MAPBOX_TOKEN, diff --git a/src/utils/trackRoute.tsx b/src/utils/trackRoute.tsx new file mode 100644 index 00000000000..6f2da8019e6 --- /dev/null +++ b/src/utils/trackRoute.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import usePageTracking from '../hooks/usePageTracking'; +import ReactGA from 'react-ga4'; +import { USE_GOOGLE_ANALYTICS } from './const'; + +const TrackPageRoute: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + if (ReactGA.isInitialized) { + usePageTracking(); + } + return <>{children}</>; +}; + +export const withOptionalGAPageTracking = (element: React.ReactElement) => { + if (USE_GOOGLE_ANALYTICS) { + return <TrackPageRoute>{element}</TrackPageRoute>; + } + return element; +}; diff --git a/tsconfig.json b/tsconfig.json index d21814b633a..caccfc76acf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "dom", "esnext" ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, - "jsx": "react" /* Specify what JSX code is generated. */, + "jsx": "react-jsx" /* Specify what JSX code is generated. */, // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ diff --git a/vite-env.d.ts b/vite-env.d.ts index 11f02fe2a00..b1f45c78666 100644 --- a/vite-env.d.ts +++ b/vite-env.d.ts @@ -1 +1,2 @@ /// <reference types="vite/client" /> +/// <reference types="vite-plugin-svgr/client" /> diff --git a/vite.config.ts b/vite.config.ts index 11908d05f30..ed4bbaa4710 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,11 +2,39 @@ import process from 'node:process'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import viteTsconfigPaths from 'vite-tsconfig-paths'; -import svgrPlugin from 'vite-plugin-svgr'; +import svgr from 'vite-plugin-svgr'; + +// The following are known larger packages or packages that can be loaded asynchronously. +const individuallyPackages = ['activities', 'github.svg', 'grid.svg']; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react(), viteTsconfigPaths(), svgrPlugin()], + plugins: [ + react(), + viteTsconfigPaths(), + svgr({ + include: ['**/*.svg'], + svgrOptions: { + exportType: 'named', + namedExport: 'ReactComponent', + plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'], + svgoConfig: { + floatPrecision: 2, + plugins: [ + { + name: 'preset-default', + params: { + overrides: { + removeTitle: false, + removeViewBox: false, + }, + }, + }, + ], + }, + }, + }), + ], base: process.env.PATH_PREFIX || '/', build: { manifest: true, @@ -14,11 +42,21 @@ export default defineConfig({ rollupOptions: { output: { manualChunks: (id: string) => { - if (id.includes('activities')) { - return 'activities' + if (id.includes('node_modules')) { + return 'vendors'; + // If there will be more and more external packages referenced in the future, + // the following approach can be considered. + // const name = id.split('node_modules/')[1].split('/'); + // return name[0] == '.pnpm' ? name[1] : name[0]; + } else { + for (const item of individuallyPackages) { + if (id.includes(item)) { + return item; + } + } } }, - } + }, }, }, });