├── .dockerignore
├── .empty
├── .eslintignore
├── .eslintrc.json
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ └── codeql.yml
├── .gitignore
├── .npmrc
├── LICENSE
├── Makefile
├── README.md
├── assets
├── images
│ ├── great_and_powerful.xcf
│ ├── screenshot.jpg
│ ├── slideshow.webp
│ └── slideshow.xcf
└── notes
│ ├── command_notes
│ └── splines
├── build
├── entitlements.mac.plist
├── icon.png
├── images
│ ├── app-icon.icns
│ ├── app-icon.ico
│ └── icon256-dev.png
├── linux.Dockerfile
└── scripts
│ └── notarize.js
├── https
└── .gitignore
├── images
├── mac-trayicon.png
├── mac-trayicon@2x.png
├── win-trayicon.png
└── win-trayicon@2x.png
├── package.json
├── pages
├── analysis-manifest.json
├── analysis-settings.html
├── analysis.html
├── athletes.html
├── browser-source-settings.html
├── browser-source.html
├── chat-manifest.json
├── chat-settings.html
├── chat.html
├── confirm-dialog.html
├── deps
│ ├── .gitignore
│ └── Makefile
├── eula.html
├── events-manifest.json
├── events-settings.html
├── events.html
├── file-replay.html
├── fonts
│ ├── MaterialSymbolsRounded.woff2
│ ├── PermanentMarker.woff2
│ └── Saira.woff2
├── game-control-manifest.json
├── game-control-settings.html
├── game-control.html
├── gauge.html
├── gauges
│ ├── cadence-manifest.json
│ ├── cadence.html
│ ├── draft-manifest.json
│ ├── draft.html
│ ├── hr-manifest.json
│ ├── hr.html
│ ├── pace-manifest.json
│ ├── pace.html
│ ├── power-manifest.json
│ ├── power.html
│ ├── settings.html
│ ├── wbal-manifest.json
│ └── wbal.html
├── geo-manifest.json
├── geo-settings.html
├── geo.html
├── groups-manifest.json
├── groups-settings.html
├── groups.html
├── images
│ ├── become_a_patron_button@2x.png
│ ├── blankavatar.png
│ ├── examples
│ │ ├── elevation-profile-chart.webp
│ │ ├── power-zones-horiz-chart.png
│ │ ├── power-zones-pie-chart.png
│ │ ├── power-zones-vert-chart.png
│ │ └── sauce-line-chart-cap-50pct.png
│ ├── fa
│ │ ├── bolt-duotone.svg
│ │ ├── caret-left-regular.svg
│ │ ├── caret-right-regular.svg
│ │ ├── cog-duotone.svg
│ │ ├── flag-checkered-duotone.svg
│ │ ├── heartbeat-duotone.svg
│ │ ├── plus-square-duotone.svg
│ │ ├── search-minus-duotone.svg
│ │ ├── search-plus-duotone.svg
│ │ ├── solar-system-duotone.svg
│ │ ├── stopwatch-regular.svg
│ │ ├── tachometer-duotone.svg
│ │ ├── times-circle-duotone.svg
│ │ ├── undo-regular.svg
│ │ ├── user-circle-solid.svg
│ │ └── wind-duotone.svg
│ ├── favicon.png
│ ├── great_and_powerful.webp
│ ├── great_and_powerful_bubble.webp
│ ├── icon128.png
│ ├── icon16.png
│ ├── icon19.png
│ ├── icon256.png
│ ├── icon32.png
│ ├── icon38.png
│ ├── icon48.png
│ ├── icon64.png
│ ├── logo_horiz_128x48.png
│ ├── logo_horiz_320x120.png
│ ├── logo_horiz_64x24.png
│ ├── logo_vert_120x320.png
│ ├── logo_vert_32x85.png
│ ├── logo_vert_48x128.png
│ ├── logo_vert_96x256.png
│ ├── map-fade-mask.png
│ ├── mod-store-badge.webp
│ ├── ranking
│ │ ├── cat1-darkbg.png
│ │ ├── cat1.png
│ │ ├── cat2-darkbg.png
│ │ ├── cat2.png
│ │ ├── cat3-darkbg.png
│ │ ├── cat3.png
│ │ ├── cat4-darkbg.png
│ │ ├── cat4.png
│ │ ├── cat5-darkbg.png
│ │ ├── cat5.png
│ │ ├── pro-darkbg.png
│ │ ├── pro.png
│ │ ├── world-tour-darkbg.png
│ │ └── world-tour.png
│ ├── smart_trainer.svg
│ └── zp_logo.png
├── index.html
├── logs.html
├── nearby-manifest.json
├── nearby-settings.html
├── nearby.html
├── non-patron.html
├── overview-manifest.json
├── overview-settings.html
├── overview.html
├── patron-a51.html
├── patron-checking.html
├── patron-success.html
├── patron-waiting.html
├── patron.html
├── profile-avatar.html
├── profile.html
├── release-notes.html
├── scss
│ ├── _charts.scss
│ ├── _color.scss
│ ├── analysis.scss
│ ├── animations.scss
│ ├── athletes.scss
│ ├── browser-source.scss
│ ├── chat.scss
│ ├── common.scss
│ ├── elevation.scss
│ ├── events.scss
│ ├── expandable-table.scss
│ ├── file-replay.scss
│ ├── game-control.scss
│ ├── gauge.scss
│ ├── geo.scss
│ ├── groups.scss
│ ├── logs.scss
│ ├── map.scss
│ ├── nearby.scss
│ ├── overview-settings.scss
│ ├── overview.scss
│ ├── profile.scss
│ ├── profile_tpl.scss
│ ├── segments.scss
│ ├── stats-for-nerds.scss
│ ├── themes.scss
│ ├── watching.scss
│ └── welcome.scss
├── segments-settings.html
├── segments.html
├── sounds
│ ├── great_and_powerful.ogg
│ └── soundtweet.ogg
├── src
│ ├── analysis.mjs
│ ├── athletes.mjs
│ ├── browser-source.mjs
│ ├── charts.mjs
│ ├── chat.mjs
│ ├── color.mjs
│ ├── common.mjs
│ ├── custom-elements.mjs
│ ├── echarts-sauce-theme.mjs
│ ├── elevation.mjs
│ ├── events.mjs
│ ├── fields.mjs
│ ├── file-replay.mjs
│ ├── game-control.mjs
│ ├── gauge.mjs
│ ├── geo.mjs
│ ├── groups.mjs
│ ├── logs.mjs
│ ├── map.mjs
│ ├── nearby.mjs
│ ├── overview.mjs
│ ├── preloads.js
│ ├── profile.mjs
│ ├── segments.mjs
│ ├── sentry.js
│ ├── stats-for-nerds.mjs
│ ├── watching.mjs
│ └── welcome.mjs
├── stats-for-nerds.html
├── system-message.html
├── templates
│ ├── analysis
│ │ ├── activity-summary.html.tpl
│ │ ├── laps.html.tpl
│ │ ├── main.html.tpl
│ │ ├── peak-efforts.html.tpl
│ │ ├── segment-results.html.tpl
│ │ ├── segments.html.tpl
│ │ └── selection-stats.html.tpl
│ ├── athlete-cards.html.tpl
│ ├── events
│ │ ├── details.html.tpl
│ │ ├── list.html.tpl
│ │ ├── subgroup.html.tpl
│ │ └── summary.html.tpl
│ ├── profile.html.tpl
│ ├── segment-results.html.tpl
│ └── watching-screen-layout.html.tpl
├── update.html
├── watching-manifest.json
├── watching-settings.html
├── watching.html
├── welcome.html
├── zwift-login.html
└── zwift-monitor-login.html
├── shared
├── curves.mjs
├── deps
│ ├── .gitignore
│ └── Makefile
├── report.mjs
└── sauce
│ ├── base.mjs
│ ├── browser.mjs
│ ├── data.mjs
│ ├── geo.mjs
│ ├── index.mjs
│ ├── locale.mjs
│ ├── pace.mjs
│ ├── perf.mjs
│ ├── power.mjs
│ └── template.mjs
├── src
├── app.mjs
├── argparse.mjs
├── db.mjs
├── env.mjs
├── fs-safe.js
├── headless.mjs
├── loader.js
├── logging.js
├── main.mjs
├── menu.mjs
├── mime.mjs
├── mods-core.mjs
├── mods.mjs
├── patreon.mjs
├── preload
│ ├── common.js
│ ├── patron-link.js
│ ├── storage-proxy.js
│ ├── webview.js
│ └── zwift-login.js
├── rpc.mjs
├── secrets.mjs
├── stats.mjs
├── storage.mjs
├── unzoom.mjs
├── webserver.mjs
├── windows.mjs
├── zwift.mjs
└── zwift.proto
├── test
├── .eslintrc.json
├── curves.test.mjs
├── lru.test.mjs
└── wbal.test.mjs
└── tools
└── bin
├── buildenv
├── jsonminify
├── lintwatch
└── watch
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | assets
4 |
--------------------------------------------------------------------------------
/.empty:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/.empty
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | pages/src/sentry.js
2 | shared/deps/segments.mjs
3 | shared/deps/routes.mjs
4 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["eslint-plugin-html"],
3 | "env": {
4 | "browser": true,
5 | "commonjs": true,
6 | "es2021": true
7 | },
8 | "extends": [
9 | "eslint:recommended"
10 | ],
11 | "parserOptions": {
12 | "ecmaVersion": 2022
13 | },
14 | "rules": {
15 | "quotes": "off",
16 | "no-console": "off",
17 | "no-debugger": "off",
18 | "linebreak-style": ["error", "unix"],
19 | "semi": ["warn", "always"],
20 | "no-unused-vars": ["warn", {"args": "none"}],
21 | "no-constant-condition": ["error", {"checkLoops": false }],
22 | "no-constant-binary-expression": "warn",
23 | "no-constructor-return": "error",
24 | "no-new-native-nonconstructor": "error",
25 | "no-unreachable-loop": "error",
26 | "no-use-before-define": ["warn", "nofunc"],
27 | "complexity": ["warn", 60],
28 | "max-len": ["warn", {
29 | "code": 110,
30 | "ignoreComments": false,
31 | "ignoreTrailingComments": false,
32 | "ignoreStrings": false,
33 | "ignoreTemplateLiterals": false,
34 | "ignoreRegExpLiterals": false
35 | }],
36 | "max-nested-callbacks": ["warn", 3],
37 | "max-depth": ["warn", 8],
38 | "curly": "warn",
39 | "eqeqeq": ["error", "always", {"null": "ignore"}],
40 | "no-implicit-globals": "error",
41 | "no-throw-literal": "error",
42 | "no-var": "error",
43 | "no-unreachable": "warn",
44 | "no-warning-comments": "off",
45 | "prefer-const": ["warn", {
46 | "destructuring": "all",
47 | "ignoreReadBeforeAssign": true
48 | }],
49 | "require-await": "warn",
50 | "func-call-spacing": "warn",
51 | "indent": ["warn", 4, {
52 | "CallExpression": {"arguments": "first"},
53 | "FunctionDeclaration": {"parameters": "first"},
54 | "FunctionExpression": {"parameters": "first"},
55 | "ObjectExpression": "first",
56 | "ArrayExpression": 1,
57 | "SwitchCase": 1
58 | }],
59 | "no-trailing-spaces": "warn"
60 | },
61 | "overrides": [{
62 | "files": ["*.mjs"],
63 | "parserOptions": {
64 | "sourceType": "module",
65 | "allowImportExportEverywhere": true
66 | }
67 | }]
68 | }
69 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | patreon: sauce4strava
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Additional context**
32 | Add any other context about the problem here.
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ "main" ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ "main" ]
20 | schedule:
21 | - cron: '21 10 * * 3'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'javascript' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
38 |
39 | steps:
40 | - name: Checkout repository
41 | uses: actions/checkout@v3
42 |
43 | # Initializes the CodeQL tools for scanning.
44 | - name: Initialize CodeQL
45 | uses: github/codeql-action/init@v2
46 | with:
47 | languages: ${{ matrix.language }}
48 | # If you wish to specify custom queries, you can do so here or in a config file.
49 | # By default, queries listed here will override any specified in a config file.
50 | # Prefix the list here with "+" to use these queries and those in the config file.
51 |
52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
53 | # queries: security-extended,security-and-quality
54 |
55 |
56 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
57 | # If this step fails, then you should remove it and run the build manually (see below)
58 | - name: Autobuild
59 | uses: github/codeql-action/autobuild@v2
60 |
61 | # ℹ️ Command-line programs to run using the OS shell.
62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
63 |
64 | # If the Autobuild fails above, remove it and uncomment the following three lines.
65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
66 |
67 | # - run: |
68 | # echo "Run, Build Application using script"
69 | # ./location_of_script_within_repo/buildscript.sh
70 |
71 | - name: Perform CodeQL Analysis
72 | uses: github/codeql-action/analyze@v2
73 | with:
74 | category: "/language:${{matrix.language}}"
75 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build.json
2 |
3 | # Logs
4 | logs
5 | *.log
6 | *.swp
7 | npm-debug.log*
8 | yarn-debug.log*
9 | yarn-error.log*
10 | lerna-debug.log*
11 |
12 | # Diagnostic reports (https://nodejs.org/api/report.html)
13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
14 |
15 | # Runtime data
16 | pids
17 | *.pid
18 | *.seed
19 | *.pid.lock
20 |
21 | # Directory for instrumented libs generated by jscoverage/JSCover
22 | lib-cov
23 |
24 | # Coverage directory used by tools like istanbul
25 | coverage
26 | *.lcov
27 |
28 | # nyc test coverage
29 | .nyc_output
30 |
31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
32 | .grunt
33 |
34 | # Bower dependency directory (https://bower.io/)
35 | bower_components
36 |
37 | # node-waf configuration
38 | .lock-wscript
39 |
40 | # Compiled binary addons (https://nodejs.org/api/addons.html)
41 | build/Release
42 |
43 | # Dependency directories
44 | node_modules/
45 | jspm_packages/
46 |
47 | # TypeScript v1 declaration files
48 | typings/
49 |
50 | # TypeScript cache
51 | *.tsbuildinfo
52 |
53 | # Optional npm cache directory
54 | .npm
55 |
56 | # Optional eslint cache
57 | .eslintcache
58 |
59 | # Microbundle cache
60 | .rpt2_cache/
61 | .rts2_cache_cjs/
62 | .rts2_cache_es/
63 | .rts2_cache_umd/
64 |
65 | # Optional REPL history
66 | .node_repl_history
67 |
68 | # Output of 'npm pack'
69 | *.tgz
70 |
71 | # Yarn Integrity file
72 | .yarn-integrity
73 |
74 | # dotenv environment variables file
75 | .env
76 | .env.test
77 |
78 | # parcel-bundler cache (https://parceljs.org/)
79 | .cache
80 |
81 | # Next.js build output
82 | .next
83 |
84 | # Nuxt.js build / generate output
85 | .nuxt
86 | dist
87 |
88 | # Gatsby files
89 | .cache/
90 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
91 | # https://nextjs.org/blog/next-9-1#public-directory-support
92 | # public
93 |
94 | # vuepress build output
95 | .vuepress/dist
96 |
97 | # Serverless directories
98 | .serverless/
99 |
100 | # FuseBox cache
101 | .fusebox/
102 |
103 | # DynamoDB Local files
104 | .dynamodb/
105 |
106 | # TernJS port file
107 | .tern-port
108 |
109 | out
110 |
111 | .DS_Store
112 |
113 | pages/css
114 | .build
115 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 | prefer-dedupe=true
3 | loglevel="verbose"
4 | color=true
5 | logs-max=0
6 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | default: build
2 |
3 | PACKAGES := node_modules/.build
4 | BUILD := build.json
5 |
6 | ifeq ($(OS),Windows_NT)
7 | WINBLOWS := true
8 | #SHELL := powershell.exe
9 | #.SHELLFLAGS := -C
10 | else
11 | T := $(shell uname -s)
12 | ifeq ($(T),Linux)
13 | LINUX := true
14 | endif
15 | ifeq ($(UNAME_S),Darwin)
16 | MAC := true
17 | endif
18 | endif
19 |
20 | MODS := $(CURDIR)/node_modules
21 | NPATH := $(MODS)/.bin
22 | TOOLPATH := $(CURDIR)/tools/bin
23 | ifndef WINBLOWS
24 | PAGES_SRC := $(shell find pages -type f)
25 | endif
26 |
27 |
28 | $(PACKAGES): package.json
29 | npm install
30 | echo "" > $@
31 |
32 |
33 | $(BUILD): $(PAGES_SRC) $(PACKAGES) sass deps Makefile .git/index test
34 | node tools/bin/buildenv $@
35 |
36 | build: $(BUILD)
37 |
38 |
39 | run: $(BUILD)
40 | npm start
41 |
42 | run-debug: $(BUILD)
43 | npm run start-debug
44 |
45 | run-debug-brk: $(BUILD)
46 | npm run start-debug-brk
47 |
48 |
49 | unpacked: $(BUILD)
50 | ifndef WINBLOWS
51 | SKIP_NOTARIZE=1 npm run unpacked
52 | else
53 | npm run unpacked
54 | endif
55 |
56 | packed: $(BUILD)
57 | ifndef WINBLOWS
58 | SKIP_NOTARIZE=1 npm run build
59 | else
60 | npm run build
61 | endif
62 |
63 | publish: $(BUILD)
64 | ifndef WINBLOWS
65 | GH_TOKEN="$${GH_TOKEN_SAUCE4ZWIFT_RELEASE}" npm run publish
66 | else
67 | npm run publish
68 | endif
69 |
70 | publish-docker-linux-native:
71 | docker build --build-arg arch=amd64 -t linux-s4z-build -f ./build/linux.Dockerfile .
72 | docker run -it -v $$HOME/.git-credentials:/root/.git-credentials \
73 | -e GH_TOKEN_SAUCE4ZWIFT_RELEASE -v $(CURDIR)/dist/docker-dist:/sauce4zwift/dist linux-s4z-build make publish
74 |
75 | _publis-docker-linux-arm_DO_NOT_USE:
76 | # Artifacts collide with non arm builds. I think this is possible to avoid but haven't dived in
77 | # Also this takes like an hour or more to finish on highend 2023 AMD CPU, yikes.
78 | docker build --build-arg arch=arm64 -t linux-s4z-build-arm -f ./build/linux.Dockerfile .
79 | docker run -it -v $$HOME/.git-credentials:/root/.git-credentials \
80 | -e GH_TOKEN_SAUCE4ZWIFT_RELEASE linux-s4z-build-arm make publish
81 |
82 |
83 | deps:
84 | $(MAKE) -j 32 -C pages/deps
85 | $(MAKE) -j 32 -C shared/deps
86 |
87 |
88 | sass:
89 | $(NPATH)/sass pages/scss:pages/css
90 |
91 | sass-watch:
92 | $(NPATH)/sass pages/scss:pages/css --watch
93 |
94 |
95 | lint:
96 | $(NPATH)/eslint src shared pages/src
97 |
98 | lint-watch:
99 | ifndef WINBLOWS
100 | ifdef LINUX
101 | tools/bin/lintwatch
102 | else
103 | while true ; do \
104 | $(MAKE) lint; \
105 | sleep 5; \
106 | done
107 | endif
108 | else
109 | @echo Unsupported on winblows
110 | endif
111 |
112 |
113 | realclean: clean
114 | rm -rf node_modules
115 |
116 | clean:
117 | rm -f $(BUILD)
118 | rm -rf pages/css
119 | $(MAKE) -C shared/deps clean
120 | $(MAKE) -C pages/deps clean
121 |
122 |
123 | test:
124 | npm run test
125 |
126 |
127 | .PHONY: build packed unpacked publish lint sass deps clean realclean test
128 |
--------------------------------------------------------------------------------
/assets/images/great_and_powerful.xcf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/assets/images/great_and_powerful.xcf
--------------------------------------------------------------------------------
/assets/images/screenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/assets/images/screenshot.jpg
--------------------------------------------------------------------------------
/assets/images/slideshow.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/assets/images/slideshow.webp
--------------------------------------------------------------------------------
/assets/images/slideshow.xcf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/assets/images/slideshow.xcf
--------------------------------------------------------------------------------
/assets/notes/command_notes:
--------------------------------------------------------------------------------
1 | command | sub_command | action
2 | 1 1 CHANGE CAMERA
3 | 2 JOIN PLAYER
4 | subject: athleteId to join/teleport to
5 | 3 TELEPORT HOME
6 | 4 4 ELBOW (pull through signal)
7 | 5 5 WAVE
8 | 6 6 SAY (RideOn)
9 | 7 7 RING BELL
10 | 8 8 SAY (hammer time)
11 | 9 9 SAY (i'm toast)
12 | 10 10 SAY (nice)
13 | 11 11 SAY (bringit)
14 | 14 14 END RIDE
15 | 17 17 TAKE PICTURE
16 | 21 21 USE POWER UP
17 | 23 23 REVERSE
18 | 24 - SWITCH VIEW
19 | subject(f3): athleteid
20 | 16 - REQUEST PROFILE
21 | (f5): athleteId
22 | 25 - SOCIAL ACTION (CHAT)
23 | f11: {
24 | 1: 1
25 | 2: 1(direct/private?) or 0 (nearby)
26 | 3: 1
27 | 4: "z" // firstname
28 | 5: "offline" // lastname
29 | 6: "Aaaaaaaaa" // message
30 | 7: "https://avatar.jpg" // avatar
31 | 8: 0
32 | }
33 | 29 - GAME PACKET (f21 = message(f1 = 8, f9 = msg/string(f1 = 1)))
34 |
35 | 22 [1-?] REPLICATE COMMANDS subcommand is treated as the command
36 | 22 1003 ORANGE BAR INCREASE ?? XXX not sure what it is
37 | 22 1004 ORANGE BAR DECREASE ?? XXX not sure what it is
38 | 22 1005 BEEP SOUND XXX Maybe other effect?
39 | 22 1006 BOOP SOUND XXX Maybe other effect?
40 | 22 1020 Some sort of pairing thing XXX
41 | 22 1021 Some sort of pairing thing XXX
42 | 22 1050 START STEERING CALIBRATION
43 | 22 1060 TOGGLE GRAPHS
44 | 22 1080 HUD ON
45 | 22 1081 HUD OFF
46 |
47 |
48 | From decompiled dex classes:
49 | PHONE_TO_GAME_UNKNOWN_COMMAND(0),
50 | CHANGE_CAMERA_ANGLE(1),
51 | JOIN_ANOTHER_PLAYER(2),
52 | TELEPORT_TO_START(3),
53 | ELBOW_FLICK(4),
54 | WAVE(5),
55 | RIDE_ON(6),
56 | BELL(7),
57 | HAMMER_TIME(8),
58 | TOAST(9),
59 | NICE(10),
60 | BRING_IT(11),
61 | DONE_RIDING(14),
62 | CANCEL_DONE_RIDING(15),
63 | DISCARD_ACTIVITY(12),
64 | SAVE_ACTIVITY(13),
65 | REQUEST_FOR_PROFILE(16),
66 | TAKE_SCREENSHOT(17),
67 | OBSOLETE_GROUP_TEXT_MESSAGE(18),
68 | OBSOLETE_SINGLE_PLAYER_TEXT_MESSAGE(19),
69 | MOBILE_API_VERSION(20),
70 | ACTIVATE_POWER_UP(21),
71 | CUSTOM_ACTION(22),
72 | U_TURN(23),
73 | FAN_VIEW(24),
74 | SOCIAL_PLAYER_ACTION(25),
75 | MOBILE_ALERT_RESPONSE(26),
76 | BLEPERIPHERAL_RESPONSE(27),
77 | PAIRING_AS(28),
78 | PHONE_TO_GAME_PACKET(29),
79 | BLEPERIPHERAL_DISCOVERY(30);
80 |
81 |
--------------------------------------------------------------------------------
/assets/notes/splines:
--------------------------------------------------------------------------------
1 | catmull-rom spline and beizer are the two road splines used.
2 |
--------------------------------------------------------------------------------
/build/entitlements.mac.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.cs.allow-jit
6 |
7 | com.apple.security.cs.allow-unsigned-executable-memory
8 |
9 | com.apple.security.cs.disable-library-validation
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/build/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/build/icon.png
--------------------------------------------------------------------------------
/build/images/app-icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/build/images/app-icon.icns
--------------------------------------------------------------------------------
/build/images/app-icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/build/images/app-icon.ico
--------------------------------------------------------------------------------
/build/images/icon256-dev.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/build/images/icon256-dev.png
--------------------------------------------------------------------------------
/build/linux.Dockerfile:
--------------------------------------------------------------------------------
1 | ARG arch
2 | FROM --platform=linux/${arch} fedora:36
3 | RUN dnf install -y make nodejs git python gcc g++ libsecret-devel findutils
4 | RUN git config --global credential.helper store
5 | COPY . sauce4zwift
6 | WORKDIR sauce4zwift
7 |
--------------------------------------------------------------------------------
/build/scripts/notarize.js:
--------------------------------------------------------------------------------
1 | const {notarize} = require('@electron/notarize');
2 |
3 | exports.default = async function notarizing(context) {
4 | const {electronPlatformName, appOutDir} = context;
5 | const skip = process.env.SKIP_NOTARIZE;
6 | if (electronPlatformName !== 'darwin' || skip) {
7 | return;
8 | }
9 | const appName = context.packager.appInfo.productFilename;
10 | const appleId = process.env.APPLE_ID;
11 | const appleIdPassword = process.env.APPLE_ID_PASSWORD;
12 | const teamId = process.env.APPLE_TEAM_ID;
13 | if (!appleId || !appleIdPassword) {
14 | throw new Error("APPLE_ID env not set");
15 | }
16 | return await notarize({
17 | appBundleId: 'io.saucellc.sauce4zwift',
18 | appPath: `${appOutDir}/${appName}.app`,
19 | appleId,
20 | appleIdPassword,
21 | teamId,
22 | });
23 | };
24 |
--------------------------------------------------------------------------------
/https/.gitignore:
--------------------------------------------------------------------------------
1 | *.pem
2 |
--------------------------------------------------------------------------------
/images/mac-trayicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/images/mac-trayicon.png
--------------------------------------------------------------------------------
/images/mac-trayicon@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/images/mac-trayicon@2x.png
--------------------------------------------------------------------------------
/images/win-trayicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/images/win-trayicon.png
--------------------------------------------------------------------------------
/images/win-trayicon@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/images/win-trayicon@2x.png
--------------------------------------------------------------------------------
/pages/analysis-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Analysis",
3 | "name": "Sauce for Zwift™ - Analysis",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Analysis",
7 | "start_url": "/pages/analysis.html",
8 | "display": "standalone",
9 | "orientation": "any",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/analysis-settings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Analysis - Settings - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
25 | Analysis - Settings
26 |
27 |
30 |
31 |
32 |
33 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/pages/analysis.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
9 |
10 | Analysis - Sauce for Zwift™
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
23 |
24 |
25 |
26 |
27 |
28 | Analysis
29 |
30 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/pages/athletes.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Athletes - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
44 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/pages/browser-source-settings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Browser Source - Settings - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
29 |
30 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/pages/browser-source.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Browser Source - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
Browser Source
25 |
32 |
33 |
37 |
43 |
44 |
45 |
49 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/pages/chat-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Chat",
3 | "name": "Sauce for Zwift™ - Chat",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Chat Window",
7 | "start_url": "/pages/chat.html",
8 | "display": "standalone",
9 | "orientation": "portrait",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/chat.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Chat - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 | Chat
28 |
29 |
35 |
36 |
37 |
38 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/pages/deps/.gitignore:
--------------------------------------------------------------------------------
1 | flags
2 | src
3 | css
4 |
--------------------------------------------------------------------------------
/pages/deps/Makefile:
--------------------------------------------------------------------------------
1 |
2 | NODE := ../../node_modules
3 |
4 | default: flags echarts saucecharts
5 |
6 |
7 | flags:
8 | mkdir -p flags
9 | cp $(NODE)/world_countries_lists/data/flags/64x64/*.png flags/
10 |
11 | echarts:
12 | mkdir -p src
13 | cp $(NODE)/echarts/dist/echarts.esm.min.js src/echarts.mjs
14 |
15 | saucecharts:
16 | mkdir -p css/saucecharts src/saucecharts
17 | cp -r $(NODE)/saucecharts/src/* src/saucecharts/
18 | cp -r $(NODE)/saucecharts/css/* css/saucecharts/
19 |
20 | dev: default
21 | rm -f src/saucecharts/*
22 | for x in ~/project/saucecharts/src/*; do \
23 | ln $$x src/saucecharts/ ; \
24 | done
25 | rm -f css/saucecharts/*
26 | for x in ~/project/saucecharts/css/*; do \
27 | ln $$x css/saucecharts/ ; \
28 | done
29 |
30 |
31 | clean:
32 | rm -rf flags src css
33 |
34 |
35 | .PHONY: flags echarts saucecharts
36 |
--------------------------------------------------------------------------------
/pages/eula.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Legal Junk - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
65 |
66 |
78 |
79 |
80 | Obligatory legal stuff...
81 | Click "I Agree" if you consent to all this mumbo jumbo.
82 |
83 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/pages/events-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Events",
3 | "name": "Sauce for Zwift™ - Events",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Events",
7 | "start_url": "/pages/events.html",
8 | "display": "standalone",
9 | "orientation": "any",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/events-settings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Events - Settings - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
25 | Events - Settings
26 |
27 |
30 |
31 |
32 |
33 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/pages/events.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Events - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/pages/file-replay.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | File Replay - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
21 |
22 |
23 |
24 |
25 |
26 | File Replay
27 |
28 |
34 |
35 |
36 |
37 |
Load activity (i.e. .fit file):
38 |
39 |
47 |
Time code:
48 |
53 |
54 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/pages/fonts/MaterialSymbolsRounded.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/fonts/MaterialSymbolsRounded.woff2
--------------------------------------------------------------------------------
/pages/fonts/PermanentMarker.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/fonts/PermanentMarker.woff2
--------------------------------------------------------------------------------
/pages/fonts/Saira.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/fonts/Saira.woff2
--------------------------------------------------------------------------------
/pages/game-control-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Game Control",
3 | "name": "Sauce for Zwift™ - Game Control",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Game Control using companion protocol",
7 | "start_url": "/pages/game-control.html",
8 | "display": "standalone",
9 | "orientation": "any",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/game-control-settings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Game Control - Settings - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
25 | Game Control - Settings
26 |
27 |
30 |
31 |
32 |
33 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/pages/gauge.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/pages/gauges/cadence-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Cadence Gauge",
3 | "name": "Sauce for Zwift™ - Cadence Gauge",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Cadence Gauge Window",
7 | "start_url": "/pages/gauges/cadence.html",
8 | "display": "standalone",
9 | "orientation": "any",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/gauges/cadence.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Cadence Gauge - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 | Cadence Gauge
28 |
29 |
35 |
36 |
37 |
40 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/pages/gauges/draft-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Draft Gauge",
3 | "name": "Sauce for Zwift™ - Draft Gauge",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Draft Gauge Window",
7 | "start_url": "/pages/gauges/draft.html",
8 | "display": "standalone",
9 | "orientation": "any",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/gauges/draft.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Draft Gauge - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 | Draft Gauge
28 |
29 |
35 |
36 |
37 |
40 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/pages/gauges/hr-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Heart Rate Gauge",
3 | "name": "Sauce for Zwift™ - Heart Rate Gauge",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Heart Rate Gauge Window",
7 | "start_url": "/pages/gauges/hr.html",
8 | "display": "standalone",
9 | "orientation": "any",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/gauges/hr.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Heart Rate Gauge - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 | Heart Rate Gauge
28 |
29 |
35 |
36 |
37 |
40 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/pages/gauges/pace-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Speed Gauge",
3 | "name": "Sauce for Zwift™ - Speed Gauge",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Speed Gauge Window",
7 | "start_url": "/pages/gauges/pace.html",
8 | "display": "standalone",
9 | "orientation": "any",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/gauges/pace.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Speed Gauge - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 | Speed Gauge
28 |
29 |
35 |
36 |
37 |
40 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/pages/gauges/power-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Power Gauge",
3 | "name": "Sauce for Zwift™ - Power Gauge",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Power Gauge Window",
7 | "start_url": "/pages/gauges/power.html",
8 | "display": "standalone",
9 | "orientation": "any",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/gauges/power.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Power Gauge - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 | Power Gauge
28 |
29 |
35 |
36 |
37 |
40 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/pages/gauges/wbal-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "W'bal Gauge",
3 | "name": "Sauce for Zwift™ - W'bal Gauge",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - W'bal Gauge Window",
7 | "start_url": "/pages/gauges/wbal.html",
8 | "display": "standalone",
9 | "orientation": "any",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/gauges/wbal.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | W'bal Gauge - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 | W'bal Gauge
28 |
29 |
35 |
36 |
37 |
40 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/pages/geo-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Map",
3 | "name": "Sauce for Zwift™ - Map",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Map Window",
7 | "start_url": "/pages/geo.html",
8 | "display": "standalone",
9 | "orientation": "portrait",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/geo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Map - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 | Map
28 |
29 |
30 |
31 |
37 |
38 |
39 |
40 |
42 |
43 |
44 |
explore
46 |
my_location
48 |
49 |
50 |
51 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/pages/groups-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Groups",
3 | "name": "Sauce for Zwift™ - Groups",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Groups Window",
7 | "start_url": "/pages/groups.html",
8 | "display": "standalone",
9 | "orientation": "portrait",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/groups.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Groups - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 | Groups
28 |
29 |
35 |
36 |
37 |
45 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/pages/images/become_a_patron_button@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/become_a_patron_button@2x.png
--------------------------------------------------------------------------------
/pages/images/blankavatar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/blankavatar.png
--------------------------------------------------------------------------------
/pages/images/examples/elevation-profile-chart.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/examples/elevation-profile-chart.webp
--------------------------------------------------------------------------------
/pages/images/examples/power-zones-horiz-chart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/examples/power-zones-horiz-chart.png
--------------------------------------------------------------------------------
/pages/images/examples/power-zones-pie-chart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/examples/power-zones-pie-chart.png
--------------------------------------------------------------------------------
/pages/images/examples/power-zones-vert-chart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/examples/power-zones-vert-chart.png
--------------------------------------------------------------------------------
/pages/images/examples/sauce-line-chart-cap-50pct.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/examples/sauce-line-chart-cap-50pct.png
--------------------------------------------------------------------------------
/pages/images/fa/bolt-duotone.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/pages/images/fa/caret-left-regular.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/pages/images/fa/caret-right-regular.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/pages/images/fa/cog-duotone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pages/images/fa/flag-checkered-duotone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pages/images/fa/heartbeat-duotone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pages/images/fa/plus-square-duotone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pages/images/fa/search-minus-duotone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pages/images/fa/search-plus-duotone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pages/images/fa/solar-system-duotone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pages/images/fa/stopwatch-regular.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/pages/images/fa/tachometer-duotone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pages/images/fa/times-circle-duotone.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/pages/images/fa/undo-regular.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/pages/images/fa/user-circle-solid.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/pages/images/fa/wind-duotone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pages/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/favicon.png
--------------------------------------------------------------------------------
/pages/images/great_and_powerful.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/great_and_powerful.webp
--------------------------------------------------------------------------------
/pages/images/great_and_powerful_bubble.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/great_and_powerful_bubble.webp
--------------------------------------------------------------------------------
/pages/images/icon128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon128.png
--------------------------------------------------------------------------------
/pages/images/icon16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon16.png
--------------------------------------------------------------------------------
/pages/images/icon19.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon19.png
--------------------------------------------------------------------------------
/pages/images/icon256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon256.png
--------------------------------------------------------------------------------
/pages/images/icon32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon32.png
--------------------------------------------------------------------------------
/pages/images/icon38.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon38.png
--------------------------------------------------------------------------------
/pages/images/icon48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon48.png
--------------------------------------------------------------------------------
/pages/images/icon64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/icon64.png
--------------------------------------------------------------------------------
/pages/images/logo_horiz_128x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_horiz_128x48.png
--------------------------------------------------------------------------------
/pages/images/logo_horiz_320x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_horiz_320x120.png
--------------------------------------------------------------------------------
/pages/images/logo_horiz_64x24.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_horiz_64x24.png
--------------------------------------------------------------------------------
/pages/images/logo_vert_120x320.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_vert_120x320.png
--------------------------------------------------------------------------------
/pages/images/logo_vert_32x85.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_vert_32x85.png
--------------------------------------------------------------------------------
/pages/images/logo_vert_48x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_vert_48x128.png
--------------------------------------------------------------------------------
/pages/images/logo_vert_96x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/logo_vert_96x256.png
--------------------------------------------------------------------------------
/pages/images/map-fade-mask.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/map-fade-mask.png
--------------------------------------------------------------------------------
/pages/images/mod-store-badge.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/mod-store-badge.webp
--------------------------------------------------------------------------------
/pages/images/ranking/cat1-darkbg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat1-darkbg.png
--------------------------------------------------------------------------------
/pages/images/ranking/cat1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat1.png
--------------------------------------------------------------------------------
/pages/images/ranking/cat2-darkbg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat2-darkbg.png
--------------------------------------------------------------------------------
/pages/images/ranking/cat2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat2.png
--------------------------------------------------------------------------------
/pages/images/ranking/cat3-darkbg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat3-darkbg.png
--------------------------------------------------------------------------------
/pages/images/ranking/cat3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat3.png
--------------------------------------------------------------------------------
/pages/images/ranking/cat4-darkbg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat4-darkbg.png
--------------------------------------------------------------------------------
/pages/images/ranking/cat4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat4.png
--------------------------------------------------------------------------------
/pages/images/ranking/cat5-darkbg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat5-darkbg.png
--------------------------------------------------------------------------------
/pages/images/ranking/cat5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/cat5.png
--------------------------------------------------------------------------------
/pages/images/ranking/pro-darkbg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/pro-darkbg.png
--------------------------------------------------------------------------------
/pages/images/ranking/pro.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/pro.png
--------------------------------------------------------------------------------
/pages/images/ranking/world-tour-darkbg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/world-tour-darkbg.png
--------------------------------------------------------------------------------
/pages/images/ranking/world-tour.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/ranking/world-tour.png
--------------------------------------------------------------------------------
/pages/images/smart_trainer.svg:
--------------------------------------------------------------------------------
1 |
25 |
--------------------------------------------------------------------------------
/pages/images/zp_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/images/zp_logo.png
--------------------------------------------------------------------------------
/pages/logs.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Debug Logs - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
21 |
22 |
23 |
50 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/pages/nearby-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Nearby",
3 | "name": "Sauce for Zwift™ - Nearby",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Nearby Raw Data",
7 | "start_url": "/pages/nearby.html",
8 | "display": "standalone",
9 | "orientation": "any",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/nearby.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Nearby - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 | Nearby Athletes
28 |
30 |
31 |
32 |
38 |
39 |
40 |
46 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/pages/overview-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Overview",
3 | "name": "Sauce for Zwift™ - Overview",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Overview Window",
7 | "start_url": "/pages/overview.html",
8 | "display": "standalone",
9 | "orientation": "any",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/overview.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |

24 |
open_with
25 |
26 |
27 |
28 |
29 |
30 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/pages/patron-a51.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Enter Code - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | Enter your special code below
65 |
66 |
70 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/pages/patron-checking.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Checking membership - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
53 |
54 |
55 |
56 |
57 |
58 | 🤔 Checking membership...
59 |
60 |
61 |
62 |
63 | Asking mom and dad if I can play...
64 |
65 |
66 |
67 | For assistance send an email to support@sauce.llc.
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/pages/patron-success.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Access Granted - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
56 |
57 |
58 |
59 |
66 |
67 |
68 | Never a doubt in my mind!
69 |
70 |
71 |
72 | Proceed...
73 |
74 |
75 |
76 |
77 |
78 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/pages/patron-waiting.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Waiting for validation - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
52 |
53 |
54 |
55 |
56 |
57 | Waiting for validation...
58 |
59 |
60 |
61 | Leave this window open while you log in to Patreon with your browser.
62 |
63 |
64 |
65 | Or if something went wrong: Try again
66 |
67 |
68 |
69 | HINT: A new browser window should have opened to guide you through the next steps.
70 | For assistance send an email to support@sauce.llc.
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/pages/profile-avatar.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Profile Avatar - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
26 |
27 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | Profile Avatar
63 |
64 |
68 |
69 |
70 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/pages/profile.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Athlete Profile - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/pages/scss/_charts.scss:
--------------------------------------------------------------------------------
1 | @use 'color';
2 |
3 | @mixin legend($selector) {
4 | #{$selector} {
5 | display: flex;
6 | flex-wrap: wrap;
7 | align-items: center;
8 | justify-content: space-evenly;
9 | max-width: 100%;
10 | user-select: none;
11 |
12 | .s-legend-item {
13 | display: flex;
14 | align-items: center;
15 | justify-content: center;
16 | padding: 0.12em 0.33em;
17 | cursor: pointer;
18 |
19 | &.hidden {
20 | opacity: 0.4;
21 |
22 | .color {
23 | background-color: color.get(fg, 0.4) !important;
24 | }
25 | }
26 |
27 | &:hover {
28 | text-decoration: underline;
29 | }
30 |
31 | .color {
32 | border-radius: 50%;
33 | width: 0.7em;
34 | height: 0.7em;
35 | overflow: hidden;
36 | border: 0.075em solid color.shade(fg, 10%, 0.8);
37 | margin-right: 0.3em;
38 | opacity: 0.82;
39 | }
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/pages/scss/_color.scss:
--------------------------------------------------------------------------------
1 | @function get($key, $alpha: 100%) {
2 | @return hsl(
3 | var(--theme-#{$key}-hue)
4 | var(--theme-#{$key}-sat)
5 | var(--theme-#{$key}-light)
6 | / $alpha);
7 | }
8 |
9 | @function alpha($key, $alpha) {
10 | @return hsl(
11 | var(--theme-#{$key}-hue)
12 | var(--theme-#{$key}-sat)
13 | var(--theme-#{$key}-light)
14 | / $alpha);
15 | }
16 |
17 | @function darken($key, $amount, $alpha: 100%) {
18 | @return hsl(
19 | var(--theme-#{$key}-hue)
20 | var(--theme-#{$key}-sat)
21 | calc(var(--theme-#{$key}-light) - #{$amount})
22 | / $alpha);
23 | }
24 |
25 | @function lighten($key, $amount, $alpha: 100%) {
26 | @return hsl(
27 | var(--theme-#{$key}-hue)
28 | var(--theme-#{$key}-sat)
29 | calc(var(--theme-#{$key}-light) + #{$amount})
30 | / $alpha);
31 | }
32 |
33 | @function shade($key, $amount, $alpha: 100%, $saturate: null) {
34 | @if $saturate != null {
35 | @return hsl(
36 | var(--theme-#{$key}-hue)
37 | calc(var(--theme-#{$key}-sat) + #{$saturate})
38 | calc(var(--theme-#{$key}-light) + (#{$amount} * var(--theme-#{$key}-shade-dir)))
39 | / $alpha);
40 | } @else {
41 | @return hsl(
42 | var(--theme-#{$key}-hue)
43 | var(--theme-#{$key}-sat)
44 | calc(var(--theme-#{$key}-light) + (#{$amount} * var(--theme-#{$key}-shade-dir)))
45 | / $alpha);
46 | }
47 | }
48 |
49 | @function hue($key, $amount, $alpha: 100%, $shade: 0%) {
50 | @return hsl(
51 | calc(var(--theme-#{$key}-hue) + #{$amount})
52 | var(--theme-#{$key}-sat)
53 | calc(var(--theme-#{$key}-light) + (#{$shade} * var(--theme-#{$key}-shade-dir)))
54 | / $alpha);
55 | }
56 |
--------------------------------------------------------------------------------
/pages/scss/animations.scss:
--------------------------------------------------------------------------------
1 | :root {
2 | --tilt: 0deg;
3 | }
4 |
5 | @keyframes pournshake {
6 | 0%,
7 | 60%,
8 | 100% { transform: translate(0, 0) rotate(var(--tilt)) scale(1); }
9 | 30% { transform: translate(0, 0) rotate(calc(var(--tilt) + 180deg)) scale(1.2); }
10 | 32% { transform: translate(0.2em, 0.2em) rotate(calc(var(--tilt) + 185deg)) scale(1.2); }
11 | 34% { transform: translate(-0.2em, 0) rotate(calc(var(--tilt) + 175deg)) scale(0.8); }
12 | 36% { transform: translate(0.2em, 0.1em) rotate(calc(var(--tilt) + 180deg)) scale(1.2); }
13 | 38% { transform: translate(-0.2em, -0.2em) rotate(calc(var(--tilt) + 185deg)) scale(0.8); }
14 | 40% { transform: translate(0.2em, 0.2em) rotate(calc(var(--tilt) + 185deg)) scale(1.2); }
15 | 42% { transform: translate(-0.2em, 0) rotate(calc(var(--tilt) + 175deg)) scale(0.8); }
16 | 44% { transform: translate(0.2em, 0.1em) rotate(calc(var(--tilt) + 180deg)) scale(1.2); }
17 | 46% { transform: translate(-0.2em, -0.2em) rotate(calc(var(--tilt) + 185deg)) scale(0.8); }
18 | }
19 |
20 | @keyframes highlight {
21 | from {
22 | filter: brightness(1);
23 | }
24 |
25 | 33% {
26 | filter: brightness(0.80);
27 | }
28 |
29 | 66% {
30 | filter: brightness(1.20);
31 | }
32 |
33 | to {
34 | filter: brightness(1);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/pages/scss/browser-source.scss:
--------------------------------------------------------------------------------
1 | @use 'color';
2 |
3 | html {
4 | font-size: 16px;
5 |
6 | &.solid-background {
7 | background: var(--background-color);
8 |
9 | body {
10 | background: transparent;
11 | }
12 | }
13 |
14 | --opacity: 1;
15 | }
16 |
17 | #content {
18 | align-items: center;
19 | justify-content: center;
20 | opacity: var(--opacity);
21 |
22 | > webview {
23 | width: 100%;
24 | height: 100%;
25 | }
26 |
27 | > .load-fail-reason {
28 | width: 100%;
29 | height: 100%;
30 | background: color.get(bg);
31 | color: color.get(fg);
32 | display: flex;
33 | align-items: center;
34 | justify-content: center;
35 | }
36 |
37 | &:not(.load-failed) > .load-fail-reason {
38 | display: none;
39 | }
40 |
41 | &.load-failed > webview {
42 | display: none;
43 | }
44 | }
45 |
46 | #titlebar {
47 | position: relative !important;
48 | --extra-height: 1.4em;
49 |
50 | header {
51 | // XXX probably won't work on windows
52 | app-region: drag;
53 |
54 | > * {
55 | app-region: no-drag;
56 | }
57 | }
58 |
59 | header .double-row {
60 | padding-top: 0.15em;
61 | padding-bottom: 0.15em;
62 | display: flex;
63 | overflow: hidden;
64 | flex-direction: column;
65 | justify-content: center;
66 | flex-shrink: 0.00000001; // last resort only to keep right side buttons
67 |
68 | .title {
69 | display: initial; // fixes text-overflow
70 | padding-top: 0.15em;
71 | padding-left: 0.3em;
72 | // Let buttons exclusively control width
73 | width: 0;
74 | min-width: 100%;
75 | height: auto;
76 | text-overflow: ellipsis;
77 | overflow: hidden;
78 | }
79 |
80 | .buttons { }
81 | }
82 |
83 | .url-holder {
84 | display: flex;
85 | overflow: hidden;
86 | flex: 1 0.5 100%;
87 | min-width: 0;
88 | background-color: #111;
89 | color: #fff;
90 | border-radius: 0.28em;
91 | margin-left: 0.4em;
92 | margin-right: 0.4em;
93 | padding-left: 0.3em;
94 |
95 | &:focus-within {
96 | outline: 1px solid color.get(primary);
97 | }
98 |
99 | input {
100 | margin: 0;
101 | background: transparent;
102 | border: none;
103 | color: inherit;
104 | flex: 1 1 100%;
105 | overflow: hidden;
106 | outline: none !important;
107 | }
108 |
109 | .pin {
110 | &.pinned {
111 | font-variation-settings: 'FILL' 1;
112 | color: #1c9be3;
113 | -webkit-text-stroke: 1px #fff;
114 | }
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/pages/scss/elevation.scss:
--------------------------------------------------------------------------------
1 | .sauce-elevation-profile-container {
2 | font-variant-numeric: tabular-nums;
3 | }
4 |
--------------------------------------------------------------------------------
/pages/scss/expandable-table.scss:
--------------------------------------------------------------------------------
1 | @use 'color';
2 |
3 | table.expandable {
4 | border-collapse: collapse;
5 |
6 | > tbody > tr {
7 | transition: background 150ms, border 150ms;
8 |
9 | &:nth-child(4n - 1) {
10 | background-color: color.get(intrinsic-inverted, 0.03);
11 | }
12 |
13 | &:nth-child(2n - 1) {
14 | &:not(.expanded) + tr {
15 | display: none;
16 | }
17 | }
18 |
19 | &.summary {
20 | cursor: pointer;
21 |
22 | &:not(.expanded):hover {
23 | background-color: color.get(intrinsic-inverted, 0.15);
24 | }
25 | }
26 |
27 | &.expanded,
28 | &.expanded + tr.details {
29 | background-color: color.shade(intrinsic-inverted, 20%, 0.1);
30 | }
31 |
32 | &.expanded {
33 | border-top: 0.2em solid color.shade(intrinsic-inverted, 0%, 50%);
34 |
35 | > td {
36 | font-weight: bold;
37 | }
38 | }
39 |
40 | &.expanded + .details {
41 | border-bottom: 0.2em solid color.shade(intrinsic-inverted, 0%, 50%);
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/pages/scss/file-replay.scss:
--------------------------------------------------------------------------------
1 | input[type="file"] {
2 | color: fieldtext;
3 | }
4 |
5 | #content {
6 | overflow: auto;
7 | font-size: 0.6em;
8 | padding: 1em;
9 |
10 | .category {
11 | font-weight: 800;
12 | font-size: 1.1em;
13 | font-variant: all-small-caps;
14 | line-height: 1;
15 | text-shadow: 0 0 1px #000;
16 | opacity: 0.9;
17 | }
18 |
19 | .button-group {
20 | margin: 0.5em 0;
21 | }
22 |
23 | .button {
24 | margin: 0;
25 | padding: 0.1rem;
26 |
27 | &:not(:last-child) {
28 | margin-right: 0.4em;
29 | }
30 |
31 | ms {
32 | font-size: 2em;
33 | }
34 | }
35 |
36 | .timecode {
37 | justify-content: space-evenly;
38 | align-items: center;
39 |
40 | .button {
41 | flex: 0 0 auto;
42 | margin: 0;
43 | }
44 | }
45 |
46 | .timecode-value {
47 | font-size: 1.2em;
48 | font-weight: 800;
49 | letter-spacing: 5px;
50 | background: white;
51 | color: black;
52 | padding: 0.2em 1em;
53 | margin-top: 0.2em;
54 | border-radius: 10em;
55 | box-shadow: 1px 1px 4px 0 #0003;
56 | font-variant-numeric: tabular-nums;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/pages/scss/game-control.scss:
--------------------------------------------------------------------------------
1 | html:not(.connected) {
2 | #content .button {
3 | pointer-events: none;
4 | opacity: 0.5;
5 | }
6 | }
7 |
8 | html.settings-mode #content header {
9 | app-region: initial !important;
10 | }
11 |
12 | #titlebar .title {
13 | flex-direction: column !important;
14 | align-items: flex-start !important;
15 | justify-content: center !important;
16 |
17 | .top { }
18 |
19 | .bottom {
20 | font-variant: all-small-caps;
21 | font-weight: 600;
22 | line-height: 0.65;
23 | opacity: 0.7;
24 | }
25 | }
26 |
27 | #content {
28 | overflow: auto;
29 | font-size: 0.6em;
30 |
31 | .category {
32 | font-weight: 800;
33 | font-size: 1.1em;
34 | font-variant: all-small-caps;
35 | margin-left: 0.6rem;
36 | line-height: 1;
37 | text-shadow: 0 0 1px #000;
38 | opacity: 0.9;
39 | }
40 |
41 | .category + .button-group {
42 | margin-top: 0;
43 | }
44 |
45 | .button-group {
46 | margin: 0.5em;
47 |
48 | b {
49 | writing-mode: vertical-lr;
50 | text-align: center;
51 | }
52 | }
53 |
54 | .button {
55 | text-transform: uppercase;
56 | font-weight: 700;
57 | min-height: 3em;
58 | padding-left: 0.4em;
59 | padding-right: 0.4em;
60 | text-align: center; // XXX tempted to make this std, makes wrapped text centered
61 |
62 | ms:not([xl]) {
63 | padding-left: 0.3em;
64 | margin-bottom: 0;
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/pages/scss/gauge.scss:
--------------------------------------------------------------------------------
1 | html {
2 | &.solid-background {
3 | background: var(--background-color);
4 |
5 | body {
6 | background: transparent;
7 | }
8 | }
9 | }
10 |
11 | #content {
12 | .gauge {
13 | flex-grow: 1;
14 | margin-bottom: max(-20vw, -20vh);
15 | font-variant-numeric: tabular-nums;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/pages/scss/profile.scss:
--------------------------------------------------------------------------------
1 | @import url(profile_tpl.css);
2 |
3 | html {
4 | font-size: 3.9mm;
5 | }
6 |
7 | body > main {
8 | display: flex;
9 | flex-direction: column;
10 | flex: 1 1;
11 | max-height: 100%;
12 |
13 | > .profile {
14 | max-height: 100%;
15 |
16 | section {
17 | overflow: auto;
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/pages/scss/segments.scss:
--------------------------------------------------------------------------------
1 | @use 'color';
2 |
3 | html {
4 | &.solid-background {
5 | background: var(--background-color);
6 |
7 | body {
8 | background: transparent !important;
9 | }
10 | }
11 | }
12 |
13 |
14 | #content {
15 | overflow: hidden;
16 | }
17 |
18 | .tabbed {
19 | overflow: hidden;
20 | flex-grow: 1;
21 |
22 | > .tab {
23 | padding: 0;
24 | }
25 | }
26 |
27 | .results {
28 | font-size: 0.7em;
29 | display: grid;
30 | grid-auto-columns: min-content 1fr auto auto auto;
31 | grid-auto-rows: auto auto;
32 |
33 | .result {
34 | display: contents;
35 |
36 | &:nth-child(even) > * {
37 | background-color: color.shade(fg, 100%, 0.1);
38 | }
39 | &:nth-child(odd) > * {
40 | background-color: color.shade(fg, 100%, 0.2);
41 | }
42 |
43 | > * {
44 | padding: 0.2em;
45 | display: flex;
46 | justify-content: flex-start;
47 | align-items: baseline;
48 | }
49 |
50 | .place {
51 | grid-column: 1;
52 | grid-row: span 2;
53 | display: flex;
54 | align-items: center;
55 | justify-content: center;
56 | padding-left: 0.5em;
57 | padding-right: 0.5em;
58 | font-weight: 700;
59 |
60 | .trophy {
61 | font-size: 1.8em;
62 | font-weight: 600;
63 | filter: drop-shadow(1px 1px 4px #0004);
64 |
65 | &.gold {
66 | color: gold;
67 | }
68 | &.silver {
69 | color: silver;
70 | }
71 | &.bronze {
72 | color: #b35534;
73 | }
74 | }
75 | }
76 |
77 | .name {
78 | grid-column: 2 / 5;
79 | grid-row: span 1;
80 | padding-bottom: 0;
81 | white-space: nowrap;
82 | overflow: hidden;
83 | }
84 |
85 | .time {
86 | padding-top: 0;
87 | grid-column: 2 / 5;
88 | grid-row: span 1;
89 | font-weight: 600;
90 |
91 | .milliseconds {
92 | font-size: 0.8em;
93 | }
94 | }
95 |
96 | .hr {
97 | grid-column: 4;
98 | grid-row: span 1;
99 | }
100 |
101 | .power {
102 | grid-column: 5;
103 | grid-row: span 1;
104 | }
105 |
106 | .when {
107 | font-size: 0.9em;
108 | font-weight: 300;
109 | grid-column: 5;
110 | grid-row: span 1;
111 | padding-right: 1em;
112 | }
113 | }
114 | }
115 |
116 |
--------------------------------------------------------------------------------
/pages/segments-settings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Segments - Settings - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | Segments - Settings
23 |
24 |
27 |
28 |
29 |
30 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/pages/segments.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Segments - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
35 |
36 |
37 |
38 | Live
39 | Just me
40 |
41 |
Live...
42 |
Just me...
43 |
44 |
47 |
48 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/pages/sounds/great_and_powerful.ogg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/sounds/great_and_powerful.ogg
--------------------------------------------------------------------------------
/pages/sounds/soundtweet.ogg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SauceLLC/sauce4zwift/2127b548c9e6e8f2f485a0079b183f1d814c96b4/pages/sounds/soundtweet.ogg
--------------------------------------------------------------------------------
/pages/src/custom-elements.mjs:
--------------------------------------------------------------------------------
1 |
2 | export const themes = [
3 | {id: "sauce", name: "Sauce Default"},
4 | {id: "bluepink", name: "Ice Blue Pink"},
5 | {id: "green", name: "Green Lantern"},
6 | {id: "burgundy", name: "Ron Burgundy"},
7 | {id: "aqua", name: "Aqua Salad"},
8 | {id: "watermelon", name: "Watermelon"},
9 | {id: "light", name: "Light"},
10 | {id: "dark", name: "Dark"},
11 | {id: "transparent-light", name: "Light", group: "Transparent"},
12 | {id: "transparent-dark", name: "Dark", group: "Transparent"},
13 | {id: "semi-transparent-dark", name: "Semi", group: "Transparent"},
14 | {id: "gpt-vibrant", name: "Vibrant", group: "AI Generated"},
15 | {id: "gpt-sunset", name: "Sunset", group: "AI Generated"},
16 | ];
17 |
18 | class ThemeSelect extends HTMLSelectElement {
19 | constructor() {
20 | super();
21 | this.setAttribute('name', this.hasAttribute('override') ? 'themeOverride' : '/theme');
22 | this.render();
23 | }
24 |
25 | render() {
26 | const _themes = this.hasAttribute('override') ?
27 | [{id: '', name: 'Use app setting'}, ...themes] :
28 | themes.map(x => x.id === 'sauce' ? {...x, id: ''} : x);
29 | const groups = new Map();
30 | for (const x of _themes) {
31 | if (!groups.has(x.group)) {
32 | groups.set(x.group, []);
33 | }
34 | groups.get(x.group).push(x);
35 | }
36 | for (const [group, entries] of groups.entries()) {
37 | let parent;
38 | if (group) {
39 | parent = document.createElement('optgroup');
40 | parent.label = group;
41 | this.append(parent);
42 | } else {
43 | parent = this;
44 | }
45 | for (const x of entries) {
46 | const option = document.createElement('option');
47 | option.value = x.id;
48 | option.label = x.name;
49 | parent.append(option);
50 | }
51 | }
52 | }
53 |
54 | update() {
55 | while (this.childNodes.length) {
56 | this.removeChild(this.childNodes[0]);
57 | }
58 | this.render();
59 | }
60 | }
61 | customElements.define('sauce-theme', ThemeSelect, {extends: 'select'});
62 |
--------------------------------------------------------------------------------
/pages/src/file-replay.mjs:
--------------------------------------------------------------------------------
1 | import * as common from './common.mjs';
2 | import * as locale from '../../shared/sauce/locale.mjs';
3 |
4 | common.enableSentry();
5 |
6 | let playing;
7 | let timecodeOffset;
8 | let timecode;
9 |
10 |
11 | const _timeCodeEl = document.querySelector('.timecode-value');
12 | function drawTimeCode() {
13 | requestAnimationFrame(drawTimeCode);
14 | const realTimeAdjust = playing ? (Date.now() - timecodeOffset) / 1000 : 0;
15 | const ts = timecode + realTimeAdjust || 0;
16 | common.softInnerHTML(_timeCodeEl, locale.human.timer(ts, {ms: true, long: true}));
17 | }
18 |
19 |
20 | async function updateStatus() {
21 | const status = await common.rpc.fileReplayStatus();
22 | if (status.state === 'playing') {
23 | playing = true;
24 | }
25 | if (status.state !== 'inactive') {
26 | timecodeOffset = Date.now();
27 | timecode = status.ts;
28 | }
29 | document.querySelectorAll('.button-group .button.disabled')
30 | .forEach(x => x.classList.toggle('disabled', status.state === 'inactive'));
31 | }
32 |
33 |
34 | export async function main() {
35 | common.initInteractionListeners();
36 | document.querySelector('input[name="activity"]').addEventListener('input', async ev => {
37 | const file = ev.currentTarget.files[0];
38 | if (!file) {
39 | return;
40 | }
41 | const ab = await file.arrayBuffer();
42 | const payload = btoa(Array.from(new Uint8Array(ab)).map(x => String.fromCharCode(x)).join(''));
43 | try {
44 | await common.rpc.fileReplayLoad({
45 | type: 'base64',
46 | payload,
47 | });
48 | } catch(e) {
49 | alert(e.message);
50 | }
51 | document.querySelectorAll('.button-group .button.disabled')
52 | .forEach(x => x.classList.remove('disabled'));
53 | });
54 | document.querySelector('#content').addEventListener('click', async ev => {
55 | const btn = ev.target.closest('.button');
56 | if (!btn) {
57 | return;
58 | }
59 | const args = btn.dataset.args ? JSON.parse(btn.dataset.args) : [];
60 | await common.rpc[btn.dataset.call](...args);
61 | await updateStatus();
62 | });
63 | common.subscribe('file-replay-timesync', ev => {
64 | timecodeOffset = Date.now();
65 | timecode = ev.ts;
66 | playing = ev.playing;
67 | });
68 | await updateStatus();
69 | drawTimeCode();
70 | }
71 |
72 |
73 | export async function settingsMain() {
74 | common.initInteractionListeners();
75 | await common.initSettingsForm('form')();
76 | }
77 |
--------------------------------------------------------------------------------
/pages/src/game-control.mjs:
--------------------------------------------------------------------------------
1 | import * as common from './common.mjs';
2 |
3 | common.enableSentry();
4 |
5 |
6 | function updateConnStatus(s) {
7 | if (!s) {
8 | s = {connected: false, state: 'disabled'};
9 | }
10 | document.documentElement.classList.toggle('connected', s.connected);
11 | const statusEl = document.querySelector('.status');
12 | statusEl.textContent = s.state;
13 | }
14 |
15 |
16 | export async function main() {
17 | common.initInteractionListeners();
18 | common.subscribe('status', updateConnStatus, {source: 'gameConnection', persistent: true});
19 | document.querySelector('#content').addEventListener('click', ev => {
20 | const btn = ev.target.closest('.button');
21 | if (!btn) {
22 | return;
23 | }
24 | const args = btn.dataset.args ? JSON.parse(btn.dataset.args) : [];
25 | common.rpc[btn.dataset.call](...args);
26 | });
27 | document.addEventListener('sauce-ws-status', async ({detail}) => {
28 | if (detail === 'connected') {
29 | updateConnStatus(await common.rpc.getGameConnectionStatus());
30 | } else {
31 | updateConnStatus({connected: false, state: 'not running'});
32 | updateConnStatus(await common.rpc.getGameConnectionStatus());
33 | }
34 | });
35 | updateConnStatus(await common.rpc.getGameConnectionStatus());
36 | }
37 |
38 |
39 | export async function settingsMain() {
40 | common.initInteractionListeners();
41 | await common.initSettingsForm('form')();
42 | }
43 |
--------------------------------------------------------------------------------
/pages/src/preloads.js:
--------------------------------------------------------------------------------
1 | const fonts = [
2 | '/pages/fonts/MaterialSymbolsRounded.woff2?v=2',
3 | '/pages/fonts/Saira.woff2?v=1',
4 | ];
5 |
6 | document.head.append(...fonts.map(x => {
7 | const link = document.createElement('link');
8 | link.rel = 'preload';
9 | link.href = x;
10 | link.as = 'font';
11 | link.type = 'font/woff2';
12 | // preloads + cors + file:// are a trainwreck in the w3c spec.
13 | if (location.protocol !== 'file:') {
14 | link.crossOrigin = 'anonymous';
15 | }
16 | return link;
17 | }));
18 |
--------------------------------------------------------------------------------
/pages/src/welcome.mjs:
--------------------------------------------------------------------------------
1 | import * as common from './common.mjs';
2 |
3 | common.enableSentry();
4 |
5 | export async function main() {
6 | common.initInteractionListeners();
7 | const version = await common.rpc.getVersion();
8 | setTimeout(() => {
9 | document.documentElement.classList.add('animate');
10 | setTimeout(() => document.documentElement.classList.add('fadeout'), 14000);
11 | document.querySelector('footer').textContent = `v${version}`;
12 | }, 200);
13 | }
14 |
--------------------------------------------------------------------------------
/pages/system-message.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | System Message - Sauce for Zwift™
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
19 |
20 |
21 |
22 |
Hello
23 | Hello
24 | Hello
25 | Hello
26 | Hello
27 | Hello
28 | Hello
29 | Hello
30 | Hello
31 | Hello
32 | Hello
33 | Hello
34 | Hello
35 | Hello
36 | Hello
37 | Hello
38 | Hello
39 | Hello
40 | Hello
41 | Hello
42 | Hello
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/pages/templates/analysis/activity-summary.html.tpl:
--------------------------------------------------------------------------------
1 |
2 | <% if (athleteData?.stats) { %>
3 | Start:{-humanTime(Date.now() - (athleteData.stats.elapsedTime * 1000), {html: true})-}
4 | Time:{-humanTimer(athleteData.stats.activeTime, {full: true, html: true})-}
5 | Distance:{-humanDistance(athleteData.state.distance, {suffix: true, html: true})-}
6 | <% } %>
7 |
8 |
--------------------------------------------------------------------------------
/pages/templates/analysis/laps.html.tpl:
--------------------------------------------------------------------------------
1 | <% const hasLaps = !!(laps && laps.length); %>
2 |
3 |
4 |
5 | Lap |
6 | Time |
7 | Distance |
8 | Power |
9 | Pace |
10 | HR |
11 | Coffee |
12 |
13 |
14 |
15 | <% if (hasLaps) { %>
16 | <% for (const [i, x] of laps.entries()) { %>
17 |
18 | {{i+1}} |
19 | {-humanTimer(x.stats.activeTime, {long: true, ms: true, html: true})-} |
20 | {-humanDistance(streams.distance[x.endIndex + 1] - streams.distance[x.startIndex], {suffix: true, html: true})-} |
21 | <% if (settings.preferWkg && athleteData.athlete?.weight) { %>
22 | {-humanWkg(x.stats.power.avg / athleteData.athlete?.weight, {suffix: true, html: true})-} |
24 | <% } else { %>
25 | {-humanPower(x.stats.power.avg, {suffix: true, html: true})-} |
27 | <% } %>
28 | {-humanPace(x.stats.speed.avg, {suffix: true, html: true, sport: x.sport})-} |
29 | {-humanNumber(x.stats.hr.avg, {suffix: 'bpm', html: true})-} |
30 | {-humanTimer(x.stats.coffeeTime, {long: true, html: true})-} |
31 |
32 | <% } %>
33 | <% } else { %>
34 |
35 | No Lap Data |
36 |
37 | <% } %>
38 |
39 |
40 |
--------------------------------------------------------------------------------
/pages/templates/analysis/peak-efforts.html.tpl:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 | <% if (obj.peaks) { %>
12 | <% for (const [k, x] of Object.entries(peaks)) { %>
13 |
14 | {-humanDuration(k, {html: true})-} |
15 |
16 | {-formatter(x.avg)-}
17 | <% if (x.rank?.badge) { %>
18 |
19 | <% } %>
20 | |
21 |
22 | <% } %>
23 | <% } %>
24 |
25 |
26 |
--------------------------------------------------------------------------------
/pages/templates/analysis/segment-results.html.tpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Place |
6 | Name |
7 | Time |
8 | Power |
9 | HR |
10 | Date |
11 |
12 |
13 | <% for (const [i, x] of results.entries()) { %>
14 |
15 |
16 | <% if (i < 3) { %>
17 | trophy
18 | <% } else { %>
19 | {-humanPlace(i + 1, {suffix: true, html: true})-}
20 | <% } %>
21 | |
22 |
23 | {{x.firstName}} {{x.lastName}}
25 | <% if (x.gender === 'female') { %>
26 | female
27 | <% } %>
28 | |
29 | {-humanTimer(x.elapsed, {long: true, ms: true, html: true})-} |
30 |
31 | <% if (x.powerType !== 'POWER_METER') { %>
32 | ~
33 | <% } %>
34 | {-humanPower(x.avgPower, {suffix: true, html: true})-} |
35 | <% if (x.powerType !== 'POWER_METER') { %>
36 |
37 | <% } %>
38 | {-humanNumber(x.avgHR || undefined, {suffix: 'bpm', html: true})-} |
39 | {{humanRelTime(x.ts, {short: true, maxParts: 1})}} |
40 |
41 | <% } %>
42 |
43 |
44 |
--------------------------------------------------------------------------------
/pages/templates/analysis/segments.html.tpl:
--------------------------------------------------------------------------------
1 | <% const hasSegments = !!(segments && segments.length); %>
2 |
3 |
4 |
5 | Segment |
6 | Time |
7 | Distance |
8 | Power |
9 | Pace |
10 | HR |
11 |
12 |
13 |
14 | <% if (hasSegments) { %>
15 | <% for (const [i, x] of segments.entries()) { %>
16 |
17 | {{x.segment.friendlyName || x.segment.name}} |
18 | {-humanTimer(x.stats.elapsedTime, {long: true, ms: true, html: true})-} |
19 | {-humanDistance(x.segment.distance, {suffix: true, html: true})-} |
20 | <% if (settings.preferWkg && athleteData.athlete?.weight) { %>
21 | {-humanWkg(x.stats.power.avg / athleteData.athlete?.weight, {suffix: true, html: true})-} |
23 | <% } else { %>
24 | {-humanPower(x.stats.power.avg, {suffix: true, html: true})-} |
26 | <% } %>
27 | {-humanPace(x.stats.speed.avg, {suffix: true, html: true, sport: x.sport})-} |
28 | {-humanNumber(x.stats.hr.avg, {suffix: 'bpm', html: true})-} |
29 |
30 | |
31 | <% } %>
32 | <% } else { %>
33 |
34 | No Segment Data |
35 |
36 | <% } %>
37 |
38 |
39 |
--------------------------------------------------------------------------------
/pages/templates/athlete-cards.html.tpl:
--------------------------------------------------------------------------------
1 | <% for (const x of obj) { %>
2 | <% const athlete = x.athlete || {}; %>
3 |
5 | <% if (x.athlete) { %>
6 |
7 | <% if (athlete.avatar) { %>
8 |

9 | <% } else {%>
10 |

11 | <% } %>
12 |
13 | {{athlete.sanitizedFullname}}
14 | <% } else if (x.profile) { %>
15 | <% const p = x.profile; %>
16 |
17 | <% if (p.imageSrcLarge || p.imageSrc) { %>
18 |

19 | <% } else {%>
20 |

21 | <% } %>
22 |
23 | {{p.firstName}} {{p.lastName}}
24 | <% } else { %>
25 | Justin messed up :(
26 | <% } %>
27 |
28 | <% } %>
29 |
--------------------------------------------------------------------------------
/pages/templates/events/list.html.tpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Start |
5 | Type |
6 | Name |
7 | Route |
8 | Length |
9 | landscape |
10 | Groups |
11 | Entrants |
12 |
13 |
14 | Load More |
15 |
16 | <% for (const event of events) { %>
17 | {-embed(templates.eventsSummary, {event, eventBadge})-}
18 | |
19 | <% } %>
20 |
21 | Load More |
22 |
23 |
--------------------------------------------------------------------------------
/pages/templates/events/summary.html.tpl:
--------------------------------------------------------------------------------
1 | <% const started = event.ts < Date.now(); %>
2 | <% const joinable = event.ts + ((event.lateJoinInMinutes || 0) * 60 * 1000) - Date.now(); %>
3 |
8 | 0) { %>
10 | title="Can late join until {{humanTime(event.ts + ((event.lateJoinInMinutes || 0) * 60 * 1000))}}"
11 | <% } %>>
12 | {-humanDateTime(event.eventStart, {html: true, concise: true, style: 'short', today_style: 'short'})-}
13 | <% if (event.lateJoinInMinutes) { %>
14 | acute
15 | <% } %>
16 | |
17 |
18 | {{event.prettyTypeShort}}
19 | <% if (event.sport === 'running') { %>
20 | directions_run
21 | <% } %>
22 | |
23 | {{event.name}} |
24 | <% if (event.sameRouteName && event.route?.name) { %>
25 | route {{event.route?.name}} |
26 | <% } else { %>
27 | - |
28 | <% } %>
29 | <% if (event.durations.length) { %>
30 | <% if (event.durations.length > 1) { %>
31 | {-humanDuration(event.durations[0], {html: true, short: true})-} -
32 | {-humanDuration(event.durations.at(-1), {html: true, short: true})-} |
33 | <% } else { %>
34 | {-humanDuration(event.durations[0], {html: true})-} |
35 | <% } %>
36 | <% } else if (event.distances.length) { %>
37 | <% if (event.distances.length > 1) { %>
38 | {-humanDistance(event.distances[0])-} -
39 | {-humanDistance(event.distances.at(-1), {suffix: true, html: true})-} |
40 | <% } else { %>
41 | {-humanDistance(event.distances[0], {suffix: true, html: true})-} |
42 | <% } %>
43 | <% } else { %>
44 | {{console.warn("Event duration/distance bug:", event)}}
45 | - |
46 | <% } %>
47 | {-humanElevation(event.routeClimbing, {suffix: true, html: true})-} |
48 |
49 | <% if (event.eventSubgroups) { %>
50 | {-event.eventSubgroups.map(x => eventBadge(x.subgroupLabel)).join('')-}
51 | <% if (event.cullingType === 'CULLING_EVENT_ONLY') { %>
52 | group_work
53 | <% } else if (event.cullingType === 'CULLING_SUBGROUP_ONLY') { %>
54 | workspaces
55 | <% } %>
56 | <% } %>
57 | |
58 |
59 | {{event.totalEntrantCount}}<% if (event.followeeEntrantCount) { %>,
60 | follow_the_signs {{event.followeeEntrantCount}}
61 | <% } %>
62 | |
63 |
64 |
--------------------------------------------------------------------------------
/pages/templates/segment-results.html.tpl:
--------------------------------------------------------------------------------
1 |
2 | <% for (const [i, x] of results.entries()) { %>
3 |
4 |
5 | <% if (i < 3) { %>
6 | trophy
7 | <% } else { %>
8 | {-humanPlace(i + 1, {suffix: true, html: true})-}
9 | <% } %>
10 |
11 |
18 |
19 | <% if (x.powerType !== 'POWER_METER') { %>
20 | ~
21 | <% } %>
22 | {-humanPower(x.avgPower, {suffix: true, html: true})-}
23 | <% if (x.powerType !== 'POWER_METER') { %>
24 |
25 | <% } %>
26 |
27 |
{-humanTimer(x.elapsed, {long: true, ms: true, html: true})-}
28 |
29 |
{{humanRelTime(x.ts, {short: true, maxParts: 1})}}
30 |
31 | <% } %>
32 |
33 |
--------------------------------------------------------------------------------
/pages/watching-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Grid",
3 | "name": "Sauce for Zwift™ - Grid",
4 | "background_color": "black",
5 | "theme_color": "black",
6 | "description": "Sauce for Zwift - Grid Window",
7 | "start_url": "/pages/watching.html",
8 | "display": "standalone",
9 | "orientation": "any",
10 | "icons": [{
11 | "src": "/pages/images/icon256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }, {
15 | "src": "/pages/images/icon128.png",
16 | "sizes": "128x128",
17 | "type": "image/png"
18 | }, {
19 | "src": "/pages/images/icon64.png",
20 | "sizes": "64x64",
21 | "type": "image/png"
22 | }, {
23 | "src": "/pages/images/icon32.png",
24 | "sizes": "32x32",
25 | "type": "image/png"
26 | }]
27 | }
28 |
--------------------------------------------------------------------------------
/pages/watching.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Grid - Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 | Grid
28 |
29 |
35 |
36 |
42 |
43 |
49 |
50 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/pages/welcome.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Welcome to Sauce for Zwift™
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
25 |
26 |
28 |
42 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/shared/deps/.gitignore:
--------------------------------------------------------------------------------
1 | data
2 |
--------------------------------------------------------------------------------
/shared/deps/Makefile:
--------------------------------------------------------------------------------
1 |
2 | rwildcard=$(foreach d,$(wildcard $(1:=/*)),$(call rwildcard,$d,$2) $(filter $(subst *,%,$2),$d))
3 |
4 | ZU_SRCS = $(call rwildcard,../../node_modules/zwift-utils/dist,*.json)
5 | ZU_OBJS = $(patsubst ../../node_modules/zwift-utils/dist/%,data/%,$(ZU_SRCS))
6 |
7 | WORLD_SRC = ../../node_modules/world_countries_lists/data/countries/_combined/world.json
8 | WORLD_OBJ = data/countries.json
9 |
10 | default: $(ZU_OBJS) $(WORLD_OBJ)
11 |
12 |
13 | $(ZU_OBJS): $(ZU_SRCS) Makefile
14 | mkdir -p $(@D)
15 | node ../../tools/bin/jsonminify $(patsubst data/%,../../node_modules/zwift-utils/dist/%,$@) $@
16 |
17 |
18 | $(WORLD_OBJ): $(WORLD_SRC) Makefile
19 | mkdir -p $(@D)
20 | cp $< $@
21 |
22 |
23 | clean:
24 | rm -rf data
25 |
--------------------------------------------------------------------------------
/shared/sauce/browser.mjs:
--------------------------------------------------------------------------------
1 |
2 |
3 | export function throttledAnimationFrame() {
4 | let nextFrame;
5 | return function(callback) {
6 | if (nextFrame) {
7 | cancelAnimationFrame(nextFrame);
8 | }
9 | nextFrame = requestAnimationFrame(() => {
10 | nextFrame = null;
11 | callback();
12 | });
13 | };
14 | }
15 |
16 |
17 | export function downloadBlob(blob, name) {
18 | const url = URL.createObjectURL(blob);
19 | try {
20 | downloadURL(url, name || blob.name);
21 | } finally {
22 | URL.revokeObjectURL(url);
23 | }
24 | }
25 |
26 |
27 | export function downloadURL(url, name) {
28 | const link = document.createElement('a');
29 | link.href = url;
30 | link.download = name;
31 | link.style.display = 'none';
32 | document.body.appendChild(link);
33 | try {
34 | link.click();
35 | } finally {
36 | link.remove();
37 | }
38 | }
39 |
40 |
41 | const _fetchCache = new Map();
42 | const _fetching = new Map();
43 | export async function cachedFetch(url, options={}) {
44 | if (!_fetchCache.has(url)) {
45 | if (!_fetching.has(url)) {
46 | _fetching.set(url, fetch(url).then(async resp => {
47 | if (!resp.ok) {
48 | if (resp.status === 404) {
49 | console.warn("Not found:", url);
50 | _fetchCache.set(url, undefined);
51 | return;
52 | }
53 | throw new Error('Fetch HTTP failure: ' + resp.status);
54 | }
55 | let data;
56 | if (options.mode === 'json') {
57 | data = await resp.json();
58 | } else if (options.mode === 'blob') {
59 | data = await resp.blob();
60 | } else if (options.mode === 'arrayBuffer') {
61 | data = await resp.arrayBuffer();
62 | } else {
63 | data = await resp.text();
64 | }
65 | _fetchCache.set(url, data);
66 | }).finally(() => _fetching.delete(url)));
67 | }
68 | await _fetching.get(url);
69 | }
70 | return _fetchCache.get(url);
71 | }
72 |
--------------------------------------------------------------------------------
/shared/sauce/index.mjs:
--------------------------------------------------------------------------------
1 | export * from './base.mjs';
2 | export * as data from './data.mjs';
3 | export * as power from './power.mjs';
4 | export * as geo from './geo.mjs';
5 | export * as perf from './perf.mjs';
6 | export * as locale from './locale.mjs';
7 | export * as browser from './browser.mjs';
8 | export * as template from './template.mjs';
9 |
--------------------------------------------------------------------------------
/shared/sauce/pace.mjs:
--------------------------------------------------------------------------------
1 |
2 | import {RollingBase} from './data.mjs';
3 |
4 |
5 | export class RollingPace extends RollingBase {
6 | distance(options) {
7 | options = options || {};
8 | const offt = (options.offt || 0) + this._offt;
9 | const start = this._values[offt];
10 | const end = this._values[this._length - 1];
11 | if (start != null && end != null) {
12 | return end - start;
13 | }
14 | }
15 |
16 | avg() {
17 | const dist = this.distance();
18 | const elapsed = this.elapsed();
19 | if (!dist || !elapsed) {
20 | return;
21 | }
22 | return elapsed / dist;
23 | }
24 |
25 | full(options) {
26 | options = options || {};
27 | const offt = options.offt;
28 | return this.distance({offt}) >= this.period;
29 | }
30 | }
31 |
32 |
33 | export function bestPace(distance, timeStream, distStream) {
34 | if (timeStream.length < 2 || distance[distance.length - 1] < distance) {
35 | return;
36 | }
37 | const roll = new RollingPace(distance);
38 | return roll.importReduce(timeStream, distStream, (cur, lead) => cur.avg() <= lead.avg());
39 | }
40 |
41 |
42 | export function work(weight, dist, isWalking) {
43 | const cost = isWalking ? 2 : 4.35; // Hand tuned by intuition
44 | const j = cost / ((1 / weight) * (1 / dist));
45 | const humanMechFactor = 0.24; // Human mechanical efficiency percentage
46 | const kj = j * humanMechFactor / 1000;
47 | return kj;
48 | }
49 |
--------------------------------------------------------------------------------
/shared/sauce/perf.mjs:
--------------------------------------------------------------------------------
1 |
2 | export function calcTRIMP(duration, hrr, gender) {
3 | const y = hrr * (gender === 'female' ? 1.67 : 1.92);
4 | return (duration / 60) * hrr * 0.64 * Math.exp(y);
5 | }
6 |
7 |
8 | /* TRIMP based TSS, more accurate than hrTSS.
9 | * See: https://fellrnr.com/wiki/TRIMP
10 | */
11 | export function tTSS(hrStream, timeStream, activeStream, ltHR, minHR, maxHR, gender) {
12 | let t = 0;
13 | let lastTime = timeStream[0];
14 | for (let i = 1; i < timeStream.length; i++) {
15 | if (!activeStream[i]) {
16 | lastTime = timeStream[i];
17 | continue;
18 | }
19 | const dur = timeStream[i] - lastTime;
20 | lastTime = timeStream[i];
21 | const hrr = (hrStream[i] - minHR) / (maxHR - minHR);
22 | t += calcTRIMP(dur, hrr, gender);
23 | }
24 | const tHourAtLT = calcTRIMP(3600, (ltHR - minHR) / (maxHR - minHR), gender);
25 | return (t / tHourAtLT) * 100;
26 | }
27 |
28 |
29 | export function estimateRestingHR(ftp) {
30 | // Use handwavy assumption that high FTP = low resting HR.
31 | const baselineW = 300;
32 | const baselineR = 50;
33 | const range = 20;
34 | const delta = ftp - baselineW;
35 | const diff = range * (delta / baselineW);
36 | return baselineR - diff;
37 | }
38 |
39 |
40 | export function estimateMaxHR(zones) {
41 | // Estimate max from inner zone ranges.
42 | const avgRange = ((zones.z4 - zones.z3) + (zones.z3 - zones.z2)) / 2;
43 | return zones.z4 + avgRange;
44 | }
45 |
46 |
47 | // See:
48 | // https://www.trainerroad.com/forum/t/tss-spreadsheets-with-atl-ctl-form/7613/10
49 | // http://www.timetriallingforum.co.uk/index.php?/topic/74961-calculating-ctl-and-atl/#comment-1045764
50 | const chronicTrainingLoadConstant = 42;
51 | const acuteTrainingLoadConstant = 7;
52 |
53 | function _makeExpWeightedCalc(size) {
54 | const c = 1 - Math.exp(-1 / size);
55 | return function(data, seed=0) {
56 | let v = seed;
57 | for (const x of data) {
58 | v = (v * (1 - c)) + (x * c);
59 | }
60 | return v;
61 | };
62 | }
63 |
64 | export const calcCTL = _makeExpWeightedCalc(chronicTrainingLoadConstant);
65 | export const calcATL = _makeExpWeightedCalc(acuteTrainingLoadConstant);
66 |
67 | export function expWeightedAvg(size, data, seed) {
68 | return _makeExpWeightedCalc(size)(data, seed);
69 | }
70 |
71 |
72 | export function makeExpWeightedAccumulator(fixedSize, seed=0) {
73 | let v = seed;
74 | const fixedC = fixedSize ? 1 - Math.exp(-1 / fixedSize) : null;
75 | return function(x, size) {
76 | const c = size ? (1 - Math.exp(-1 / size)) : fixedC;
77 | v = (v * (1 - c)) + (x * c);
78 | return v;
79 | };
80 | }
81 |
--------------------------------------------------------------------------------
/src/db.mjs:
--------------------------------------------------------------------------------
1 | import fs from './fs-safe.js';
2 | import process from 'node:process';
3 | import Database from 'better-sqlite3';
4 |
5 | export const databases = new Map();
6 |
7 |
8 | export class SqliteDatabase extends Database {
9 | constructor(name, {tables, indexes={}, ...options}={}) {
10 | console.info("Opening DB:", name);
11 | super(name, options);
12 | this.pragma('journal_mode = WAL'); // improve performance substantially
13 | for (const [table, schema] of Object.entries(tables)) {
14 | const schemaText = Object.entries(schema).map(([col, type]) => `${col} ${type}`).join(', ');
15 | this.prepare(`CREATE TABLE IF NOT EXISTS ${table} (${schemaText})`).run();
16 | }
17 | for (const [index, x] of Object.entries(indexes)) {
18 | this.prepare(`CREATE ${x.unique ? 'UNIQUE' : ''} INDEX IF NOT EXISTS ${index} ON ` +
19 | `${x.table} (${x.columns.join(',')})`).run();
20 | }
21 | databases.set(name, this);
22 | }
23 | }
24 |
25 |
26 | export function deleteDatabase(name) {
27 | const db = databases.get(name);
28 | if (db) {
29 | console.warn(`Closing DB [via delete]:`, name);
30 | db.close();
31 | databases.delete(name);
32 | }
33 | console.warn(`Deleting DB:`, name);
34 | fs.rmSync(name, {force: true});
35 | }
36 |
37 |
38 | function shutdown(origin) {
39 | const dbs = Array.from(databases.values());
40 | databases.clear();
41 | for (const db of dbs) {
42 | console.warn(`Closing DB [via ${origin}]:`, db.name);
43 | try {
44 | db.close();
45 | } catch(e) {
46 | console.error(e);
47 | }
48 | }
49 | }
50 |
51 | // NOTE: we don't handle kill signal's because it's only useful
52 | // in dev and can cause interop problems.
53 | process.on('exit', shutdown);
54 |
--------------------------------------------------------------------------------
/src/fs-safe.js:
--------------------------------------------------------------------------------
1 | const node = require('node:fs');
2 |
3 |
4 | const _emptySharedArray = new Int32Array(new SharedArrayBuffer(4));
5 | function sleepSync(ms) {
6 | Atomics.wait(_emptySharedArray, 0, 0, ms);
7 | }
8 |
9 |
10 | function rmSync(path, {maxRetries=10, recursive, ...options}={}) {
11 | recursive = recursive == null ? !!maxRetries : recursive;
12 | return node.rmSync(path, {...options, maxRetries, recursive});
13 | }
14 |
15 |
16 | function renameSync(oldPath, newPath, {maxRetries=10}={}) {
17 | const delay = 100;
18 | let error;
19 | for (let i = 0; i < maxRetries; i++) {
20 | try {
21 | return node.renameSync(oldPath, newPath);
22 | } catch(e) {
23 | if (e.errno === -4048 && e.code === 'EPERM') {
24 | error = e;
25 | sleepSync(delay * (2 ** i));
26 | continue;
27 | }
28 | throw e;
29 | }
30 | }
31 | throw error;
32 | }
33 |
34 |
35 | module.exports = {
36 | ...node,
37 | rmSync,
38 | renameSync,
39 | sleepSync,
40 | };
41 |
--------------------------------------------------------------------------------
/src/mime.mjs:
--------------------------------------------------------------------------------
1 | export const mimeTypes = {
2 | 'application/gzip': ['gz'],
3 | 'application/javascript': ['js', 'mjs'],
4 | 'application/json': ['json', 'map'],
5 | 'application/pdf': ['pdf'],
6 | 'application/postscript': ['ps', 'eps', 'ai'],
7 | 'application/zip': ['zip'],
8 | 'audio/mp3': ['mp3'],
9 | 'audio/mp4': ['m4a', 'mp4a'],
10 | 'audio/mpeg': ['mpga', 'mp2a', 'm2a'],
11 | 'audio/ogg': ['ogg', 'oga', 'spx', 'opus'],
12 | 'font/woff': ['woff'],
13 | 'font/woff2': ['woff2'],
14 | 'image/apng': ['apng'],
15 | 'image/avif': ['avif'],
16 | 'image/bmp': ['bmp'],
17 | 'image/gif': ['gif'],
18 | 'image/heic': ['heic'],
19 | 'image/heif': ['heif'],
20 | 'image/jp2': ['jp2', 'jpg2'],
21 | 'image/jpeg': ['jpg', 'jpeg', 'jpe'],
22 | 'image/png': ['png'],
23 | 'image/svg+xml': ['svg'],
24 | 'image/tiff': ['tif', 'tiff'],
25 | 'image/webp': ['webp'],
26 | 'image/wmf': ['wmf'],
27 | 'text/css': ['css'],
28 | 'text/csv': ['csv'],
29 | 'text/html': ['html', 'htm'],
30 | 'text/markdown': ['md', 'markdown'],
31 | 'text/plain': ['txt', 'text', 'conf', 'log', 'ini'],
32 | 'text/x-scss': ['scss'],
33 | 'text/xml': ['xml'],
34 | 'text/yaml': ['yaml', 'yml'],
35 | 'video/mp4': ['mp4', 'mp4v'],
36 | 'video/mpeg': ['mpeg', 'mpg', 'mpe', 'm2v', 'm1v'],
37 | 'video/ogg': ['ogv'],
38 | 'video/quicktime': ['qt', 'mov'],
39 | 'video/vnd.mpegurl': ['mxu', 'm4u'],
40 | 'video/webm': ['webm'],
41 | 'video/x-matroska': ['mkv'],
42 | };
43 |
44 | export const mimeTypesByExt = new Map();
45 | for (const [mime, exts] of Object.entries(mimeTypes)) {
46 | for (const x of exts) {
47 | mimeTypesByExt.set(x, mime);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/patreon.mjs:
--------------------------------------------------------------------------------
1 | import * as storage from './storage.mjs';
2 | import Sentry from '@sentry/node';
3 | import fetch from 'node-fetch';
4 |
5 | export class NonMember extends Error {}
6 |
7 |
8 | async function _api(res, options) {
9 | const r = await fetch('https://api.saucellc.io' + res, options);
10 | const body = await r.text();
11 | const data = body ? JSON.parse(body) : null;
12 | if (r.status === 404) {
13 | throw new NonMember();
14 | } else if (!r.ok) {
15 | throw new Error(JSON.stringify({status: r.status, data}, null, 4));
16 | } else {
17 | return data;
18 | }
19 | }
20 |
21 |
22 | export async function link(code, options={}) {
23 | storage.set('patreon-auth', null);
24 | let auth;
25 | try {
26 | auth = await _api('/patreon/auth', {
27 | method: 'POST',
28 | headers: {
29 | 'x-sauce-app': 'zwift',
30 | 'x-sauce-version': options.legacy ? '' : 2,
31 | },
32 | body: JSON.stringify({code}),
33 | });
34 | } catch(e) {
35 | if (!(e instanceof NonMember)) {
36 | Sentry.captureException(e);
37 | throw e;
38 | }
39 | return false;
40 | }
41 | storage.set('patreon-auth', auth);
42 | return true;
43 | }
44 |
45 |
46 | export function getUserId(options={}) {
47 | const auth = storage.get('patreon-auth');
48 | if (auth) {
49 | return auth.id;
50 | }
51 | }
52 |
53 |
54 | export async function getMembership(options={}) {
55 | const auth = storage.get('patreon-auth');
56 | if (!auth) {
57 | throw new TypeError('Patreon link not established');
58 | }
59 | const q = options.detailed ? 'detailed=1' : '';
60 | const r = await fetch(`https://api.saucellc.io/patreon/membership?${q}`, {
61 | headers: {
62 | 'x-sauce-app': 'zwift',
63 | 'x-sauce-version': options.legacy ? '' : 2,
64 | Authorization: `${auth.id} ${auth.secret}`
65 | }
66 | });
67 | if (!r.ok) {
68 | if ([401, 403].includes(r.status)) {
69 | storage.set('patreon-auth', null);
70 | } else if (r.status !== 404) {
71 | throw new Error('Failed to get patreon membership: ' + r.status);
72 | }
73 | return null;
74 | } else {
75 | return await r.json();
76 | }
77 | }
78 |
79 | export async function getLegacyMembership(token) {
80 | const r = await fetch('https://www.sauce.llc/patrons.json');
81 | const patrons = await r.json();
82 | if (patrons[token]) {
83 | return {
84 | patronLevel: patrons[token].level,
85 | };
86 | } else {
87 | return null;
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/preload/common.js:
--------------------------------------------------------------------------------
1 | const {ipcRenderer, contextBridge, webFrame} = require('electron');
2 |
3 |
4 | ipcRenderer.on('subscribe-port', (ev, subId) =>
5 | window.postMessage({channel: 'subscribe-port', subId}, '*', ev.ports));
6 |
7 | ipcRenderer.on('sauce-highlight-window', () => {
8 | console.debug("Highlight window request");
9 | const doc = document.documentElement;
10 | if (!doc || !document.body) {
11 | return;
12 | }
13 | if (document.body.classList.contains('transparent-bg')) {
14 | document.body.classList.remove('transparent-bg');
15 | setTimeout(() => document.body.classList.add('transparent-bg'), 3000);
16 | }
17 | doc.classList.remove('highlight-window');
18 | doc.offsetWidth; // force layout
19 | doc.classList.add('highlight-window');
20 | });
21 |
22 |
23 | const meta = ipcRenderer.sendSync('getWindowMetaSync');
24 | contextBridge.exposeInMainWorld('electron', {
25 | context: meta.context,
26 | ipcInvoke: ipcRenderer.invoke.bind(ipcRenderer),
27 | });
28 | contextBridge.exposeInMainWorld('isElectron', true);
29 |
30 | document.addEventListener('click', ev => {
31 | const link = ev.target.closest('a[external][href]');
32 | if (link) {
33 | ev.preventDefault();
34 | ipcRenderer.invoke('rpc', 'openExternalLink', link.href).catch(e =>
35 | console.error('Error opening external page:', e));
36 | }
37 | });
38 |
39 | if (meta.internal) {
40 | const onReadyStateChange = ev => {
41 | if (document.readyState === 'interactive') {
42 | // Do some important DOM work before first paint to avoid flashing
43 | document.removeEventListener('readystatechange', onReadyStateChange);
44 | const doc = document.documentElement;
45 | doc.classList.add('electron-mode');
46 | doc.classList.toggle('frame', !!meta.context.frame);
47 | doc.dataset.platform = meta.context.platform;
48 | const theme = localStorage.getItem('/theme');
49 | if (theme) {
50 | doc.dataset.theme = JSON.parse(theme);
51 | }
52 | }
53 | };
54 | // Fires for interactive before defer scripts.
55 | document.addEventListener('readystatechange', onReadyStateChange);
56 |
57 | if (meta.modContentScripts && meta.modContentScripts.length) {
58 | for (const x of meta.modContentScripts) {
59 | try {
60 | webFrame.executeJavaScript(x);
61 | } catch(e) {
62 | console.error("Mod content script error:", e);
63 | }
64 | }
65 | }
66 |
67 | if (meta.modContentStylesheets && meta.modContentStylesheets.length) {
68 | for (const x of meta.modContentStylesheets) {
69 | try {
70 | webFrame.insertCSS(x);
71 | } catch(e) {
72 | console.error("Mod content stylesheet error:", e);
73 | }
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/preload/patron-link.js:
--------------------------------------------------------------------------------
1 | const {ipcRenderer, contextBridge} = require('electron');
2 |
3 | const authUrl = 'https://www.patreon.com/oauth2/authorize';
4 | const authArgs = {
5 | response_type: 'code',
6 | client_id: '5pxCmg6NBYOjHDVBL8XWJ4tzbwb_LxFO_pUDONDlZkPD0EOnz2NfRDUblE6J2k-C',
7 | scope: 'identity campaigns.members',
8 | };
9 |
10 | const meta = ipcRenderer.sendSync('getWindowMetaSync');
11 | contextBridge.exposeInMainWorld('isElectron', true);
12 | contextBridge.exposeInMainWorld('electron', {
13 | context: {
14 | ...meta.context,
15 | id: 'patron-link',
16 | spec: {},
17 | },
18 | ipcInvoke: (...args) => ipcRenderer.invoke(...args),
19 | closeWindow: () => window.close(),
20 | });
21 |
22 | // Proxy the code from our public page to the renderer process for further processing.
23 | // The renderer will bounce them to the proper internal page immediately.
24 | document.addEventListener('patreon-auth-code', ev =>
25 | void ipcRenderer.send('patreon-auth-code', ev.detail));
26 |
27 | document.addEventListener('patreon-reset-session', ev =>
28 | void ipcRenderer.send('patreon-reset-session'));
29 |
30 | document.addEventListener('click', ev => {
31 | const link = ev.target.closest('a[external][href]');
32 | if (link) {
33 | ev.preventDefault();
34 | ipcRenderer.invoke('rpc', 'openExternalLink', link.href).catch(e =>
35 | console.error('Error opening external page:', e));
36 | }
37 | });
38 |
39 | document.addEventListener('DOMContentLoaded', () => {
40 | for (const x of document.querySelectorAll('.button.patron-link')) {
41 | const q = new URLSearchParams({
42 | ...authArgs,
43 | redirect_uri: 'https://www.sauce.llc/sauce4zwift-patron-link-v2',
44 | });
45 | x.href = `${authUrl}?${q}`;
46 | x.addEventListener('click', () => {
47 | // Slight delay to avoid flashing new content while an external window is opening
48 | setTimeout(() => location.assign('patron-waiting.html'), 1000);
49 | });
50 | }
51 | for (const x of document.querySelectorAll('.button.patron-link-legacy')) {
52 | x.addEventListener('click', () => {
53 | const q = new URLSearchParams({
54 | ...authArgs,
55 | redirect_uri: 'https://saucellc.io/sauce4zwift-patron-link',
56 | });
57 | location.assign(`${authUrl}?${q}`);
58 | }, {capture: true});
59 | }
60 | const special = document.querySelector('#specialtoken');
61 | if (special) {
62 | special.addEventListener('submit', ev => {
63 | ev.preventDefault();
64 | const token = ev.currentTarget.querySelector('input[name="specialtoken"]').value;
65 | ipcRenderer.send('patreon-special-token', token);
66 | });
67 | }
68 | });
69 |
--------------------------------------------------------------------------------
/src/preload/storage-proxy.js:
--------------------------------------------------------------------------------
1 | const {ipcRenderer} = require('electron');
2 |
3 | ipcRenderer.on('export', ev =>
4 | ev.sender.send('response', JSON.parse(JSON.stringify({localStorage}))));
5 |
6 | ipcRenderer.on('import', (ev, storage) => {
7 | let success = true;
8 | try {
9 | localStorage.clear();
10 | for (const [key, value] of Object.entries(storage.localStorage)) {
11 | localStorage.setItem(key, value);
12 | }
13 | } catch(e) {
14 | console.error('Import error:', e);
15 | success = false;
16 | }
17 | ev.sender.send('response', success);
18 | });
19 |
--------------------------------------------------------------------------------
/src/preload/webview.js:
--------------------------------------------------------------------------------
1 | const {ipcRenderer} = require('electron');
2 |
3 | // TODO: Register these events based on requests from the host renderer.
4 |
5 | addEventListener('contextmenu', ev => {
6 | ipcRenderer.sendToHost('interaction', ev.type);
7 | });
8 |
9 | addEventListener('mouseup', ev => {
10 | if (ev.button === 3 || ev.button === 4) {
11 | ipcRenderer.sendToHost('interaction', 'navigate', {
12 | direction: ev.button === 3 ? 'back' : 'forward',
13 | });
14 | }
15 | });
16 |
--------------------------------------------------------------------------------
/src/preload/zwift-login.js:
--------------------------------------------------------------------------------
1 | const {ipcRenderer} = require('electron');
2 |
3 | document.addEventListener('DOMContentLoaded', () => {
4 | const form = document.querySelector('form');
5 | form.addEventListener('submit', ev => {
6 | ev.preventDefault();
7 | ipcRenderer.send('zwift-creds',
8 | Object.fromEntries(Array.from(form.elements).map(x => [x.name, x.value])));
9 | document.documentElement.classList.add('validating');
10 | document.querySelector('form .error').innerHTML = '';
11 | });
12 | });
13 |
14 | ipcRenderer.on('validation-error', (ev, status) => {
15 | document.documentElement.classList.remove('validating');
16 | document.querySelector('form .error').textContent = status;
17 | });
18 |
--------------------------------------------------------------------------------
/src/rpc.mjs:
--------------------------------------------------------------------------------
1 | export const handlers = new Map();
2 |
3 |
4 | export function errorReply(e, extra) {
5 | console.warn("RPC error:", e);
6 | return {
7 | ...extra,
8 | success: false,
9 | error: {
10 | name: e.name,
11 | message: e.message,
12 | stack: e.stack,
13 | }
14 | };
15 | }
16 |
17 |
18 | export function successReply(value, extra) {
19 | return {
20 | ...extra,
21 | success: true,
22 | value
23 | };
24 | }
25 |
26 |
27 | export async function invoke() {
28 | try {
29 | return await _invoke.apply(this, arguments);
30 | } catch(e) {
31 | return errorReply(e);
32 | }
33 | }
34 |
35 |
36 | async function _invoke(name, ...args) {
37 | const handler = handlers.get(name);
38 | if (!handler) {
39 | throw new Error('Invalid handler name: ' + name);
40 | }
41 | const warning = handler.warning; // deprecation, etc
42 | if (warning) {
43 | console.warn(warning);
44 | }
45 | try {
46 | return successReply(await handler.fn.call(handler.scope || this, ...args), {warning});
47 | } catch(e) {
48 | return errorReply(e, {warning});
49 | }
50 | }
51 |
52 |
53 | export function register(fn, options={}) {
54 | const name = options.name || fn.name;
55 | if (!name) {
56 | throw new TypeError("Function name could not be inferred, use options.name");
57 | }
58 | let warning;
59 | if (options.deprecatedBy) {
60 | warning = `DEPRECATED RPC [${name}]: migrate to -> ${options.deprecatedBy.name}`;
61 | } else if (options.deprecated) {
62 | warning = `DEPRECATED RPC [${name}]`;
63 | }
64 | handlers.set(options.name || fn.name, {fn, warning, scope: options.scope});
65 | }
66 |
--------------------------------------------------------------------------------
/src/secrets.mjs:
--------------------------------------------------------------------------------
1 | import keytar from 'keytar';
2 |
3 | const service = 'Zwift Credentials - Sauce for Zwift';
4 |
5 |
6 | export async function get(key) {
7 | if (!key) {
8 | throw new TypeError('key required');
9 | }
10 | const raw = await keytar.getPassword(service, key);
11 | return raw ? JSON.parse(raw) : undefined;
12 | }
13 |
14 |
15 | export async function set(key, data) {
16 | if (!key || !data) {
17 | throw new TypeError('key and data required');
18 | }
19 | await keytar.setPassword(service, key, JSON.stringify(data));
20 | }
21 |
22 |
23 | export async function remove(key) {
24 | if (!key) {
25 | throw new TypeError('key required');
26 | }
27 | return await keytar.deletePassword(service, key);
28 | }
29 |
--------------------------------------------------------------------------------
/src/storage.mjs:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import {SqliteDatabase, deleteDatabase} from './db.mjs';
3 |
4 |
5 | let _initialized = false;
6 | let _dir;
7 | let _db;
8 |
9 |
10 | function getName() {
11 | if (!_initialized) {
12 | throw new Error("initialize(...) required before use");
13 | }
14 | return path.join(_dir, 'storage.sqlite');
15 | }
16 |
17 |
18 | function getDB() {
19 | if (_db) {
20 | return _db;
21 | }
22 | _db = new SqliteDatabase(getName(), {
23 | tables: {
24 | store: {
25 | id: 'TEXT PRIMARY KEY',
26 | data: 'TEXT',
27 | }
28 | }
29 | });
30 | return _db;
31 | }
32 |
33 |
34 | export function initialize(dir) {
35 | if (_initialized) {
36 | throw new Error("Already initialized");
37 | }
38 | _dir = dir;
39 | _initialized = true;
40 | }
41 |
42 |
43 | export function reset() {
44 | deleteDatabase(getName());
45 | }
46 |
47 |
48 | export function get(key) {
49 | const db = getDB();
50 | const r = db.prepare('SELECT data from store WHERE id = ?').get(key);
51 | return r ? JSON.parse(r.data) : undefined;
52 | }
53 |
54 |
55 | export function set(key, data) {
56 | const db = getDB();
57 | db.prepare('INSERT OR REPLACE INTO store (id, data) VALUES(?, ?)').run(key, JSON.stringify(data));
58 | }
59 |
60 |
61 | export function remove(key) {
62 | const db = getDB();
63 | db.prepare('DELETE FROM store WHERE id = ?').run(key);
64 | }
65 |
--------------------------------------------------------------------------------
/src/unzoom.mjs:
--------------------------------------------------------------------------------
1 | import * as mwc from 'macos-window-control';
2 | import process from 'node:process';
3 |
4 | const saucePid = Number(process.argv.at(-1));
5 |
6 | if (!saucePid) {
7 | console.error("Missing PID argument");
8 | process.exit(1);
9 | }
10 |
11 |
12 | function pidAlive(pid) {
13 | try {
14 | process.kill(pid, 0);
15 | return true;
16 | } catch(e) {
17 | return false;
18 | }
19 | }
20 |
21 |
22 | async function main() {
23 | while (true) {
24 | await new Promise(r => setTimeout(r, 400));
25 | if (!pidAlive(saucePid)) {
26 | console.info("Sauce not running, unzooming...");
27 | const displays = mwc.getDisplays();
28 | for (const x of displays) {
29 | mwc.setZoom({scale: 1, displayId: x.id});
30 | }
31 | process.exit(0);
32 | }
33 | }
34 | }
35 |
36 | main();
37 |
--------------------------------------------------------------------------------
/test/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "globals": {
3 | "test": "readonly",
4 | "expect": "readonly"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/test/lru.test.mjs:
--------------------------------------------------------------------------------
1 | import test from 'node:test';
2 | import assert from 'node:assert';
3 | import {LRUCache} from '../shared/sauce/index.mjs';
4 |
5 |
6 | function assertCacheKeys(cache, keys) {
7 | assert.deepStrictEqual(new Set(cache.keys()), new Set(keys));
8 | }
9 |
10 |
11 | test('lru replace', () => {
12 | const c = new LRUCache(10);
13 | c.set('A', 111);
14 | assert.strictEqual(c.get('A'), 111);
15 | c.set('A', 222);
16 | assert.strictEqual(c.get('A'), 222);
17 | });
18 |
19 | test('lru fill exact', () => {
20 | const size = 4;
21 | const c = new LRUCache(size);
22 | for (let i = 0; i < size; i++) {
23 | c.set(i, i * 10);
24 | }
25 | for (let i = 0; i < size; i++) {
26 | assert.strictEqual(c.get(i), i * 10);
27 | }
28 | });
29 |
30 | test('lru fill overflow 1', () => {
31 | const size = 4;
32 | const c = new LRUCache(size);
33 | for (let i = 0; i < size + 1; i++) {
34 | c.set(i, i * 10);
35 | }
36 | assert.strictEqual(c.get(0), undefined);
37 | for (let i = 1; i < size + 1; i++) {
38 | assert.strictEqual(c.get(i), i * 10);
39 | }
40 | });
41 |
42 | test('lru fill overflow 1, reordered', () => {
43 | const size = 4;
44 | const c = new LRUCache(size);
45 | for (let i = 0; i < size; i++) {
46 | c.set(i, i * 10);
47 | }
48 | c.get(0);
49 | c.set(size, size * 10);
50 | assert.strictEqual(c.get(0), 0);
51 | assert.strictEqual(c.get(1), undefined);
52 | assert.strictEqual(c.get(2), 20);
53 | assert.strictEqual(c.get(3), 30);
54 | assert.strictEqual(c.get(4), 40);
55 | });
56 |
57 | test('lru cache consistency', () => {
58 | const c = new LRUCache(3);
59 | c.set(1, true);
60 | c.set(2, true);
61 | c.set(3, true);
62 | c.set(4, true); // bump 1
63 | c.get(2); // 2 4 3
64 | assertCacheKeys(c, [2, 4, 3]);
65 | c.get(4); // 4 2 3
66 | assertCacheKeys(c, [4, 2, 3]);
67 | c.set(5, true); // 5 4 2
68 | assertCacheKeys(c, [5, 4, 2]);
69 | c.set(6, true); // 6 5 4
70 | assertCacheKeys(c, [6, 5, 4]);
71 | });
72 |
73 | test('lru fuzz', () => {
74 | for (let _case = 0; _case < 50; _case++) {
75 | const ref = new Map();
76 | const size = 1 + Math.random() * 100 | 0;
77 | const iterations = Math.random() * 1000 | 0;
78 | const cache = new LRUCache(size);
79 | const setPropensity = Math.abs(Math.sin(_case + 1));
80 | for (let i = 0; i < iterations; i++) {
81 | const key = Math.random() * 1500 | 0;
82 | if (Math.random() < setPropensity) {
83 | cache.set(key, true);
84 | if (!ref.has(key) && ref.size === size) {
85 | const byAge = Array.from(ref.entries()).sort((a, b) => a[1] - b[1]);
86 | ref.delete(byAge[0][0]);
87 | }
88 | ref.set(key, i);
89 | } else {
90 | cache.get(key);
91 | if (ref.has(key)) {
92 | ref.set(key, i);
93 | }
94 | }
95 | assertCacheKeys(cache, ref.keys());
96 | }
97 | }
98 | });
99 |
--------------------------------------------------------------------------------
/test/wbal.test.mjs:
--------------------------------------------------------------------------------
1 | import test from 'node:test';
2 | import assert from 'node:assert';
3 | import * as sauce from '../shared/sauce/index.mjs';
4 |
5 |
6 | test('wbal sample rate accuracy', () => {
7 | const cp = 300;
8 | const wp = 20000;
9 | const incCalcNom = new sauce.power.makeIncWPrimeBalDifferential(cp, wp);
10 | const incCalcHalf = new sauce.power.makeIncWPrimeBalDifferential(cp, wp);
11 | const incCalcDbl = new sauce.power.makeIncWPrimeBalDifferential(cp, wp);
12 | const incCalcRnd = new sauce.power.makeIncWPrimeBalDifferential(cp, wp);
13 | let lastRnd = 0;
14 | const wbals = {};
15 | for (let i = 0; i < 10000; i++) {
16 | const v = Math.sin(i / 1000) * (cp / 2) + cp;
17 | wbals.fine = incCalcHalf(v, 0.5);
18 | if (i % 2 === 0) {
19 | wbals.nom = incCalcNom(v, 1);
20 | }
21 | if (i % 4 === 0) {
22 | wbals.dbl = incCalcDbl(v, 2);
23 | }
24 | if (Math.random() < 0.8) {
25 | const t = i ? i - lastRnd : 1;
26 | lastRnd = i;
27 | wbals.rnd = incCalcRnd(v, t / 2);
28 | }
29 | if (i && (i % 10 === 0)) {
30 | assert(Math.abs(wbals.fine - wbals.nom) < 1000);
31 | assert(Math.abs(wbals.fine - wbals.dbl) < 1000);
32 | assert(Math.abs(wbals.fine - wbals.rnd) < 1000);
33 | }
34 | }
35 | });
36 |
--------------------------------------------------------------------------------
/tools/bin/buildenv:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const fs = require('node:fs');
3 | const process = require('node:process');
4 | const childProcess = require('node:child_process');
5 |
6 | function shexec(command) {
7 | return new Promise((resolve, reject) => {
8 | childProcess.exec(command, (error, stdout, stderr) => {
9 | if (error) {
10 | reject(error);
11 | } else {
12 | resolve({stdout, stderr});
13 | }
14 | });
15 | });
16 | }
17 |
18 | async function main() {
19 | if (process.argv.length < 3) {
20 | throw new Error("Missing OUTFILE argument");
21 | }
22 | fs.writeFileSync(process.argv[2], JSON.stringify({
23 | git_commit: (await shexec('git rev-parse HEAD')).stdout.trim(),
24 | sentry_dsn: process.env.SAUCE4ZWIFT_SENTRY_DSN,
25 | google_map_tile_key: process.env.SAUCE4ZWIFT_GOOGLE_MAP_TILE_KEY,
26 | }));
27 | }
28 |
29 | main().catch(e => {
30 | console.error(e);
31 | process.exit(1);
32 | });
33 |
--------------------------------------------------------------------------------
/tools/bin/jsonminify:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const fs = require('node:fs');
3 |
4 | if (process.argv.length !== 4) {
5 | console.error(`Usage: ${process.argv[1]} INPUT_FILE OUTPUT_FILE`);
6 | process.exit(1);
7 | } else {
8 | const [origFile, minFile] = process.argv.slice(2);
9 | const orig = JSON.parse(fs.readFileSync(origFile));
10 | fs.writeFileSync(minFile, JSON.stringify(orig));
11 | }
12 |
--------------------------------------------------------------------------------
/tools/bin/lintwatch:
--------------------------------------------------------------------------------
1 | #!/bin/sh -e
2 | TOOLS=$(dirname $0)
3 | while true ; do
4 | FILE=$($TOOLS/watch src pages/src shared/*.mjs shared/sauce '*.js' '*.mjs')
5 | clear
6 | $TOOLS/../../node_modules/.bin/eslint --no-ignore "$FILE" && echo CLEAN
7 | done
8 |
--------------------------------------------------------------------------------
/tools/bin/watch:
--------------------------------------------------------------------------------
1 | #!/bin/sh -e
2 | EXT="${@:$#}"
3 | DIRS="${@:1:$#-1}"
4 | find $DIRS -type f -name "$EXT" 2>/dev/null | xargs fswatch --event Updated -1 --latency 0.001
5 |
--------------------------------------------------------------------------------