aboutsummaryrefslogtreecommitdiff
path: root/web/rides.go
diff options
context:
space:
mode:
Diffstat (limited to 'web/rides.go')
-rw-r--r--web/rides.go205
1 files changed, 205 insertions, 0 deletions
diff --git a/web/rides.go b/web/rides.go
new file mode 100644
index 0000000..aa39d69
--- /dev/null
+++ b/web/rides.go
@@ -0,0 +1,205 @@
+package web
+
+import (
+ "errors"
+ "log/slog"
+ "net/http"
+ "strconv"
+
+ "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/kagamigo/kcore"
+)
+
+func (s *Server) handleRides(w http.ResponseWriter, r *http.Request) {
+ rideSort := parseRideSort(r.URL.Query())
+ rideFilters := parseRideFilters(r.URL.Query())
+ items, err := s.listRides(rideSort)
+ if err != nil {
+ slog.Error("Failed to load rides", "sort", rideSort.Column, "descending", rideSort.Descending, "error", err)
+ http.Error(w, "failed to load rides", http.StatusInternalServerError)
+ return
+ }
+ items = filterRides(items, rideFilters)
+ if rideFilters.Active() {
+ slog.Info("Applied ride library filters", "matches", len(items))
+ }
+ views := buildRideViews(items)
+ kcore.RenderPage(r.Context(), RidesPage(RidesPageData{
+ Items: views,
+ Headers: rideSortHeadersWithFilters(rideSort, rideFilters),
+ Filters: buildRideFilterView(rideFilters, rideSort),
+ Sort: rideSort,
+ }), w)
+}
+
+func (s *Server) handleRide(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", "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 == "" {
+ slog.Info("Loaded ride detail without route", "ride_id", id)
+ kcore.RenderPage(r.Context(), RideDetailPage(RideDetailView{RideView: buildRideView(item), Notice: rideDetailNotice(r)}), w)
+ return
+ }
+ parsed, err := ride.ParseFile(ride.GPXRideParser{}, item.GPXPath)
+ if err != nil {
+ slog.Error("Failed to load ride route", "ride_id", id, "file", item.GPXPath, "error", err)
+ kcore.RenderPage(r.Context(), RideDetailPage(RideDetailView{
+ RideView: buildRideView(item),
+ RouteError: "The recorded route is unavailable.",
+ Notice: rideDetailNotice(r),
+ }), w)
+ return
+ }
+ passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed, rideDetailMountainPassMarginM)
+ if err != nil {
+ slog.Warn("Failed to load nearby 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)
+ view := buildRideDetailView(item, parsed, passes, officialClimbs, matchPolicy)
+ view.Notice = rideDetailNotice(r)
+ kcore.RenderPage(r.Context(), RideDetailPage(view), w)
+}
+
+func (s *Server) handleAnalytics(w http.ResponseWriter, r *http.Request) {
+ items, err := s.listRides(RideSort{})
+ if err != nil {
+ slog.Error("Failed to load rides for analytics", "error", err)
+ http.Error(w, "failed to load rides", http.StatusInternalServerError)
+ return
+ }
+ eligible := make([]rides.Ride, 0, len(items))
+ for _, item := range items {
+ if item.DistanceM >= minimumDisplayedDistanceM {
+ eligible = append(eligible, item)
+ }
+ }
+ slog.Info("Loaded ride analytics", "rides", len(eligible))
+ kcore.RenderPage(r.Context(), AnalyticsPage(AnalyticsPageData{
+ Points: buildAnalyticsPoints(eligible),
+ }), 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) {
+ column, found := rideSort.databaseColumn()
+ if !found {
+ return rides.List(s.db)
+ }
+ return rides.ListSorted(s.db, column, rideSort.Descending)
+}
+
+const minimumDisplayedDistanceM = 10_000
+const rideDetailMountainPassMarginM = 10_000
+
+func buildRideViews(items []rides.Ride) []RideView {
+ views := make([]RideView, 0, len(items))
+ for _, item := range items {
+ if item.DistanceM < minimumDisplayedDistanceM {
+ continue
+ }
+ views = append(views, buildRideView(item))
+ }
+ return views
+}
+
+func buildRideView(item rides.Ride) RideView {
+ view := RideView{Ride: item, Cotacol: "-", CotacolPer100Km: "-", MovingTime: formatDuration(item.EffectiveMovingTimeS())}
+ cotacol, ready := item.CotacolScore()
+ if ready && item.CotacolAlgorithmVersion() == ride.CotacolAlgorithmVersion && item.DistanceM > 0 {
+ view.Cotacol = formatCotacol(cotacol)
+ view.CotacolPer100Km = formatCotacolPer100Km(cotacol, item.DistanceM)
+ }
+ return view
+}