From be78280c669a04c112afaf1801f22ed2eec7c55a Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 16 Aug 2026 13:19:22 +0200 Subject: refactor(web): establish JavaScript module tooling --- .gitignore | 1 + .mise.toml | 3 + biome.json | 15 + package-lock.json | 599 +++++++++++++++++++++ package.json | 18 + tsconfig.json | 15 + web/server_test.go | 27 +- web/static/official-climb-profile-logic.js | 90 ++-- web/static/official-climb-profile.js | 261 ++++----- web/static/ride-detail.js | 137 +++-- web/static/sync.js | 103 ++++ .../official-climb-profile-logic.test.js | 10 +- web/templates.templ | 104 +--- web/templates_templ.go | 12 +- web/types/global.d.ts | 3 + 15 files changed, 1070 insertions(+), 328 deletions(-) create mode 100644 biome.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 web/static/sync.js create mode 100644 web/types/global.d.ts 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, ``) - assert.Contains(t, body, ``) - assert.Contains(t, body, ``) - assert.Contains(t, body, ``) + assert.Contains(t, body, ``) + assert.Contains(t, body, ``) + assert.NotContains(t, body, ``) + 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} */ + 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: '© OpenStreetMap 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) - - - - + + } } @@ -214,101 +212,5 @@ templ SyncContent(data SyncPageData) { } - + } 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(" ") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") 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("") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") 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"); +} -- cgit v1.2.3