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/request.go | 36 +++ web/rides.go | 205 +++++++++++++++++ web/server.go | 585 +------------------------------------------------ web/server_test.go | 42 ++++ web/strava.go | 144 ++++++++++++ web/sync.go | 255 +++++++++++++++++++++ web/templates.templ | 22 +- web/templates_templ.go | 415 +++++++++++++++++++++-------------- web/views.go | 30 +++ 9 files changed, 982 insertions(+), 752 deletions(-) create mode 100644 web/request.go create mode 100644 web/rides.go create mode 100644 web/strava.go create mode 100644 web/sync.go (limited to 'web') diff --git a/web/request.go b/web/request.go new file mode 100644 index 0000000..d51a582 --- /dev/null +++ b/web/request.go @@ -0,0 +1,36 @@ +package web + +import ( + "fmt" + "net/http" + "strings" +) + +func queryOrDefault(r *http.Request, key, fallback string) string { + if value := r.URL.Query().Get(key); value != "" { + return value + } + return fallback +} + +func syncNotice(r *http.Request) string { + imported := r.URL.Query().Get("imported") + if imported == "" { + return "" + } + 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" + } + return value +} 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 +} diff --git a/web/server.go b/web/server.go index 06ee425..198cb44 100644 --- a/web/server.go +++ b/web/server.go @@ -1,27 +1,11 @@ package web import ( - "crypto/rand" "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "log/slog" "net/http" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" "sync" "time" - "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" "github.com/martinlehoux/kagamigo/kcore" ) @@ -53,6 +37,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("GET /analytics", s.handleAnalytics) mux.HandleFunc("POST /rides/{id}/official-climbs", s.handleCreateOfficialClimb) mux.HandleFunc("GET /sync", s.handleSyncForm) mux.HandleFunc("POST /sync", s.handleSync) @@ -64,571 +49,3 @@ func (s *Server) Handler() http.Handler { func (s *Server) ListenAndServe(addr string) error { return http.ListenAndServe(addr, s.Handler()) } - -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) 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 -} - -func (s *Server) handleSyncForm(w http.ResponseWriter, r *http.Request) { - data := SyncPageData{ - From: queryOrDefault(r, "from", time.Now().AddDate(0, 0, -30).Format(dateFormat)), - To: queryOrDefault(r, "to", time.Now().Format(dateFormat)), - Notice: syncNotice(r), - HasAuth: s.hasStravaToken(), - } - if _, _, err := s.stravaClient(); err != nil { - data.Error = err.Error() - } - kcore.RenderPage(r.Context(), SyncPage(data), w) -} - -func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { - if err := r.ParseMultipartForm(10 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { - http.Error(w, "invalid form", http.StatusBadRequest) - return - } - from, to, err := parseDateRange(r.FormValue("from"), r.FormValue("to")) - if err != nil { - s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) - return - } - appConfig, err := s.loadConfig() - if err != nil { - s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) - return - } - client, authorized, err := newStravaClient(appConfig) - if err != nil { - s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) - return - } - if !authorized { - query := url.Values{} - query.Set("return_to", "/sync?from="+r.FormValue("from")+"&to="+r.FormValue("to")) - http.Redirect(w, r, "/strava/login?"+query.Encode(), http.StatusFound) - return - } - if !s.beginSync() { - http.Error(w, "a Strava sync is already in progress", http.StatusConflict) - return - } - defer s.endSync() - - if refreshed, err := client.RefreshIfNeeded(); err != nil { - s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) - return - } else if refreshed { - if err := s.saveStravaToken(client); err != nil { - s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) - return - } - } - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming responses are not supported", http.StatusInternalServerError) - return - } - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Content-Type", "text/event-stream") - progress, err := s.syncRides(client, appConfig.Storage.GPXDir, from, to, func(progress SyncProgress) error { - return writeSyncEvent(w, flusher, "progress", progress) - }) - if err != nil { - _ = writeSyncEvent(w, flusher, "error", syncErrorEvent{Message: err.Error(), Progress: progress}) - return - } - slog.Info("Completed Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total, "imported", progress.Imported, "skipped", progress.Skipped) - _ = writeSyncEvent(w, flusher, "complete", progress) -} - -func (s *Server) handleStravaLogin(w http.ResponseWriter, r *http.Request) { - appConfig, err := s.loadConfig() - if err != nil { - http.Error(w, "failed to load configuration", http.StatusInternalServerError) - return - } - clientID := appConfig.Strava.ClientID - clientSecret := appConfig.Strava.ClientSecret - if clientID == "" || clientSecret == "" { - http.Error(w, "STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET are required", http.StatusInternalServerError) - return - } - state, err := newOAuthState() - if err != nil { - http.Error(w, "failed to start OAuth flow", http.StatusInternalServerError) - return - } - returnTo := safeReturnTo(r.URL.Query().Get("return_to")) - s.oauthMu.Lock() - s.oauthState = state - s.returnToURL = returnTo - s.oauthMu.Unlock() - redirectURI := appConfig.Server.PublicURL + "/strava/callback" - http.Redirect(w, r, strava.AuthorizeURLWithState(clientID, redirectURI, state), http.StatusFound) -} - -func (s *Server) handleStravaCallback(w http.ResponseWriter, r *http.Request) { - if callbackError := r.URL.Query().Get("error"); callbackError != "" { - http.Error(w, "Strava authorization was denied: "+callbackError, http.StatusBadRequest) - return - } - returnTo, ok := s.consumeOAuthState(r.URL.Query().Get("state")) - if !ok { - http.Error(w, "invalid OAuth state", http.StatusBadRequest) - return - } - code := r.URL.Query().Get("code") - if code == "" { - http.Error(w, "missing authorization code", http.StatusBadRequest) - return - } - appConfig, err := s.loadConfig() - if err != nil { - http.Error(w, "failed to load configuration", http.StatusInternalServerError) - return - } - redirectURI := appConfig.Server.PublicURL + "/strava/callback" - token, err := strava.ExchangeCode(appConfig.Strava.ClientID, appConfig.Strava.ClientSecret, code, redirectURI) - if err != nil { - http.Error(w, "failed to exchange Strava authorization code", http.StatusBadGateway) - return - } - appConfig.Strava.AccessToken = token.AccessToken - appConfig.Strava.RefreshToken = token.RefreshToken - appConfig.Strava.ExpiresAt = token.ExpiresAt.Unix() - if err := config.Save(s.configPath, appConfig); err != nil { - http.Error(w, "failed to store Strava token", http.StatusInternalServerError) - return - } - slog.Info("Strava authorization completed") - http.Redirect(w, r, returnTo, http.StatusSeeOther) -} - -func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, report func(SyncProgress) error) (SyncProgress, error) { - activities, err := client.List(from, to) - if err != nil { - return SyncProgress{}, err - } - progress := SyncProgress{Total: len(activities)} - if err := reportSyncProgress(report, progress); err != nil { - return progress, err - } - slog.Info("Started Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total) - if err := os.MkdirAll(gpxDir, 0o755); err != nil { - return progress, fmt.Errorf("create GPX directory: %w", err) - } - for _, summary := range activities { - externalID := fmt.Sprintf("strava:%d", summary.ID) - _, exists, err := rides.GetByExternalID(s.db, externalID) - if err != nil { - return progress, err - } - if exists { - progress.Skipped++ - progress.Completed++ - if err := reportSyncProgress(report, progress); err != nil { - return progress, err - } - continue - } - activity, gpxData, err := client.Get(summary.ID) - if err != nil { - if errors.Is(err, strava.ErrNoTrackPoints) { - _ = os.Remove(filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", summary.ID))) - if saveErr := rides.Save(s.db, rideFromActivity(externalID, "", activity)); saveErr != nil { - if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save metadata: %w", saveErr)); reportErr != nil { - return progress, reportErr - } - continue - } - progress.Imported++ - progress.Completed++ - if reportErr := reportSyncProgress(report, progress); reportErr != nil { - return progress, reportErr - } - slog.Info("Imported Strava activity without route", "activity", activity.ID, "name", activity.Name) - continue - } - if err := skipSyncActivity(&progress, report, summary, err); err != nil { - return progress, err - } - continue - } - gpxPath := filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", activity.ID)) - if err := os.WriteFile(gpxPath, gpxData, 0o644); err != nil { - _ = os.Remove(gpxPath) - if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("write GPX: %w", err)); reportErr != nil { - return progress, reportErr - } - continue - } - if err := rides.Save(s.db, rideFromActivity(externalID, gpxPath, activity)); err != nil { - _ = os.Remove(gpxPath) - if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save activity: %w", err)); reportErr != nil { - return progress, reportErr - } - continue - } - progress.Imported++ - progress.Completed++ - if err := reportSyncProgress(report, progress); err != nil { - return progress, err - } - slog.Info("Imported Strava ride", "activity", activity.ID, "name", activity.Name) - } - return progress, nil -} - -func rideFromActivity(externalID, gpxPath string, activity strava.Activity) rides.Ride { - activityType := activity.SportType - if activityType == "" { - activityType = activity.Type - } - return rides.Ride{ - ExternalID: externalID, - GPXPath: gpxPath, - Name: activity.Name, - Type: activityType, - StartDate: activity.StartDate, - DistanceM: activity.DistanceM, - ExternalMovingTimeS: activity.MovingTimeS, - ElapsedTimeS: activity.ElapsedTimeS, - TotalElevationGainM: activity.TotalElevationGainM, - AverageSpeedMps: activity.AverageSpeedMps, - } -} - -func skipSyncActivity(progress *SyncProgress, report func(SyncProgress) error, activity strava.Activity, err error) error { - progress.Skipped++ - progress.Completed++ - slog.Warn("Skipped Strava activity", "activity", activity.ID, "name", activity.Name, "error", err) - return reportSyncProgress(report, *progress) -} - -func reportSyncProgress(report func(SyncProgress) error, progress SyncProgress) error { - if report == nil { - return nil - } - return report(progress) -} - -func (s *Server) beginSync() bool { - s.syncMu.Lock() - defer s.syncMu.Unlock() - if s.syncActive { - return false - } - s.syncActive = true - return true -} - -func (s *Server) endSync() { - s.syncMu.Lock() - s.syncActive = false - s.syncMu.Unlock() -} - -type syncErrorEvent struct { - Message string `json:"message"` - Progress SyncProgress `json:"progress"` -} - -func writeSyncEvent(w http.ResponseWriter, flusher http.Flusher, event string, data any) error { - payload, err := json.Marshal(data) - if err != nil { - return err - } - if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, payload); err != nil { - return err - } - flusher.Flush() - return nil -} - -func (s *Server) stravaClient() (*strava.Client, bool, error) { - appConfig, err := s.loadConfig() - if err != nil { - return nil, false, fmt.Errorf("load configuration: %w", err) - } - return newStravaClient(appConfig) -} - -func (s *Server) loadConfig() (config.Config, error) { - return config.Load(s.configPath) -} - -func newStravaClient(appConfig config.Config) (*strava.Client, bool, error) { - if appConfig.Strava.ClientID == "" || appConfig.Strava.ClientSecret == "" { - return nil, false, errors.New("STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET are required") - } - if appConfig.Strava.AccessToken == "" || appConfig.Strava.RefreshToken == "" { - return nil, false, nil - } - var expiresAt time.Time - if appConfig.Strava.ExpiresAt != 0 { - expiresAt = time.Unix(appConfig.Strava.ExpiresAt, 0) - } - return strava.NewClient(appConfig.Strava.ClientID, appConfig.Strava.ClientSecret, strava.Token{ - AccessToken: appConfig.Strava.AccessToken, - RefreshToken: appConfig.Strava.RefreshToken, - ExpiresAt: expiresAt, - }), true, nil -} - -func (s *Server) saveStravaToken(client *strava.Client) error { - appConfig, err := s.loadConfig() - if err != nil { - return err - } - token := client.Tokens() - appConfig.Strava.AccessToken = token.AccessToken - appConfig.Strava.RefreshToken = token.RefreshToken - appConfig.Strava.ExpiresAt = token.ExpiresAt.Unix() - return config.Save(s.configPath, appConfig) -} - -func (s *Server) hasStravaToken() bool { - _, authorized, err := s.stravaClient() - return err == nil && authorized -} - -func (s *Server) consumeOAuthState(state string) (string, bool) { - s.oauthMu.Lock() - defer s.oauthMu.Unlock() - if state == "" || state != s.oauthState { - return "", false - } - returnTo := s.returnToURL - s.oauthState = "" - s.returnToURL = "" - return returnTo, true -} - -func (s *Server) renderSyncError(w http.ResponseWriter, r *http.Request, from, to string, err error) { - if strings.Contains(r.Header.Get("Accept"), "text/event-stream") { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusBadRequest) - kcore.RenderPage(r.Context(), SyncPage(SyncPageData{From: from, To: to, Error: err.Error(), HasAuth: s.hasStravaToken()}), w) -} - -func newOAuthState() (string, error) { - data := make([]byte, 32) - if _, err := rand.Read(data); err != nil { - return "", err - } - return hex.EncodeToString(data), nil -} - -func parseDateRange(from, to string) (time.Time, time.Time, error) { - start, err := time.Parse(dateFormat, from) - if err != nil { - return time.Time{}, time.Time{}, errors.New("a valid start date is required") - } - end, err := time.Parse(dateFormat, to) - if err != nil { - return time.Time{}, time.Time{}, errors.New("a valid end date is required") - } - if end.Before(start) { - return time.Time{}, time.Time{}, errors.New("the end date must not be before the start date") - } - return start.UTC(), end.AddDate(0, 0, 1).UTC(), nil -} - -func queryOrDefault(r *http.Request, key, fallback string) string { - if value := r.URL.Query().Get(key); value != "" { - return value - } - return fallback -} - -func syncNotice(r *http.Request) string { - imported := r.URL.Query().Get("imported") - if imported == "" { - return "" - } - 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" - } - return value -} diff --git a/web/server_test.go b/web/server_test.go index 299575e..42b85b1 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -348,6 +348,48 @@ func TestHandlerRendersRideDetailWithoutRoute(t *testing.T) { assert.NotContains(t, body, `id="ride-map"`) } +func TestHandlerRendersAnalyticsChart(t *testing.T) { + server, db := newWebTestServer(t) + first := rides.Ride{ + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440001", + Name: "Fastish Ride", + Type: "Ride", + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 40_000, + } + second := rides.Ride{ + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440002", + Name: "Climbing Ride", + Type: "Ride", + StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), + DistanceM: 40_000, + } + require.NoError(t, rides.Save(db, first)) + require.NoError(t, rides.Save(db, second)) + _, err := db.Exec(` + UPDATE rides + SET cotacol_score = CASE external_id WHEN ? THEN 20 ELSE 120 END, + cotacol_algo_version = ?, + average_speed_mps = CASE external_id WHEN ? THEN 7.0 ELSE 4.0 END + `, first.ExternalID, ride.CotacolAlgorithmVersion, second.ExternalID) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/analytics", nil) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, req) + + body := response.Body.String() + assert.Equal(t, http.StatusOK, response.Code) + assert.Contains(t, body, "Ride analytics") + assert.Contains(t, body, `id="analytics-chart"`) + assert.Contains(t, body, `id="analytics-points"`) + assert.Contains(t, body, `"speedKmh":25.2`) + assert.Contains(t, body, `"cotacolPer100Km":50`) + assert.NotContains(t, body, "No eligible rides yet") + assert.Contains(t, body, ``) + assert.Contains(t, body, `href="/analytics"`) +} + func TestHandlerReturnsNotFoundForUnknownRide(t *testing.T) { server, _ := newWebTestServer(t) req := httptest.NewRequest(http.MethodGet, "/rides/999999", nil) diff --git a/web/strava.go b/web/strava.go new file mode 100644 index 0000000..1e498f5 --- /dev/null +++ b/web/strava.go @@ -0,0 +1,144 @@ +package web + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/martinlehoux/biking_home/config" + "github.com/martinlehoux/biking_home/strava" +) + +func (s *Server) handleStravaLogin(w http.ResponseWriter, r *http.Request) { + appConfig, err := s.loadConfig() + if err != nil { + http.Error(w, "failed to load configuration", http.StatusInternalServerError) + return + } + clientID := appConfig.Strava.ClientID + clientSecret := appConfig.Strava.ClientSecret + if clientID == "" || clientSecret == "" { + http.Error(w, "STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET are required", http.StatusInternalServerError) + return + } + state, err := newOAuthState() + if err != nil { + http.Error(w, "failed to start OAuth flow", http.StatusInternalServerError) + return + } + returnTo := safeReturnTo(r.URL.Query().Get("return_to")) + s.oauthMu.Lock() + s.oauthState = state + s.returnToURL = returnTo + s.oauthMu.Unlock() + redirectURI := appConfig.Server.PublicURL + "/strava/callback" + http.Redirect(w, r, strava.AuthorizeURLWithState(clientID, redirectURI, state), http.StatusFound) +} + +func (s *Server) handleStravaCallback(w http.ResponseWriter, r *http.Request) { + if callbackError := r.URL.Query().Get("error"); callbackError != "" { + http.Error(w, "Strava authorization was denied: "+callbackError, http.StatusBadRequest) + return + } + returnTo, ok := s.consumeOAuthState(r.URL.Query().Get("state")) + if !ok { + http.Error(w, "invalid OAuth state", http.StatusBadRequest) + return + } + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + return + } + appConfig, err := s.loadConfig() + if err != nil { + http.Error(w, "failed to load configuration", http.StatusInternalServerError) + return + } + redirectURI := appConfig.Server.PublicURL + "/strava/callback" + token, err := strava.ExchangeCode(appConfig.Strava.ClientID, appConfig.Strava.ClientSecret, code, redirectURI) + if err != nil { + http.Error(w, "failed to exchange Strava authorization code", http.StatusBadGateway) + return + } + appConfig.Strava.AccessToken = token.AccessToken + appConfig.Strava.RefreshToken = token.RefreshToken + appConfig.Strava.ExpiresAt = token.ExpiresAt.Unix() + if err := config.Save(s.configPath, appConfig); err != nil { + http.Error(w, "failed to store Strava token", http.StatusInternalServerError) + return + } + slog.Info("Strava authorization completed") + http.Redirect(w, r, returnTo, http.StatusSeeOther) +} + +func (s *Server) stravaClient() (*strava.Client, bool, error) { + appConfig, err := s.loadConfig() + if err != nil { + return nil, false, fmt.Errorf("load configuration: %w", err) + } + return newStravaClient(appConfig) +} + +func (s *Server) loadConfig() (config.Config, error) { + return config.Load(s.configPath) +} + +func newStravaClient(appConfig config.Config) (*strava.Client, bool, error) { + if appConfig.Strava.ClientID == "" || appConfig.Strava.ClientSecret == "" { + return nil, false, errors.New("STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET are required") + } + if appConfig.Strava.AccessToken == "" || appConfig.Strava.RefreshToken == "" { + return nil, false, nil + } + var expiresAt time.Time + if appConfig.Strava.ExpiresAt != 0 { + expiresAt = time.Unix(appConfig.Strava.ExpiresAt, 0) + } + return strava.NewClient(appConfig.Strava.ClientID, appConfig.Strava.ClientSecret, strava.Token{ + AccessToken: appConfig.Strava.AccessToken, + RefreshToken: appConfig.Strava.RefreshToken, + ExpiresAt: expiresAt, + }), true, nil +} + +func (s *Server) saveStravaToken(client *strava.Client) error { + appConfig, err := s.loadConfig() + if err != nil { + return err + } + token := client.Tokens() + appConfig.Strava.AccessToken = token.AccessToken + appConfig.Strava.RefreshToken = token.RefreshToken + appConfig.Strava.ExpiresAt = token.ExpiresAt.Unix() + return config.Save(s.configPath, appConfig) +} + +func (s *Server) hasStravaToken() bool { + _, authorized, err := s.stravaClient() + return err == nil && authorized +} + +func (s *Server) consumeOAuthState(state string) (string, bool) { + s.oauthMu.Lock() + defer s.oauthMu.Unlock() + if state == "" || state != s.oauthState { + return "", false + } + returnTo := s.returnToURL + s.oauthState = "" + s.returnToURL = "" + return returnTo, true +} + +func newOAuthState() (string, error) { + data := make([]byte, 32) + if _, err := rand.Read(data); err != nil { + return "", err + } + return hex.EncodeToString(data), nil +} diff --git a/web/sync.go b/web/sync.go new file mode 100644 index 0000000..4a6ba5f --- /dev/null +++ b/web/sync.go @@ -0,0 +1,255 @@ +package web + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/martinlehoux/biking_home/rides" + "github.com/martinlehoux/biking_home/strava" + "github.com/martinlehoux/kagamigo/kcore" +) + +func (s *Server) handleSyncForm(w http.ResponseWriter, r *http.Request) { + data := SyncPageData{ + From: queryOrDefault(r, "from", time.Now().AddDate(0, 0, -30).Format(dateFormat)), + To: queryOrDefault(r, "to", time.Now().Format(dateFormat)), + Notice: syncNotice(r), + HasAuth: s.hasStravaToken(), + } + if _, _, err := s.stravaClient(); err != nil { + data.Error = err.Error() + } + kcore.RenderPage(r.Context(), SyncPage(data), w) +} + +func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(10 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + from, to, err := parseDateRange(r.FormValue("from"), r.FormValue("to")) + if err != nil { + s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) + return + } + appConfig, err := s.loadConfig() + if err != nil { + s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) + return + } + client, authorized, err := newStravaClient(appConfig) + if err != nil { + s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) + return + } + if !authorized { + query := url.Values{} + query.Set("return_to", "/sync?from="+r.FormValue("from")+"&to="+r.FormValue("to")) + http.Redirect(w, r, "/strava/login?"+query.Encode(), http.StatusFound) + return + } + if !s.beginSync() { + http.Error(w, "a Strava sync is already in progress", http.StatusConflict) + return + } + defer s.endSync() + + if refreshed, err := client.RefreshIfNeeded(); err != nil { + s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) + return + } else if refreshed { + if err := s.saveStravaToken(client); err != nil { + s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) + return + } + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming responses are not supported", http.StatusInternalServerError) + return + } + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Content-Type", "text/event-stream") + progress, err := s.syncRides(client, appConfig.Storage.GPXDir, from, to, func(progress SyncProgress) error { + return writeSyncEvent(w, flusher, "progress", progress) + }) + if err != nil { + _ = writeSyncEvent(w, flusher, "error", syncErrorEvent{Message: err.Error(), Progress: progress}) + return + } + slog.Info("Completed Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total, "imported", progress.Imported, "skipped", progress.Skipped) + _ = writeSyncEvent(w, flusher, "complete", progress) +} + +func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, report func(SyncProgress) error) (SyncProgress, error) { + activities, err := client.List(from, to) + if err != nil { + return SyncProgress{}, err + } + progress := SyncProgress{Total: len(activities)} + if err := reportSyncProgress(report, progress); err != nil { + return progress, err + } + slog.Info("Started Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total) + if err := os.MkdirAll(gpxDir, 0o755); err != nil { + return progress, fmt.Errorf("create GPX directory: %w", err) + } + for _, summary := range activities { + externalID := fmt.Sprintf("strava:%d", summary.ID) + _, exists, err := rides.GetByExternalID(s.db, externalID) + if err != nil { + return progress, err + } + if exists { + progress.Skipped++ + progress.Completed++ + if err := reportSyncProgress(report, progress); err != nil { + return progress, err + } + continue + } + activity, gpxData, err := client.Get(summary.ID) + if err != nil { + if errors.Is(err, strava.ErrNoTrackPoints) { + _ = os.Remove(filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", summary.ID))) + if saveErr := rides.Save(s.db, rideFromActivity(externalID, "", activity)); saveErr != nil { + if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save metadata: %w", saveErr)); reportErr != nil { + return progress, reportErr + } + continue + } + progress.Imported++ + progress.Completed++ + if reportErr := reportSyncProgress(report, progress); reportErr != nil { + return progress, reportErr + } + slog.Info("Imported Strava activity without route", "activity", activity.ID, "name", activity.Name) + continue + } + if err := skipSyncActivity(&progress, report, summary, err); err != nil { + return progress, err + } + continue + } + gpxPath := filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", activity.ID)) + if err := os.WriteFile(gpxPath, gpxData, 0o644); err != nil { + _ = os.Remove(gpxPath) + if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("write GPX: %w", err)); reportErr != nil { + return progress, reportErr + } + continue + } + if err := rides.Save(s.db, rideFromActivity(externalID, gpxPath, activity)); err != nil { + _ = os.Remove(gpxPath) + if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save activity: %w", err)); reportErr != nil { + return progress, reportErr + } + continue + } + progress.Imported++ + progress.Completed++ + if err := reportSyncProgress(report, progress); err != nil { + return progress, err + } + slog.Info("Imported Strava ride", "activity", activity.ID, "name", activity.Name) + } + return progress, nil +} + +func rideFromActivity(externalID, gpxPath string, activity strava.Activity) rides.Ride { + activityType := activity.SportType + if activityType == "" { + activityType = activity.Type + } + return rides.Ride{ + ExternalID: externalID, + GPXPath: gpxPath, + Name: activity.Name, + Type: activityType, + StartDate: activity.StartDate, + DistanceM: activity.DistanceM, + ExternalMovingTimeS: activity.MovingTimeS, + ElapsedTimeS: activity.ElapsedTimeS, + TotalElevationGainM: activity.TotalElevationGainM, + AverageSpeedMps: activity.AverageSpeedMps, + } +} + +func skipSyncActivity(progress *SyncProgress, report func(SyncProgress) error, activity strava.Activity, err error) error { + progress.Skipped++ + progress.Completed++ + slog.Warn("Skipped Strava activity", "activity", activity.ID, "name", activity.Name, "error", err) + return reportSyncProgress(report, *progress) +} + +func reportSyncProgress(report func(SyncProgress) error, progress SyncProgress) error { + if report == nil { + return nil + } + return report(progress) +} + +func (s *Server) beginSync() bool { + s.syncMu.Lock() + defer s.syncMu.Unlock() + if s.syncActive { + return false + } + s.syncActive = true + return true +} + +func (s *Server) endSync() { + s.syncMu.Lock() + s.syncActive = false + s.syncMu.Unlock() +} + +type syncErrorEvent struct { + Message string `json:"message"` + Progress SyncProgress `json:"progress"` +} + +func writeSyncEvent(w http.ResponseWriter, flusher http.Flusher, event string, data any) error { + payload, err := json.Marshal(data) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, payload); err != nil { + return err + } + flusher.Flush() + return nil +} + +func (s *Server) renderSyncError(w http.ResponseWriter, r *http.Request, from, to string, err error) { + if strings.Contains(r.Header.Get("Accept"), "text/event-stream") { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusBadRequest) + kcore.RenderPage(r.Context(), SyncPage(SyncPageData{From: from, To: to, Error: err.Error(), HasAuth: s.hasStravaToken()}), w) +} + +func parseDateRange(from, to string) (time.Time, time.Time, error) { + start, err := time.Parse(dateFormat, from) + if err != nil { + return time.Time{}, time.Time{}, errors.New("a valid start date is required") + } + end, err := time.Parse(dateFormat, to) + if err != nil { + return time.Time{}, time.Time{}, errors.New("a valid end date is required") + } + if end.Before(start) { + return time.Time{}, time.Time{}, errors.New("the end date must not be before the start date") + } + return start.UTC(), end.AddDate(0, 0, 1).UTC(), nil +} diff --git a/web/templates.templ b/web/templates.templ index 9e143b4..1cda7f2 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -13,7 +13,7 @@ templ Layout(title string, content templ.Component) {
@content @@ -117,6 +117,26 @@ templ RidesContent(data RidesPageData) { } +templ AnalyticsPage(data AnalyticsPageData) { + @Layout("Analytics", AnalyticsContent(data)) +} + +templ AnalyticsContent(data AnalyticsPageData) { +

Analytics

+

Ride analytics

+

One point per ride: average speed on the x axis, Cotacol per 100 km on the y axis.

+
+

Chart

Speed vs. Cotacol per 100 km

+ if len(data.Points) == 0 { +
No eligible rides yet. Import rides to see the chart.
+ } else { +
+ } +
+ @templ.JSONScript("analytics-points", data.Points) + +} + templ RideDetailPage(data RideDetailView) { @Layout("Ride detail", RideDetailContent(data)) } diff --git a/web/templates_templ.go b/web/templates_templ.go index 57540be..aeedf66 100644 --- a/web/templates_templ.go +++ b/web/templates_templ.go @@ -42,7 +42,7 @@ func Layout(title string, content templ.Component) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" · biking_home
biking_home
") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" · biking_home
biking_home
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -899,7 +899,7 @@ func RidesContent(data RidesPageData) templ.Component { }) } -func RideDetailPage(data RideDetailView) templ.Component { +func AnalyticsPage(data AnalyticsPageData) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -920,7 +920,7 @@ func RideDetailPage(data RideDetailView) templ.Component { templ_7745c5c3_Var61 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = Layout("Ride detail", RideDetailContent(data)).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Layout("Analytics", AnalyticsContent(data)).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -928,7 +928,7 @@ func RideDetailPage(data RideDetailView) templ.Component { }) } -func RideDetailContent(data RideDetailView) templ.Component { +func AnalyticsContent(data AnalyticsPageData) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -949,16 +949,97 @@ func RideDetailContent(data RideDetailView) templ.Component { templ_7745c5c3_Var62 = templ.NopComponent } ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Analytics

Ride analytics

One point per ride: average speed on the x axis, Cotacol per 100 km on the y axis.

Chart

Speed vs. Cotacol per 100 km

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Points) == 0 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
No eligible rides yet. Import rides to see the chart.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + 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 + } + templ_7745c5c3_Err = templ.JSONScript("analytics-points", data.Points).Render(ctx, templ_7745c5c3_Buffer) + 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 + } + return templ_7745c5c3_Err + }) +} + +func RideDetailPage(data RideDetailView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var63 := templ.GetChildren(ctx) + if templ_7745c5c3_Var63 == nil { + templ_7745c5c3_Var63 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = Layout("Ride detail", RideDetailContent(data)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func RideDetailContent(data RideDetailView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var64 := templ.GetChildren(ctx) + if templ_7745c5c3_Var64 == nil { + templ_7745c5c3_Var64 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var63 string - templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(data.Ride.Name) + var templ_7745c5c3_Var65 string + templ_7745c5c3_Var65, 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: 126, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 146, Col: 53} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -966,12 +1047,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var64 string - templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(data.Ride.Type) + var templ_7745c5c3_Var66 string + templ_7745c5c3_Var66, 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: 126, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 146, Col: 92} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -979,12 +1060,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var65 string - templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(formatRideDate(data.Ride.StartDate)) + var templ_7745c5c3_Var67 string + templ_7745c5c3_Var67, 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: 126, Col: 135} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 146, Col: 135} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -997,12 +1078,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var66 string - templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(data.RouteError) + var templ_7745c5c3_Var68 string + templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(data.RouteError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 130, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 150, Col: 38} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1016,12 +1097,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var67 string - templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice) + var templ_7745c5c3_Var69 string + templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 133, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 153, Col: 35} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1035,12 +1116,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var68 string - templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(data.ActionError) + var templ_7745c5c3_Var70 string + templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(data.ActionError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 136, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 156, Col: 39} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1053,12 +1134,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var69 string - templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(formatDistance(data.Ride.DistanceM)) + var templ_7745c5c3_Var71 string + templ_7745c5c3_Var71, 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: 141, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 161, Col: 67} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1066,12 +1147,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var70 string - templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(data.MovingTime) + var templ_7745c5c3_Var72 string + templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(data.MovingTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 142, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 162, Col: 50} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1079,12 +1160,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var71 string - templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(formatSpeed(data.Ride.AverageSpeedMps)) + var templ_7745c5c3_Var73 string + templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(formatSpeed(data.Ride.AverageSpeedMps)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 143, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 163, Col: 67} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1092,12 +1173,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var72 string - templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(formatElevation(data.Ride.TotalElevationGainM)) + var templ_7745c5c3_Var74 string + templ_7745c5c3_Var74, 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: 144, Col: 79} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 164, Col: 79} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1116,12 +1197,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var73 string - templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbNumber(climb.Index)) + var templ_7745c5c3_Var75 string + templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbNumber(climb.Index)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 157, Col: 114} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 177, Col: 114} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1129,12 +1210,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var74 string - templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(climb.OfficialName) + var templ_7745c5c3_Var76 string + templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(climb.OfficialName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 157, Col: 151} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 177, Col: 151} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1142,12 +1223,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var75 string - templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.DistanceKm)) + var templ_7745c5c3_Var77 string + templ_7745c5c3_Var77, 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: 159, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 179, Col: 75} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1155,12 +1236,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var76 string - templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(formatCotacol(climb.Cotacol)) + var templ_7745c5c3_Var78 string + templ_7745c5c3_Var78, 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: 160, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 180, Col: 65} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1168,12 +1249,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var77 string - templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(formatSlope(climb.SlopePercent)) + var templ_7745c5c3_Var79 string + templ_7745c5c3_Var79, 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: 161, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 181, Col: 70} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1181,12 +1262,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var78 string - templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.StartIndex)) + var templ_7745c5c3_Var80 string + templ_7745c5c3_Var80, 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: 166, Col: 125} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 186, Col: 125} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1194,12 +1275,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var79 string - templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.EndIndex)) + var templ_7745c5c3_Var81 string + templ_7745c5c3_Var81, 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: 166, Col: 179} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 186, Col: 179} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1207,12 +1288,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var80 string - templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(officialProfileLabel(climb.OfficialName)) + var templ_7745c5c3_Var82 string + templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(officialProfileLabel(climb.OfficialName)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 166, Col: 259} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 186, Col: 259} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1230,8 +1311,8 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var81 = []any{rideDetailColumnsClass(len(data.Profile.UnmatchedClimbs()))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var81...) + var templ_7745c5c3_Var83 = []any{rideDetailColumnsClass(len(data.Profile.UnmatchedClimbs()))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var83...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1239,12 +1320,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var82 string - templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var81).String()) + var templ_7745c5c3_Var84 string + templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var83).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1257,12 +1338,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var83 string - templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbCount(len(data.Profile.UnmatchedClimbs()))) + var templ_7745c5c3_Var85 string + templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbCount(len(data.Profile.UnmatchedClimbs()))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 185, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 205, Col: 89} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1270,12 +1351,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var84 string - templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbCount(len(data.Profile.UnmatchedClimbs()))) + var templ_7745c5c3_Var86 string + templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbCount(len(data.Profile.UnmatchedClimbs()))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 189, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 209, Col: 100} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1288,12 +1369,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var85 string - templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.Index)) + var templ_7745c5c3_Var87 string + templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.Index)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 194, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 214, Col: 99} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1301,12 +1382,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var86 string - templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbNumber(climb.Index)) + var templ_7745c5c3_Var88 string + templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbNumber(climb.Index)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 196, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 216, Col: 82} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1314,12 +1395,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var87 string - templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(climb.Name) + var templ_7745c5c3_Var89 string + templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(climb.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 196, Col: 103} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 216, Col: 103} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1327,12 +1408,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var88 string - templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.StartKm)) + var templ_7745c5c3_Var90 string + templ_7745c5c3_Var90, 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: 197, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 217, Col: 92} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1340,12 +1421,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var89 string - templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.EndKm)) + var templ_7745c5c3_Var91 string + templ_7745c5c3_Var91, 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: 197, Col: 133} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 217, Col: 133} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1353,12 +1434,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var90 string - templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(climb.Category) + var templ_7745c5c3_Var92 string + templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(climb.Category) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 199, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 219, Col: 68} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1366,12 +1447,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var91 string - templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.DistanceKm)) + var templ_7745c5c3_Var93 string + templ_7745c5c3_Var93, 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: 199, Col: 115} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 219, Col: 115} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1379,12 +1460,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var92 string - templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(formatSlope(climb.SlopePercent)) + var templ_7745c5c3_Var94 string + templ_7745c5c3_Var94, 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: 199, Col: 154} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 219, Col: 154} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1392,12 +1473,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var93 string - templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(formatCotacol(climb.Cotacol)) + var templ_7745c5c3_Var95 string + templ_7745c5c3_Var95, 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: 199, Col: 198} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 219, Col: 198} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1405,8 +1486,8 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var94 templ.SafeURL = templ.URL(officialClimbCreateURL(data.Ride.ID)) - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var94))) + var templ_7745c5c3_Var96 templ.SafeURL = templ.URL(officialClimbCreateURL(data.Ride.ID)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var96))) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1414,12 +1495,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var95 string - templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.StartIndex)) + var templ_7745c5c3_Var97 string + templ_7745c5c3_Var97, 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: 203, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 223, Col: 92} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1427,12 +1508,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var96 string - templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(climb.EndIndex)) + var templ_7745c5c3_Var98 string + templ_7745c5c3_Var98, 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: 204, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 224, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1440,12 +1521,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var97 string - templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.StartKm)) + var templ_7745c5c3_Var99 string + templ_7745c5c3_Var99, 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: 206, Col: 428} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 226, Col: 428} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1453,12 +1534,12 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var98 string - templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(formatProfileDistance(climb.EndKm)) + var templ_7745c5c3_Var100 string + templ_7745c5c3_Var100, 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: 207, Col: 414} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 227, Col: 414} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1527,9 +1608,9 @@ func SyncPage(data SyncPageData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var99 := templ.GetChildren(ctx) - if templ_7745c5c3_Var99 == nil { - templ_7745c5c3_Var99 = templ.NopComponent + templ_7745c5c3_Var101 := templ.GetChildren(ctx) + if templ_7745c5c3_Var101 == nil { + templ_7745c5c3_Var101 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = Layout("Sync Strava", SyncContent(data)).Render(ctx, templ_7745c5c3_Buffer) @@ -1556,9 +1637,9 @@ func SyncContent(data SyncPageData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var100 := templ.GetChildren(ctx) - if templ_7745c5c3_Var100 == nil { - templ_7745c5c3_Var100 = templ.NopComponent + templ_7745c5c3_Var102 := templ.GetChildren(ctx) + if templ_7745c5c3_Var102 == nil { + templ_7745c5c3_Var102 = templ.NopComponent } ctx = templ.ClearChildren(ctx) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Data import

Sync Strava rides

Choose a date range. New rides are downloaded as GPX files and recorded in the local library.

") @@ -1570,12 +1651,12 @@ func SyncContent(data SyncPageData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var101 string - templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice) + var templ_7745c5c3_Var103 string + templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 253, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 273, Col: 52} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1594,12 +1675,12 @@ func SyncContent(data SyncPageData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var102 string - templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) + var templ_7745c5c3_Var104 string + templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 258, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 278, Col: 49} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1622,8 +1703,8 @@ func SyncContent(data SyncPageData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var103 templ.SafeURL = templ.URL("/strava/login?return_to=/sync") - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var103))) + var templ_7745c5c3_Var105 templ.SafeURL = templ.URL("/strava/login?return_to=/sync") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var105))) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1636,12 +1717,12 @@ func SyncContent(data SyncPageData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var104 string - templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(data.From) + var templ_7745c5c3_Var106 string + templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(data.From) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 269, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 289, Col: 64} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1649,12 +1730,12 @@ func SyncContent(data SyncPageData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var105 string - templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(data.To) + var templ_7745c5c3_Var107 string + templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(data.To) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 270, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 290, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/views.go b/web/views.go index 5422421..9771a4d 100644 --- a/web/views.go +++ b/web/views.go @@ -26,6 +26,36 @@ type RidesPageData struct { Sort RideSort } +type AnalyticsPageData struct { + Points []AnalyticsPoint +} + +type AnalyticsPoint struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + SpeedKmh float64 `json:"speedKmh"` + CotacolPer100Km float64 `json:"cotacolPer100Km"` +} + +func buildAnalyticsPoints(items []rides.Ride) []AnalyticsPoint { + points := make([]AnalyticsPoint, 0, len(items)) + for _, item := range items { + cotacol, ready := item.CotacolScore() + if !ready || item.CotacolAlgorithmVersion() != ride.CotacolAlgorithmVersion || item.DistanceM <= 0 { + continue + } + points = append(points, AnalyticsPoint{ + ID: item.ID, + Name: item.Name, + URL: rideDetailURL(item.ID), + SpeedKmh: item.AverageSpeedMps * 3.6, + CotacolPer100Km: cotacol * 100 / (item.DistanceM / 1000), + }) + } + return points +} + type RideDetailView struct { RideView Route GeoJSONFeatureCollection -- cgit v1.3