aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-21 19:02:05 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-21 19:02:05 +0200
commit1e63f5c91f0e5ff7dfc582b67d262d5939cc34bc (patch)
tree7965a98e4bd286df502d658e687fa83230895c39
parent9ec4d3dbc1f55ca412f6df67b8576b087b15887c (diff)
Add global analytics page shell and split server.go by concern
-rw-r--r--web/request.go36
-rw-r--r--web/rides.go205
-rw-r--r--web/server.go585
-rw-r--r--web/server_test.go42
-rw-r--r--web/strava.go144
-rw-r--r--web/sync.go255
-rw-r--r--web/templates.templ22
-rw-r--r--web/templates_templ.go415
-rw-r--r--web/views.go30
9 files changed, 982 insertions, 752 deletions
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, `<script type="module" src="/static/analytics.js"></script>`)
+ 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) {
<body>
<header class="site-header">
<a class="brand" href="/">biking_home</a>
- <nav class="nav"><a href="/">Rides</a><a href="/sync">Sync Strava</a></nav>
+ <nav class="nav"><a href="/">Rides</a><a href="/analytics">Analytics</a><a href="/sync">Sync Strava</a></nav>
</header>
<main class="container">
@content
@@ -117,6 +117,26 @@ templ RidesContent(data RidesPageData) {
<script type="module" src="/static/ride-filters.js"></script>
}
+templ AnalyticsPage(data AnalyticsPageData) {
+ @Layout("Analytics", AnalyticsContent(data))
+}
+
+templ AnalyticsContent(data AnalyticsPageData) {
+ <p class="eyebrow">Analytics</p>
+ <h1>Ride analytics</h1>
+ <p class="lead">One point per ride: average speed on the x axis, Cotacol per 100 km on the y axis.</p>
+ <section class="panel analytics-panel">
+ <div class="section-heading"><div><p class="eyebrow">Chart</p><h2>Speed vs. Cotacol per 100 km</h2></div></div>
+ if len(data.Points) == 0 {
+ <div class="empty">No eligible rides yet. <a href="/sync">Import rides to see the chart.</a></div>
+ } else {
+ <div class="analytics-chart"><canvas id="analytics-chart" role="img" aria-label="Scatter plot of ride average speed against Cotacol per 100 km"></canvas></div>
+ }
+ </section>
+ @templ.JSONScript("analytics-points", data.Points)
+ <script type="module" src="/static/analytics.js"></script>
+}
+
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</title><link rel=\"stylesheet\" href=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\"><link rel=\"stylesheet\" href=\"/static/style.css\"></head><body><header class=\"site-header\"><a class=\"brand\" href=\"/\">biking_home</a><nav class=\"nav\"><a href=\"/\">Rides</a><a href=\"/sync\">Sync Strava</a></nav></header><main class=\"container\">")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" · biking_home</title><link rel=\"stylesheet\" href=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\"><link rel=\"stylesheet\" href=\"/static/style.css\"></head><body><header class=\"site-header\"><a class=\"brand\" href=\"/\">biking_home</a><nav class=\"nav\"><a href=\"/\">Rides</a><a href=\"/analytics\">Analytics</a><a href=\"/sync\">Sync Strava</a></nav></header><main class=\"container\">")
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("<p class=\"eyebrow\">Analytics</p><h1>Ride analytics</h1><p class=\"lead\">One point per ride: average speed on the x axis, Cotacol per 100 km on the y axis.</p><section class=\"panel analytics-panel\"><div class=\"section-heading\"><div><p class=\"eyebrow\">Chart</p><h2>Speed vs. Cotacol per 100 km</h2></div></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Points) == 0 {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"empty\">No eligible rides yet. <a href=\"/sync\">Import rides to see the chart.</a></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"analytics-chart\"><canvas id=\"analytics-chart\" role=\"img\" aria-label=\"Scatter plot of ride average speed against Cotacol per 100 km\"></canvas></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.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("<script type=\"module\" src=\"/static/analytics.js\"></script>")
+ 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("<div class=\"toolbar\"><div><h1 class=\"ride-detail-title\">")
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("<p class=\"eyebrow\">Data import</p><h1>Sync Strava rides</h1><p class=\"lead\">Choose a date range. New rides are downloaded as GPX files and recorded in the local library.</p>")
@@ -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