summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-16 11:30:30 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-16 11:30:30 +0200
commite7bd55d20974f4ee5b892cd2e81c62b5c5408920 (patch)
tree4e0aa6c107d7096962beffdd491814805a350a89
parent3e1c6f9c0056c488f51dc2e13d6ddf5365adb312 (diff)
refactor(web): extract testable profile logic
-rw-r--r--.mise.toml10
-rw-r--r--web/server_test.go27
-rw-r--r--web/static/official-climb-profile-logic.js47
-rw-r--r--web/static/official-climb-profile.js61
-rw-r--r--web/static_test/official-climb-profile-logic.test.js41
-rw-r--r--web/templates.templ1
-rw-r--r--web/templates_templ.go10
7 files changed, 123 insertions, 74 deletions
diff --git a/.mise.toml b/.mise.toml
index 6c64293..785f457 100644
--- a/.mise.toml
+++ b/.mise.toml
@@ -12,9 +12,15 @@ description = "Format Go sources"
run = "go fmt ./..."
[tasks.check]
-description = "Run generation, vet, and tests"
+description = "Run formatting, JavaScript and Go gate checks"
depends = ["generate", "format"]
-run = "go vet ./... && go test ./..."
+run = [
+ "for file in web/static/*.js; do node --check \"$file\"; done",
+ "node --test web/static_test/*.test.js",
+ "go vet ./...",
+ "go build ./...",
+ "go test ./...",
+]
[tasks.build]
description = "Build the application"
diff --git a/web/server_test.go b/web/server_test.go
index 281f46a..a4ee0b7 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -135,9 +135,9 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
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.NotContains(t, body, "pointermove")
staticRequest := httptest.NewRequest(http.MethodGet, "/static/ride-detail.js", nil)
staticResponse := httptest.NewRecorder()
@@ -146,17 +146,8 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
script := staticResponse.Body.String()
assert.Equal(t, http.StatusOK, staticResponse.Code)
assert.Equal(t, "text/javascript; charset=utf-8", staticResponse.Header().Get("Content-Type"))
- assert.Contains(t, script, "pointermove")
- assert.Contains(t, script, "circleMarker")
- assert.Contains(t, script, "crossing.passElevationM")
- assert.Contains(t, script, "climbRoute")
- assert.Contains(t, script, "L.polyline")
- assert.Contains(t, script, "zoomToClimb")
- assert.Contains(t, script, "Cotacol")
- assert.Contains(t, script, "cotacolForClimb")
- assert.Contains(t, script, "const labelY = plot.top - 6")
+ assert.NotEmpty(t, script)
assert.Contains(t, body, "leaflet@1.9.4/dist/leaflet.js")
- assert.Contains(t, script, "tile.openstreetmap.org/{z}/{x}/{y}.png")
assert.Contains(t, body, `"type":"FeatureCollection"`)
assert.Contains(t, body, `"type":"LineString"`)
assert.Contains(t, body, `"coordinates":[[5,43]`)
@@ -168,11 +159,15 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
profileScript := profileStaticResponse.Body.String()
assert.Equal(t, http.StatusOK, profileStaticResponse.Code)
assert.Equal(t, "text/javascript; charset=utf-8", profileStaticResponse.Header().Get("Content-Type"))
- assert.Contains(t, profileScript, "officialProfileSections")
- assert.Contains(t, profileScript, "profileStepSizesM = [100, 200, 500, 1000]")
- assert.Contains(t, profileScript, "displayStepForLength")
- assert.Contains(t, profileScript, "profileBandForSlope")
- assert.Contains(t, profileScript, "section.slopePercent.toFixed(1)")
+ assert.NotEmpty(t, profileScript)
+
+ logicStaticRequest := httptest.NewRequest(http.MethodGet, "/static/official-climb-profile-logic.js", nil)
+ logicStaticResponse := httptest.NewRecorder()
+ server.Handler().ServeHTTP(logicStaticResponse, logicStaticRequest)
+
+ 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())
}
func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) {
diff --git a/web/static/official-climb-profile-logic.js b/web/static/official-climb-profile-logic.js
new file mode 100644
index 0000000..c84960b
--- /dev/null
+++ b/web/static/official-climb-profile-logic.js
@@ -0,0 +1,47 @@
+(() => {
+ 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);
+ };
+ 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;
+})();
diff --git a/web/static/official-climb-profile.js b/web/static/official-climb-profile.js
index d03f0d5..603edfe 100644
--- a/web/static/official-climb-profile.js
+++ b/web/static/official-climb-profile.js
@@ -1,5 +1,6 @@
(() => {
const createOfficialClimbProfileController = (points) => {
+ const { displayStepForLength, officialProfileSections } = window.OfficialClimbProfileLogic;
const colorProbe = document.createElement("span");
colorProbe.hidden = true;
document.body.append(colorProbe);
@@ -8,12 +9,12 @@
return getComputedStyle(colorProbe).color;
};
const colors = {
- profileDownhill: resolveColor("--color-profile-downhill"),
- profile0To3: resolveColor("--color-profile-0-3"),
- profile3To6: resolveColor("--color-profile-3-6"),
- profile6To9: resolveColor("--color-profile-6-9"),
- profile9To12: resolveColor("--color-profile-9-12"),
- profile12Plus: resolveColor("--color-profile-12-plus"),
+ downhill: resolveColor("--color-profile-downhill"),
+ "0-3": resolveColor("--color-profile-0-3"),
+ "3-6": resolveColor("--color-profile-3-6"),
+ "6-9": resolveColor("--color-profile-6-9"),
+ "9-12": resolveColor("--color-profile-9-12"),
+ "12-plus": resolveColor("--color-profile-12-plus"),
plotSurface: resolveColor("--color-plot-surface"),
grid: resolveColor("--color-plot-grid"),
subtle: resolveColor("--color-subtle"),
@@ -21,48 +22,6 @@
};
colorProbe.remove();
const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
- 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 colors.profileDownhill;
- if (slopePercent < 3) return colors.profile0To3;
- if (slopePercent < 6) return colors.profile3To6;
- if (slopePercent < 9) return colors.profile6To9;
- if (slopePercent < 12) return colors.profile9To12;
- return colors.profile12Plus;
- };
- const officialProfileSections = (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,
- color: profileBandForSlope(slopePercent),
- });
- }
- return sections;
- };
const drawOfficialProfile = (profileCanvas) => {
const startIndex = Number.parseInt(profileCanvas.dataset.profileStart, 10);
const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd, 10);
@@ -77,7 +36,7 @@
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(startIndex, endIndex, displayStepForLength(climbLengthM));
+ const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM));
if (sections.length === 0) return;
let minElevation = sections[0].startElevation;
let maxElevation = minElevation;
@@ -113,13 +72,13 @@
context.lineTo(endX, plot.bottom);
context.closePath();
context.globalAlpha = 0.25;
- context.fillStyle = section.color;
+ 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 = section.color;
+ context.strokeStyle = colors[section.band];
context.lineWidth = 2.5;
context.stroke();
}
diff --git a/web/static_test/official-climb-profile-logic.test.js b/web/static_test/official-climb-profile-logic.test.js
new file mode 100644
index 0000000..f663a65
--- /dev/null
+++ b/web/static_test/official-climb-profile-logic.test.js
@@ -0,0 +1,41 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const {
+ displayStepForLength,
+ profileBandForSlope,
+ officialProfileSections,
+} = require("../static/official-climb-profile-logic.js");
+
+test("selects a display step that keeps long profiles readable", () => {
+ assert.equal(displayStepForLength(2_400), 100);
+ assert.equal(displayStepForLength(4_500), 200);
+ assert.equal(displayStepForLength(7_000), 500);
+ assert.equal(displayStepForLength(20_000), 1000);
+});
+
+test("assigns each slope to its color band", () => {
+ assert.equal(profileBandForSlope(-0.1), "downhill");
+ assert.equal(profileBandForSlope(2.9), "0-3");
+ assert.equal(profileBandForSlope(3), "3-6");
+ assert.equal(profileBandForSlope(6), "6-9");
+ assert.equal(profileBandForSlope(9), "9-12");
+ assert.equal(profileBandForSlope(12), "12-plus");
+});
+
+test("interpolates profile sections at the selected step", () => {
+ const points = [
+ { distanceKm: 0, elevationM: 100 },
+ { distanceKm: 0.12, elevationM: 106 },
+ { distanceKm: 0.2, elevationM: 114 },
+ { distanceKm: 0.25, elevationM: 120 },
+ ];
+ const sections = officialProfileSections(points, 0, 3, 100);
+
+ assert.equal(sections.length, 3);
+ assert.equal(sections[0].startDistanceKm, 0);
+ assert.equal(sections[0].endDistanceKm, 0.1);
+ assert.equal(sections[0].startElevation, 100);
+ assert.equal(sections[0].endElevation, 105);
+ assert.equal(sections[2].endDistanceKm, 0.25);
+ assert.equal(sections[2].endElevation, 120);
+});
diff --git a/web/templates.templ b/web/templates.templ
index 1decad5..fe5c363 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -171,6 +171,7 @@ templ RideDetailContent(data RideDetailView) {
@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>
}
diff --git a/web/templates_templ.go b/web/templates_templ.go
index 0ecca3d..eee38dd 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.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\" 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>")
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: 188, Col: 52}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 189, 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: 193, Col: 49}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 194, 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: 204, Col: 64}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 205, 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: 205, Col: 58}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 206, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
if templ_7745c5c3_Err != nil {