summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--.mise.toml3
-rw-r--r--biome.json15
-rw-r--r--package-lock.json599
-rw-r--r--package.json18
-rw-r--r--tsconfig.json15
-rw-r--r--web/server_test.go27
-rw-r--r--web/static/official-climb-profile-logic.js90
-rw-r--r--web/static/official-climb-profile.js261
-rw-r--r--web/static/ride-detail.js137
-rw-r--r--web/static/sync.js103
-rw-r--r--web/static_test/official-climb-profile-logic.test.js10
-rw-r--r--web/templates.templ104
-rw-r--r--web/templates_templ.go12
-rw-r--r--web/types/global.d.ts3
15 files changed, 1070 insertions, 328 deletions
diff --git a/.gitignore b/.gitignore
index ceaa2e1..41a41c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ data/rides/*.gpx
debug_department_*.csv
france-latest.osm.pbf
biking_home
+node_modules/
diff --git a/.mise.toml b/.mise.toml
index 785f457..1f21ba9 100644
--- a/.mise.toml
+++ b/.mise.toml
@@ -15,6 +15,9 @@ run = "go fmt ./..."
description = "Run formatting, JavaScript and Go gate checks"
depends = ["generate", "format"]
run = [
+ "npm run format:check",
+ "npm run lint",
+ "npm run typecheck",
"for file in web/static/*.js; do node --check \"$file\"; done",
"node --test web/static_test/*.test.js",
"go vet ./...",
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..566ee72
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineWidth": 140
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true
+ }
+ }
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..3690bcb
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,599 @@
+{
+ "name": "biking_home",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "devDependencies": {
+ "@biomejs/biome": "^2.5.8",
+ "@types/leaflet": "^1.9.22",
+ "@types/node": "^26.2.0",
+ "typescript": "^7.0.2"
+ }
+ },
+ "node_modules/@biomejs/biome": {
+ "version": "2.5.8",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.8.tgz",
+ "integrity": "sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "bin": {
+ "biome": "bin/biome"
+ },
+ "engines": {
+ "node": ">=14.21.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/biome"
+ },
+ "optionalDependencies": {
+ "@biomejs/cli-darwin-arm64": "2.5.8",
+ "@biomejs/cli-darwin-x64": "2.5.8",
+ "@biomejs/cli-linux-arm64": "2.5.8",
+ "@biomejs/cli-linux-arm64-musl": "2.5.8",
+ "@biomejs/cli-linux-x64": "2.5.8",
+ "@biomejs/cli-linux-x64-musl": "2.5.8",
+ "@biomejs/cli-win32-arm64": "2.5.8",
+ "@biomejs/cli-win32-x64": "2.5.8"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-arm64": {
+ "version": "2.5.8",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.8.tgz",
+ "integrity": "sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-x64": {
+ "version": "2.5.8",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.8.tgz",
+ "integrity": "sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64": {
+ "version": "2.5.8",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.8.tgz",
+ "integrity": "sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64-musl": {
+ "version": "2.5.8",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.8.tgz",
+ "integrity": "sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64": {
+ "version": "2.5.8",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.8.tgz",
+ "integrity": "sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64-musl": {
+ "version": "2.5.8",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.8.tgz",
+ "integrity": "sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-arm64": {
+ "version": "2.5.8",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.8.tgz",
+ "integrity": "sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-x64": {
+ "version": "2.5.8",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.8.tgz",
+ "integrity": "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/leaflet": {
+ "version": "1.9.22",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
+ "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "26.2.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
+ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..13f91b1
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "format": "biome format --write web/static/*.js web/static_test/*.js",
+ "format:check": "biome format web/static/*.js web/static_test/*.js",
+ "lint": "biome lint web/static/*.js web/static_test/*.js",
+ "typecheck": "tsc --noEmit",
+ "test": "node --test web/static_test/*.test.js",
+ "check": "npm run format:check && npm run lint && npm run typecheck && npm test"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.5.8",
+ "@types/leaflet": "^1.9.22",
+ "@types/node": "^26.2.0",
+ "typescript": "^7.0.2"
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..6ad150d
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "lib": ["DOM", "ES2022"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "noEmit": true,
+ "strict": false,
+ "skipLibCheck": true,
+ "target": "ES2022",
+ "types": ["leaflet", "node"]
+ },
+ "include": ["web/static/**/*.js", "web/static_test/**/*.js", "web/types/**/*.d.ts"]
+}
diff --git a/web/server_test.go b/web/server_test.go
index a4ee0b7..654d6d5 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -134,10 +134,10 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
assert.Contains(t, body, `class="ride-detail-columns`)
assert.NotContains(t, body, "Climbs to match")
assert.Contains(t, body, `class="ride-detail-main"`)
- assert.Contains(t, body, `<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" defer></script>`)
- assert.Contains(t, body, `<script src="/static/official-climb-profile-logic.js" defer></script>`)
- assert.Contains(t, body, `<script src="/static/official-climb-profile.js" defer></script>`)
- assert.Contains(t, body, `<script src="/static/ride-detail.js" defer></script>`)
+ assert.Contains(t, body, `<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>`)
+ assert.Contains(t, body, `<script type="module" src="/static/ride-detail.js"></script>`)
+ assert.NotContains(t, body, `<script src="/static/official-climb-profile-logic.js"`)
+ assert.NotContains(t, body, `<script src="/static/official-climb-profile.js"`)
staticRequest := httptest.NewRequest(http.MethodGet, "/static/ride-detail.js", nil)
staticResponse := httptest.NewRecorder()
@@ -168,6 +168,14 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
assert.Equal(t, http.StatusOK, logicStaticResponse.Code)
assert.Equal(t, "text/javascript; charset=utf-8", logicStaticResponse.Header().Get("Content-Type"))
assert.NotEmpty(t, logicStaticResponse.Body.String())
+
+ syncStaticRequest := httptest.NewRequest(http.MethodGet, "/static/sync.js", nil)
+ syncStaticResponse := httptest.NewRecorder()
+ server.Handler().ServeHTTP(syncStaticResponse, syncStaticRequest)
+
+ assert.Equal(t, http.StatusOK, syncStaticResponse.Code)
+ assert.Equal(t, "text/javascript; charset=utf-8", syncStaticResponse.Header().Get("Content-Type"))
+ assert.NotEmpty(t, syncStaticResponse.Body.String())
}
func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) {
@@ -386,7 +394,16 @@ func TestSyncPageIncludesProgressUIWhenAuthorized(t *testing.T) {
assert.Contains(t, body, `id="sync-form"`)
assert.Contains(t, body, `id="sync-progress"`)
assert.Contains(t, body, `<progress id="sync-progress-bar"`)
- assert.Contains(t, body, `text/event-stream`)
+ assert.Contains(t, body, `<script type="module" src="/static/sync.js"></script>`)
+ assert.NotContains(t, body, `const consumeEvents = async`)
+
+ syncRequest := httptest.NewRequest(http.MethodGet, "/static/sync.js", nil)
+ syncResponse := httptest.NewRecorder()
+ server.Handler().ServeHTTP(syncResponse, syncRequest)
+
+ assert.Equal(t, http.StatusOK, syncResponse.Code)
+ assert.Equal(t, "text/javascript; charset=utf-8", syncResponse.Header().Get("Content-Type"))
+ assert.Contains(t, syncResponse.Body.String(), `text/event-stream`)
}
func TestSyncRedirectsToOAuthWhenUnauthenticated(t *testing.T) {
diff --git a/web/static/official-climb-profile-logic.js b/web/static/official-climb-profile-logic.js
index c84960b..87dcef8 100644
--- a/web/static/official-climb-profile-logic.js
+++ b/web/static/official-climb-profile-logic.js
@@ -1,47 +1,45 @@
-(() => {
- const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
- const profileStepSizesM = [100, 200, 500, 1000];
- const displayStepForLength = (lengthM) => profileStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= 30) || profileStepSizesM[profileStepSizesM.length - 1];
- const profileBandForSlope = (slopePercent) => {
- if (slopePercent < 0) return "downhill";
- if (slopePercent < 3) return "0-3";
- if (slopePercent < 6) return "3-6";
- if (slopePercent < 9) return "6-9";
- if (slopePercent < 12) return "9-12";
- return "12-plus";
+const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
+const profileStepSizesM = [100, 200, 500, 1000];
+const displayStepForLength = (lengthM) =>
+ profileStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= 30) || profileStepSizesM[profileStepSizesM.length - 1];
+const profileBandForSlope = (slopePercent) => {
+ if (slopePercent < 0) return "downhill";
+ if (slopePercent < 3) return "0-3";
+ if (slopePercent < 6) return "3-6";
+ if (slopePercent < 9) return "6-9";
+ if (slopePercent < 12) return "9-12";
+ return "12-plus";
+};
+const officialProfileSections = (points, startIndex, endIndex, stepM) => {
+ const startDistanceM = points[startIndex].distanceKm * 1000;
+ const endDistanceM = points[endIndex].distanceKm * 1000;
+ const sections = [];
+ let pointIndex = startIndex;
+ const elevationAtDistance = (distanceM) => {
+ while (pointIndex < endIndex - 1 && points[pointIndex + 1].distanceKm * 1000 < distanceM) pointIndex++;
+ const nextIndex = Math.min(pointIndex + 1, endIndex);
+ const first = points[pointIndex];
+ const next = points[nextIndex];
+ const distanceSpan = next.distanceKm * 1000 - first.distanceKm * 1000;
+ if (distanceSpan <= 0) return first.elevationM;
+ const fraction = (distanceM - first.distanceKm * 1000) / distanceSpan;
+ return first.elevationM + clamp(fraction, 0, 1) * (next.elevationM - first.elevationM);
};
- const officialProfileSections = (points, startIndex, endIndex, stepM) => {
- const startDistanceM = points[startIndex].distanceKm * 1000;
- const endDistanceM = points[endIndex].distanceKm * 1000;
- const sections = [];
- let pointIndex = startIndex;
- const elevationAtDistance = (distanceM) => {
- while (pointIndex < endIndex - 1 && points[pointIndex + 1].distanceKm * 1000 < distanceM) pointIndex++;
- const nextIndex = Math.min(pointIndex + 1, endIndex);
- const first = points[pointIndex];
- const next = points[nextIndex];
- const distanceSpan = next.distanceKm * 1000 - first.distanceKm * 1000;
- if (distanceSpan <= 0) return first.elevationM;
- const fraction = (distanceM - first.distanceKm * 1000) / distanceSpan;
- return first.elevationM + clamp(fraction, 0, 1) * (next.elevationM - first.elevationM);
- };
- for (let sectionStartM = startDistanceM; sectionStartM < endDistanceM; sectionStartM += stepM) {
- const sectionEndM = Math.min(sectionStartM + stepM, endDistanceM);
- const startElevation = elevationAtDistance(sectionStartM);
- const endElevation = elevationAtDistance(sectionEndM);
- const slopePercent = (endElevation - startElevation) / (sectionEndM - sectionStartM) * 100;
- sections.push({
- startDistanceKm: sectionStartM / 1000,
- endDistanceKm: sectionEndM / 1000,
- startElevation,
- endElevation,
- slopePercent,
- band: profileBandForSlope(slopePercent),
- });
- }
- return sections;
- };
- const api = { displayStepForLength, profileBandForSlope, officialProfileSections };
- if (typeof module !== "undefined" && module.exports) module.exports = api;
- else window.OfficialClimbProfileLogic = api;
-})();
+ for (let sectionStartM = startDistanceM; sectionStartM < endDistanceM; sectionStartM += stepM) {
+ const sectionEndM = Math.min(sectionStartM + stepM, endDistanceM);
+ const startElevation = elevationAtDistance(sectionStartM);
+ const endElevation = elevationAtDistance(sectionEndM);
+ const slopePercent = ((endElevation - startElevation) / (sectionEndM - sectionStartM)) * 100;
+ sections.push({
+ startDistanceKm: sectionStartM / 1000,
+ endDistanceKm: sectionEndM / 1000,
+ startElevation,
+ endElevation,
+ slopePercent,
+ band: profileBandForSlope(slopePercent),
+ });
+ }
+ return sections;
+};
+
+export { displayStepForLength, profileBandForSlope, officialProfileSections };
diff --git a/web/static/official-climb-profile.js b/web/static/official-climb-profile.js
index 603edfe..3f0a229 100644
--- a/web/static/official-climb-profile.js
+++ b/web/static/official-climb-profile.js
@@ -1,6 +1,24 @@
-(() => {
- const createOfficialClimbProfileController = (points) => {
- const { displayStepForLength, officialProfileSections } = window.OfficialClimbProfileLogic;
+import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js";
+
+export class OfficialClimbProfileController {
+ constructor(points) {
+ this.points = points;
+ this.colors = this.resolveColors();
+ this.cards = [...document.querySelectorAll("[data-official-climb-card]")].map((card) => /** @type {HTMLDetailsElement} */ (card));
+ for (const card of this.cards) {
+ card.addEventListener("toggle", () => {
+ if (!card.open) return;
+ for (const other of this.cards) {
+ if (other !== card) other.open = false;
+ }
+ /** @type {HTMLCanvasElement | null} */
+ const profileCanvas = card.querySelector("[data-official-profile]");
+ if (profileCanvas) this.drawOfficialProfile(profileCanvas);
+ });
+ }
+ }
+
+ resolveColors() {
const colorProbe = document.createElement("span");
colorProbe.hidden = true;
document.body.append(colorProbe);
@@ -21,127 +39,128 @@
accent: resolveColor("--color-accent"),
};
colorProbe.remove();
- const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
- const drawOfficialProfile = (profileCanvas) => {
- const startIndex = Number.parseInt(profileCanvas.dataset.profileStart, 10);
- const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd, 10);
- if (!Number.isInteger(startIndex) || !Number.isInteger(endIndex) || startIndex < 0 || endIndex >= points.length || startIndex >= endIndex) return;
- const rect = profileCanvas.getBoundingClientRect();
- if (rect.width === 0 || rect.height === 0) return;
- const context = profileCanvas.getContext("2d");
- if (!context) return;
- const ratio = window.devicePixelRatio || 1;
- profileCanvas.width = Math.floor(rect.width * ratio);
- profileCanvas.height = Math.floor(rect.height * ratio);
- context.setTransform(ratio, 0, 0, ratio, 0, 0);
- const plot = { left: 10, right: rect.width - 10, top: 16, bottom: rect.height - 24 };
- const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000;
- const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM));
- if (sections.length === 0) return;
- let minElevation = sections[0].startElevation;
- let maxElevation = minElevation;
- for (const section of sections) {
- minElevation = Math.min(minElevation, section.startElevation, section.endElevation);
- maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation);
- }
- const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8);
- minElevation -= elevationPadding;
- maxElevation += elevationPadding;
- const minDistance = sections[0].startDistanceKm;
- const maxDistance = sections[sections.length - 1].endDistanceKm;
- const distanceSpan = Math.max(maxDistance - minDistance, 0.1);
- const elevationSpan = Math.max(maxElevation - minElevation, 1);
- const xForDistance = (distanceKm) => plot.left + (distanceKm - minDistance) / distanceSpan * (plot.right - plot.left);
- const yForElevation = (elevationM) => plot.bottom - (elevationM - minElevation) / elevationSpan * (plot.bottom - plot.top);
- context.clearRect(0, 0, rect.width, rect.height);
- context.fillStyle = colors.plotSurface;
- context.fillRect(0, 0, rect.width, rect.height);
- context.strokeStyle = colors.grid;
- context.lineWidth = 1;
+ return colors;
+ }
+
+ drawOfficialProfile(profileCanvas) {
+ const { points, colors } = this;
+ const startIndex = Number.parseInt(profileCanvas.dataset.profileStart, 10);
+ const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd, 10);
+ if (
+ !Number.isInteger(startIndex) ||
+ !Number.isInteger(endIndex) ||
+ startIndex < 0 ||
+ endIndex >= points.length ||
+ startIndex >= endIndex
+ )
+ return;
+ const rect = profileCanvas.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+ const context = profileCanvas.getContext("2d");
+ if (!context) return;
+ const ratio = window.devicePixelRatio || 1;
+ profileCanvas.width = Math.floor(rect.width * ratio);
+ profileCanvas.height = Math.floor(rect.height * ratio);
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
+ const plot = { left: 10, right: rect.width - 10, top: 16, bottom: rect.height - 24 };
+ const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000;
+ const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM));
+ if (sections.length === 0) return;
+ let minElevation = sections[0].startElevation;
+ let maxElevation = minElevation;
+ for (const section of sections) {
+ minElevation = Math.min(minElevation, section.startElevation, section.endElevation);
+ maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation);
+ }
+ const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8);
+ minElevation -= elevationPadding;
+ maxElevation += elevationPadding;
+ const minDistance = sections[0].startDistanceKm;
+ const maxDistance = sections[sections.length - 1].endDistanceKm;
+ const distanceSpan = Math.max(maxDistance - minDistance, 0.1);
+ const elevationSpan = Math.max(maxElevation - minElevation, 1);
+ const xForDistance = (distanceKm) => plot.left + ((distanceKm - minDistance) / distanceSpan) * (plot.right - plot.left);
+ const yForElevation = (elevationM) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (plot.bottom - plot.top);
+ context.clearRect(0, 0, rect.width, rect.height);
+ context.fillStyle = colors.plotSurface;
+ context.fillRect(0, 0, rect.width, rect.height);
+ context.strokeStyle = colors.grid;
+ context.lineWidth = 1;
+ context.beginPath();
+ context.moveTo(plot.left, plot.bottom);
+ context.lineTo(plot.right, plot.bottom);
+ context.stroke();
+ for (const section of sections) {
+ const startX = xForDistance(section.startDistanceKm);
+ const endX = xForDistance(section.endDistanceKm);
context.beginPath();
- context.moveTo(plot.left, plot.bottom);
- context.lineTo(plot.right, plot.bottom);
- context.stroke();
- for (const section of sections) {
- const startX = xForDistance(section.startDistanceKm);
- const endX = xForDistance(section.endDistanceKm);
- context.beginPath();
- context.moveTo(startX, plot.bottom);
- context.lineTo(startX, yForElevation(section.startElevation));
- context.lineTo(endX, yForElevation(section.endElevation));
- context.lineTo(endX, plot.bottom);
- context.closePath();
- context.globalAlpha = 0.25;
- context.fillStyle = colors[section.band];
- context.fill();
- context.globalAlpha = 1;
- context.beginPath();
- context.moveTo(startX, yForElevation(section.startElevation));
- context.lineTo(endX, yForElevation(section.endElevation));
- context.strokeStyle = colors[section.band];
- context.lineWidth = 2.5;
- context.stroke();
- }
- context.font = "9px system-ui, sans-serif";
- context.fillStyle = colors.subtle;
- context.textBaseline = "bottom";
- context.textAlign = "center";
- context.save();
+ context.moveTo(startX, plot.bottom);
+ context.lineTo(startX, yForElevation(section.startElevation));
+ context.lineTo(endX, yForElevation(section.endElevation));
+ context.lineTo(endX, plot.bottom);
+ context.closePath();
+ context.globalAlpha = 0.25;
+ context.fillStyle = colors[section.band];
+ context.fill();
+ context.globalAlpha = 1;
context.beginPath();
- context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
- context.clip();
- for (const section of sections) {
- const startX = xForDistance(section.startDistanceKm);
- const endX = xForDistance(section.endDistanceKm);
- const label = `${section.slopePercent.toFixed(1)}%`;
- const labelY = Math.max(plot.top + 10, Math.min(yForElevation(section.startElevation), yForElevation(section.endElevation)) - 4);
- context.fillText(label, (startX + endX) / 2, labelY);
+ context.moveTo(startX, yForElevation(section.startElevation));
+ context.lineTo(endX, yForElevation(section.endElevation));
+ context.strokeStyle = colors[section.band];
+ context.lineWidth = 2.5;
+ context.stroke();
+ }
+ context.font = "9px system-ui, sans-serif";
+ context.fillStyle = colors.subtle;
+ context.textBaseline = "bottom";
+ context.textAlign = "center";
+ context.save();
+ context.beginPath();
+ context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
+ context.clip();
+ for (const section of sections) {
+ const startX = xForDistance(section.startDistanceKm);
+ const endX = xForDistance(section.endDistanceKm);
+ const label = `${section.slopePercent.toFixed(1)}%`;
+ const labelY = Math.max(plot.top + 10, Math.min(yForElevation(section.startElevation), yForElevation(section.endElevation)) - 4);
+ context.fillText(label, (startX + endX) / 2, labelY);
+ }
+ context.restore();
+ let topElevation = sections[0].startElevation;
+ let topDistance = sections[0].startDistanceKm;
+ for (const section of sections) {
+ if (section.startElevation > topElevation) {
+ topElevation = section.startElevation;
+ topDistance = section.startDistanceKm;
}
- context.restore();
- let topElevation = sections[0].startElevation;
- let topDistance = sections[0].startDistanceKm;
- for (const section of sections) {
- if (section.startElevation > topElevation) {
- topElevation = section.startElevation;
- topDistance = section.startDistanceKm;
- }
- if (section.endElevation > topElevation) {
- topElevation = section.endElevation;
- topDistance = section.endDistanceKm;
- }
+ if (section.endElevation > topElevation) {
+ topElevation = section.endElevation;
+ topDistance = section.endDistanceKm;
}
- context.fillStyle = colors.accent;
- context.beginPath();
- context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI);
- context.fill();
- context.font = "11px system-ui, sans-serif";
- context.fillStyle = colors.subtle;
- context.textBaseline = "top";
- context.textAlign = "left";
- context.fillText(formatDistance(0), plot.left, rect.height - 16);
- context.textAlign = "right";
- context.fillText(formatDistance(maxDistance - minDistance), plot.right, rect.height - 16);
- };
- const cards = [...document.querySelectorAll("[data-official-climb-card]")];
- for (const card of cards) {
- card.addEventListener("toggle", () => {
- if (!card.open) return;
- for (const other of cards) {
- if (other !== card) other.open = false;
- }
- const profileCanvas = card.querySelector("[data-official-profile]");
- if (profileCanvas) drawOfficialProfile(profileCanvas);
- });
}
- return {
- redrawOpen: () => {
- for (const profileCanvas of document.querySelectorAll("[data-official-profile]")) {
- const card = profileCanvas.closest("[data-official-climb-card]");
- if (card?.open) drawOfficialProfile(profileCanvas);
- }
- },
- };
- };
+ context.fillStyle = colors.accent;
+ context.beginPath();
+ context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI);
+ context.fill();
+ context.font = "11px system-ui, sans-serif";
+ context.fillStyle = colors.subtle;
+ context.textBaseline = "top";
+ context.textAlign = "left";
+ context.fillText(this.formatDistance(0), plot.left, rect.height - 16);
+ context.textAlign = "right";
+ context.fillText(this.formatDistance(maxDistance - minDistance), plot.right, rect.height - 16);
+ }
- window.createOfficialClimbProfileController = createOfficialClimbProfileController;
-})();
+ formatDistance(distance) {
+ return `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
+ }
+
+ redrawOpen() {
+ /** @type {NodeListOf<HTMLCanvasElement>} */
+ const profileCanvases = document.querySelectorAll("[data-official-profile]");
+ for (const profileCanvas of profileCanvases) {
+ const card = /** @type {HTMLDetailsElement | null} */ (profileCanvas.closest("[data-official-climb-card]"));
+ if (card?.open) this.drawOfficialProfile(profileCanvas);
+ }
+ }
+}
diff --git a/web/static/ride-detail.js b/web/static/ride-detail.js
index 70878a1..75cb54a 100644
--- a/web/static/ride-detail.js
+++ b/web/static/ride-detail.js
@@ -1,10 +1,15 @@
-(() => {
+import { OfficialClimbProfileController } from "./official-climb-profile.js";
+
+export const mountRideDetail = () => {
const mapElement = document.getElementById("ride-map");
- const routeElement = document.getElementById("ride-route");
- const profileElement = document.getElementById("ride-profile");
- const canvas = document.getElementById("ride-profile-chart");
+ const routeElement = /** @type {HTMLScriptElement | null} */ (document.getElementById("ride-route"));
+ const profileElement = /** @type {HTMLScriptElement | null} */ (document.getElementById("ride-profile"));
+ const canvas = /** @type {HTMLCanvasElement | null} */ (document.getElementById("ride-profile-chart"));
+ /** @type {HTMLElement | null} */
const hoverOutput = document.getElementById("ride-profile-hover");
if (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return;
+ const leaflet = window.L;
+ if (!leaflet) return;
const colorProbe = document.createElement("span");
colorProbe.hidden = true;
document.body.append(colorProbe);
@@ -30,15 +35,17 @@
colorProbe.remove();
const route = JSON.parse(routeElement.textContent);
const profile = JSON.parse(profileElement.textContent);
- const map = L.map(mapElement);
- const tiles = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ const map = leaflet.map(mapElement);
+ const tiles = leaflet.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
});
tiles.addTo(map);
- const routeLayer = L.geoJSON(route, {
- style: { color: colors.accent, weight: 4, opacity: 0.9 },
- }).addTo(map);
+ const routeLayer = leaflet
+ .geoJSON(route, {
+ style: { color: colors.accent, weight: 4, opacity: 0.9 },
+ })
+ .addTo(map);
const bounds = routeLayer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 });
@@ -46,29 +53,52 @@
const points = profile.points || [];
if (points.length === 0) return;
- const climbItems = [...document.querySelectorAll("[data-climb-item]")];
+ const climbItems = [...document.querySelectorAll("[data-climb-item]")].map((item) => /** @type {HTMLElement} */ (item));
const climbItemIndices = climbItems.map((item) => Number.parseInt(item.dataset.climbIndex, 10));
+ /** @type {HTMLButtonElement | null} */
const previousClimbButton = document.querySelector("[data-climb-previous]");
+ /** @type {HTMLButtonElement | null} */
const nextClimbButton = document.querySelector("[data-climb-next]");
+ /** @type {HTMLElement | null} */
const climbPosition = document.querySelector("[data-climb-position]");
+ /** @type {(import("leaflet").Polyline | null)[]} */
const climbLayers = [];
for (const climb of profile.climbs || []) {
- if (!Number.isInteger(climb.startIndex) || !Number.isInteger(climb.endIndex) || climb.startIndex < 0 || climb.endIndex >= points.length || climb.startIndex >= climb.endIndex) {
+ if (
+ !Number.isInteger(climb.startIndex) ||
+ !Number.isInteger(climb.endIndex) ||
+ climb.startIndex < 0 ||
+ climb.endIndex >= points.length ||
+ climb.startIndex >= climb.endIndex
+ ) {
climbLayers.push(null);
continue;
}
const coordinates = points.slice(climb.startIndex, climb.endIndex + 1).map((point) => [point.latitude, point.longitude]);
- climbLayers.push(L.polyline(coordinates, { color: colors.climbRoute, weight: 7, opacity: 0.65, lineCap: "round", lineJoin: "round", interactive: false }).addTo(map));
+ climbLayers.push(
+ leaflet
+ .polyline(coordinates, {
+ color: colors.climbRoute,
+ weight: 7,
+ opacity: 0.65,
+ lineCap: "round",
+ lineJoin: "round",
+ interactive: false,
+ })
+ .addTo(map),
+ );
}
- const routeCursor = L.circleMarker([points[0].latitude, points[0].longitude], {
- color: colors.forest,
- fillColor: colors.accent,
- fillOpacity: 0,
- opacity: 0,
- radius: 7,
- weight: 3,
- interactive: false,
- }).addTo(map);
+ const routeCursor = leaflet
+ .circleMarker([points[0].latitude, points[0].longitude], {
+ color: colors.forest,
+ fillColor: colors.accent,
+ fillOpacity: 0,
+ opacity: 0,
+ radius: 7,
+ weight: 3,
+ interactive: false,
+ })
+ .addTo(map);
const context = canvas.getContext("2d");
if (!context) return;
@@ -96,14 +126,14 @@
const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
const formatElevation = (elevation) => `${Math.round(elevation)} m`;
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
- const officialProfileController = window.createOfficialClimbProfileController(points);
+ const officialProfileController = new OfficialClimbProfileController(points);
const xForDistance = (distance) => {
const span = Math.max(maxDistance - minDistance, 1);
- return state.plot.left + (distance - minDistance) / span * (state.plot.right - state.plot.left);
+ return state.plot.left + ((distance - minDistance) / span) * (state.plot.right - state.plot.left);
};
const yForElevation = (elevation) => {
const span = Math.max(maxElevation - minElevation, 1);
- return state.plot.bottom - (elevation - minElevation) / span * (state.plot.bottom - state.plot.top);
+ return state.plot.bottom - ((elevation - minElevation) / span) * (state.plot.bottom - state.plot.top);
};
const nearestPointIndex = (distance) => {
let low = 0;
@@ -122,7 +152,7 @@
let nearestDistance = Infinity;
for (let index = 0; index < points.length; index++) {
const point = points[index];
- const distance = L.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]);
+ const distance = leaflet.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]);
if (distance < nearestDistance) {
nearestIndex = index;
nearestDistance = distance;
@@ -130,7 +160,7 @@
}
return nearestIndex;
};
- const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")];
+ const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")].map((form) => /** @type {HTMLFormElement} */ (form));
let activeBoundary = null;
const boundaryLabel = (index) => {
const point = points[index];
@@ -164,20 +194,37 @@
while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 < segmentEndM) pointIndex++;
const endElevation = elevationAtDistance(pointIndex, segmentEndM);
const slope = (endElevation - startElevation) / (segmentEndM - segmentStartM);
- if (slope > 0) score += (segmentEndM - segmentStartM) / 1000 * (slope * 100) ** 2;
+ if (slope > 0) score += ((segmentEndM - segmentStartM) / 1000) * (slope * 100) ** 2;
}
return score;
};
const climbMetrics = (index) => {
const bounds = state.climbBounds[index];
- if (!bounds || !Number.isInteger(bounds.startIndex) || !Number.isInteger(bounds.endIndex) || bounds.startIndex < 0 || bounds.endIndex >= points.length || bounds.startIndex >= bounds.endIndex) return null;
+ if (
+ !bounds ||
+ !Number.isInteger(bounds.startIndex) ||
+ !Number.isInteger(bounds.endIndex) ||
+ bounds.startIndex < 0 ||
+ bounds.endIndex >= points.length ||
+ bounds.startIndex >= bounds.endIndex
+ )
+ return null;
const start = points[bounds.startIndex];
const end = points[bounds.endIndex];
const distanceKm = end.distanceKm - start.distanceKm;
const elevationGain = end.elevationM - start.elevationM;
const slope = distanceKm > 0 ? elevationGain / (distanceKm * 10) : 0;
- const score = distanceKm > 0 ? Math.abs(elevationGain) * elevationGain / (distanceKm * 1000) * 10 : 0;
- return { start, end, distanceKm, elevationGain, slope, score, cotacol: cotacolForClimb(bounds.startIndex, bounds.endIndex), category: categoryForScore(score) };
+ const score = distanceKm > 0 ? ((Math.abs(elevationGain) * elevationGain) / (distanceKm * 1000)) * 10 : 0;
+ return {
+ start,
+ end,
+ distanceKm,
+ elevationGain,
+ slope,
+ score,
+ cotacol: cotacolForClimb(bounds.startIndex, bounds.endIndex),
+ category: categoryForScore(score),
+ };
};
const clearBoundarySelection = () => {
activeBoundary = null;
@@ -187,6 +234,7 @@
};
const updateBoundaryPreview = (form) => {
const preview = form.querySelector("[data-boundary-preview]");
+ /** @type {HTMLElement} */
const item = form.closest("[data-climb-item]");
const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
const metrics = climbMetrics(climbIndex);
@@ -216,10 +264,11 @@
const chooseBoundary = (index) => {
if (!activeBoundary) return;
const { form, target } = activeBoundary;
+ /** @type {HTMLElement} */
const item = form.closest("[data-climb-item]");
const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
- const input = form.querySelector(`[data-boundary-input="${target}"]`);
- const output = form.querySelector(`[data-boundary-output="${target}"]`);
+ const input = /** @type {HTMLInputElement} */ (form.querySelector(`[data-boundary-input="${target}"]`));
+ const output = /** @type {HTMLOutputElement} */ (form.querySelector(`[data-boundary-output="${target}"]`));
input.value = index;
output.textContent = boundaryLabel(index);
state.climbBounds[climbIndex][`${target}Index`] = index;
@@ -229,14 +278,16 @@
showPoint(index);
};
for (const form of boundaryForms) {
+ /** @type {HTMLElement} */
const item = form.closest("[data-climb-item]");
const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
state.climbBounds[climbIndex] = {
- startIndex: Number.parseInt(form.querySelector('[data-boundary-input="start"]').value, 10),
- endIndex: Number.parseInt(form.querySelector('[data-boundary-input="end"]').value, 10),
+ startIndex: Number.parseInt(/** @type {HTMLInputElement} */ (form.querySelector('[data-boundary-input="start"]')).value, 10),
+ endIndex: Number.parseInt(/** @type {HTMLInputElement} */ (form.querySelector('[data-boundary-input="end"]')).value, 10),
};
updateBoundaryPreview(form);
- for (const button of form.querySelectorAll("[data-boundary-button]")) {
+ const boundaryButtons = [...form.querySelectorAll("[data-boundary-button]")].map((button) => /** @type {HTMLElement} */ (button));
+ for (const button of boundaryButtons) {
button.addEventListener("click", () => {
clearBoundarySelection();
activeBoundary = { form, target: button.dataset.boundaryButton, source: button.dataset.boundarySource };
@@ -340,7 +391,7 @@
context.textAlign = "center";
context.textBaseline = "top";
for (let step = 0; step <= 4; step++) {
- const distance = minDistance + step / 4 * (maxDistance - minDistance);
+ const distance = minDistance + (step / 4) * (maxDistance - minDistance);
context.fillText(formatDistance(distance), xForDistance(distance), plot.bottom + 10);
}
@@ -381,7 +432,7 @@
const climbBounds = state.climbBounds[index];
if (!climbBounds || !Number.isInteger(climbBounds.startIndex) || !Number.isInteger(climbBounds.endIndex)) return;
const climbPoints = points.slice(climbBounds.startIndex, climbBounds.endIndex + 1);
- const mapBounds = L.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude]));
+ const mapBounds = leaflet.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude]));
if (mapBounds.isValid()) map.fitBounds(mapBounds, { padding: [32, 32], maxZoom: 15 });
};
const updateClimbFocus = (index, zoom) => {
@@ -415,21 +466,21 @@
clearHover();
return;
}
- const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance);
+ const distance = minDistance + ((x - state.plot.left) / (state.plot.right - state.plot.left)) * (maxDistance - minDistance);
showPoint(nearestPointIndex(distance));
});
canvas.addEventListener("pointerleave", clearHover);
canvas.addEventListener("pointercancel", clearHover);
canvas.addEventListener("click", (event) => {
- if (!activeBoundary || activeBoundary.source !== "profile" || !state.plot) return;
+ if (activeBoundary?.source !== "profile" || !state.plot) return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
if (x < state.plot.left || x > state.plot.right) return;
- const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance);
+ const distance = minDistance + ((x - state.plot.left) / (state.plot.right - state.plot.left)) * (maxDistance - minDistance);
chooseBoundary(nearestPointIndex(distance));
});
map.on("click", (event) => {
- if (!activeBoundary || activeBoundary.source !== "map") return;
+ if (activeBoundary?.source !== "map") return;
chooseBoundary(nearestMapPointIndex(event.latlng.lat, event.latlng.lng));
});
canvas.addEventListener("keydown", (event) => {
@@ -445,4 +496,6 @@
});
if (climbItems.length > 0) updateClimbFocus(0, false);
else draw();
-})();
+};
+
+mountRideDetail();
diff --git a/web/static/sync.js b/web/static/sync.js
new file mode 100644
index 0000000..3cf6cca
--- /dev/null
+++ b/web/static/sync.js
@@ -0,0 +1,103 @@
+export const mountSync = () => {
+ const form = /** @type {HTMLFormElement | null} */ (document.getElementById("sync-form"));
+ if (!form) return;
+ /** @type {HTMLButtonElement} */
+ const button = form.querySelector("button[type=submit]");
+ /** @type {HTMLElement} */
+ const progressPanel = document.getElementById("sync-progress");
+ const progressBar = /** @type {HTMLProgressElement} */ (document.getElementById("sync-progress-bar"));
+ /** @type {HTMLElement} */
+ const progressCount = document.getElementById("sync-progress-count");
+ /** @type {HTMLElement} */
+ const progressDetails = document.getElementById("sync-progress-details");
+ /** @type {HTMLElement} */
+ const notice = document.getElementById("sync-notice");
+ /** @type {HTMLElement} */
+ const error = document.getElementById("sync-error");
+
+ const showError = (message) => {
+ error.textContent = message;
+ error.hidden = false;
+ };
+ const updateProgress = (progress) => {
+ progressPanel.hidden = false;
+ progressBar.max = Math.max(progress.total, 1);
+ progressBar.value = progress.completed;
+ progressCount.textContent = `${progress.completed} / ${progress.total}`;
+ progressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;
+ };
+ const handleEvent = (eventName, data) => {
+ if (eventName === "progress") {
+ updateProgress(data);
+ return false;
+ }
+ if (eventName === "complete") {
+ updateProgress(data);
+ notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`;
+ notice.hidden = false;
+ return true;
+ }
+ if (eventName === "error") {
+ if (data.progress) updateProgress(data.progress);
+ throw new Error(data.message || "Strava sync failed");
+ }
+ return false;
+ };
+ const consumeEvents = async (response) => {
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let completed = false;
+ const consumeBlock = (block) => {
+ let eventName = "message";
+ let data = "";
+ for (const line of block.split(/\r?\n/)) {
+ if (line.startsWith("event: ")) eventName = line.slice(7);
+ if (line.startsWith("data: ")) data += line.slice(6);
+ }
+ if (data) completed = handleEvent(eventName, JSON.parse(data)) || completed;
+ };
+ while (true) {
+ const { value, done } = await reader.read();
+ buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
+ const blocks = buffer.split(/\r?\n\r?\n/);
+ buffer = blocks.pop();
+ for (const block of blocks) consumeBlock(block);
+ if (done) break;
+ }
+ if (buffer.trim()) consumeBlock(buffer);
+ if (!completed) throw new Error("Strava sync ended before completion");
+ };
+
+ form.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ button.disabled = true;
+ button.textContent = "Syncing...";
+ notice.hidden = true;
+ error.hidden = true;
+ progressPanel.hidden = false;
+ progressCount.textContent = "0 / 0";
+ progressDetails.textContent = "Preparing import...";
+ try {
+ const response = await fetch(form.action, {
+ method: "POST",
+ body: new FormData(form),
+ headers: { Accept: "text/event-stream" },
+ });
+ if (response.redirected) {
+ window.location.assign(response.url);
+ return;
+ }
+ if (!response.ok) throw new Error((await response.text()) || `Sync failed (HTTP ${response.status})`);
+ if (!response.body) throw new Error("The browser does not support streaming responses");
+ await consumeEvents(response);
+ } catch (caught) {
+ showError(caught instanceof Error ? caught.message : "Strava sync failed");
+ } finally {
+ button.disabled = false;
+ button.textContent = "Sync rides";
+ }
+ });
+};
+
+mountSync();
diff --git a/web/static_test/official-climb-profile-logic.test.js b/web/static_test/official-climb-profile-logic.test.js
index f663a65..9976c66 100644
--- a/web/static_test/official-climb-profile-logic.test.js
+++ b/web/static_test/official-climb-profile-logic.test.js
@@ -1,10 +1,6 @@
-const test = require("node:test");
-const assert = require("node:assert/strict");
-const {
- displayStepForLength,
- profileBandForSlope,
- officialProfileSections,
-} = require("../static/official-climb-profile-logic.js");
+import test from "node:test";
+import assert from "node:assert/strict";
+import { displayStepForLength, profileBandForSlope, officialProfileSections } from "../static/official-climb-profile-logic.js";
test("selects a display step that keeps long profiles readable", () => {
assert.equal(displayStepForLength(2_400), 100);
diff --git a/web/templates.templ b/web/templates.templ
index fe5c363..68327a1 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -170,10 +170,8 @@ templ RideDetailContent(data RideDetailView) {
if data.HasRoute {
@templ.JSONScript("ride-route", data.Route)
@templ.JSONScript("ride-profile", data.Profile)
- <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" defer></script>
- <script src="/static/official-climb-profile-logic.js" defer></script>
- <script src="/static/official-climb-profile.js" defer></script>
- <script src="/static/ride-detail.js" defer></script>
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
+ <script type="module" src="/static/ride-detail.js"></script>
}
}
@@ -214,101 +212,5 @@ templ SyncContent(data SyncPageData) {
</div>
}
</section>
- <script>
- (() => {
- const form = document.getElementById("sync-form");
- if (!form) return;
- const button = form.querySelector("button[type=submit]");
- const progressPanel = document.getElementById("sync-progress");
- const progressBar = document.getElementById("sync-progress-bar");
- const progressCount = document.getElementById("sync-progress-count");
- const progressDetails = document.getElementById("sync-progress-details");
- const notice = document.getElementById("sync-notice");
- const error = document.getElementById("sync-error");
-
- const showError = (message) => {
- error.textContent = message;
- error.hidden = false;
- };
- const updateProgress = (progress) => {
- progressPanel.hidden = false;
- progressBar.max = Math.max(progress.total, 1);
- progressBar.value = progress.completed;
- progressCount.textContent = `${progress.completed} / ${progress.total}`;
- progressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;
- };
- const handleEvent = (eventName, data) => {
- if (eventName === "progress") {
- updateProgress(data);
- return false;
- }
- if (eventName === "complete") {
- updateProgress(data);
- notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`;
- notice.hidden = false;
- return true;
- }
- if (eventName === "error") {
- if (data.progress) updateProgress(data.progress);
- throw new Error(data.message || "Strava sync failed");
- }
- return false;
- };
- const consumeEvents = async (response) => {
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- let completed = false;
- const consumeBlock = (block) => {
- let eventName = "message";
- let data = "";
- for (const line of block.split(/\r?\n/)) {
- if (line.startsWith("event: ")) eventName = line.slice(7);
- if (line.startsWith("data: ")) data += line.slice(6);
- }
- if (data) completed = handleEvent(eventName, JSON.parse(data)) || completed;
- };
- while (true) {
- const { value, done } = await reader.read();
- buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
- const blocks = buffer.split(/\r?\n\r?\n/);
- buffer = blocks.pop();
- for (const block of blocks) consumeBlock(block);
- if (done) break;
- }
- if (buffer.trim()) consumeBlock(buffer);
- if (!completed) throw new Error("Strava sync ended before completion");
- };
-
- form.addEventListener("submit", async (event) => {
- event.preventDefault();
- button.disabled = true;
- button.textContent = "Syncing...";
- notice.hidden = true;
- error.hidden = true;
- progressPanel.hidden = false;
- progressCount.textContent = "0 / 0";
- progressDetails.textContent = "Preparing import...";
- try {
- const response = await fetch(form.action, {
- method: "POST",
- body: new FormData(form),
- headers: { Accept: "text/event-stream" },
- });
- if (response.redirected) {
- window.location.assign(response.url);
- return;
- }
- if (!response.ok) throw new Error(await response.text() || `Sync failed (HTTP ${response.status})`);
- if (!response.body) throw new Error("The browser does not support streaming responses");
- await consumeEvents(response);
- } catch (caught) {
- showError(caught instanceof Error ? caught.message : "Strava sync failed");
- } finally {
- button.disabled = false;
- button.textContent = "Sync rides";
- }
- });
- })();
- </script>
+ <script type="module" src="/static/sync.js"></script>
}
diff --git a/web/templates_templ.go b/web/templates_templ.go
index eee38dd..f7a89d8 100644
--- a/web/templates_templ.go
+++ b/web/templates_templ.go
@@ -912,7 +912,7 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\" defer></script> <script src=\"/static/official-climb-profile-logic.js\" defer></script> <script src=\"/static/official-climb-profile.js\" defer></script> <script src=\"/static/ride-detail.js\" defer></script>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\"></script> <script type=\"module\" src=\"/static/ride-detail.js\"></script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -983,7 +983,7 @@ func SyncContent(data SyncPageData) templ.Component {
var templ_7745c5c3_Var59 string
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 189, Col: 52}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 187, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
if templ_7745c5c3_Err != nil {
@@ -1007,7 +1007,7 @@ func SyncContent(data SyncPageData) templ.Component {
var templ_7745c5c3_Var60 string
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 194, Col: 49}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 192, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
if templ_7745c5c3_Err != nil {
@@ -1049,7 +1049,7 @@ func SyncContent(data SyncPageData) templ.Component {
var templ_7745c5c3_Var62 string
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(data.From)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 205, Col: 64}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 203, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
if templ_7745c5c3_Err != nil {
@@ -1062,7 +1062,7 @@ func SyncContent(data SyncPageData) templ.Component {
var templ_7745c5c3_Var63 string
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(data.To)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 206, Col: 58}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 204, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
if templ_7745c5c3_Err != nil {
@@ -1073,7 +1073,7 @@ func SyncContent(data SyncPageData) templ.Component {
return templ_7745c5c3_Err
}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section><script>\n\t\t(() => {\n\t\t\tconst form = document.getElementById(\"sync-form\");\n\t\t\tif (!form) return;\n\t\t\tconst button = form.querySelector(\"button[type=submit]\");\n\t\t\tconst progressPanel = document.getElementById(\"sync-progress\");\n\t\t\tconst progressBar = document.getElementById(\"sync-progress-bar\");\n\t\t\tconst progressCount = document.getElementById(\"sync-progress-count\");\n\t\t\tconst progressDetails = document.getElementById(\"sync-progress-details\");\n\t\t\tconst notice = document.getElementById(\"sync-notice\");\n\t\t\tconst error = document.getElementById(\"sync-error\");\n\n\t\t\tconst showError = (message) => {\n\t\t\t\terror.textContent = message;\n\t\t\t\terror.hidden = false;\n\t\t\t};\n\t\t\tconst updateProgress = (progress) => {\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressBar.max = Math.max(progress.total, 1);\n\t\t\t\tprogressBar.value = progress.completed;\n\t\t\t\tprogressCount.textContent = `${progress.completed} / ${progress.total}`;\n\t\t\t\tprogressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;\n\t\t\t};\n\t\t\tconst handleEvent = (eventName, data) => {\n\t\t\t\tif (eventName === \"progress\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"complete\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\tnotice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`;\n\t\t\t\t\tnotice.hidden = false;\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"error\") {\n\t\t\t\t\tif (data.progress) updateProgress(data.progress);\n\t\t\t\t\tthrow new Error(data.message || \"Strava sync failed\");\n\t\t\t\t}\n\t\t\t\treturn false;\n\t\t\t};\n\t\t\tconst consumeEvents = async (response) => {\n\t\t\t\tconst reader = response.body.getReader();\n\t\t\t\tconst decoder = new TextDecoder();\n\t\t\t\tlet buffer = \"\";\n\t\t\t\tlet completed = false;\n\t\t\t\tconst consumeBlock = (block) => {\n\t\t\t\t\tlet eventName = \"message\";\n\t\t\t\t\tlet data = \"\";\n\t\t\t\t\tfor (const line of block.split(/\\r?\\n/)) {\n\t\t\t\t\t\tif (line.startsWith(\"event: \")) eventName = line.slice(7);\n\t\t\t\t\t\tif (line.startsWith(\"data: \")) data += line.slice(6);\n\t\t\t\t\t}\n\t\t\t\t\tif (data) completed = handleEvent(eventName, JSON.parse(data)) || completed;\n\t\t\t\t};\n\t\t\t\twhile (true) {\n\t\t\t\t\tconst { value, done } = await reader.read();\n\t\t\t\t\tbuffer += decoder.decode(value || new Uint8Array(), { stream: !done });\n\t\t\t\t\tconst blocks = buffer.split(/\\r?\\n\\r?\\n/);\n\t\t\t\t\tbuffer = blocks.pop();\n\t\t\t\t\tfor (const block of blocks) consumeBlock(block);\n\t\t\t\t\tif (done) break;\n\t\t\t\t}\n\t\t\t\tif (buffer.trim()) consumeBlock(buffer);\n\t\t\t\tif (!completed) throw new Error(\"Strava sync ended before completion\");\n\t\t\t};\n\n\t\t\tform.addEventListener(\"submit\", async (event) => {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tbutton.disabled = true;\n\t\t\t\tbutton.textContent = \"Syncing...\";\n\t\t\t\tnotice.hidden = true;\n\t\t\t\terror.hidden = true;\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressCount.textContent = \"0 / 0\";\n\t\t\t\tprogressDetails.textContent = \"Preparing import...\";\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await fetch(form.action, {\n\t\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\t\tbody: new FormData(form),\n\t\t\t\t\t\theaders: { Accept: \"text/event-stream\" },\n\t\t\t\t\t});\n\t\t\t\t\tif (response.redirected) {\n\t\t\t\t\t\twindow.location.assign(response.url);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (!response.ok) throw new Error(await response.text() || `Sync failed (HTTP ${response.status})`);\n\t\t\t\t\tif (!response.body) throw new Error(\"The browser does not support streaming responses\");\n\t\t\t\t\tawait consumeEvents(response);\n\t\t\t\t} catch (caught) {\n\t\t\t\t\tshowError(caught instanceof Error ? caught.message : \"Strava sync failed\");\n\t\t\t\t} finally {\n\t\t\t\t\tbutton.disabled = false;\n\t\t\t\t\tbutton.textContent = \"Sync rides\";\n\t\t\t\t}\n\t\t\t});\n\t\t})();\n\t</script>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section><script type=\"module\" src=\"/static/sync.js\"></script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/web/types/global.d.ts b/web/types/global.d.ts
new file mode 100644
index 0000000..2f4a39b
--- /dev/null
+++ b/web/types/global.d.ts
@@ -0,0 +1,3 @@
+interface Window {
+ L?: typeof import("leaflet");
+}