From 1e63f5c91f0e5ff7dfc582b67d262d5939cc34bc Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Fri, 21 Aug 2026 19:02:05 +0200 Subject: Add global analytics page shell and split server.go by concern --- web/rides.go | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 web/rides.go (limited to 'web/rides.go') 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 +} -- cgit v1.3