summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/server.go93
-rw-r--r--web/server_test.go118
-rw-r--r--web/static/ride-detail.js208
-rw-r--r--web/static/style.css26
-rw-r--r--web/templates.templ100
-rw-r--r--web/templates_templ.go413
-rw-r--r--web/views.go125
7 files changed, 976 insertions, 107 deletions
diff --git a/web/server.go b/web/server.go
index 58f45a3..5d5e239 100644
--- a/web/server.go
+++ b/web/server.go
@@ -19,6 +19,7 @@ import (
"github.com/martinlehoux/biking_home/config"
"github.com/martinlehoux/biking_home/mountain_pass"
+ "github.com/martinlehoux/biking_home/official_climb"
"github.com/martinlehoux/biking_home/ride"
"github.com/martinlehoux/biking_home/rides"
"github.com/martinlehoux/biking_home/strava"
@@ -52,6 +53,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /static/", http.FileServer(http.FS(staticFiles)))
mux.HandleFunc("GET /", s.handleRides)
mux.HandleFunc("GET /rides/{id}", s.handleRide)
+ mux.HandleFunc("POST /rides/{id}/official-climbs", s.handleCreateOfficialClimb)
mux.HandleFunc("GET /sync", s.handleSyncForm)
mux.HandleFunc("POST /sync", s.handleSync)
mux.HandleFunc("GET /strava/login", s.handleStravaLogin)
@@ -93,7 +95,7 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) {
}
if item.GPXPath == "" {
slog.Info("Loaded ride detail without route", "ride_id", id)
- kcore.RenderPage(r.Context(), RideDetailPage(RideDetailView{RideView: buildRideView(item)}), w)
+ kcore.RenderPage(r.Context(), RideDetailPage(RideDetailView{RideView: buildRideView(item), Notice: rideDetailNotice(r)}), w)
return
}
parsed, err := ride.ParseFile(ride.GPXRideParser{}, item.GPXPath)
@@ -102,6 +104,7 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) {
kcore.RenderPage(r.Context(), RideDetailPage(RideDetailView{
RideView: buildRideView(item),
RouteError: "The recorded route is unavailable.",
+ Notice: rideDetailNotice(r),
}), w)
return
}
@@ -109,8 +112,87 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) {
if err != nil {
slog.Warn("Failed to load mountain passes for ride detail", "ride_id", id, "error", err)
}
+ officialClimbs, err := official_climb.List(s.db)
+ if err != nil {
+ slog.Warn("Failed to load official climbs for ride detail", "ride_id", id, "error", err)
+ }
+ matchPolicy := official_climb.DefaultMatchPolicy()
+ appConfig, err := s.loadConfig()
+ if err != nil {
+ slog.Warn("Failed to load official climb matching configuration", "ride_id", id, "error", err)
+ } else {
+ matchPolicy.EndpointRadiusM = appConfig.OfficialClimb.MatchRadiusM
+ }
slog.Info("Loaded ride detail", "ride_id", id)
- kcore.RenderPage(r.Context(), RideDetailPage(buildRideDetailView(item, parsed, passes)), w)
+ view := buildRideDetailView(item, parsed, passes, officialClimbs, matchPolicy)
+ view.Notice = rideDetailNotice(r)
+ kcore.RenderPage(r.Context(), RideDetailPage(view), w)
+}
+
+func (s *Server) handleCreateOfficialClimb(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil || id <= 0 {
+ http.NotFound(w, r)
+ return
+ }
+ item, found, err := rides.GetByID(s.db, id)
+ if err != nil {
+ slog.Error("Failed to load ride for official climb creation", "ride_id", id, "error", err)
+ http.Error(w, "failed to load ride", http.StatusInternalServerError)
+ return
+ }
+ if !found {
+ http.NotFound(w, r)
+ return
+ }
+ if item.GPXPath == "" {
+ http.Error(w, "a recorded route is required to create an official climb", http.StatusBadRequest)
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid form", http.StatusBadRequest)
+ return
+ }
+ startIndex, err := parseRouteIndex(r.FormValue("start_index"))
+ if err != nil {
+ http.Error(w, "a valid start route point is required", http.StatusBadRequest)
+ return
+ }
+ endIndex, err := parseRouteIndex(r.FormValue("end_index"))
+ if err != nil {
+ http.Error(w, "a valid end route point is required", http.StatusBadRequest)
+ return
+ }
+ parsed, err := ride.ParseFile(ride.GPXRideParser{}, item.GPXPath)
+ if err != nil {
+ slog.Error("Failed to load ride route for official climb creation", "ride_id", id, "file", item.GPXPath, "error", err)
+ http.Error(w, "the recorded route is unavailable", http.StatusBadRequest)
+ return
+ }
+ if startIndex >= endIndex || endIndex >= parsed.Len() {
+ http.Error(w, "the official climb boundaries must be ordered route points", http.StatusBadRequest)
+ return
+ }
+ created, err := official_climb.Create(s.db, official_climb.OfficialClimb{
+ Name: r.FormValue("name"),
+ StartCoord: parsed.Coord(startIndex),
+ EndCoord: parsed.Coord(endIndex),
+ })
+ if err != nil {
+ slog.Warn("Failed to create official climb", "ride_id", id, "error", err)
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ slog.Info("Created official climb", "official_climb_id", created.ID, "ride_id", id, "start_index", startIndex, "end_index", endIndex)
+ http.Redirect(w, r, rideDetailURL(id)+"?official_climb=created", http.StatusSeeOther)
+}
+
+func parseRouteIndex(value string) (int, error) {
+ index, err := strconv.Atoi(value)
+ if err != nil || index < 0 {
+ return 0, errors.New("invalid route point index")
+ }
+ return index, nil
}
func (s *Server) listRides(rideSort RideSort) ([]rides.Ride, error) {
@@ -526,6 +608,13 @@ func syncNotice(r *http.Request) string {
return fmt.Sprintf("Sync complete: %s imported, %s skipped.", imported, r.URL.Query().Get("skipped"))
}
+func rideDetailNotice(r *http.Request) string {
+ if r.URL.Query().Get("official_climb") == "created" {
+ return "Official climb saved and matched to this ride by coordinates."
+ }
+ return ""
+}
+
func safeReturnTo(value string) string {
if value == "" || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") {
return "/sync"
diff --git a/web/server_test.go b/web/server_test.go
index 23270ed..e8c3012 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -17,6 +17,7 @@ import (
"github.com/jftuga/geodist"
"github.com/martinlehoux/biking_home/config"
"github.com/martinlehoux/biking_home/mountain_pass"
+ "github.com/martinlehoux/biking_home/official_climb"
"github.com/martinlehoux/biking_home/ride"
"github.com/martinlehoux/biking_home/rides"
"github.com/martinlehoux/biking_home/strava"
@@ -63,6 +64,19 @@ func newWebTestServer(t *testing.T) (*Server, *sql.DB) {
)
`)
require.NoError(t, err)
+ _, err = db.Exec(`
+ create table official_climbs (
+ id integer primary key,
+ name text not null,
+ start_latitude real not null,
+ start_longitude real not null,
+ end_latitude real not null,
+ end_longitude real not null,
+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ )
+ `)
+ require.NoError(t, err)
configPath := filepath.Join(t.TempDir(), "config.yaml")
appConfig := config.Default()
appConfig.Strava.ClientID = "123"
@@ -103,6 +117,7 @@ func (c fakeSyncClient) Get(id int64) (strava.Activity, []byte, error) {
const emptyTrackGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg></trkseg></trk></gpx>`
const validTrackGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg><trkpt lat="43.0" lon="5.0"><ele>100</ele></trkpt><trkpt lat="43.001" lon="5.001"><ele>200</ele></trkpt></trkseg></trk></gpx>`
+const officialClimbTrackGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg><trkpt lat="43.0" lon="5.0"><ele>100</ele></trkpt><trkpt lat="43.005" lon="5.005"><ele>300</ele></trkpt><trkpt lat="43.01" lon="5.01"><ele>100</ele></trkpt></trkseg></trk></gpx>`
func TestHandlerRendersRidesPage(t *testing.T) {
server, db := newWebTestServer(t)
@@ -157,10 +172,15 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
body := response.Body.String()
assert.Equal(t, http.StatusOK, response.Code)
assert.Contains(t, body, "Detailed Ride")
+ assert.Contains(t, body, `<h1 class="ride-detail-title">Detailed Ride</h1>`)
+ assert.NotContains(t, body, `<p class="eyebrow">Ride detail</p>`)
assert.Contains(t, body, `id="ride-route"`)
assert.Contains(t, body, `id="ride-profile"`)
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-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>`)
assert.NotContains(t, body, "pointermove")
@@ -175,6 +195,11 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
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.Contains(t, body, "leaflet@1.9.4/dist/leaflet.js")
assert.Contains(t, script, "tile.openstreetmap.org/{z}/{x}/{y}.png")
@@ -196,7 +221,7 @@ func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) {
Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43},
}}
- profile := buildRideProfile(parsed, passes)
+ profile := buildRideProfile(parsed, passes, nil, official_climb.DefaultMatchPolicy())
require.Len(t, profile.Points, 3)
assert.Equal(t, 1.0, profile.Points[1].DistanceKm)
@@ -209,6 +234,97 @@ func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) {
assert.Equal(t, 1.0, profile.Crossings[0].DistanceKm)
}
+func TestBuildRideProfileIncludesOfficialClimbMatch(t *testing.T) {
+ parsed := ride.FromColumns(
+ []float64{0, 1000, 2000},
+ []float64{300, 447, 380},
+ []geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.63, Lon: 5.44}},
+ make([]time.Time, 3),
+ )
+ profile := buildRideProfile(parsed, nil, []official_climb.OfficialClimb{{
+ ID: 42,
+ Name: "Col de Test",
+ StartCoord: parsed.Coord(0),
+ EndCoord: parsed.Coord(1),
+ }}, official_climb.DefaultMatchPolicy())
+
+ require.Len(t, profile.Climbs, 1)
+ 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)
+}
+
+func TestBuildRideProfileUsesOfficialClimbBoundaries(t *testing.T) {
+ parsed := ride.FromColumns(
+ []float64{0, 1000, 2000},
+ []float64{300, 447, 380},
+ []geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.6205, Lon: 5.4305}},
+ make([]time.Time, 3),
+ )
+ profile := buildRideProfile(parsed, nil, []official_climb.OfficialClimb{{
+ ID: 42,
+ Name: "Col de Test",
+ StartCoord: parsed.Coord(0),
+ EndCoord: parsed.Coord(2),
+ }}, official_climb.DefaultMatchPolicy())
+
+ require.Len(t, profile.Climbs, 1)
+ assert.Equal(t, 0, profile.Climbs[0].StartIndex)
+ assert.Equal(t, 2, profile.Climbs[0].EndIndex)
+ assert.Equal(t, 2.0, profile.Climbs[0].EndKm)
+ assert.Equal(t, 2.0, profile.Climbs[0].DistanceKm)
+ assert.Equal(t, 4.0, profile.Climbs[0].SlopePercent)
+ assert.Greater(t, profile.Climbs[0].Cotacol, 0.0)
+}
+
+func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) {
+ server, db := newWebTestServer(t)
+ routePath := filepath.Join(t.TempDir(), "official-climb.gpx")
+ require.NoError(t, os.WriteFile(routePath, []byte(officialClimbTrackGPX), 0o600))
+ require.NoError(t, rides.Save(db, rides.Ride{
+ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000",
+ GPXPath: routePath,
+ Name: "Climb Candidate",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
+ DistanceM: 2_000,
+ }))
+ item, found, err := rides.GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000")
+ require.NoError(t, err)
+ require.True(t, found)
+
+ getRequest := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d", item.ID), nil)
+ 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(), "Select on profile")
+ assert.Contains(t, getResponse.Body.String(), "Select on map")
+ assert.Contains(t, getResponse.Body.String(), "Climb 1")
+ assert.Contains(t, getResponse.Body.String(), "data-climb-next")
+
+ form := url.Values{"name": {"Col de Test"}, "start_index": {"0"}, "end_index": {"1"}}
+ request := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/rides/%d/official-climbs", item.ID), strings.NewReader(form.Encode()))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+
+ assert.Equal(t, http.StatusSeeOther, response.Code)
+ assert.Equal(t, fmt.Sprintf("/rides/%d?official_climb=created", item.ID), response.Header().Get("Location"))
+ climbs, err := official_climb.List(db)
+ require.NoError(t, err)
+ require.Len(t, climbs, 1)
+ assert.Equal(t, "Col de Test", climbs[0].Name)
+ assert.Equal(t, geodist.Coord{Lat: 43.0, Lon: 5.0}, climbs[0].StartCoord)
+ assert.Equal(t, geodist.Coord{Lat: 43.005, Lon: 5.005}, climbs[0].EndCoord)
+
+ 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")
+}
+
func TestHandlerRendersRideDetailWithoutRoute(t *testing.T) {
server, db := newWebTestServer(t)
item := rides.Ride{
diff --git a/web/static/ride-detail.js b/web/static/ride-detail.js
index 178b486..bd36da4 100644
--- a/web/static/ride-detail.js
+++ b/web/static/ride-detail.js
@@ -24,6 +24,8 @@
crossing: resolveColor("--color-crossing"),
crossingLabel: resolveColor("--color-crossing-label"),
hoverLine: resolveColor("--color-hover-line"),
+ climbRoute: resolveColor("--color-climb-route"),
+ climbFocusFill: resolveColor("--color-climb-focus-fill"),
};
colorProbe.remove();
const route = JSON.parse(routeElement.textContent);
@@ -44,6 +46,19 @@
const points = profile.points || [];
if (points.length === 0) return;
+ const climbItems = [...document.querySelectorAll("[data-climb-item]")];
+ const previousClimbButton = document.querySelector("[data-climb-previous]");
+ const nextClimbButton = document.querySelector("[data-climb-next]");
+ const climbPosition = document.querySelector("[data-climb-position]");
+ const climbLayers = [];
+ for (const climb of profile.climbs || []) {
+ if (!Number.isInteger(climb.startIndex) || !Number.isInteger(climb.endIndex) || climb.startIndex < 0 || climb.endIndex >= points.length || climb.startIndex >= climb.endIndex) {
+ climbLayers.push(null);
+ continue;
+ }
+ const coordinates = points.slice(climb.startIndex, climb.endIndex + 1).map((point) => [point.latitude, point.longitude]);
+ climbLayers.push(L.polyline(coordinates, { color: colors.climbRoute, weight: 7, opacity: 0.65, lineCap: "round", lineJoin: "round", interactive: false }).addTo(map));
+ }
const routeCursor = L.circleMarker([points[0].latitude, points[0].longitude], {
color: colors.forest,
fillColor: colors.accent,
@@ -56,7 +71,14 @@
const context = canvas.getContext("2d");
if (!context) return;
- const state = { hoveredIndex: -1, plot: null, width: 0, height: 0 };
+ const state = {
+ hoveredIndex: -1,
+ focusedClimbIndex: 0,
+ climbBounds: (profile.climbs || []).map((climb) => ({ startIndex: climb.startIndex, endIndex: climb.endIndex })),
+ plot: null,
+ width: 0,
+ height: 0,
+ };
const minDistance = points[0].distanceKm;
const maxDistance = points[points.length - 1].distanceKm;
let minElevation = points[0].elevationM;
@@ -92,6 +114,135 @@
const previous = points[low - 1];
return distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low;
};
+ const nearestMapPointIndex = (latitude, longitude) => {
+ let nearestIndex = 0;
+ let nearestDistance = Infinity;
+ for (let index = 0; index < points.length; index++) {
+ const point = points[index];
+ const distance = L.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]);
+ if (distance < nearestDistance) {
+ nearestIndex = index;
+ nearestDistance = distance;
+ }
+ }
+ return nearestIndex;
+ };
+ const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")];
+ let activeBoundary = null;
+ const boundaryLabel = (index) => {
+ const point = points[index];
+ return `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
+ };
+ const categoryForScore = (score) => {
+ if (score < 35) return "NO";
+ if (score < 80) return "Cat 4";
+ if (score < 180) return "Cat 3";
+ if (score < 250) return "Cat 2";
+ if (score < 600) return "Cat 1";
+ return "HC";
+ };
+ const elevationAtDistance = (index, distanceM) => {
+ if (index + 1 >= points.length) return points[index].elevationM;
+ const startDistanceM = points[index].distanceKm * 1000;
+ const endDistanceM = points[index + 1].distanceKm * 1000;
+ const fraction = (distanceM - startDistanceM) / (endDistanceM - startDistanceM);
+ return points[index].elevationM + fraction * (points[index + 1].elevationM - points[index].elevationM);
+ };
+ const cotacolForClimb = (startIndex, endIndex) => {
+ const startDistanceM = points[startIndex].distanceKm * 1000;
+ const lastDistanceM = points[endIndex].distanceKm * 1000;
+ if (lastDistanceM <= startDistanceM) return 0;
+ let score = 0;
+ let pointIndex = startIndex;
+ for (let segmentStartM = startDistanceM; segmentStartM < lastDistanceM; segmentStartM += 100) {
+ const segmentEndM = Math.min(segmentStartM + 100, lastDistanceM);
+ while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 <= segmentStartM) pointIndex++;
+ const startElevation = elevationAtDistance(pointIndex, segmentStartM);
+ while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 < segmentEndM) pointIndex++;
+ const endElevation = elevationAtDistance(pointIndex, segmentEndM);
+ const slope = (endElevation - startElevation) / (segmentEndM - segmentStartM);
+ if (slope > 0) score += (segmentEndM - segmentStartM) / 1000 * (slope * 100) ** 2;
+ }
+ return score;
+ };
+ const climbMetrics = (index) => {
+ const bounds = state.climbBounds[index];
+ if (!bounds || !Number.isInteger(bounds.startIndex) || !Number.isInteger(bounds.endIndex) || bounds.startIndex < 0 || bounds.endIndex >= points.length || bounds.startIndex >= bounds.endIndex) return null;
+ const start = points[bounds.startIndex];
+ const end = points[bounds.endIndex];
+ const distanceKm = end.distanceKm - start.distanceKm;
+ const elevationGain = end.elevationM - start.elevationM;
+ const slope = distanceKm > 0 ? elevationGain / (distanceKm * 10) : 0;
+ const score = distanceKm > 0 ? Math.abs(elevationGain) * elevationGain / (distanceKm * 1000) * 10 : 0;
+ return { start, end, distanceKm, elevationGain, slope, score, cotacol: cotacolForClimb(bounds.startIndex, bounds.endIndex), category: categoryForScore(score) };
+ };
+ const clearBoundarySelection = () => {
+ activeBoundary = null;
+ for (const form of boundaryForms) {
+ for (const button of form.querySelectorAll("[data-boundary-button]")) button.classList.remove("active");
+ }
+ };
+ const updateBoundaryPreview = (form) => {
+ const preview = form.querySelector("[data-boundary-preview]");
+ const item = form.closest("[data-climb-item]");
+ const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
+ const metrics = climbMetrics(climbIndex);
+ if (!metrics) {
+ preview.textContent = "Choose an end point after the start point.";
+ return;
+ }
+ const summary = item.querySelector("[data-climb-summary]");
+ const metricsOutput = item.querySelector("[data-climb-metrics]");
+ summary.textContent = `${formatDistance(metrics.start.distanceKm)}–${formatDistance(metrics.end.distanceKm)}`;
+ metricsOutput.textContent = `${metrics.category} · ${formatDistance(metrics.distanceKm)} at ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`;
+ preview.textContent = `Preview: ${formatDistance(metrics.distanceKm)} · ${metrics.elevationGain >= 0 ? "+" : ""}${formatElevation(metrics.elevationGain)} · ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`;
+ };
+ const updateClimbLayer = (index) => {
+ const layer = climbLayers[index];
+ const metrics = climbMetrics(index);
+ if (!layer) return;
+ if (!metrics) {
+ layer.setLatLngs([]);
+ return;
+ }
+ const bounds = state.climbBounds[index];
+ layer.setLatLngs(points.slice(bounds.startIndex, bounds.endIndex + 1).map((point) => [point.latitude, point.longitude]));
+ const active = index === state.focusedClimbIndex;
+ layer.setStyle({ weight: active ? 9 : 7, opacity: active ? 1 : 0.65 });
+ };
+ const chooseBoundary = (index) => {
+ if (!activeBoundary) return;
+ const { form, target } = activeBoundary;
+ const item = form.closest("[data-climb-item]");
+ const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
+ const input = form.querySelector(`[data-boundary-input="${target}"]`);
+ const output = form.querySelector(`[data-boundary-output="${target}"]`);
+ input.value = index;
+ output.textContent = boundaryLabel(index);
+ state.climbBounds[climbIndex][`${target}Index`] = index;
+ updateBoundaryPreview(form);
+ updateClimbLayer(climbIndex);
+ clearBoundarySelection();
+ showPoint(index);
+ };
+ for (const form of boundaryForms) {
+ const item = form.closest("[data-climb-item]");
+ const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
+ state.climbBounds[climbIndex] = {
+ startIndex: Number.parseInt(form.querySelector('[data-boundary-input="start"]').value, 10),
+ endIndex: Number.parseInt(form.querySelector('[data-boundary-input="end"]').value, 10),
+ };
+ updateBoundaryPreview(form);
+ for (const button of form.querySelectorAll("[data-boundary-button]")) {
+ button.addEventListener("click", () => {
+ clearBoundarySelection();
+ activeBoundary = { form, target: button.dataset.boundaryButton, source: button.dataset.boundarySource };
+ button.classList.add("active");
+ const surface = activeBoundary.source === "map" ? "map" : "profile";
+ hoverOutput.textContent = `Click the ${surface} to select the ${activeBoundary.target} point.`;
+ });
+ }
+ }
const draw = () => {
const rect = canvas.getBoundingClientRect();
@@ -125,12 +276,15 @@
context.fillText(formatElevation(elevation), plot.left - 8, y);
}
- for (const climb of profile.climbs || []) {
- const startX = clamp(xForDistance(climb.startKm), plot.left, plot.right);
- const endX = clamp(xForDistance(climb.endKm), plot.left, plot.right);
- context.fillStyle = colors.accentFill;
+ for (let climbIndex = 0; climbIndex < (profile.climbs || []).length; climbIndex++) {
+ const climb = profile.climbs[climbIndex];
+ const metrics = climbMetrics(climbIndex);
+ if (!metrics) continue;
+ const startX = clamp(xForDistance(metrics.start.distanceKm), plot.left, plot.right);
+ const endX = clamp(xForDistance(metrics.end.distanceKm), plot.left, plot.right);
+ context.fillStyle = climbIndex === state.focusedClimbIndex ? colors.climbFocusFill : colors.accentFill;
context.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top);
- const label = climb.name || `${climb.category} ${Math.round(climb.score)}`;
+ const label = climb.name || `${metrics.category} ${Math.round(metrics.score)}`;
context.fillStyle = colors.climbLabel;
context.textAlign = "center";
context.textBaseline = "top";
@@ -220,6 +374,33 @@
hoverOutput.textContent = `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
draw();
};
+ const zoomToClimb = (index) => {
+ const climbBounds = state.climbBounds[index];
+ if (!climbBounds || !Number.isInteger(climbBounds.startIndex) || !Number.isInteger(climbBounds.endIndex)) return;
+ const climbPoints = points.slice(climbBounds.startIndex, climbBounds.endIndex + 1);
+ const mapBounds = L.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude]));
+ if (mapBounds.isValid()) map.fitBounds(mapBounds, { padding: [32, 32], maxZoom: 15 });
+ };
+ const updateClimbFocus = (index, zoom) => {
+ if (climbItems.length === 0) return;
+ state.focusedClimbIndex = clamp(index, 0, climbItems.length - 1);
+ for (let itemIndex = 0; itemIndex < climbItems.length; itemIndex++) {
+ const active = itemIndex === state.focusedClimbIndex;
+ climbItems[itemIndex].hidden = !active;
+ climbItems[itemIndex].setAttribute("aria-hidden", String(!active));
+ if (climbLayers[itemIndex]) {
+ updateClimbLayer(itemIndex);
+ }
+ }
+ 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;
+ 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));
canvas.addEventListener("pointermove", (event) => {
if (!state.plot) return;
const rect = canvas.getBoundingClientRect();
@@ -233,6 +414,18 @@
});
canvas.addEventListener("pointerleave", clearHover);
canvas.addEventListener("pointercancel", clearHover);
+ canvas.addEventListener("click", (event) => {
+ if (!activeBoundary || activeBoundary.source !== "profile" || !state.plot) return;
+ const rect = canvas.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ if (x < state.plot.left || x > state.plot.right) return;
+ const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance);
+ chooseBoundary(nearestPointIndex(distance));
+ });
+ map.on("click", (event) => {
+ if (!activeBoundary || activeBoundary.source !== "map") return;
+ chooseBoundary(nearestMapPointIndex(event.latlng.lat, event.latlng.lng));
+ });
canvas.addEventListener("keydown", (event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
@@ -241,5 +434,6 @@
showPoint(index);
});
window.addEventListener("resize", draw);
- draw();
+ if (climbItems.length > 0) updateClimbFocus(0, false);
+ else draw();
})();
diff --git a/web/static/style.css b/web/static/style.css
index b24fd21..8a35844 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -36,6 +36,8 @@
--color-crossing: oklch(from var(--palette-leaf) l c h / 60%);
--color-crossing-label: var(--color-notice-text);
--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%);
}
* { box-sizing: border-box; }
@@ -48,6 +50,7 @@ a { color: var(--color-leaf); }
.container { width: min(70rem, calc(100% - 2rem)); margin: 0 auto; padding: 2.5rem 0 4rem; }
.eyebrow { margin: 0 0 .4rem; color: var(--color-leaf); font-size: .78rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(2rem, 4vw, 3.2rem); line-height: 1; }
+.ride-detail-title { margin: 0 0 .4rem; color: var(--color-leaf); font-size: .78rem; font-weight: 700; letter-spacing: .12em; line-height: normal; }
.lead { max-width: 42rem; color: var(--color-muted); }
.panel { margin-top: 2rem; padding: 1.25rem; border: 1px solid var(--color-border); border-radius: 1rem; background: var(--color-surface); box-shadow: 0 1rem 2.5rem var(--color-panel-shadow); }
.toolbar { display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
@@ -61,6 +64,9 @@ 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 .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; }
th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }
@@ -79,8 +85,28 @@ 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; }
+.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; }
+.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; }
+.climb-status { margin: .35rem 0 0; }
+.climb-status.matched { color: var(--color-notice-text); }
+.official-climb-form { display: grid; gap: .8rem; margin-top: .8rem; padding: .8rem; border: 1px solid var(--color-border-subtle); border-radius: .65rem; background: var(--color-plot-surface); }
+.boundary-controls { display: flex; gap: .75rem; flex-wrap: wrap; }
+.boundary-controls > div { display: grid; gap: .35rem; }
+.boundary-controls > div > div { display: flex; gap: .35rem; flex-wrap: wrap; }
+.boundary-controls output { color: var(--color-muted); font-size: .85rem; }
+.boundary-controls .button.active { outline: .2rem solid var(--color-focus); }
+.boundary-preview { color: var(--color-notice-text); font-size: .9rem; }
+.climb-navigation { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-top: 1.25rem; }
+.climbs-panel > .climb-navigation { margin-top: 0; }
+.climb-navigation output { color: var(--color-muted); font-size: .9rem; font-weight: 700; }
+.climb-navigation .button:disabled { opacity: .45; cursor: default; }
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: 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); } }
diff --git a/web/templates.templ b/web/templates.templ
index 515c482..9ebeedb 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -57,35 +57,91 @@ templ RideDetailPage(data RideDetailView) {
}
templ RideDetailContent(data RideDetailView) {
- <p class="eyebrow">Ride detail</p>
<div class="toolbar">
- <div><h1>{ data.Ride.Name }</h1><p class="lead">{ data.Ride.Type } · { formatRideDate(data.Ride.StartDate) }</p></div>
+ <div><h1 class="ride-detail-title">{ data.Ride.Name }</h1><p class="lead">{ data.Ride.Type } · { formatRideDate(data.Ride.StartDate) }</p></div>
<a class="button secondary" href="/">Back to rides</a>
</div>
- <section class="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.RouteError != "" {
<div class="error">{ data.RouteError }</div>
}
- if data.HasRoute {
- <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>
+ if data.Notice != "" {
+ <div class="notice">{ data.Notice }</div>
+ }
+ if data.ActionError != "" {
+ <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 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="climb-item-heading">
+ <strong><span class="climb-number">Climb { formatClimbNumber(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>
+ }
+ </article>
+ }
+ </div>
+ }
+ </section>
+ }
+ </div>
+ if data.HasRoute {
+ <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>
- <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>
+ if data.HasRoute {
@templ.JSONScript("ride-route", data.Route)
@templ.JSONScript("ride-profile", data.Profile)
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" defer></script>
diff --git a/web/templates_templ.go b/web/templates_templ.go
index 021de0b..e63eef0 100644
--- a/web/templates_templ.go
+++ b/web/templates_templ.go
@@ -380,14 +380,14 @@ func RideDetailContent(data RideDetailView) templ.Component {
templ_7745c5c3_Var21 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"eyebrow\">Ride detail</p><div class=\"toolbar\"><div><h1>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"toolbar\"><div><h1 class=\"ride-detail-title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.Ride.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 62, Col: 27}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 61, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -400,7 +400,7 @@ func RideDetailContent(data RideDetailView) templ.Component {
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(data.Ride.Type)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 62, Col: 66}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 61, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
@@ -413,22 +413,83 @@ func RideDetailContent(data RideDetailView) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(formatRideDate(data.Ride.StartDate))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 62, Col: 109}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 61, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p></div><a class=\"button secondary\" href=\"/\">Back to rides</a></div><section class=\"panel\"><dl><div><dt>Distance</dt><dd>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p></div><a class=\"button secondary\" href=\"/\">Back to rides</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var25 string
- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(formatDistance(data.Ride.DistanceM))
+ if data.RouteError != "" {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"error\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var25 string
+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(data.RouteError)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 65, Col: 38}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
+ 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.Notice != "" {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"notice\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var26 string
+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 68, Col: 35}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
+ 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.ActionError != "" {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"error\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var27 string
+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(data.ActionError)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 71, Col: 39}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
+ 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("<div class=\"ride-detail-grid\"><div class=\"ride-detail-sidebar\"><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: 67, Col: 66}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 68}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -436,12 +497,12 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var26 string
- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(formatDuration(data.Ride.MovingTimeS))
+ 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: 68, Col: 71}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 78, Col: 73}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -449,12 +510,12 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var27 string
- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(formatElevation(data.Ride.TotalElevationGainM))
+ 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: 69, Col: 78}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 79, Col: 80}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -462,30 +523,278 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- if data.RouteError != "" {
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"error\">")
+ if data.HasRoute {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<section class=\"panel climbs-panel\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var28 string
- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(data.RouteError)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 73, Col: 38}
+ 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 ")
+ 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))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 95}
+ }
+ _, 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("</output> <button class=\"button secondary\" type=\"button\" data-climb-next aria-label=\"Next climb\">→</button></nav>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
- 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>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+ if len(data.Profile.Climbs) > 0 {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<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=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var32 string
+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(index))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 97, Col: 94}
+ }
+ _, 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("\"><div class=\"climb-item-heading\"><strong><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))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 99, Col: 76}
+ }
+ _, 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("</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var34 string
+ templ_7745c5c3_Var34, 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}
+ }
+ _, 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("</strong> <span class=\"climb-range\" data-climb-summary>")
+ 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))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 100, Col: 92}
+ }
+ _, 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("–")
+ 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))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 100, Col: 133}
+ }
+ _, 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("</span></div><p class=\"climb-metrics\" data-climb-metrics>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var37 string
+ templ_7745c5c3_Var37, 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}
+ }
+ _, 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(" · ")
+ 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))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 102, Col: 115}
+ }
+ _, 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(" at ")
+ 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))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 102, Col: 154}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" · Cotacol ")
+ 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
}
}
+ _, 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("<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>")
+ _, 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>")
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.JSONScript("ride-route", data.Route).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -523,9 +832,9 @@ func SyncPage(data SyncPageData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var29 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var29 == nil {
- templ_7745c5c3_Var29 = templ.NopComponent
+ templ_7745c5c3_Var47 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var47 == nil {
+ templ_7745c5c3_Var47 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout("Sync Strava", SyncContent(data)).Render(ctx, templ_7745c5c3_Buffer)
@@ -552,9 +861,9 @@ func SyncContent(data SyncPageData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var30 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var30 == nil {
- templ_7745c5c3_Var30 = templ.NopComponent
+ templ_7745c5c3_Var48 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var48 == nil {
+ templ_7745c5c3_Var48 = 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>")
@@ -566,12 +875,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var31 string
- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice)
+ var templ_7745c5c3_Var49 string
+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 105, Col: 52}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 161, Col: 52}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -590,12 +899,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var32 string
- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error)
+ var templ_7745c5c3_Var50 string
+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 110, Col: 49}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 166, Col: 49}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -618,8 +927,8 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var33 templ.SafeURL = templ.URL("/strava/login?return_to=/sync")
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var33)))
+ 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)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -632,12 +941,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var34 string
- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(data.From)
+ var templ_7745c5c3_Var52 string
+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(data.From)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 121, Col: 64}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 177, Col: 64}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -645,12 +954,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var35 string
- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(data.To)
+ var templ_7745c5c3_Var53 string
+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(data.To)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 122, Col: 58}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 178, Col: 58}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/web/views.go b/web/views.go
index 2ce9eb2..310c39c 100644
--- a/web/views.go
+++ b/web/views.go
@@ -2,10 +2,13 @@ package web
import (
"fmt"
+ "math"
"net/url"
"time"
+ "github.com/jftuga/geodist"
"github.com/martinlehoux/biking_home/mountain_pass"
+ "github.com/martinlehoux/biking_home/official_climb"
"github.com/martinlehoux/biking_home/ride"
"github.com/martinlehoux/biking_home/rides"
)
@@ -18,10 +21,12 @@ type RideView struct {
type RideDetailView struct {
RideView
- Route GeoJSONFeatureCollection
- HasRoute bool
- Profile RideProfile
- RouteError string
+ Route GeoJSONFeatureCollection
+ HasRoute bool
+ Profile RideProfile
+ RouteError string
+ ActionError string
+ Notice string
}
type RideProfile struct {
@@ -38,13 +43,20 @@ type RideProfilePoint struct {
}
type RideProfileClimb struct {
- StartKm float64 `json:"startKm"`
- EndKm float64 `json:"endKm"`
- TopKm float64 `json:"topKm"`
- TopElevationM float64 `json:"topElevationM"`
- Name string `json:"name"`
- Score float64 `json:"score"`
- Category string `json:"category"`
+ StartKm float64 `json:"startKm"`
+ EndKm float64 `json:"endKm"`
+ TopKm float64 `json:"topKm"`
+ TopElevationM float64 `json:"topElevationM"`
+ Name string `json:"name"`
+ Score float64 `json:"score"`
+ Category string `json:"category"`
+ DistanceKm float64 `json:"distanceKm"`
+ SlopePercent float64 `json:"slopePercent"`
+ Cotacol float64 `json:"cotacol"`
+ OfficialClimbID int64 `json:"officialClimbId,omitempty"`
+ OfficialName string `json:"officialName,omitempty"`
+ StartIndex int `json:"startIndex"`
+ EndIndex int `json:"endIndex"`
}
type RideProfileCrossing struct {
@@ -184,13 +196,17 @@ func rideDetailURL(id int64) string {
return fmt.Sprintf("/rides/%d", id)
}
-func buildRideDetailView(item rides.Ride, parsed ride.Ride, passes []mountain_pass.MountainPass) RideDetailView {
+func officialClimbCreateURL(id int64) string {
+ return fmt.Sprintf("/rides/%d/official-climbs", id)
+}
+
+func buildRideDetailView(item rides.Ride, parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideDetailView {
coordinates := make([][]float64, parsed.Len())
for i := 0; i < parsed.Len(); i++ {
coordinate := parsed.Coord(i)
coordinates[i] = []float64{coordinate.Lon, coordinate.Lat}
}
- profile := buildRideProfile(parsed, passes)
+ profile := buildRideProfile(parsed, passes, officialClimbs, matchPolicy)
return RideDetailView{
RideView: buildRideView(item),
Route: GeoJSONFeatureCollection{
@@ -208,7 +224,7 @@ func buildRideDetailView(item rides.Ride, parsed ride.Ride, passes []mountain_pa
}
}
-func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass) RideProfile {
+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),
@@ -225,17 +241,37 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass) Rid
}
climbs := parsed.AllClimbs()
for i := range climbs {
- if matched, ok := mountain_pass.MatchClimb(climbs[i], passes, 300, 50); ok {
- climbs[i].Name = matched.Name
+ displayClimb := climbs[i]
+ if matchedPass, ok := mountain_pass.MatchClimb(displayClimb, passes, 300, 50); ok {
+ displayClimb.Name = matchedPass.Name
+ }
+ matchedOfficial, officialFound := official_climb.MatchClimb(climbs[i], officialClimbs, matchPolicy)
+ if officialFound {
+ startIndex := nearestCoordIndex(parsed, matchedOfficial.StartCoord)
+ endIndex := nearestCoordIndex(parsed, matchedOfficial.EndCoord)
+ if startIndex < endIndex {
+ displayClimb = parsed.ClimbFromIndexes(startIndex, endIndex)
+ displayClimb.Name = matchedOfficial.Name
+ } else {
+ officialFound = false
+ matchedOfficial = official_climb.OfficialClimb{}
+ }
}
profile.Climbs = append(profile.Climbs, RideProfileClimb{
- StartKm: climbs[i].StartDistanceM() / 1000,
- EndKm: climbs[i].EndDistanceM() / 1000,
- TopKm: climbs[i].TopDistanceM() / 1000,
- TopElevationM: climbs[i].TopElevationM(),
- Name: climbs[i].Name,
- Score: climbs[i].Score(),
- Category: ride.Category(climbs[i].Score()),
+ StartKm: displayClimb.StartDistanceM() / 1000,
+ EndKm: displayClimb.EndDistanceM() / 1000,
+ TopKm: displayClimb.TopDistanceM() / 1000,
+ TopElevationM: displayClimb.TopElevationM(),
+ Name: displayClimb.Name,
+ Score: displayClimb.Score(),
+ Category: ride.Category(displayClimb.Score()),
+ DistanceKm: (displayClimb.EndDistanceM() - displayClimb.StartDistanceM()) / 1000,
+ SlopePercent: ride.Slope(parsed, displayClimb.StartIndex(), displayClimb.EndIndex()) * 100,
+ Cotacol: displayClimb.DifficultyScore(),
+ OfficialClimbID: officialClimbID(matchedOfficial, officialFound),
+ OfficialName: officialClimbName(matchedOfficial, officialFound),
+ StartIndex: displayClimb.StartIndex(),
+ EndIndex: displayClimb.EndIndex(),
})
}
for _, crossing := range mountain_pass.DetectCrossings(parsed, passes, 100, 25) {
@@ -251,6 +287,33 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass) Rid
return profile
}
+func nearestCoordIndex(parsed ride.Ride, target geodist.Coord) int {
+ bestIndex := 0
+ bestDistance := math.Inf(1)
+ for index := 0; index < parsed.Len(); index++ {
+ _, distanceKm := geodist.HaversineDistance(parsed.Coord(index), target)
+ if distanceKm < bestDistance {
+ bestDistance = distanceKm
+ bestIndex = index
+ }
+ }
+ return bestIndex
+}
+
+func officialClimbID(climb official_climb.OfficialClimb, found bool) int64 {
+ if !found {
+ return 0
+ }
+ return climb.ID
+}
+
+func officialClimbName(climb official_climb.OfficialClimb, found bool) string {
+ if !found {
+ return ""
+ }
+ return climb.Name
+}
+
type SyncPageData struct {
From string
To string
@@ -295,3 +358,19 @@ func formatCotacolPer100Km(score, distanceM float64) string {
}
return fmt.Sprintf("%.1f", score*100/distanceKm)
}
+
+func formatRouteIndex(index int) string {
+ return fmt.Sprintf("%d", index)
+}
+
+func formatClimbNumber(index int) string {
+ return fmt.Sprintf("%d", index+1)
+}
+
+func formatProfileDistance(distanceKm float64) string {
+ return fmt.Sprintf("%.1f km", distanceKm)
+}
+
+func formatSlope(slopePercent float64) string {
+ return fmt.Sprintf("%.1f%%", slopePercent)
+}