diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/server.go | 93 | ||||
| -rw-r--r-- | web/server_test.go | 118 | ||||
| -rw-r--r-- | web/static/ride-detail.js | 208 | ||||
| -rw-r--r-- | web/static/style.css | 26 | ||||
| -rw-r--r-- | web/templates.templ | 100 | ||||
| -rw-r--r-- | web/templates_templ.go | 413 | ||||
| -rw-r--r-- | web/views.go | 125 |
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) +} |