summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-16 10:31:32 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-16 10:31:32 +0200
commit0c3507139795c3117bcdf82ca4b444e934bd3c74 (patch)
tree626f62c7dc15ded26bfeedbfa32a6638c07c3dfa
parentc720b572573774504cbbeda5ac1b5da2d1afeff6 (diff)
feat(web): improve ride detail climb profiles
-rw-r--r--README.md8
-rw-r--r--web/server_test.go26
-rw-r--r--web/static/ride-detail.js191
-rw-r--r--web/static/style.css52
-rw-r--r--web/templates.templ120
-rw-r--r--web/templates_templ.go495
-rw-r--r--web/views.go57
7 files changed, 671 insertions, 278 deletions
diff --git a/README.md b/README.md
index 91ae2c4..1518a30 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c
- **Mountain pass download** — imports French mountain passes from centcols.org into a SQLite database
- **Pass crossing detection** — enriches passes with OSM coordinates, flags which passes a ride crosses, and names each climb after the pass it tops (e.g. "Col de Castellaras")
- **Interactive ride profiles** — explores elevation, climbs, and mountain-pass crossings in the web ride detail view
+- **Official climb cards** — separates matched climbs from pending matches and expands each matched climb with its own elevation profile
- **Web ride library** — starts a local web server by default, imports Strava rides over a date range, stores metadata in SQLite, and keeps their GPX files on disk
- **Materialized ride values** — computes Cotacol on import, stores its algorithm version in SQLite, and refreshes all computed values with `-backfill`
- **Strava stream metrics** — preserves heart rate, cadence, and power in Garmin-compatible GPX files and in-memory ride columns
@@ -29,7 +30,7 @@ go build -o biking_home .
```bash
# Start the web server on http://localhost:8080
-mise watch dev-watch --restart --exts go,templ --ignore '*_templ.go'
+mise watch dev-watch --restart --exts go,templ,css,js --ignore '*_templ.go'
# Recompute and persist all ride computed values
./biking_home -backfill
@@ -128,12 +129,15 @@ mise run build
## TODO
+- Bug: cotacol on global profile differs from cotacol in climbs to match
- Compute estimated power, speed
- Plot speed and slope per segment, colored by heart rate
- Plot speed vs ctc/100km
- Blog with pictures and markdown
- Road quality (Arbois = 2/5, Roquefavour = 4/5)
-- Climb - Display step by step slope curve like TdF
+- OffClimb
+ - Keep trace appart from ride to display slope profile, compute cotacol
+ - Profile - Adaptative to show ramps
- Planning rides
- Ride details
- Display mountain pass on map in bounding box
diff --git a/web/server_test.go b/web/server_test.go
index 9ecda80..6a0f9c3 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -131,7 +131,8 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
assert.Contains(t, body, `id="ride-profile-chart"`)
assert.Contains(t, body, `id="ride-map"`)
assert.Contains(t, body, `class="ride-detail-grid"`)
- assert.Contains(t, body, `class="ride-detail-sidebar"`)
+ 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/ride-detail.js" defer></script>`)
@@ -152,6 +153,11 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
assert.Contains(t, script, "zoomToClimb")
assert.Contains(t, script, "Cotacol")
assert.Contains(t, script, "cotacolForClimb")
+ assert.Contains(t, script, "officialProfileSections")
+ assert.Contains(t, script, "profileStepSizesM = [100, 200, 500, 1000]")
+ assert.Contains(t, script, "displayStepForLength")
+ assert.Contains(t, script, "profileBandForSlope")
+ assert.Contains(t, script, "section.slopePercent.toFixed(1)")
assert.Contains(t, script, "const labelY = plot.top - 6")
assert.Contains(t, body, "leaflet@1.9.4/dist/leaflet.js")
assert.Contains(t, script, "tile.openstreetmap.org/{z}/{x}/{y}.png")
@@ -181,6 +187,8 @@ func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) {
require.Len(t, profile.Climbs, 1)
assert.Equal(t, "Pas de Magnan", profile.Climbs[0].Name)
assert.Equal(t, 1.0, profile.Climbs[0].TopKm)
+ assert.Empty(t, profile.OfficialClimbs)
+ assert.Len(t, profile.UnmatchedClimbs, 1)
require.Len(t, profile.Crossings, 1)
assert.Equal(t, "Pas de Magnan", profile.Crossings[0].Name)
assert.Equal(t, 1.0, profile.Crossings[0].DistanceKm)
@@ -204,6 +212,8 @@ func TestBuildRideProfileIncludesOfficialClimbMatch(t *testing.T) {
assert.Equal(t, int64(42), profile.Climbs[0].OfficialClimbID)
assert.Equal(t, "Col de Test", profile.Climbs[0].OfficialName)
assert.Equal(t, "Col de Test", profile.Climbs[0].Name)
+ assert.Len(t, profile.OfficialClimbs, 1)
+ assert.Empty(t, profile.UnmatchedClimbs)
}
func TestBuildRideProfileUsesOfficialClimbBoundaries(t *testing.T) {
@@ -249,7 +259,7 @@ func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) {
getResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(getResponse, getRequest)
assert.Equal(t, http.StatusOK, getResponse.Code)
- assert.Contains(t, getResponse.Body.String(), "No official climb matched yet.")
+ assert.Contains(t, getResponse.Body.String(), "Waiting for an official climb match.")
assert.Contains(t, getResponse.Body.String(), "Select on profile")
assert.Contains(t, getResponse.Body.String(), "Select on map")
assert.Contains(t, getResponse.Body.String(), "Climb 1")
@@ -273,8 +283,16 @@ func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) {
getRequest = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d?official_climb=created", item.ID), nil)
getResponse = httptest.NewRecorder()
server.Handler().ServeHTTP(getResponse, getRequest)
- assert.Contains(t, getResponse.Body.String(), "Official climb saved and matched to this ride by coordinates.")
- assert.Contains(t, getResponse.Body.String(), "Official climb matched: Col de Test")
+ body := getResponse.Body.String()
+ assert.Contains(t, body, "Official climb saved and matched to this ride by coordinates.")
+ assert.Contains(t, body, "data-official-climb-card")
+ assert.Contains(t, body, "Length")
+ assert.Contains(t, body, "Cotacol")
+ assert.Contains(t, body, "Avg slope")
+ assert.Contains(t, body, "data-official-profile")
+ assert.Contains(t, body, "0–3%")
+ assert.Contains(t, body, "12%+")
+ assert.NotContains(t, body, "Waiting for an official climb match.")
}
func TestHandlerRendersRideDetailWithoutRoute(t *testing.T) {
diff --git a/web/static/ride-detail.js b/web/static/ride-detail.js
index bd36da4..fc9c123 100644
--- a/web/static/ride-detail.js
+++ b/web/static/ride-detail.js
@@ -26,6 +26,12 @@
hoverLine: resolveColor("--color-hover-line"),
climbRoute: resolveColor("--color-climb-route"),
climbFocusFill: resolveColor("--color-climb-focus-fill"),
+ 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"),
};
colorProbe.remove();
const route = JSON.parse(routeElement.textContent);
@@ -47,6 +53,8 @@
const points = profile.points || [];
if (points.length === 0) return;
const climbItems = [...document.querySelectorAll("[data-climb-item]")];
+ const climbItemIndices = climbItems.map((item) => Number.parseInt(item.dataset.climbIndex, 10));
+ const officialProfileCanvases = [...document.querySelectorAll("[data-official-profile]")];
const previousClimbButton = document.querySelector("[data-climb-previous]");
const nextClimbButton = document.querySelector("[data-climb-next]");
const climbPosition = document.querySelector("[data-climb-position]");
@@ -73,7 +81,8 @@
if (!context) return;
const state = {
hoveredIndex: -1,
- focusedClimbIndex: 0,
+ focusedClimbItemIndex: 0,
+ focusedClimbIndex: climbItemIndices[0] ?? 0,
climbBounds: (profile.climbs || []).map((climb) => ({ startIndex: climb.startIndex, endIndex: climb.endIndex })),
plot: null,
width: 0,
@@ -94,6 +103,157 @@
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 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);
+ 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(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(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 = section.color;
+ context.fill();
+ context.globalAlpha = 1;
+ context.beginPath();
+ context.moveTo(startX, yForElevation(section.startElevation));
+ context.lineTo(endX, yForElevation(section.endElevation));
+ context.strokeStyle = section.color;
+ 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;
+ }
+ 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);
+ };
+ for (const card of document.querySelectorAll("[data-official-climb-card]")) {
+ card.addEventListener("toggle", () => {
+ if (!card.open) return;
+ for (const other of document.querySelectorAll("[data-official-climb-card]")) {
+ if (other !== card) other.open = false;
+ }
+ const profileCanvas = card.querySelector("[data-official-profile]");
+ if (profileCanvas) drawOfficialProfile(profileCanvas);
+ });
+ }
const xForDistance = (distance) => {
const span = Math.max(maxDistance - minDistance, 1);
return state.plot.left + (distance - minDistance) / span * (state.plot.right - state.plot.left);
@@ -383,24 +543,27 @@
};
const updateClimbFocus = (index, zoom) => {
if (climbItems.length === 0) return;
- state.focusedClimbIndex = clamp(index, 0, climbItems.length - 1);
+ state.focusedClimbItemIndex = clamp(index, 0, climbItems.length - 1);
+ state.focusedClimbIndex = climbItemIndices[state.focusedClimbItemIndex];
for (let itemIndex = 0; itemIndex < climbItems.length; itemIndex++) {
- const active = itemIndex === state.focusedClimbIndex;
+ const active = itemIndex === state.focusedClimbItemIndex;
climbItems[itemIndex].hidden = !active;
climbItems[itemIndex].setAttribute("aria-hidden", String(!active));
- if (climbLayers[itemIndex]) {
- updateClimbLayer(itemIndex);
+ climbItems[itemIndex].classList.toggle("focused", active);
+ const climbIndex = climbItemIndices[itemIndex];
+ if (climbLayers[climbIndex]) {
+ updateClimbLayer(climbIndex);
}
}
- if (climbPosition) climbPosition.textContent = `Climb ${state.focusedClimbIndex + 1} of ${climbItems.length}`;
- if (previousClimbButton) previousClimbButton.disabled = state.focusedClimbIndex === 0;
- if (nextClimbButton) nextClimbButton.disabled = state.focusedClimbIndex === climbItems.length - 1;
+ if (climbPosition) climbPosition.textContent = `Climb ${state.focusedClimbItemIndex + 1} of ${climbItems.length}`;
+ if (previousClimbButton) previousClimbButton.disabled = state.focusedClimbItemIndex === 0;
+ if (nextClimbButton) nextClimbButton.disabled = state.focusedClimbItemIndex === climbItems.length - 1;
clearBoundarySelection();
draw();
if (zoom) zoomToClimb(state.focusedClimbIndex);
};
- if (previousClimbButton) previousClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbIndex - 1, true));
- if (nextClimbButton) nextClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbIndex + 1, true));
+ if (previousClimbButton) previousClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbItemIndex - 1, true));
+ if (nextClimbButton) nextClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbItemIndex + 1, true));
canvas.addEventListener("pointermove", (event) => {
if (!state.plot) return;
const rect = canvas.getBoundingClientRect();
@@ -433,7 +596,13 @@
const index = state.hoveredIndex < 0 ? (direction > 0 ? 0 : points.length - 1) : state.hoveredIndex + direction;
showPoint(index);
});
- window.addEventListener("resize", draw);
+ window.addEventListener("resize", () => {
+ draw();
+ for (const profileCanvas of officialProfileCanvases) {
+ const card = profileCanvas.closest("[data-official-climb-card]");
+ if (card?.open) drawOfficialProfile(profileCanvas);
+ }
+ });
if (climbItems.length > 0) updateClimbFocus(0, false);
else draw();
})();
diff --git a/web/static/style.css b/web/static/style.css
index 8a35844..c867501 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -38,6 +38,12 @@
--color-hover-line: oklch(from var(--palette-forest) l c h / 60%);
--color-climb-route: var(--palette-leaf);
--color-climb-focus-fill: oklch(from var(--palette-leaf) l c h / 18%);
+ --color-profile-downhill: oklch(58% 0.12 245);
+ --color-profile-0-3: oklch(66% 0.13 145);
+ --color-profile-3-6: oklch(72% 0.16 112);
+ --color-profile-6-9: oklch(78% 0.16 91);
+ --color-profile-9-12: oklch(70% 0.17 57);
+ --color-profile-12-plus: oklch(60% 0.18 32);
}
* { box-sizing: border-box; }
@@ -64,8 +70,10 @@ input { border: 1px solid var(--color-input-border); border-radius: .55rem; padd
progress { width: 100%; height: .8rem; accent-color: var(--color-accent); }
.notice { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: var(--color-notice-surface); color: var(--color-notice-text); }
.error { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: var(--color-error-surface); color: var(--color-error-text); }
-.ride-detail-grid { display: grid; grid-template-columns: minmax(16rem, .85fr) minmax(0, 1.75fr); gap: 1.25rem; margin-top: 2rem; align-items: start; }
-.ride-detail-sidebar, .ride-detail-main { display: grid; gap: 1.25rem; min-width: 0; }
+.ride-detail-grid { display: grid; grid-template-columns: 1fr; gap: 1.25rem; margin-top: 2rem; align-items: start; }
+.ride-detail-columns { display: grid; grid-template-columns: minmax(16rem, .85fr) minmax(0, 1.75fr); gap: 1.25rem; align-items: start; min-width: 0; }
+.ride-detail-columns-single > .ride-detail-main { grid-column: 1 / -1; }
+.ride-detail-main { display: grid; gap: 1.25rem; min-width: 0; }
.ride-detail-grid .panel { margin-top: 0; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: .85rem .5rem; border-bottom: 1px solid var(--color-border-subtle); text-align: left; }
@@ -85,9 +93,45 @@ th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text-
.profile-chart canvas { display: block; width: 100%; height: 22rem; border-radius: .65rem; background: var(--color-plot-surface); outline: none; }
.profile-chart canvas:focus-visible { box-shadow: 0 0 0 .2rem var(--color-focus); }
.profile-hover { display: block; min-height: 1.4rem; margin: .7rem 0 0; color: var(--color-muted); font-size: .9rem; }
+.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
+.section-heading .eyebrow { margin-bottom: .2rem; }
+.section-heading h2 { margin: 0; font-size: 1.2rem; }
+.section-count { display: grid; place-items: center; min-width: 2rem; height: 2rem; border-radius: 999px; background: var(--color-secondary-surface); color: var(--color-forest); font-weight: 800; }
+.official-climbs-panel { padding: 0; border: 0; background: transparent; box-shadow: none; }
+.official-climbs-panel .section-heading { margin-bottom: .75rem; }
+.official-climb-list { display: grid; gap: 0; }
+.official-climb-card { overflow: hidden; border: 1px solid var(--color-border); border-bottom: 0; border-radius: 0; background: var(--color-plot-surface); }
+.official-climb-card:last-child { border-bottom: 1px solid var(--color-border); }
+.official-climb-card > summary { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .9rem 1rem; color: var(--color-forest); cursor: pointer; list-style: none; }
+.official-climb-card > summary::-webkit-details-marker { display: none; }
+.official-climb-card > summary::marker { display: none; }
+.official-climb-card > summary:hover, .official-climb-card > summary:focus-visible { background: var(--color-secondary-surface); }
+.official-climb-card > summary:focus-visible { outline: .2rem solid var(--color-focus); outline-offset: -.2rem; }
+.official-climb-card-title { display: flex; align-items: baseline; gap: .2rem; min-width: 0; }
+.official-climb-card-title strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.official-climb-summary-stats { display: flex; gap: .8rem; margin: 0 0 0 auto; }
+.official-climb-summary-stats dt { font-size: .6rem; }
+.official-climb-summary-stats dd { margin-top: .1rem; font-size: .85rem; }
+.official-climb-chevron { color: var(--color-accent); font-size: 1.5rem; font-weight: 500; line-height: 1; }
+.official-climb-card[open] .official-climb-chevron { transform: rotate(180deg); }
+.official-climb-card-body { padding: .9rem 1rem 1rem; border-top: 1px solid var(--color-border-subtle); background: var(--color-surface); }
+.official-climb-profile { display: block; width: 100%; height: 10rem; margin-top: .75rem; border-radius: .55rem; background: var(--color-plot-surface); outline: none; }
+.official-climb-profile:focus-visible { box-shadow: 0 0 0 .2rem var(--color-focus); }
+.official-climb-profile-legend { display: flex; flex-wrap: wrap; gap: .45rem .8rem; margin-top: .65rem; color: var(--color-muted); font-size: .72rem; }
+.official-climb-profile-legend > span { display: inline-flex; align-items: center; gap: .3rem; }
+.profile-band-swatch { display: inline-block; width: .65rem; height: .65rem; border-radius: .15rem; }
+.profile-band-downhill { background: var(--color-profile-downhill); }
+.profile-band-0-3 { background: var(--color-profile-0-3); }
+.profile-band-3-6 { background: var(--color-profile-3-6); }
+.profile-band-6-9 { background: var(--color-profile-6-9); }
+.profile-band-9-12 { background: var(--color-profile-9-12); }
+.profile-band-12-plus { background: var(--color-profile-12-plus); }
.climb-list { display: grid; gap: 1rem; margin-top: 1.25rem; }
.climb-item { padding-top: 1rem; border-top: 1px solid var(--color-border-subtle); }
.climb-item-heading { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
+.todo-item { padding-left: .8rem; border-left: .2rem solid var(--color-accent); }
+.todo-item.focused { border-left-color: var(--color-forest); background: var(--color-plot-surface); }
+.todo-check { display: inline-block; width: .9rem; height: .9rem; margin-right: .5rem; border: 2px solid var(--color-accent); border-radius: .2rem; vertical-align: -.05rem; }
.climb-number { display: inline-block; margin-right: .45rem; color: var(--color-accent); font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }
.climb-item-heading > span, .climb-status { color: var(--color-muted); font-size: .9rem; }
.climb-metrics { margin: .35rem 0 0; color: var(--color-muted); font-size: .9rem; }
@@ -107,6 +151,6 @@ th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text-
dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 0; }
dt { color: var(--color-subtle); font-size: .78rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
dd { margin: .25rem 0 0; font-size: 1.2rem; font-weight: 700; }
-@media (max-width: 900px) { .ride-detail-grid { grid-template-columns: 1fr; } }
+@media (max-width: 900px) { .ride-detail-columns { grid-template-columns: 1fr; } }
@media (max-width: 700px) { .container { width: min(100% - 1rem, 70rem); padding-top: 1.5rem; } .site-header { padding-inline: 1rem; } th:nth-child(n+4), td:nth-child(n+4) { display: none; } .panel { padding: .8rem; } }
-@media (max-width: 500px) { dl { grid-template-columns: repeat(2, 1fr); } }
+@media (max-width: 500px) { dl { grid-template-columns: repeat(2, 1fr); } .official-climb-summary-stats { gap: .55rem; } .official-climb-card-title { display: block; } }
diff --git a/web/templates.templ b/web/templates.templ
index 9ebeedb..c640171 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -71,60 +71,85 @@ templ RideDetailContent(data RideDetailView) {
<div class="error">{ data.ActionError }</div>
}
<div class="ride-detail-grid">
- <div class="ride-detail-sidebar">
- <section class="panel summary-panel">
- <dl>
- <div><dt>Distance</dt><dd>{ formatDistance(data.Ride.DistanceM) }</dd></div>
- <div><dt>Moving time</dt><dd>{ formatDuration(data.Ride.MovingTimeS) }</dd></div>
- <div><dt>Elevation</dt><dd>{ formatElevation(data.Ride.TotalElevationGainM) }</dd></div>
- </dl>
- </section>
- if data.HasRoute {
+ <section class="panel summary-panel">
+ <dl>
+ <div><dt>Distance</dt><dd>{ formatDistance(data.Ride.DistanceM) }</dd></div>
+ <div><dt>Moving time</dt><dd>{ formatDuration(data.Ride.MovingTimeS) }</dd></div>
+ <div><dt>Elevation</dt><dd>{ formatElevation(data.Ride.TotalElevationGainM) }</dd></div>
+ </dl>
+ </section>
+ if data.HasRoute {
+ if len(data.Profile.OfficialClimbs) > 0 {
+ <section class="panel official-climbs-panel">
+ <div class="section-heading">
+ <p class="eyebrow">Official climbs</p>
+ </div>
+ <div class="official-climb-list">
+ for _, climb := range data.Profile.OfficialClimbs {
+ <details class="official-climb-card" data-official-climb-card>
+ <summary>
+ <span class="official-climb-card-title"><span class="climb-number">Climb { formatClimbNumber(climb.Index) }</span><strong>{ climb.OfficialName }</strong></span>
+ <dl class="official-climb-summary-stats">
+ <div><dt>Length</dt><dd>{ formatProfileDistance(climb.DistanceKm) }</dd></div>
+ <div><dt>Cotacol</dt><dd>{ formatCotacol(climb.Cotacol) }</dd></div>
+ <div><dt>Avg slope</dt><dd>{ formatSlope(climb.SlopePercent) }</dd></div>
+ </dl>
+ <span class="official-climb-chevron" aria-hidden="true">⌄</span>
+ </summary>
+ <div class="official-climb-card-body">
+ <canvas class="official-climb-profile" data-official-profile data-profile-start={ formatRouteIndex(climb.StartIndex) } data-profile-end={ formatRouteIndex(climb.EndIndex) } tabindex="0" role="img" aria-label={ officialProfileLabel(climb.OfficialName) }></canvas>
+ <div class="official-climb-profile-legend" role="list" aria-label="Average slope color scale">
+ <span role="listitem"><i class="profile-band-swatch profile-band-downhill" aria-hidden="true"></i>Downhill</span>
+ <span role="listitem"><i class="profile-band-swatch profile-band-0-3" aria-hidden="true"></i>0–3%</span>
+ <span role="listitem"><i class="profile-band-swatch profile-band-3-6" aria-hidden="true"></i>3–6%</span>
+ <span role="listitem"><i class="profile-band-swatch profile-band-6-9" aria-hidden="true"></i>6–9%</span>
+ <span role="listitem"><i class="profile-band-swatch profile-band-9-12" aria-hidden="true"></i>9–12%</span>
+ <span role="listitem"><i class="profile-band-swatch profile-band-12-plus" aria-hidden="true"></i>12%+</span>
+ </div>
+ </div>
+ </details>
+ }
+ </div>
+ </section>
+ }
+ <div class={ rideDetailColumnsClass(len(data.Profile.UnmatchedClimbs)) }>
+ if len(data.Profile.UnmatchedClimbs) > 0 {
<section class="panel climbs-panel">
- if len(data.Profile.Climbs) > 0 {
- <nav class="climb-navigation" aria-label="Climb navigation">
- <button class="button secondary" type="button" data-climb-previous aria-label="Previous climb">←</button>
- <output data-climb-position>Climb 1 of { formatClimbNumber(len(data.Profile.Climbs) - 1) }</output>
- <button class="button secondary" type="button" data-climb-next aria-label="Next climb">→</button>
- </nav>
- }
- if len(data.Profile.Climbs) == 0 {
- <p class="empty">No climbs detected in this ride.</p>
- }
- if len(data.Profile.Climbs) > 0 {
- <div class="climb-list" data-climb-list>
- for index, climb := range data.Profile.Climbs {
- <article class="climb-item" data-climb-item data-climb-index={ formatRouteIndex(index) }>
+ <div class="section-heading">
+ <div><p class="eyebrow">Next steps</p><h2>Climbs to match</h2></div>
+ <span class="section-count">{ formatClimbCount(len(data.Profile.UnmatchedClimbs)) }</span>
+ </div>
+ <nav class="climb-navigation" aria-label="Climb navigation">
+ <button class="button secondary" type="button" data-climb-previous aria-label="Previous climb">←</button>
+ <output data-climb-position>Climb 1 of { formatClimbCount(len(data.Profile.UnmatchedClimbs)) }</output>
+ <button class="button secondary" type="button" data-climb-next aria-label="Next climb">→</button>
+ </nav>
+ <div class="climb-list" data-climb-list>
+ for _, climb := range data.Profile.UnmatchedClimbs {
+ <article class="climb-item todo-item" data-climb-item data-climb-index={ formatRouteIndex(climb.Index) }>
<div class="climb-item-heading">
- <strong><span class="climb-number">Climb { formatClimbNumber(index) }</span>{ climb.Name }</strong>
+ <strong><span class="todo-check" aria-hidden="true"></span><span class="climb-number">Climb { formatClimbNumber(climb.Index) }</span>{ climb.Name }</strong>
<span class="climb-range" data-climb-summary>{ formatProfileDistance(climb.StartKm) }–{ formatProfileDistance(climb.EndKm) }</span>
</div>
<p class="climb-metrics" data-climb-metrics>{ climb.Category } · { formatProfileDistance(climb.DistanceKm) } at { formatSlope(climb.SlopePercent) } · Cotacol { formatCotacol(climb.Cotacol) }</p>
- if climb.OfficialClimbID > 0 {
- <p class="climb-status matched">Official climb matched: { climb.OfficialName }</p>
- } else {
- <p class="climb-status">No official climb matched yet.</p>
- <form class="official-climb-form" data-official-climb-form method="post" action={ templ.URL(officialClimbCreateURL(data.Ride.ID)) }>
- <label>Name<input type="text" name="name" required placeholder="Official climb name"/></label>
- <input type="hidden" name="start_index" value={ formatRouteIndex(climb.StartIndex) } data-boundary-input="start"/>
- <input type="hidden" name="end_index" value={ formatRouteIndex(climb.EndIndex) } data-boundary-input="end"/>
- <div class="boundary-controls">
- <div><strong>Start</strong><div><button class="button secondary" type="button" data-boundary-button="start" data-boundary-source="profile">Select on profile</button><button class="button secondary" type="button" data-boundary-button="start" data-boundary-source="map">Select on map</button></div><output data-boundary-output="start">{ formatProfileDistance(climb.StartKm) }</output></div>
- <div><strong>End</strong><div><button class="button secondary" type="button" data-boundary-button="end" data-boundary-source="profile">Select on profile</button><button class="button secondary" type="button" data-boundary-button="end" data-boundary-source="map">Select on map</button></div><output data-boundary-output="end">{ formatProfileDistance(climb.EndKm) }</output></div>
- </div>
- <output class="boundary-preview" data-boundary-preview>Preview the corrected climb boundaries.</output>
- <button class="button" type="submit">Save official climb</button>
- </form>
- }
+ <p class="climb-status">Waiting for an official climb match.</p>
+ <form class="official-climb-form" data-official-climb-form method="post" action={ templ.URL(officialClimbCreateURL(data.Ride.ID)) }>
+ <label>Name<input type="text" name="name" required placeholder="Official climb name"/></label>
+ <input type="hidden" name="start_index" value={ formatRouteIndex(climb.StartIndex) } data-boundary-input="start"/>
+ <input type="hidden" name="end_index" value={ formatRouteIndex(climb.EndIndex) } data-boundary-input="end"/>
+ <div class="boundary-controls">
+ <div><strong>Start</strong><div><button class="button secondary" type="button" data-boundary-button="start" data-boundary-source="profile">Select on profile</button><button class="button secondary" type="button" data-boundary-button="start" data-boundary-source="map">Select on map</button></div><output data-boundary-output="start">{ formatProfileDistance(climb.StartKm) }</output></div>
+ <div><strong>End</strong><div><button class="button secondary" type="button" data-boundary-button="end" data-boundary-source="profile">Select on profile</button><button class="button secondary" type="button" data-boundary-button="end" data-boundary-source="map">Select on map</button></div><output data-boundary-output="end">{ formatProfileDistance(climb.EndKm) }</output></div>
+ </div>
+ <output class="boundary-preview" data-boundary-preview>Preview the corrected climb boundaries.</output>
+ <button class="button" type="submit">Save official climb</button>
+ </form>
</article>
- }
- </div>
- }
+ }
+ </div>
</section>
- }
- </div>
- if data.HasRoute {
- <div class="ride-detail-main">
+ }
+ <div class="ride-detail-main">
<section class="panel profile-panel">
<div class="profile-heading">
<h2>Elevation profile</h2>
@@ -138,6 +163,7 @@ templ RideDetailContent(data RideDetailView) {
<section class="panel map-panel">
<div id="ride-map" class="ride-map" role="img" aria-label="Recorded ride route map"></div>
</section>
+ </div>
</div>
}
</div>
diff --git a/web/templates_templ.go b/web/templates_templ.go
index e63eef0..74dca27 100644
--- a/web/templates_templ.go
+++ b/web/templates_templ.go
@@ -480,14 +480,14 @@ func RideDetailContent(data RideDetailView) templ.Component {
return templ_7745c5c3_Err
}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"ride-detail-grid\"><div class=\"ride-detail-sidebar\"><section class=\"panel summary-panel\"><dl><div><dt>Distance</dt><dd>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"ride-detail-grid\"><section class=\"panel summary-panel\"><dl><div><dt>Distance</dt><dd>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(formatDistance(data.Ride.DistanceM))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 68}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 76, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
@@ -500,7 +500,7 @@ func RideDetailContent(data RideDetailView) templ.Component {
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(formatDuration(data.Ride.MovingTimeS))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 78, Col: 73}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
@@ -513,7 +513,7 @@ func RideDetailContent(data RideDetailView) templ.Component {
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(formatElevation(data.Ride.TotalElevationGainM))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 79, Col: 80}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 78, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
@@ -524,64 +524,207 @@ func RideDetailContent(data RideDetailView) templ.Component {
return templ_7745c5c3_Err
}
if data.HasRoute {
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<section class=\"panel climbs-panel\">")
+ if len(data.Profile.OfficialClimbs) > 0 {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<section class=\"panel official-climbs-panel\"><div class=\"section-heading\"><p class=\"eyebrow\">Official climbs</p></div><div class=\"official-climb-list\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, climb := range data.Profile.OfficialClimbs {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<details class=\"official-climb-card\" data-official-climb-card><summary><span class=\"official-climb-card-title\"><span class=\"climb-number\">Climb ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var31 string
+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbNumber(climb.Index))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 91, Col: 114}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span><strong>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var32 string
+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(climb.OfficialName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 91, Col: 151}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</strong></span><dl class=\"official-climb-summary-stats\"><div><dt>Length</dt><dd>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var33 string
+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.DistanceKm))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 93, Col: 75}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</dd></div><div><dt>Cotacol</dt><dd>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var34 string
+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(formatCotacol(climb.Cotacol))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 94, Col: 65}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</dd></div><div><dt>Avg slope</dt><dd>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var35 string
+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(formatSlope(climb.SlopePercent))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 95, Col: 70}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</dd></div></dl><span class=\"official-climb-chevron\" aria-hidden=\"true\">⌄</span></summary><div class=\"official-climb-card-body\"><canvas class=\"official-climb-profile\" data-official-profile data-profile-start=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var36 string
+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.StartIndex))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 100, Col: 125}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" data-profile-end=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var37 string
+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.EndIndex))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 100, Col: 179}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" tabindex=\"0\" role=\"img\" aria-label=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var38 string
+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(officialProfileLabel(climb.OfficialName))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 100, Col: 259}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"></canvas><div class=\"official-climb-profile-legend\" role=\"list\" aria-label=\"Average slope color scale\"><span role=\"listitem\"><i class=\"profile-band-swatch profile-band-downhill\" aria-hidden=\"true\"></i>Downhill</span> <span role=\"listitem\"><i class=\"profile-band-swatch profile-band-0-3\" aria-hidden=\"true\"></i>0–3%</span> <span role=\"listitem\"><i class=\"profile-band-swatch profile-band-3-6\" aria-hidden=\"true\"></i>3–6%</span> <span role=\"listitem\"><i class=\"profile-band-swatch profile-band-6-9\" aria-hidden=\"true\"></i>6–9%</span> <span role=\"listitem\"><i class=\"profile-band-swatch profile-band-9-12\" aria-hidden=\"true\"></i>9–12%</span> <span role=\"listitem\"><i class=\"profile-band-swatch profile-band-12-plus\" aria-hidden=\"true\"></i>12%+</span></div></div></details>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></section>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- if len(data.Profile.Climbs) > 0 {
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<nav class=\"climb-navigation\" aria-label=\"Climb navigation\"><button class=\"button secondary\" type=\"button\" data-climb-previous aria-label=\"Previous climb\">←</button> <output data-climb-position>Climb 1 of ")
+ var templ_7745c5c3_Var39 = []any{rideDetailColumnsClass(len(data.Profile.UnmatchedClimbs))}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var39...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var40 string
+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var39).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Profile.UnmatchedClimbs) > 0 {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<section class=\"panel climbs-panel\"><div class=\"section-heading\"><div><p class=\"eyebrow\">Next steps</p><h2>Climbs to match</h2></div><span class=\"section-count\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var31 string
- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbNumber(len(data.Profile.Climbs) - 1))
+ var templ_7745c5c3_Var41 string
+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbCount(len(data.Profile.UnmatchedClimbs)))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 95}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 120, Col: 87}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</output> <button class=\"button secondary\" type=\"button\" data-climb-next aria-label=\"Next climb\">→</button></nav>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span></div><nav class=\"climb-navigation\" aria-label=\"Climb navigation\"><button class=\"button secondary\" type=\"button\" data-climb-previous aria-label=\"Previous climb\">←</button> <output data-climb-position>Climb 1 of ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- }
- if len(data.Profile.Climbs) == 0 {
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"empty\">No climbs detected in this ride.</p>")
+ var templ_7745c5c3_Var42 string
+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbCount(len(data.Profile.UnmatchedClimbs)))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 124, Col: 98}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- }
- if len(data.Profile.Climbs) > 0 {
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"climb-list\" data-climb-list>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</output> <button class=\"button secondary\" type=\"button\" data-climb-next aria-label=\"Next climb\">→</button></nav><div class=\"climb-list\" data-climb-list>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- for index, climb := range data.Profile.Climbs {
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<article class=\"climb-item\" data-climb-item data-climb-index=\"")
+ for _, climb := range data.Profile.UnmatchedClimbs {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<article class=\"climb-item todo-item\" data-climb-item data-climb-index=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var32 string
- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(index))
+ var templ_7745c5c3_Var43 string
+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.Index))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 97, Col: 94}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 129, Col: 109}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><div class=\"climb-item-heading\"><strong><span class=\"climb-number\">Climb ")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><div class=\"climb-item-heading\"><strong><span class=\"todo-check\" aria-hidden=\"true\"></span><span class=\"climb-number\">Climb ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var33 string
- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbNumber(index))
+ var templ_7745c5c3_Var44 string
+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbNumber(climb.Index))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 99, Col: 76}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 131, Col: 133}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -589,12 +732,12 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var34 string
- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(climb.Name)
+ var templ_7745c5c3_Var45 string
+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(climb.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 99, Col: 97}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 131, Col: 154}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -602,12 +745,12 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var35 string
- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.StartKm))
+ var templ_7745c5c3_Var46 string
+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.StartKm))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 100, Col: 92}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 132, Col: 92}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -615,12 +758,12 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var36 string
- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.EndKm))
+ var templ_7745c5c3_Var47 string
+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.EndKm))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 100, Col: 133}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 132, Col: 133}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -628,12 +771,12 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var37 string
- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(climb.Category)
+ var templ_7745c5c3_Var48 string
+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(climb.Category)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 102, Col: 68}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 134, Col: 68}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -641,12 +784,12 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var38 string
- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.DistanceKm))
+ var templ_7745c5c3_Var49 string
+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.DistanceKm))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 102, Col: 115}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 134, Col: 115}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -654,12 +797,12 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var39 string
- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(formatSlope(climb.SlopePercent))
+ var templ_7745c5c3_Var50 string
+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(formatSlope(climb.SlopePercent))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 102, Col: 154}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 134, Col: 154}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -667,125 +810,87 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var40 string
- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(formatCotacol(climb.Cotacol))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 102, Col: 198}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p>")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- if climb.OfficialClimbID > 0 {
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"climb-status matched\">Official climb matched: ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var41 string
- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(climb.OfficialName)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 104, Col: 86}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p>")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- } else {
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"climb-status\">No official climb matched yet.</p><form class=\"official-climb-form\" data-official-climb-form method=\"post\" action=\"")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var42 templ.SafeURL = templ.URL(officialClimbCreateURL(data.Ride.ID))
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var42)))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><label>Name<input type=\"text\" name=\"name\" required placeholder=\"Official climb name\"></label> <input type=\"hidden\" name=\"start_index\" value=\"")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var43 string
- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.StartIndex))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 109, Col: 93}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" data-boundary-input=\"start\"> <input type=\"hidden\" name=\"end_index\" value=\"")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var44 string
- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.EndIndex))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 110, Col: 89}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" data-boundary-input=\"end\"><div class=\"boundary-controls\"><div><strong>Start</strong><div><button class=\"button secondary\" type=\"button\" data-boundary-button=\"start\" data-boundary-source=\"profile\">Select on profile</button><button class=\"button secondary\" type=\"button\" data-boundary-button=\"start\" data-boundary-source=\"map\">Select on map</button></div><output data-boundary-output=\"start\">")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var45 string
- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.StartKm))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 112, Col: 383}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</output></div><div><strong>End</strong><div><button class=\"button secondary\" type=\"button\" data-boundary-button=\"end\" data-boundary-source=\"profile\">Select on profile</button><button class=\"button secondary\" type=\"button\" data-boundary-button=\"end\" data-boundary-source=\"map\">Select on map</button></div><output data-boundary-output=\"end\">")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var46 string
- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.EndKm))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 113, Col: 373}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</output></div></div><output class=\"boundary-preview\" data-boundary-preview>Preview the corrected climb boundaries.</output> <button class=\"button\" type=\"submit\">Save official climb</button></form>")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</article>")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section>")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
+ var templ_7745c5c3_Var51 string
+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(formatCotacol(climb.Cotacol))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 134, Col: 198}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p><p class=\"climb-status\">Waiting for an official climb match.</p><form class=\"official-climb-form\" data-official-climb-form method=\"post\" action=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var52 templ.SafeURL = templ.URL(officialClimbCreateURL(data.Ride.ID))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var52)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><label>Name<input type=\"text\" name=\"name\" required placeholder=\"Official climb name\"></label> <input type=\"hidden\" name=\"start_index\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var53 string
+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.StartIndex))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 138, Col: 92}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" data-boundary-input=\"start\"> <input type=\"hidden\" name=\"end_index\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var54 string
+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.EndIndex))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 139, Col: 88}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" data-boundary-input=\"end\"><div class=\"boundary-controls\"><div><strong>Start</strong><div><button class=\"button secondary\" type=\"button\" data-boundary-button=\"start\" data-boundary-source=\"profile\">Select on profile</button><button class=\"button secondary\" type=\"button\" data-boundary-button=\"start\" data-boundary-source=\"map\">Select on map</button></div><output data-boundary-output=\"start\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var55 string
+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.StartKm))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 141, Col: 382}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</output></div><div><strong>End</strong><div><button class=\"button secondary\" type=\"button\" data-boundary-button=\"end\" data-boundary-source=\"profile\">Select on profile</button><button class=\"button secondary\" type=\"button\" data-boundary-button=\"end\" data-boundary-source=\"map\">Select on map</button></div><output data-boundary-output=\"end\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var56 string
+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.EndKm))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 142, Col: 372}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</output></div></div><output class=\"boundary-preview\" data-boundary-preview>Preview the corrected climb boundaries.</output> <button class=\"button\" type=\"submit\">Save official climb</button></form></article>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></section>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- if data.HasRoute {
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"ride-detail-main\"><section class=\"panel profile-panel\"><div class=\"profile-heading\"><h2>Elevation profile</h2><p>Hover or focus the profile to inspect the ride at that distance.</p></div><div class=\"profile-chart\"><canvas id=\"ride-profile-chart\" tabindex=\"0\" role=\"img\" aria-describedby=\"ride-profile-hover\" aria-label=\"Interactive ride elevation profile\"></canvas></div><output id=\"ride-profile-hover\" class=\"profile-hover\" aria-live=\"polite\">Hover or focus the profile to inspect elevation.</output></section><section class=\"panel map-panel\"><div id=\"ride-map\" class=\"ride-map\" role=\"img\" aria-label=\"Recorded ride route map\"></div></section></div>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"ride-detail-main\"><section class=\"panel profile-panel\"><div class=\"profile-heading\"><h2>Elevation profile</h2><p>Hover or focus the profile to inspect the ride at that distance.</p></div><div class=\"profile-chart\"><canvas id=\"ride-profile-chart\" tabindex=\"0\" role=\"img\" aria-describedby=\"ride-profile-hover\" aria-label=\"Interactive ride elevation profile\"></canvas></div><output id=\"ride-profile-hover\" class=\"profile-hover\" aria-live=\"polite\">Hover or focus the profile to inspect elevation.</output></section><section class=\"panel map-panel\"><div id=\"ride-map\" class=\"ride-map\" role=\"img\" aria-label=\"Recorded ride route map\"></div></section></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -832,9 +937,9 @@ func SyncPage(data SyncPageData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var47 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var47 == nil {
- templ_7745c5c3_Var47 = templ.NopComponent
+ templ_7745c5c3_Var57 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var57 == nil {
+ templ_7745c5c3_Var57 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout("Sync Strava", SyncContent(data)).Render(ctx, templ_7745c5c3_Buffer)
@@ -861,9 +966,9 @@ func SyncContent(data SyncPageData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var48 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var48 == nil {
- templ_7745c5c3_Var48 = templ.NopComponent
+ templ_7745c5c3_Var58 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var58 == nil {
+ templ_7745c5c3_Var58 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"eyebrow\">Data import</p><h1>Sync Strava rides</h1><p class=\"lead\">Choose a date range. New rides are downloaded as GPX files and recorded in the local library.</p>")
@@ -875,12 +980,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var49 string
- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice)
+ 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: 161, 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_Var49))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -899,12 +1004,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var50 string
- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error)
+ 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: 166, 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_Var50))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -927,8 +1032,8 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var51 templ.SafeURL = templ.URL("/strava/login?return_to=/sync")
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var51)))
+ var templ_7745c5c3_Var61 templ.SafeURL = templ.URL("/strava/login?return_to=/sync")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var61)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -941,12 +1046,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var52 string
- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(data.From)
+ 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: 177, 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_Var52))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -954,12 +1059,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var53 string
- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(data.To)
+ 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: 178, 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_Var53))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/web/views.go b/web/views.go
index 79356d2..6c96576 100644
--- a/web/views.go
+++ b/web/views.go
@@ -29,9 +29,11 @@ type RideDetailView struct {
}
type RideProfile struct {
- Points []RideProfilePoint `json:"points"`
- Climbs []RideProfileClimb `json:"climbs"`
- Crossings []RideProfileCrossing `json:"crossings"`
+ Points []RideProfilePoint `json:"points"`
+ Climbs []RideProfileClimb `json:"climbs"`
+ OfficialClimbs []RideProfileClimb `json:"-"`
+ UnmatchedClimbs []RideProfileClimb `json:"-"`
+ Crossings []RideProfileCrossing `json:"crossings"`
}
type RideProfilePoint struct {
@@ -42,6 +44,7 @@ type RideProfilePoint struct {
}
type RideProfileClimb struct {
+ Index int `json:"-"`
StartKm float64 `json:"startKm"`
EndKm float64 `json:"endKm"`
TopKm float64 `json:"topKm"`
@@ -225,9 +228,11 @@ func buildRideDetailView(item rides.Ride, parsed ride.Ride, passes []mountain_pa
func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideProfile {
profile := RideProfile{
- Points: make([]RideProfilePoint, parsed.Len()),
- Climbs: make([]RideProfileClimb, 0),
- Crossings: make([]RideProfileCrossing, 0),
+ Points: make([]RideProfilePoint, parsed.Len()),
+ Climbs: make([]RideProfileClimb, 0),
+ OfficialClimbs: make([]RideProfileClimb, 0),
+ UnmatchedClimbs: make([]RideProfileClimb, 0),
+ Crossings: make([]RideProfileCrossing, 0),
}
for i := 0; i < parsed.Len(); i++ {
coordinate := parsed.Coord(i)
@@ -240,12 +245,13 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, off
}
analysis := rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy)
for _, analyzedClimb := range analysis.Climbs {
- climb := analyzedClimb.Segment
- profile.Climbs = append(profile.Climbs, RideProfileClimb{
- StartKm: climb.StartDistanceM() / 1000,
- EndKm: climb.EndDistanceM() / 1000,
- TopKm: climb.TopDistanceM() / 1000,
- TopElevationM: climb.TopElevationM(),
+ segment := analyzedClimb.Segment
+ climb := RideProfileClimb{
+ Index: len(profile.Climbs),
+ StartKm: segment.StartDistanceM() / 1000,
+ EndKm: segment.EndDistanceM() / 1000,
+ TopKm: segment.TopDistanceM() / 1000,
+ TopElevationM: segment.TopElevationM(),
Name: analyzedClimb.Name,
Score: analyzedClimb.Score,
Category: analyzedClimb.Category,
@@ -254,9 +260,15 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, off
Cotacol: analyzedClimb.Cotacol,
OfficialClimbID: analyzedClimb.OfficialClimbID,
OfficialName: analyzedClimb.OfficialName,
- StartIndex: climb.StartIndex(),
- EndIndex: climb.EndIndex(),
- })
+ StartIndex: segment.StartIndex(),
+ EndIndex: segment.EndIndex(),
+ }
+ profile.Climbs = append(profile.Climbs, climb)
+ if climb.OfficialClimbID > 0 {
+ profile.OfficialClimbs = append(profile.OfficialClimbs, climb)
+ } else {
+ profile.UnmatchedClimbs = append(profile.UnmatchedClimbs, climb)
+ }
}
for _, crossing := range analysis.Crossings {
profile.Crossings = append(profile.Crossings, RideProfileCrossing{
@@ -324,6 +336,10 @@ func formatClimbNumber(index int) string {
return fmt.Sprintf("%d", index+1)
}
+func formatClimbCount(count int) string {
+ return fmt.Sprintf("%d", count)
+}
+
func formatProfileDistance(distanceKm float64) string {
return fmt.Sprintf("%.1f km", distanceKm)
}
@@ -331,3 +347,14 @@ func formatProfileDistance(distanceKm float64) string {
func formatSlope(slopePercent float64) string {
return fmt.Sprintf("%.1f%%", slopePercent)
}
+
+func officialProfileLabel(name string) string {
+ return "Elevation profile for " + name
+}
+
+func rideDetailColumnsClass(unmatchedClimbCount int) string {
+ if unmatchedClimbCount == 0 {
+ return "ride-detail-columns ride-detail-columns-single"
+ }
+ return "ride-detail-columns"
+}