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 } filters := parseRideFilters(r.URL.Query()) minKm := minimumDisplayedDistanceM / 1000 if filters.DistanceMinKm != nil && *filters.DistanceMinKm > minKm { minKm = *filters.DistanceMinKm } maxKm := 0 if filters.DistanceMaxKm != nil { maxKm = *filters.DistanceMaxKm } eligible := make([]rides.Ride, 0, len(items)) for _, item := range items { distanceKm := int(item.DistanceM / 1000) if distanceKm < minKm || (maxKm != 0 && distanceKm > maxKm) { continue } eligible = append(eligible, item) } filterView := RideFilterView{ DistanceMinKm: rideFilterValue(filters.DistanceMinKm, rideFilterDistanceMinKm), DistanceMaxKm: rideFilterValue(filters.DistanceMaxKm, rideFilterDistanceMaxKm), Active: filters.Active(), ResetURL: "/analytics", } slog.Info("Loaded ride analytics", "rides", len(eligible)) kcore.RenderPage(r.Context(), AnalyticsPage(AnalyticsPageData{ Points: buildAnalyticsPoints(eligible), Filter: filterView, }), 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 }