summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--config.example.yaml3
-rw-r--r--config/config.go21
-rw-r--r--db/migrations/20260813000000_official_climbs.sql14
-rw-r--r--db/schema.sql15
-rw-r--r--official_climb/official_climb.go184
-rw-r--r--official_climb/official_climb_test.go187
-rw-r--r--ride/climb.go13
-rw-r--r--ride/ride.go5
-rw-r--r--web/server.go93
-rw-r--r--web/server_test.go118
-rw-r--r--web/static/ride-detail.js208
-rw-r--r--web/static/style.css26
-rw-r--r--web/templates.templ100
-rw-r--r--web/templates_templ.go413
-rw-r--r--web/views.go125
15 files changed, 1410 insertions, 115 deletions
diff --git a/config.example.yaml b/config.example.yaml
index b0e7d80..a4f099d 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -8,6 +8,9 @@ server:
storage:
gpx_dir: data/rides
+official_climb:
+ match_radius_m: 100
+
strava:
client_id: ""
client_secret: ""
diff --git a/config/config.go b/config/config.go
index d165db5..6fe9048 100644
--- a/config/config.go
+++ b/config/config.go
@@ -2,17 +2,20 @@ package config
import (
"fmt"
+ "math"
"os"
"path/filepath"
+ "github.com/martinlehoux/biking_home/official_climb"
"gopkg.in/yaml.v3"
)
type Config struct {
- Database DatabaseConfig `yaml:"database"`
- Server ServerConfig `yaml:"server"`
- Storage StorageConfig `yaml:"storage"`
- Strava StravaConfig `yaml:"strava"`
+ Database DatabaseConfig `yaml:"database"`
+ Server ServerConfig `yaml:"server"`
+ Storage StorageConfig `yaml:"storage"`
+ Strava StravaConfig `yaml:"strava"`
+ OfficialClimb OfficialClimbConfig `yaml:"official_climb"`
}
type DatabaseConfig struct {
@@ -36,6 +39,10 @@ type StravaConfig struct {
ExpiresAt int64 `yaml:"expires_at"`
}
+type OfficialClimbConfig struct {
+ MatchRadiusM float64 `yaml:"match_radius_m"`
+}
+
func Default() Config {
return Config{
Database: DatabaseConfig{Path: "biking_home.db"},
@@ -43,7 +50,8 @@ func Default() Config {
Address: "127.0.0.1:8080",
PublicURL: "http://localhost:8080",
},
- Storage: StorageConfig{GPXDir: "data/rides"},
+ Storage: StorageConfig{GPXDir: "data/rides"},
+ OfficialClimb: OfficialClimbConfig{MatchRadiusM: official_climb.DefaultMatchRadiusM},
}
}
@@ -65,6 +73,9 @@ func Load(filename string) (Config, error) {
if config.Storage.GPXDir == "" {
return Config{}, fmt.Errorf("config %q: storage.gpx_dir is required", filename)
}
+ if math.IsNaN(config.OfficialClimb.MatchRadiusM) || math.IsInf(config.OfficialClimb.MatchRadiusM, 0) || config.OfficialClimb.MatchRadiusM <= 0 {
+ return Config{}, fmt.Errorf("config %q: official_climb.match_radius_m must be greater than zero", filename)
+ }
return config, nil
}
diff --git a/db/migrations/20260813000000_official_climbs.sql b/db/migrations/20260813000000_official_climbs.sql
new file mode 100644
index 0000000..0b1696a
--- /dev/null
+++ b/db/migrations/20260813000000_official_climbs.sql
@@ -0,0 +1,14 @@
+-- migrate:up
+create table official_climbs (
+ id integer primary key,
+ name text not null,
+ start_latitude real not null,
+ start_longitude real not null,
+ end_latitude real not null,
+ end_longitude real not null,
+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+);
+
+-- migrate:down
+drop table official_climbs;
diff --git a/db/schema.sql b/db/schema.sql
index 1a3d62a..6c8f8ac 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -27,8 +27,16 @@ CREATE TABLE rides (
elapsed_time_s integer not null,
total_elevation_gain_m real not null,
average_speed_mps real not null,
- cotacol_score real,
- cotacol_algo_version text,
+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+, cotacol_score real, cotacol_algo_version text);
+CREATE TABLE official_climbs (
+ id integer primary key,
+ name text not null,
+ start_latitude real not null,
+ start_longitude real not null,
+ end_latitude real not null,
+ end_longitude real not null,
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
@@ -37,4 +45,5 @@ INSERT INTO "schema_migrations" (version) VALUES
('20250802140659'),
('20260802090000'),
('20260804000000'),
- ('20260807000000');
+ ('20260807000000'),
+ ('20260813000000');
diff --git a/official_climb/official_climb.go b/official_climb/official_climb.go
new file mode 100644
index 0000000..ab98633
--- /dev/null
+++ b/official_climb/official_climb.go
@@ -0,0 +1,184 @@
+package official_climb
+
+import (
+ "database/sql"
+ "fmt"
+ "math"
+ "strings"
+ "time"
+
+ "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/ride"
+)
+
+const DefaultMatchRadiusM = 100.0
+
+type OfficialClimb struct {
+ ID int64
+ Name string
+ StartCoord geodist.Coord
+ EndCoord geodist.Coord
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type MatchPolicy struct {
+ EndpointRadiusM float64
+}
+
+func DefaultMatchPolicy() MatchPolicy {
+ return MatchPolicy{EndpointRadiusM: DefaultMatchRadiusM}
+}
+
+func (policy MatchPolicy) Validate() error {
+ if math.IsNaN(policy.EndpointRadiusM) || math.IsInf(policy.EndpointRadiusM, 0) || policy.EndpointRadiusM <= 0 {
+ return fmt.Errorf("official climb endpoint radius must be greater than zero")
+ }
+ return nil
+}
+
+func (climb OfficialClimb) validate() error {
+ if strings.TrimSpace(climb.Name) == "" {
+ return fmt.Errorf("official climb name is required")
+ }
+ if !validCoord(climb.StartCoord) || !validCoord(climb.EndCoord) {
+ return fmt.Errorf("official climb coordinates are invalid")
+ }
+ return nil
+}
+
+func validCoord(coord geodist.Coord) bool {
+ return coord.Lat >= -90 && coord.Lat <= 90 && coord.Lon >= -180 && coord.Lon <= 180
+}
+
+const columns = "id, name, start_latitude, start_longitude, end_latitude, end_longitude, created_at, updated_at"
+
+func Create(db *sql.DB, climb OfficialClimb) (OfficialClimb, error) {
+ if err := climb.validate(); err != nil {
+ return OfficialClimb{}, err
+ }
+ result, err := db.Exec(`
+ INSERT INTO official_climbs (name, start_latitude, start_longitude, end_latitude, end_longitude)
+ VALUES (?, ?, ?, ?, ?)
+ `, climb.Name, climb.StartCoord.Lat, climb.StartCoord.Lon, climb.EndCoord.Lat, climb.EndCoord.Lon)
+ if err != nil {
+ return OfficialClimb{}, fmt.Errorf("create official climb: %w", err)
+ }
+ id, err := result.LastInsertId()
+ if err != nil {
+ return OfficialClimb{}, fmt.Errorf("read official climb id: %w", err)
+ }
+ created, found, err := GetByID(db, id)
+ if err != nil {
+ return OfficialClimb{}, err
+ }
+ if !found {
+ return OfficialClimb{}, fmt.Errorf("official climb %d was not found after creation", id)
+ }
+ return created, nil
+}
+
+func List(db *sql.DB) ([]OfficialClimb, error) {
+ rows, err := db.Query("SELECT " + columns + " FROM official_climbs ORDER BY id")
+ if err != nil {
+ return nil, fmt.Errorf("list official climbs: %w", err)
+ }
+ defer rows.Close()
+
+ climbs := make([]OfficialClimb, 0)
+ for rows.Next() {
+ climb, err := scan(rows)
+ if err != nil {
+ return nil, err
+ }
+ climbs = append(climbs, climb)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate official climbs: %w", err)
+ }
+ return climbs, nil
+}
+
+func GetByID(db *sql.DB, id int64) (OfficialClimb, bool, error) {
+ row := db.QueryRow("SELECT "+columns+" FROM official_climbs WHERE id = ?", id)
+ climb, err := scan(row)
+ if err == sql.ErrNoRows {
+ return OfficialClimb{}, false, nil
+ }
+ if err != nil {
+ return OfficialClimb{}, false, fmt.Errorf("get official climb %d: %w", id, err)
+ }
+ return climb, true, nil
+}
+
+type scanner interface {
+ Scan(dest ...any) error
+}
+
+func scan(s scanner) (OfficialClimb, error) {
+ var (
+ climb OfficialClimb
+ startLatitude, startLongitude float64
+ endLatitude, endLongitude float64
+ createdAt, updatedAt string
+ )
+ if err := s.Scan(&climb.ID, &climb.Name, &startLatitude, &startLongitude, &endLatitude, &endLongitude, &createdAt, &updatedAt); err != nil {
+ return OfficialClimb{}, err
+ }
+ climb.StartCoord = geodist.Coord{Lat: startLatitude, Lon: startLongitude}
+ climb.EndCoord = geodist.Coord{Lat: endLatitude, Lon: endLongitude}
+ var err error
+ climb.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
+ if err != nil {
+ return OfficialClimb{}, fmt.Errorf("invalid official_climbs.created_at: %w", err)
+ }
+ climb.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt)
+ if err != nil {
+ return OfficialClimb{}, fmt.Errorf("invalid official_climbs.updated_at: %w", err)
+ }
+ return climb, nil
+}
+
+func MatchClimb(climb ride.Climb, officialClimbs []OfficialClimb, policy MatchPolicy) (OfficialClimb, bool) {
+ if policy.Validate() != nil {
+ return OfficialClimb{}, false
+ }
+ bestDistance := math.Inf(1)
+ var best OfficialClimb
+ found := false
+ for _, official := range officialClimbs {
+ startIndex, startDistance := nearestClimbPoint(climb, official.StartCoord)
+ endIndex, endDistance := nearestClimbPoint(climb, official.EndCoord)
+ if startDistance > policy.EndpointRadiusM || endDistance > policy.EndpointRadiusM {
+ continue
+ }
+ if startIndex >= endIndex {
+ continue
+ }
+ totalDistance := startDistance + endDistance
+ if totalDistance < bestDistance || (totalDistance == bestDistance && (!found || official.ID < best.ID)) {
+ best = official
+ bestDistance = totalDistance
+ found = true
+ }
+ }
+ return best, found
+}
+
+func nearestClimbPoint(climb ride.Climb, target geodist.Coord) (int, float64) {
+ bestIndex := climb.StartIndex()
+ bestDistance := math.Inf(1)
+ for index := climb.StartIndex(); index <= climb.EndIndex(); index++ {
+ distance := distanceM(climb.PointCoord(index), target)
+ if distance < bestDistance {
+ bestIndex = index
+ bestDistance = distance
+ }
+ }
+ return bestIndex, bestDistance
+}
+
+func distanceM(a, b geodist.Coord) float64 {
+ _, distanceKm := geodist.HaversineDistance(a, b)
+ return distanceKm * 1000
+}
diff --git a/official_climb/official_climb_test.go b/official_climb/official_climb_test.go
new file mode 100644
index 0000000..553c482
--- /dev/null
+++ b/official_climb/official_climb_test.go
@@ -0,0 +1,187 @@
+package official_climb_test
+
+import (
+ "database/sql"
+ "testing"
+ "time"
+
+ "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/official_climb"
+ "github.com/martinlehoux/biking_home/ride"
+ _ "github.com/mattn/go-sqlite3"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ db, err := sql.Open("sqlite3", ":memory:")
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, db.Close()) })
+ _, err = db.Exec(`
+ CREATE TABLE official_climbs (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ start_latitude REAL NOT NULL,
+ start_longitude REAL NOT NULL,
+ end_latitude REAL NOT NULL,
+ end_longitude REAL NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ )
+ `)
+ require.NoError(t, err)
+ return db
+}
+
+func TestCreateListAndGetOfficialClimb(t *testing.T) {
+ db := newTestDB(t)
+ created, err := official_climb.Create(db, official_climb.OfficialClimb{
+ Name: "Col de Test",
+ StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1},
+ EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2},
+ })
+ require.NoError(t, err)
+ assert.Positive(t, created.ID)
+ assert.Equal(t, "Col de Test", created.Name)
+ assert.Equal(t, geodist.Coord{Lat: 43.1, Lon: 5.1}, created.StartCoord)
+
+ listed, err := official_climb.List(db)
+ require.NoError(t, err)
+ require.Len(t, listed, 1)
+ assert.Equal(t, created.ID, listed[0].ID)
+
+ got, found, err := official_climb.GetByID(db, created.ID)
+ require.NoError(t, err)
+ require.True(t, found)
+ assert.Equal(t, created.EndCoord, got.EndCoord)
+ assert.False(t, got.CreatedAt.IsZero())
+ assert.False(t, got.UpdatedAt.IsZero())
+}
+
+func TestCreateRejectsInvalidOfficialClimb(t *testing.T) {
+ db := newTestDB(t)
+ _, err := official_climb.Create(db, official_climb.OfficialClimb{
+ Name: " ",
+ StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1},
+ EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2},
+ })
+ assert.EqualError(t, err, "official climb name is required")
+}
+
+func TestMatchClimbUsesOrderedCoordinates(t *testing.T) {
+ climb := testClimb(
+ geodist.Coord{Lat: 43.1002, Lon: 5.1002},
+ geodist.Coord{Lat: 43.2002, Lon: 5.2002},
+ )
+ match, found := official_climb.MatchClimb(climb, []official_climb.OfficialClimb{
+ {Name: "Same direction", StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1}, EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2}},
+ {Name: "Reverse direction", StartCoord: geodist.Coord{Lat: 43.2, Lon: 5.2}, EndCoord: geodist.Coord{Lat: 43.1, Lon: 5.1}},
+ }, official_climb.DefaultMatchPolicy())
+
+ require.True(t, found)
+ assert.Equal(t, "Same direction", match.Name)
+}
+
+func TestMatchClimbRejectsEndpointOutsideRadius(t *testing.T) {
+ climb := testClimb(
+ geodist.Coord{Lat: 43.1, Lon: 5.1},
+ geodist.Coord{Lat: 43.3, Lon: 5.3},
+ )
+ _, found := official_climb.MatchClimb(climb, []official_climb.OfficialClimb{{
+ Name: "Partial match",
+ StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1},
+ EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2},
+ }}, official_climb.DefaultMatchPolicy())
+ assert.False(t, found)
+}
+
+func TestMatchClimbFindsOfficialEndpointsInsideDetectedClimb(t *testing.T) {
+ parsed := ride.FromColumns(
+ []float64{0, 1000, 2000, 3000},
+ []float64{100, 300, 500, 100},
+ []geodist.Coord{
+ {Lat: 43.1, Lon: 5.1},
+ {Lat: 43.101, Lon: 5.101},
+ {Lat: 43.102, Lon: 5.102},
+ {Lat: 43.103, Lon: 5.103},
+ },
+ []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)},
+ )
+ climb := parsed.ClimbFromIndexes(0, 3)
+ matched, found := official_climb.MatchClimb(climb, []official_climb.OfficialClimb{{
+ Name: "Corrected boundaries",
+ StartCoord: parsed.Coord(1),
+ EndCoord: parsed.Coord(2),
+ }}, official_climb.DefaultMatchPolicy())
+
+ require.True(t, found)
+ assert.Equal(t, "Corrected boundaries", matched.Name)
+}
+
+func TestMatchClimbMatchesSameOfficialClimbAcrossRides(t *testing.T) {
+ official := official_climb.OfficialClimb{
+ Name: "Shared official climb",
+ StartCoord: geodist.Coord{Lat: 43.100, Lon: 5.100},
+ EndCoord: geodist.Coord{Lat: 43.200, Lon: 5.200},
+ }
+ firstRide := ride.FromColumns(
+ []float64{0, 1000, 2000, 3000},
+ []float64{100, 300, 500, 100},
+ []geodist.Coord{
+ {Lat: 43.090, Lon: 5.090},
+ official.StartCoord,
+ official.EndCoord,
+ {Lat: 43.210, Lon: 5.210},
+ },
+ []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)},
+ )
+ secondRide := ride.FromColumns(
+ []float64{0, 750, 1500, 2250, 3000},
+ []float64{100, 180, 300, 500, 100},
+ []geodist.Coord{
+ {Lat: 43.080, Lon: 5.080},
+ {Lat: 43.095, Lon: 5.095},
+ official.StartCoord,
+ official.EndCoord,
+ {Lat: 43.205, Lon: 5.205},
+ },
+ []time.Time{time.Unix(0, 0), time.Unix(45, 0), time.Unix(90, 0), time.Unix(135, 0), time.Unix(180, 0)},
+ )
+
+ firstMatch, firstFound := official_climb.MatchClimb(firstRide.ClimbFromIndexes(0, 3), []official_climb.OfficialClimb{official}, official_climb.DefaultMatchPolicy())
+ secondMatch, secondFound := official_climb.MatchClimb(secondRide.ClimbFromIndexes(1, 4), []official_climb.OfficialClimb{official}, official_climb.DefaultMatchPolicy())
+
+ require.True(t, firstFound)
+ require.True(t, secondFound)
+ assert.Equal(t, official.Name, firstMatch.Name)
+ assert.Equal(t, official.Name, secondMatch.Name)
+}
+
+func TestMatchClimbChoosesNearestCandidate(t *testing.T) {
+ climb := testClimb(
+ geodist.Coord{Lat: 43.1, Lon: 5.1},
+ geodist.Coord{Lat: 43.2, Lon: 5.2},
+ )
+ match, found := official_climb.MatchClimb(climb, []official_climb.OfficialClimb{
+ {ID: 2, Name: "Farther", StartCoord: geodist.Coord{Lat: 43.1005, Lon: 5.1005}, EndCoord: geodist.Coord{Lat: 43.2005, Lon: 5.2005}},
+ {ID: 1, Name: "Nearest", StartCoord: geodist.Coord{Lat: 43.1001, Lon: 5.1001}, EndCoord: geodist.Coord{Lat: 43.2001, Lon: 5.2001}},
+ }, official_climb.DefaultMatchPolicy())
+
+ require.True(t, found)
+ assert.Equal(t, "Nearest", match.Name)
+}
+
+func TestMatchPolicyRejectsInvalidRadius(t *testing.T) {
+ assert.EqualError(t, (official_climb.MatchPolicy{}).Validate(), "official climb endpoint radius must be greater than zero")
+}
+
+func testClimb(start, end geodist.Coord) ride.Climb {
+ parsed := ride.FromColumns(
+ []float64{0, 1000, 2000},
+ []float64{100, 150, 200},
+ []geodist.Coord{{}, start, end},
+ []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0)},
+ )
+ return parsed.ClimbFromDist(1000, 2000)
+}
diff --git a/ride/climb.go b/ride/climb.go
index 9f38038..788cd61 100644
--- a/ride/climb.go
+++ b/ride/climb.go
@@ -40,6 +40,19 @@ func (climb Climb) EndDistanceM() float64 {
return climb.ride.DistanceM(climb.rideEnd)
}
+func (climb Climb) StartCoord() geodist.Coord {
+ return climb.ride.Coord(climb.rideStart)
+}
+
+func (climb Climb) EndCoord() geodist.Coord {
+ return climb.ride.Coord(climb.rideEnd)
+}
+
+func (climb Climb) PointCoord(index int) geodist.Coord {
+ kcore.Assert(index >= climb.rideStart && index <= climb.rideEnd, "point is outside climb")
+ return climb.ride.Coord(index)
+}
+
// TopIndex returns the highest point inside the climb, used as the crest anchor.
func (climb Climb) TopIndex() int {
top := climb.rideStart
diff --git a/ride/ride.go b/ride/ride.go
index 5471535..3458483 100644
--- a/ride/ride.go
+++ b/ride/ride.go
@@ -299,3 +299,8 @@ func (r *Ride) ClimbFromDist(startDist, endDist float64) Climb {
}
return Climb{ride: *r, rideStart: start, rideEnd: end}
}
+
+func (r *Ride) ClimbFromIndexes(startIndex, endIndex int) Climb {
+ kcore.Assert(startIndex >= 0 && endIndex < r.Len() && startIndex < endIndex, "invalid climb indexes")
+ return Climb{ride: *r, rideStart: startIndex, rideEnd: endIndex}
+}
diff --git a/web/server.go b/web/server.go
index 58f45a3..5d5e239 100644
--- a/web/server.go
+++ b/web/server.go
@@ -19,6 +19,7 @@ import (
"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"
@@ -52,6 +53,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("POST /rides/{id}/official-climbs", s.handleCreateOfficialClimb)
mux.HandleFunc("GET /sync", s.handleSyncForm)
mux.HandleFunc("POST /sync", s.handleSync)
mux.HandleFunc("GET /strava/login", s.handleStravaLogin)
@@ -93,7 +95,7 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) {
}
if item.GPXPath == "" {
slog.Info("Loaded ride detail without route", "ride_id", id)
- kcore.RenderPage(r.Context(), RideDetailPage(RideDetailView{RideView: buildRideView(item)}), w)
+ kcore.RenderPage(r.Context(), RideDetailPage(RideDetailView{RideView: buildRideView(item), Notice: rideDetailNotice(r)}), w)
return
}
parsed, err := ride.ParseFile(ride.GPXRideParser{}, item.GPXPath)
@@ -102,6 +104,7 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) {
kcore.RenderPage(r.Context(), RideDetailPage(RideDetailView{
RideView: buildRideView(item),
RouteError: "The recorded route is unavailable.",
+ Notice: rideDetailNotice(r),
}), w)
return
}
@@ -109,8 +112,87 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) {
if err != nil {
slog.Warn("Failed to load 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)
- kcore.RenderPage(r.Context(), RideDetailPage(buildRideDetailView(item, parsed, passes)), w)
+ 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) {
@@ -526,6 +608,13 @@ func syncNotice(r *http.Request) string {
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"
diff --git a/web/server_test.go b/web/server_test.go
index 23270ed..e8c3012 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -17,6 +17,7 @@ import (
"github.com/jftuga/geodist"
"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"
@@ -63,6 +64,19 @@ func newWebTestServer(t *testing.T) (*Server, *sql.DB) {
)
`)
require.NoError(t, err)
+ _, err = db.Exec(`
+ create table official_climbs (
+ id integer primary key,
+ name text not null,
+ start_latitude real not null,
+ start_longitude real not null,
+ end_latitude real not null,
+ end_longitude real not null,
+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ )
+ `)
+ require.NoError(t, err)
configPath := filepath.Join(t.TempDir(), "config.yaml")
appConfig := config.Default()
appConfig.Strava.ClientID = "123"
@@ -103,6 +117,7 @@ func (c fakeSyncClient) Get(id int64) (strava.Activity, []byte, error) {
const emptyTrackGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg></trkseg></trk></gpx>`
const validTrackGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg><trkpt lat="43.0" lon="5.0"><ele>100</ele></trkpt><trkpt lat="43.001" lon="5.001"><ele>200</ele></trkpt></trkseg></trk></gpx>`
+const officialClimbTrackGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg><trkpt lat="43.0" lon="5.0"><ele>100</ele></trkpt><trkpt lat="43.005" lon="5.005"><ele>300</ele></trkpt><trkpt lat="43.01" lon="5.01"><ele>100</ele></trkpt></trkseg></trk></gpx>`
func TestHandlerRendersRidesPage(t *testing.T) {
server, db := newWebTestServer(t)
@@ -157,10 +172,15 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
body := response.Body.String()
assert.Equal(t, http.StatusOK, response.Code)
assert.Contains(t, body, "Detailed Ride")
+ assert.Contains(t, body, `<h1 class="ride-detail-title">Detailed Ride</h1>`)
+ assert.NotContains(t, body, `<p class="eyebrow">Ride detail</p>`)
assert.Contains(t, body, `id="ride-route"`)
assert.Contains(t, body, `id="ride-profile"`)
assert.Contains(t, body, `id="ride-profile-chart"`)
assert.Contains(t, body, `id="ride-map"`)
+ assert.Contains(t, body, `class="ride-detail-grid"`)
+ assert.Contains(t, body, `class="ride-detail-sidebar"`)
+ assert.Contains(t, body, `class="ride-detail-main"`)
assert.Contains(t, body, `<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" defer></script>`)
assert.Contains(t, body, `<script src="/static/ride-detail.js" defer></script>`)
assert.NotContains(t, body, "pointermove")
@@ -175,6 +195,11 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
assert.Contains(t, script, "pointermove")
assert.Contains(t, script, "circleMarker")
assert.Contains(t, script, "crossing.passElevationM")
+ assert.Contains(t, script, "climbRoute")
+ assert.Contains(t, script, "L.polyline")
+ assert.Contains(t, script, "zoomToClimb")
+ assert.Contains(t, script, "Cotacol")
+ assert.Contains(t, script, "cotacolForClimb")
assert.Contains(t, script, "const labelY = plot.top - 6")
assert.Contains(t, body, "leaflet@1.9.4/dist/leaflet.js")
assert.Contains(t, script, "tile.openstreetmap.org/{z}/{x}/{y}.png")
@@ -196,7 +221,7 @@ func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) {
Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43},
}}
- profile := buildRideProfile(parsed, passes)
+ profile := buildRideProfile(parsed, passes, nil, official_climb.DefaultMatchPolicy())
require.Len(t, profile.Points, 3)
assert.Equal(t, 1.0, profile.Points[1].DistanceKm)
@@ -209,6 +234,97 @@ func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) {
assert.Equal(t, 1.0, profile.Crossings[0].DistanceKm)
}
+func TestBuildRideProfileIncludesOfficialClimbMatch(t *testing.T) {
+ parsed := ride.FromColumns(
+ []float64{0, 1000, 2000},
+ []float64{300, 447, 380},
+ []geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.63, Lon: 5.44}},
+ make([]time.Time, 3),
+ )
+ profile := buildRideProfile(parsed, nil, []official_climb.OfficialClimb{{
+ ID: 42,
+ Name: "Col de Test",
+ StartCoord: parsed.Coord(0),
+ EndCoord: parsed.Coord(1),
+ }}, official_climb.DefaultMatchPolicy())
+
+ require.Len(t, profile.Climbs, 1)
+ assert.Equal(t, int64(42), profile.Climbs[0].OfficialClimbID)
+ assert.Equal(t, "Col de Test", profile.Climbs[0].OfficialName)
+ assert.Equal(t, "Col de Test", profile.Climbs[0].Name)
+}
+
+func TestBuildRideProfileUsesOfficialClimbBoundaries(t *testing.T) {
+ parsed := ride.FromColumns(
+ []float64{0, 1000, 2000},
+ []float64{300, 447, 380},
+ []geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.6205, Lon: 5.4305}},
+ make([]time.Time, 3),
+ )
+ profile := buildRideProfile(parsed, nil, []official_climb.OfficialClimb{{
+ ID: 42,
+ Name: "Col de Test",
+ StartCoord: parsed.Coord(0),
+ EndCoord: parsed.Coord(2),
+ }}, official_climb.DefaultMatchPolicy())
+
+ require.Len(t, profile.Climbs, 1)
+ assert.Equal(t, 0, profile.Climbs[0].StartIndex)
+ assert.Equal(t, 2, profile.Climbs[0].EndIndex)
+ assert.Equal(t, 2.0, profile.Climbs[0].EndKm)
+ assert.Equal(t, 2.0, profile.Climbs[0].DistanceKm)
+ assert.Equal(t, 4.0, profile.Climbs[0].SlopePercent)
+ assert.Greater(t, profile.Climbs[0].Cotacol, 0.0)
+}
+
+func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) {
+ server, db := newWebTestServer(t)
+ routePath := filepath.Join(t.TempDir(), "official-climb.gpx")
+ require.NoError(t, os.WriteFile(routePath, []byte(officialClimbTrackGPX), 0o600))
+ require.NoError(t, rides.Save(db, rides.Ride{
+ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000",
+ GPXPath: routePath,
+ Name: "Climb Candidate",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
+ DistanceM: 2_000,
+ }))
+ item, found, err := rides.GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000")
+ require.NoError(t, err)
+ require.True(t, found)
+
+ getRequest := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d", item.ID), nil)
+ getResponse := httptest.NewRecorder()
+ server.Handler().ServeHTTP(getResponse, getRequest)
+ assert.Equal(t, http.StatusOK, getResponse.Code)
+ assert.Contains(t, getResponse.Body.String(), "No official climb matched yet.")
+ assert.Contains(t, getResponse.Body.String(), "Select on profile")
+ assert.Contains(t, getResponse.Body.String(), "Select on map")
+ assert.Contains(t, getResponse.Body.String(), "Climb 1")
+ assert.Contains(t, getResponse.Body.String(), "data-climb-next")
+
+ form := url.Values{"name": {"Col de Test"}, "start_index": {"0"}, "end_index": {"1"}}
+ request := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/rides/%d/official-climbs", item.ID), strings.NewReader(form.Encode()))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+
+ assert.Equal(t, http.StatusSeeOther, response.Code)
+ assert.Equal(t, fmt.Sprintf("/rides/%d?official_climb=created", item.ID), response.Header().Get("Location"))
+ climbs, err := official_climb.List(db)
+ require.NoError(t, err)
+ require.Len(t, climbs, 1)
+ assert.Equal(t, "Col de Test", climbs[0].Name)
+ assert.Equal(t, geodist.Coord{Lat: 43.0, Lon: 5.0}, climbs[0].StartCoord)
+ assert.Equal(t, geodist.Coord{Lat: 43.005, Lon: 5.005}, climbs[0].EndCoord)
+
+ getRequest = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d?official_climb=created", item.ID), nil)
+ getResponse = httptest.NewRecorder()
+ server.Handler().ServeHTTP(getResponse, getRequest)
+ assert.Contains(t, getResponse.Body.String(), "Official climb saved and matched to this ride by coordinates.")
+ assert.Contains(t, getResponse.Body.String(), "Official climb matched: Col de Test")
+}
+
func TestHandlerRendersRideDetailWithoutRoute(t *testing.T) {
server, db := newWebTestServer(t)
item := rides.Ride{
diff --git a/web/static/ride-detail.js b/web/static/ride-detail.js
index 178b486..bd36da4 100644
--- a/web/static/ride-detail.js
+++ b/web/static/ride-detail.js
@@ -24,6 +24,8 @@
crossing: resolveColor("--color-crossing"),
crossingLabel: resolveColor("--color-crossing-label"),
hoverLine: resolveColor("--color-hover-line"),
+ climbRoute: resolveColor("--color-climb-route"),
+ climbFocusFill: resolveColor("--color-climb-focus-fill"),
};
colorProbe.remove();
const route = JSON.parse(routeElement.textContent);
@@ -44,6 +46,19 @@
const points = profile.points || [];
if (points.length === 0) return;
+ const climbItems = [...document.querySelectorAll("[data-climb-item]")];
+ const previousClimbButton = document.querySelector("[data-climb-previous]");
+ const nextClimbButton = document.querySelector("[data-climb-next]");
+ const climbPosition = document.querySelector("[data-climb-position]");
+ const climbLayers = [];
+ for (const climb of profile.climbs || []) {
+ if (!Number.isInteger(climb.startIndex) || !Number.isInteger(climb.endIndex) || climb.startIndex < 0 || climb.endIndex >= points.length || climb.startIndex >= climb.endIndex) {
+ climbLayers.push(null);
+ continue;
+ }
+ const coordinates = points.slice(climb.startIndex, climb.endIndex + 1).map((point) => [point.latitude, point.longitude]);
+ climbLayers.push(L.polyline(coordinates, { color: colors.climbRoute, weight: 7, opacity: 0.65, lineCap: "round", lineJoin: "round", interactive: false }).addTo(map));
+ }
const routeCursor = L.circleMarker([points[0].latitude, points[0].longitude], {
color: colors.forest,
fillColor: colors.accent,
@@ -56,7 +71,14 @@
const context = canvas.getContext("2d");
if (!context) return;
- const state = { hoveredIndex: -1, plot: null, width: 0, height: 0 };
+ const state = {
+ hoveredIndex: -1,
+ focusedClimbIndex: 0,
+ climbBounds: (profile.climbs || []).map((climb) => ({ startIndex: climb.startIndex, endIndex: climb.endIndex })),
+ plot: null,
+ width: 0,
+ height: 0,
+ };
const minDistance = points[0].distanceKm;
const maxDistance = points[points.length - 1].distanceKm;
let minElevation = points[0].elevationM;
@@ -92,6 +114,135 @@
const previous = points[low - 1];
return distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low;
};
+ const nearestMapPointIndex = (latitude, longitude) => {
+ let nearestIndex = 0;
+ let nearestDistance = Infinity;
+ for (let index = 0; index < points.length; index++) {
+ const point = points[index];
+ const distance = L.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]);
+ if (distance < nearestDistance) {
+ nearestIndex = index;
+ nearestDistance = distance;
+ }
+ }
+ return nearestIndex;
+ };
+ const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")];
+ let activeBoundary = null;
+ const boundaryLabel = (index) => {
+ const point = points[index];
+ return `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
+ };
+ const categoryForScore = (score) => {
+ if (score < 35) return "NO";
+ if (score < 80) return "Cat 4";
+ if (score < 180) return "Cat 3";
+ if (score < 250) return "Cat 2";
+ if (score < 600) return "Cat 1";
+ return "HC";
+ };
+ const elevationAtDistance = (index, distanceM) => {
+ if (index + 1 >= points.length) return points[index].elevationM;
+ const startDistanceM = points[index].distanceKm * 1000;
+ const endDistanceM = points[index + 1].distanceKm * 1000;
+ const fraction = (distanceM - startDistanceM) / (endDistanceM - startDistanceM);
+ return points[index].elevationM + fraction * (points[index + 1].elevationM - points[index].elevationM);
+ };
+ const cotacolForClimb = (startIndex, endIndex) => {
+ const startDistanceM = points[startIndex].distanceKm * 1000;
+ const lastDistanceM = points[endIndex].distanceKm * 1000;
+ if (lastDistanceM <= startDistanceM) return 0;
+ let score = 0;
+ let pointIndex = startIndex;
+ for (let segmentStartM = startDistanceM; segmentStartM < lastDistanceM; segmentStartM += 100) {
+ const segmentEndM = Math.min(segmentStartM + 100, lastDistanceM);
+ while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 <= segmentStartM) pointIndex++;
+ const startElevation = elevationAtDistance(pointIndex, segmentStartM);
+ while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 < segmentEndM) pointIndex++;
+ const endElevation = elevationAtDistance(pointIndex, segmentEndM);
+ const slope = (endElevation - startElevation) / (segmentEndM - segmentStartM);
+ if (slope > 0) score += (segmentEndM - segmentStartM) / 1000 * (slope * 100) ** 2;
+ }
+ return score;
+ };
+ const climbMetrics = (index) => {
+ const bounds = state.climbBounds[index];
+ if (!bounds || !Number.isInteger(bounds.startIndex) || !Number.isInteger(bounds.endIndex) || bounds.startIndex < 0 || bounds.endIndex >= points.length || bounds.startIndex >= bounds.endIndex) return null;
+ const start = points[bounds.startIndex];
+ const end = points[bounds.endIndex];
+ const distanceKm = end.distanceKm - start.distanceKm;
+ const elevationGain = end.elevationM - start.elevationM;
+ const slope = distanceKm > 0 ? elevationGain / (distanceKm * 10) : 0;
+ const score = distanceKm > 0 ? Math.abs(elevationGain) * elevationGain / (distanceKm * 1000) * 10 : 0;
+ return { start, end, distanceKm, elevationGain, slope, score, cotacol: cotacolForClimb(bounds.startIndex, bounds.endIndex), category: categoryForScore(score) };
+ };
+ const clearBoundarySelection = () => {
+ activeBoundary = null;
+ for (const form of boundaryForms) {
+ for (const button of form.querySelectorAll("[data-boundary-button]")) button.classList.remove("active");
+ }
+ };
+ const updateBoundaryPreview = (form) => {
+ const preview = form.querySelector("[data-boundary-preview]");
+ const item = form.closest("[data-climb-item]");
+ const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
+ const metrics = climbMetrics(climbIndex);
+ if (!metrics) {
+ preview.textContent = "Choose an end point after the start point.";
+ return;
+ }
+ const summary = item.querySelector("[data-climb-summary]");
+ const metricsOutput = item.querySelector("[data-climb-metrics]");
+ summary.textContent = `${formatDistance(metrics.start.distanceKm)}–${formatDistance(metrics.end.distanceKm)}`;
+ metricsOutput.textContent = `${metrics.category} · ${formatDistance(metrics.distanceKm)} at ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`;
+ preview.textContent = `Preview: ${formatDistance(metrics.distanceKm)} · ${metrics.elevationGain >= 0 ? "+" : ""}${formatElevation(metrics.elevationGain)} · ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`;
+ };
+ const updateClimbLayer = (index) => {
+ const layer = climbLayers[index];
+ const metrics = climbMetrics(index);
+ if (!layer) return;
+ if (!metrics) {
+ layer.setLatLngs([]);
+ return;
+ }
+ const bounds = state.climbBounds[index];
+ layer.setLatLngs(points.slice(bounds.startIndex, bounds.endIndex + 1).map((point) => [point.latitude, point.longitude]));
+ const active = index === state.focusedClimbIndex;
+ layer.setStyle({ weight: active ? 9 : 7, opacity: active ? 1 : 0.65 });
+ };
+ const chooseBoundary = (index) => {
+ if (!activeBoundary) return;
+ const { form, target } = activeBoundary;
+ const item = form.closest("[data-climb-item]");
+ const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
+ const input = form.querySelector(`[data-boundary-input="${target}"]`);
+ const output = form.querySelector(`[data-boundary-output="${target}"]`);
+ input.value = index;
+ output.textContent = boundaryLabel(index);
+ state.climbBounds[climbIndex][`${target}Index`] = index;
+ updateBoundaryPreview(form);
+ updateClimbLayer(climbIndex);
+ clearBoundarySelection();
+ showPoint(index);
+ };
+ for (const form of boundaryForms) {
+ const item = form.closest("[data-climb-item]");
+ const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
+ state.climbBounds[climbIndex] = {
+ startIndex: Number.parseInt(form.querySelector('[data-boundary-input="start"]').value, 10),
+ endIndex: Number.parseInt(form.querySelector('[data-boundary-input="end"]').value, 10),
+ };
+ updateBoundaryPreview(form);
+ for (const button of form.querySelectorAll("[data-boundary-button]")) {
+ button.addEventListener("click", () => {
+ clearBoundarySelection();
+ activeBoundary = { form, target: button.dataset.boundaryButton, source: button.dataset.boundarySource };
+ button.classList.add("active");
+ const surface = activeBoundary.source === "map" ? "map" : "profile";
+ hoverOutput.textContent = `Click the ${surface} to select the ${activeBoundary.target} point.`;
+ });
+ }
+ }
const draw = () => {
const rect = canvas.getBoundingClientRect();
@@ -125,12 +276,15 @@
context.fillText(formatElevation(elevation), plot.left - 8, y);
}
- for (const climb of profile.climbs || []) {
- const startX = clamp(xForDistance(climb.startKm), plot.left, plot.right);
- const endX = clamp(xForDistance(climb.endKm), plot.left, plot.right);
- context.fillStyle = colors.accentFill;
+ for (let climbIndex = 0; climbIndex < (profile.climbs || []).length; climbIndex++) {
+ const climb = profile.climbs[climbIndex];
+ const metrics = climbMetrics(climbIndex);
+ if (!metrics) continue;
+ const startX = clamp(xForDistance(metrics.start.distanceKm), plot.left, plot.right);
+ const endX = clamp(xForDistance(metrics.end.distanceKm), plot.left, plot.right);
+ context.fillStyle = climbIndex === state.focusedClimbIndex ? colors.climbFocusFill : colors.accentFill;
context.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top);
- const label = climb.name || `${climb.category} ${Math.round(climb.score)}`;
+ const label = climb.name || `${metrics.category} ${Math.round(metrics.score)}`;
context.fillStyle = colors.climbLabel;
context.textAlign = "center";
context.textBaseline = "top";
@@ -220,6 +374,33 @@
hoverOutput.textContent = `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
draw();
};
+ const zoomToClimb = (index) => {
+ const climbBounds = state.climbBounds[index];
+ if (!climbBounds || !Number.isInteger(climbBounds.startIndex) || !Number.isInteger(climbBounds.endIndex)) return;
+ const climbPoints = points.slice(climbBounds.startIndex, climbBounds.endIndex + 1);
+ const mapBounds = L.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude]));
+ if (mapBounds.isValid()) map.fitBounds(mapBounds, { padding: [32, 32], maxZoom: 15 });
+ };
+ const updateClimbFocus = (index, zoom) => {
+ if (climbItems.length === 0) return;
+ state.focusedClimbIndex = clamp(index, 0, climbItems.length - 1);
+ for (let itemIndex = 0; itemIndex < climbItems.length; itemIndex++) {
+ const active = itemIndex === state.focusedClimbIndex;
+ climbItems[itemIndex].hidden = !active;
+ climbItems[itemIndex].setAttribute("aria-hidden", String(!active));
+ if (climbLayers[itemIndex]) {
+ updateClimbLayer(itemIndex);
+ }
+ }
+ if (climbPosition) climbPosition.textContent = `Climb ${state.focusedClimbIndex + 1} of ${climbItems.length}`;
+ if (previousClimbButton) previousClimbButton.disabled = state.focusedClimbIndex === 0;
+ if (nextClimbButton) nextClimbButton.disabled = state.focusedClimbIndex === climbItems.length - 1;
+ clearBoundarySelection();
+ draw();
+ if (zoom) zoomToClimb(state.focusedClimbIndex);
+ };
+ if (previousClimbButton) previousClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbIndex - 1, true));
+ if (nextClimbButton) nextClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbIndex + 1, true));
canvas.addEventListener("pointermove", (event) => {
if (!state.plot) return;
const rect = canvas.getBoundingClientRect();
@@ -233,6 +414,18 @@
});
canvas.addEventListener("pointerleave", clearHover);
canvas.addEventListener("pointercancel", clearHover);
+ canvas.addEventListener("click", (event) => {
+ if (!activeBoundary || activeBoundary.source !== "profile" || !state.plot) return;
+ const rect = canvas.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ if (x < state.plot.left || x > state.plot.right) return;
+ const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance);
+ chooseBoundary(nearestPointIndex(distance));
+ });
+ map.on("click", (event) => {
+ if (!activeBoundary || activeBoundary.source !== "map") return;
+ chooseBoundary(nearestMapPointIndex(event.latlng.lat, event.latlng.lng));
+ });
canvas.addEventListener("keydown", (event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
@@ -241,5 +434,6 @@
showPoint(index);
});
window.addEventListener("resize", draw);
- draw();
+ if (climbItems.length > 0) updateClimbFocus(0, false);
+ else draw();
})();
diff --git a/web/static/style.css b/web/static/style.css
index b24fd21..8a35844 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -36,6 +36,8 @@
--color-crossing: oklch(from var(--palette-leaf) l c h / 60%);
--color-crossing-label: var(--color-notice-text);
--color-hover-line: oklch(from var(--palette-forest) l c h / 60%);
+ --color-climb-route: var(--palette-leaf);
+ --color-climb-focus-fill: oklch(from var(--palette-leaf) l c h / 18%);
}
* { box-sizing: border-box; }
@@ -48,6 +50,7 @@ a { color: var(--color-leaf); }
.container { width: min(70rem, calc(100% - 2rem)); margin: 0 auto; padding: 2.5rem 0 4rem; }
.eyebrow { margin: 0 0 .4rem; color: var(--color-leaf); font-size: .78rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(2rem, 4vw, 3.2rem); line-height: 1; }
+.ride-detail-title { margin: 0 0 .4rem; color: var(--color-leaf); font-size: .78rem; font-weight: 700; letter-spacing: .12em; line-height: normal; }
.lead { max-width: 42rem; color: var(--color-muted); }
.panel { margin-top: 2rem; padding: 1.25rem; border: 1px solid var(--color-border); border-radius: 1rem; background: var(--color-surface); box-shadow: 0 1rem 2.5rem var(--color-panel-shadow); }
.toolbar { display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
@@ -61,6 +64,9 @@ input { border: 1px solid var(--color-input-border); border-radius: .55rem; padd
progress { width: 100%; height: .8rem; accent-color: var(--color-accent); }
.notice { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: var(--color-notice-surface); color: var(--color-notice-text); }
.error { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: var(--color-error-surface); color: var(--color-error-text); }
+.ride-detail-grid { display: grid; grid-template-columns: minmax(16rem, .85fr) minmax(0, 1.75fr); gap: 1.25rem; margin-top: 2rem; align-items: start; }
+.ride-detail-sidebar, .ride-detail-main { display: grid; gap: 1.25rem; min-width: 0; }
+.ride-detail-grid .panel { margin-top: 0; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: .85rem .5rem; border-bottom: 1px solid var(--color-border-subtle); text-align: left; }
th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }
@@ -79,8 +85,28 @@ th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text-
.profile-chart canvas { display: block; width: 100%; height: 22rem; border-radius: .65rem; background: var(--color-plot-surface); outline: none; }
.profile-chart canvas:focus-visible { box-shadow: 0 0 0 .2rem var(--color-focus); }
.profile-hover { display: block; min-height: 1.4rem; margin: .7rem 0 0; color: var(--color-muted); font-size: .9rem; }
+.climb-list { display: grid; gap: 1rem; margin-top: 1.25rem; }
+.climb-item { padding-top: 1rem; border-top: 1px solid var(--color-border-subtle); }
+.climb-item-heading { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
+.climb-number { display: inline-block; margin-right: .45rem; color: var(--color-accent); font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }
+.climb-item-heading > span, .climb-status { color: var(--color-muted); font-size: .9rem; }
+.climb-metrics { margin: .35rem 0 0; color: var(--color-muted); font-size: .9rem; }
+.climb-status { margin: .35rem 0 0; }
+.climb-status.matched { color: var(--color-notice-text); }
+.official-climb-form { display: grid; gap: .8rem; margin-top: .8rem; padding: .8rem; border: 1px solid var(--color-border-subtle); border-radius: .65rem; background: var(--color-plot-surface); }
+.boundary-controls { display: flex; gap: .75rem; flex-wrap: wrap; }
+.boundary-controls > div { display: grid; gap: .35rem; }
+.boundary-controls > div > div { display: flex; gap: .35rem; flex-wrap: wrap; }
+.boundary-controls output { color: var(--color-muted); font-size: .85rem; }
+.boundary-controls .button.active { outline: .2rem solid var(--color-focus); }
+.boundary-preview { color: var(--color-notice-text); font-size: .9rem; }
+.climb-navigation { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-top: 1.25rem; }
+.climbs-panel > .climb-navigation { margin-top: 0; }
+.climb-navigation output { color: var(--color-muted); font-size: .9rem; font-weight: 700; }
+.climb-navigation .button:disabled { opacity: .45; cursor: default; }
dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 0; }
dt { color: var(--color-subtle); font-size: .78rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
dd { margin: .25rem 0 0; font-size: 1.2rem; font-weight: 700; }
+@media (max-width: 900px) { .ride-detail-grid { grid-template-columns: 1fr; } }
@media (max-width: 700px) { .container { width: min(100% - 1rem, 70rem); padding-top: 1.5rem; } .site-header { padding-inline: 1rem; } th:nth-child(n+4), td:nth-child(n+4) { display: none; } .panel { padding: .8rem; } }
@media (max-width: 500px) { dl { grid-template-columns: repeat(2, 1fr); } }
diff --git a/web/templates.templ b/web/templates.templ
index 515c482..9ebeedb 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -57,35 +57,91 @@ templ RideDetailPage(data RideDetailView) {
}
templ RideDetailContent(data RideDetailView) {
- <p class="eyebrow">Ride detail</p>
<div class="toolbar">
- <div><h1>{ data.Ride.Name }</h1><p class="lead">{ data.Ride.Type } · { formatRideDate(data.Ride.StartDate) }</p></div>
+ <div><h1 class="ride-detail-title">{ data.Ride.Name }</h1><p class="lead">{ data.Ride.Type } · { formatRideDate(data.Ride.StartDate) }</p></div>
<a class="button secondary" href="/">Back to rides</a>
</div>
- <section class="panel">
- <dl>
- <div><dt>Distance</dt><dd>{ formatDistance(data.Ride.DistanceM) }</dd></div>
- <div><dt>Moving time</dt><dd>{ formatDuration(data.Ride.MovingTimeS) }</dd></div>
- <div><dt>Elevation</dt><dd>{ formatElevation(data.Ride.TotalElevationGainM) }</dd></div>
- </dl>
- </section>
if data.RouteError != "" {
<div class="error">{ data.RouteError }</div>
}
- if data.HasRoute {
- <section class="panel profile-panel">
- <div class="profile-heading">
- <h2>Elevation profile</h2>
- <p>Hover or focus the profile to inspect the ride at that distance.</p>
- </div>
- <div class="profile-chart">
- <canvas id="ride-profile-chart" tabindex="0" role="img" aria-describedby="ride-profile-hover" aria-label="Interactive ride elevation profile"></canvas>
+ if data.Notice != "" {
+ <div class="notice">{ data.Notice }</div>
+ }
+ if data.ActionError != "" {
+ <div class="error">{ data.ActionError }</div>
+ }
+ <div class="ride-detail-grid">
+ <div class="ride-detail-sidebar">
+ <section class="panel summary-panel">
+ <dl>
+ <div><dt>Distance</dt><dd>{ formatDistance(data.Ride.DistanceM) }</dd></div>
+ <div><dt>Moving time</dt><dd>{ formatDuration(data.Ride.MovingTimeS) }</dd></div>
+ <div><dt>Elevation</dt><dd>{ formatElevation(data.Ride.TotalElevationGainM) }</dd></div>
+ </dl>
+ </section>
+ if data.HasRoute {
+ <section class="panel climbs-panel">
+ if len(data.Profile.Climbs) > 0 {
+ <nav class="climb-navigation" aria-label="Climb navigation">
+ <button class="button secondary" type="button" data-climb-previous aria-label="Previous climb">←</button>
+ <output data-climb-position>Climb 1 of { formatClimbNumber(len(data.Profile.Climbs) - 1) }</output>
+ <button class="button secondary" type="button" data-climb-next aria-label="Next climb">→</button>
+ </nav>
+ }
+ if len(data.Profile.Climbs) == 0 {
+ <p class="empty">No climbs detected in this ride.</p>
+ }
+ if len(data.Profile.Climbs) > 0 {
+ <div class="climb-list" data-climb-list>
+ for index, climb := range data.Profile.Climbs {
+ <article class="climb-item" data-climb-item data-climb-index={ formatRouteIndex(index) }>
+ <div class="climb-item-heading">
+ <strong><span class="climb-number">Climb { formatClimbNumber(index) }</span>{ climb.Name }</strong>
+ <span class="climb-range" data-climb-summary>{ formatProfileDistance(climb.StartKm) }–{ formatProfileDistance(climb.EndKm) }</span>
+ </div>
+ <p class="climb-metrics" data-climb-metrics>{ climb.Category } · { formatProfileDistance(climb.DistanceKm) } at { formatSlope(climb.SlopePercent) } · Cotacol { formatCotacol(climb.Cotacol) }</p>
+ if climb.OfficialClimbID > 0 {
+ <p class="climb-status matched">Official climb matched: { climb.OfficialName }</p>
+ } else {
+ <p class="climb-status">No official climb matched yet.</p>
+ <form class="official-climb-form" data-official-climb-form method="post" action={ templ.URL(officialClimbCreateURL(data.Ride.ID)) }>
+ <label>Name<input type="text" name="name" required placeholder="Official climb name"/></label>
+ <input type="hidden" name="start_index" value={ formatRouteIndex(climb.StartIndex) } data-boundary-input="start"/>
+ <input type="hidden" name="end_index" value={ formatRouteIndex(climb.EndIndex) } data-boundary-input="end"/>
+ <div class="boundary-controls">
+ <div><strong>Start</strong><div><button class="button secondary" type="button" data-boundary-button="start" data-boundary-source="profile">Select on profile</button><button class="button secondary" type="button" data-boundary-button="start" data-boundary-source="map">Select on map</button></div><output data-boundary-output="start">{ formatProfileDistance(climb.StartKm) }</output></div>
+ <div><strong>End</strong><div><button class="button secondary" type="button" data-boundary-button="end" data-boundary-source="profile">Select on profile</button><button class="button secondary" type="button" data-boundary-button="end" data-boundary-source="map">Select on map</button></div><output data-boundary-output="end">{ formatProfileDistance(climb.EndKm) }</output></div>
+ </div>
+ <output class="boundary-preview" data-boundary-preview>Preview the corrected climb boundaries.</output>
+ <button class="button" type="submit">Save official climb</button>
+ </form>
+ }
+ </article>
+ }
+ </div>
+ }
+ </section>
+ }
+ </div>
+ if data.HasRoute {
+ <div class="ride-detail-main">
+ <section class="panel profile-panel">
+ <div class="profile-heading">
+ <h2>Elevation profile</h2>
+ <p>Hover or focus the profile to inspect the ride at that distance.</p>
+ </div>
+ <div class="profile-chart">
+ <canvas id="ride-profile-chart" tabindex="0" role="img" aria-describedby="ride-profile-hover" aria-label="Interactive ride elevation profile"></canvas>
+ </div>
+ <output id="ride-profile-hover" class="profile-hover" aria-live="polite">Hover or focus the profile to inspect elevation.</output>
+ </section>
+ <section class="panel map-panel">
+ <div id="ride-map" class="ride-map" role="img" aria-label="Recorded ride route map"></div>
+ </section>
</div>
- <output id="ride-profile-hover" class="profile-hover" aria-live="polite">Hover or focus the profile to inspect elevation.</output>
- </section>
- <section class="panel map-panel">
- <div id="ride-map" class="ride-map" role="img" aria-label="Recorded ride route map"></div>
- </section>
+ }
+ </div>
+ if data.HasRoute {
@templ.JSONScript("ride-route", data.Route)
@templ.JSONScript("ride-profile", data.Profile)
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" defer></script>
diff --git a/web/templates_templ.go b/web/templates_templ.go
index 021de0b..e63eef0 100644
--- a/web/templates_templ.go
+++ b/web/templates_templ.go
@@ -380,14 +380,14 @@ func RideDetailContent(data RideDetailView) templ.Component {
templ_7745c5c3_Var21 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"eyebrow\">Ride detail</p><div class=\"toolbar\"><div><h1>")
+ _, 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_Var22 string
templ_7745c5c3_Var22, 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: 62, Col: 27}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 61, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -400,7 +400,7 @@ func RideDetailContent(data RideDetailView) templ.Component {
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, 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: 62, Col: 66}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 61, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
@@ -413,22 +413,83 @@ func RideDetailContent(data RideDetailView) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, 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: 62, Col: 109}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 61, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p></div><a class=\"button secondary\" href=\"/\">Back to rides</a></div><section class=\"panel\"><dl><div><dt>Distance</dt><dd>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p></div><a class=\"button secondary\" href=\"/\">Back to rides</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var25 string
- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(formatDistance(data.Ride.DistanceM))
+ if data.RouteError != "" {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"error\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var25 string
+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(data.RouteError)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 65, Col: 38}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.Notice != "" {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"notice\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var26 string
+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 68, Col: 35}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.ActionError != "" {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"error\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var27 string
+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(data.ActionError)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 71, Col: 39}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"ride-detail-grid\"><div class=\"ride-detail-sidebar\"><section class=\"panel summary-panel\"><dl><div><dt>Distance</dt><dd>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var28 string
+ templ_7745c5c3_Var28, 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: 67, Col: 66}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 68}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -436,12 +497,12 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var26 string
- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(formatDuration(data.Ride.MovingTimeS))
+ var templ_7745c5c3_Var29 string
+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(formatDuration(data.Ride.MovingTimeS))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 68, Col: 71}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 78, Col: 73}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -449,12 +510,12 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var27 string
- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(formatElevation(data.Ride.TotalElevationGainM))
+ var templ_7745c5c3_Var30 string
+ templ_7745c5c3_Var30, 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: 69, Col: 78}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 79, Col: 80}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -462,30 +523,278 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- if data.RouteError != "" {
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"error\">")
+ if data.HasRoute {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<section class=\"panel climbs-panel\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var28 string
- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(data.RouteError)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 73, Col: 38}
+ if len(data.Profile.Climbs) > 0 {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<nav class=\"climb-navigation\" aria-label=\"Climb navigation\"><button class=\"button secondary\" type=\"button\" data-climb-previous aria-label=\"Previous climb\">←</button> <output data-climb-position>Climb 1 of ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var31 string
+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbNumber(len(data.Profile.Climbs) - 1))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 95}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</output> <button class=\"button secondary\" type=\"button\" data-climb-next aria-label=\"Next climb\">→</button></nav>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
+ if len(data.Profile.Climbs) == 0 {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"empty\">No climbs detected in this ride.</p>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+ if len(data.Profile.Climbs) > 0 {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"climb-list\" data-climb-list>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for index, climb := range data.Profile.Climbs {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<article class=\"climb-item\" data-climb-item data-climb-index=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var32 string
+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(formatRouteIndex(index))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 97, Col: 94}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><div class=\"climb-item-heading\"><strong><span class=\"climb-number\">Climb ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var33 string
+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(formatClimbNumber(index))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 99, Col: 76}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var34 string
+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(climb.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 99, Col: 97}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</strong> <span class=\"climb-range\" data-climb-summary>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var35 string
+ templ_7745c5c3_Var35, 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: 100, Col: 92}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
+ 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
+ }
+ var templ_7745c5c3_Var36 string
+ templ_7745c5c3_Var36, 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: 100, Col: 133}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span></div><p class=\"climb-metrics\" data-climb-metrics>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var37 string
+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(climb.Category)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 102, Col: 68}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
+ 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
+ }
+ var templ_7745c5c3_Var38 string
+ templ_7745c5c3_Var38, 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: 102, Col: 115}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" at ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var39 string
+ templ_7745c5c3_Var39, 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: 102, Col: 154}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" · Cotacol ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var40 string
+ templ_7745c5c3_Var40, 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: 102, Col: 198}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if climb.OfficialClimbID > 0 {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"climb-status matched\">Official climb matched: ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var41 string
+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(climb.OfficialName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 104, Col: 86}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"climb-status\">No official climb matched yet.</p><form class=\"official-climb-form\" data-official-climb-form method=\"post\" action=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var42 templ.SafeURL = templ.URL(officialClimbCreateURL(data.Ride.ID))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var42)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><label>Name<input type=\"text\" name=\"name\" required placeholder=\"Official climb name\"></label> <input type=\"hidden\" name=\"start_index\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var43 string
+ templ_7745c5c3_Var43, 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: 109, Col: 93}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" data-boundary-input=\"start\"> <input type=\"hidden\" name=\"end_index\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var44 string
+ templ_7745c5c3_Var44, 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: 110, Col: 89}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" data-boundary-input=\"end\"><div class=\"boundary-controls\"><div><strong>Start</strong><div><button class=\"button secondary\" type=\"button\" data-boundary-button=\"start\" data-boundary-source=\"profile\">Select on profile</button><button class=\"button secondary\" type=\"button\" data-boundary-button=\"start\" data-boundary-source=\"map\">Select on map</button></div><output data-boundary-output=\"start\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var45 string
+ templ_7745c5c3_Var45, 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: 112, Col: 383}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</output></div><div><strong>End</strong><div><button class=\"button secondary\" type=\"button\" data-boundary-button=\"end\" data-boundary-source=\"profile\">Select on profile</button><button class=\"button secondary\" type=\"button\" data-boundary-button=\"end\" data-boundary-source=\"map\">Select on map</button></div><output data-boundary-output=\"end\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var46 string
+ templ_7745c5c3_Var46, 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: 113, Col: 373}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</output></div></div><output class=\"boundary-preview\" data-boundary-preview>Preview the corrected climb boundaries.</output> <button class=\"button\" type=\"submit\">Save official climb</button></form>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</article>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</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_7745c5c3_Buffer.WriteString("</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
if data.HasRoute {
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<section class=\"panel profile-panel\"><div class=\"profile-heading\"><h2>Elevation profile</h2><p>Hover or focus the profile to inspect the ride at that distance.</p></div><div class=\"profile-chart\"><canvas id=\"ride-profile-chart\" tabindex=\"0\" role=\"img\" aria-describedby=\"ride-profile-hover\" aria-label=\"Interactive ride elevation profile\"></canvas></div><output id=\"ride-profile-hover\" class=\"profile-hover\" aria-live=\"polite\">Hover or focus the profile to inspect elevation.</output></section><section class=\"panel map-panel\"><div id=\"ride-map\" class=\"ride-map\" role=\"img\" aria-label=\"Recorded ride route map\"></div></section>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"ride-detail-main\"><section class=\"panel profile-panel\"><div class=\"profile-heading\"><h2>Elevation profile</h2><p>Hover or focus the profile to inspect the ride at that distance.</p></div><div class=\"profile-chart\"><canvas id=\"ride-profile-chart\" tabindex=\"0\" role=\"img\" aria-describedby=\"ride-profile-hover\" aria-label=\"Interactive ride elevation profile\"></canvas></div><output id=\"ride-profile-hover\" class=\"profile-hover\" aria-live=\"polite\">Hover or focus the profile to inspect elevation.</output></section><section class=\"panel map-panel\"><div id=\"ride-map\" class=\"ride-map\" role=\"img\" aria-label=\"Recorded ride route map\"></div></section></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.HasRoute {
templ_7745c5c3_Err = templ.JSONScript("ride-route", data.Route).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -523,9 +832,9 @@ func SyncPage(data SyncPageData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var29 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var29 == nil {
- templ_7745c5c3_Var29 = templ.NopComponent
+ templ_7745c5c3_Var47 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var47 == nil {
+ templ_7745c5c3_Var47 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout("Sync Strava", SyncContent(data)).Render(ctx, templ_7745c5c3_Buffer)
@@ -552,9 +861,9 @@ func SyncContent(data SyncPageData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var30 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var30 == nil {
- templ_7745c5c3_Var30 = templ.NopComponent
+ templ_7745c5c3_Var48 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var48 == nil {
+ templ_7745c5c3_Var48 = 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>")
@@ -566,12 +875,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var31 string
- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice)
+ var templ_7745c5c3_Var49 string
+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 105, Col: 52}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 161, Col: 52}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -590,12 +899,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var32 string
- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error)
+ var templ_7745c5c3_Var50 string
+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 110, Col: 49}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 166, Col: 49}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -618,8 +927,8 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var33 templ.SafeURL = templ.URL("/strava/login?return_to=/sync")
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var33)))
+ var templ_7745c5c3_Var51 templ.SafeURL = templ.URL("/strava/login?return_to=/sync")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var51)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -632,12 +941,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var34 string
- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(data.From)
+ var templ_7745c5c3_Var52 string
+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(data.From)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 121, Col: 64}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 177, Col: 64}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -645,12 +954,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var35 string
- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(data.To)
+ var templ_7745c5c3_Var53 string
+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(data.To)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 122, Col: 58}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 178, Col: 58}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/web/views.go b/web/views.go
index 2ce9eb2..310c39c 100644
--- a/web/views.go
+++ b/web/views.go
@@ -2,10 +2,13 @@ package web
import (
"fmt"
+ "math"
"net/url"
"time"
+ "github.com/jftuga/geodist"
"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"
)
@@ -18,10 +21,12 @@ type RideView struct {
type RideDetailView struct {
RideView
- Route GeoJSONFeatureCollection
- HasRoute bool
- Profile RideProfile
- RouteError string
+ Route GeoJSONFeatureCollection
+ HasRoute bool
+ Profile RideProfile
+ RouteError string
+ ActionError string
+ Notice string
}
type RideProfile struct {
@@ -38,13 +43,20 @@ type RideProfilePoint struct {
}
type RideProfileClimb struct {
- StartKm float64 `json:"startKm"`
- EndKm float64 `json:"endKm"`
- TopKm float64 `json:"topKm"`
- TopElevationM float64 `json:"topElevationM"`
- Name string `json:"name"`
- Score float64 `json:"score"`
- Category string `json:"category"`
+ StartKm float64 `json:"startKm"`
+ EndKm float64 `json:"endKm"`
+ TopKm float64 `json:"topKm"`
+ TopElevationM float64 `json:"topElevationM"`
+ Name string `json:"name"`
+ Score float64 `json:"score"`
+ Category string `json:"category"`
+ DistanceKm float64 `json:"distanceKm"`
+ SlopePercent float64 `json:"slopePercent"`
+ Cotacol float64 `json:"cotacol"`
+ OfficialClimbID int64 `json:"officialClimbId,omitempty"`
+ OfficialName string `json:"officialName,omitempty"`
+ StartIndex int `json:"startIndex"`
+ EndIndex int `json:"endIndex"`
}
type RideProfileCrossing struct {
@@ -184,13 +196,17 @@ func rideDetailURL(id int64) string {
return fmt.Sprintf("/rides/%d", id)
}
-func buildRideDetailView(item rides.Ride, parsed ride.Ride, passes []mountain_pass.MountainPass) RideDetailView {
+func officialClimbCreateURL(id int64) string {
+ return fmt.Sprintf("/rides/%d/official-climbs", id)
+}
+
+func buildRideDetailView(item rides.Ride, parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideDetailView {
coordinates := make([][]float64, parsed.Len())
for i := 0; i < parsed.Len(); i++ {
coordinate := parsed.Coord(i)
coordinates[i] = []float64{coordinate.Lon, coordinate.Lat}
}
- profile := buildRideProfile(parsed, passes)
+ profile := buildRideProfile(parsed, passes, officialClimbs, matchPolicy)
return RideDetailView{
RideView: buildRideView(item),
Route: GeoJSONFeatureCollection{
@@ -208,7 +224,7 @@ func buildRideDetailView(item rides.Ride, parsed ride.Ride, passes []mountain_pa
}
}
-func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass) RideProfile {
+func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideProfile {
profile := RideProfile{
Points: make([]RideProfilePoint, parsed.Len()),
Climbs: make([]RideProfileClimb, 0),
@@ -225,17 +241,37 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass) Rid
}
climbs := parsed.AllClimbs()
for i := range climbs {
- if matched, ok := mountain_pass.MatchClimb(climbs[i], passes, 300, 50); ok {
- climbs[i].Name = matched.Name
+ displayClimb := climbs[i]
+ if matchedPass, ok := mountain_pass.MatchClimb(displayClimb, passes, 300, 50); ok {
+ displayClimb.Name = matchedPass.Name
+ }
+ matchedOfficial, officialFound := official_climb.MatchClimb(climbs[i], officialClimbs, matchPolicy)
+ if officialFound {
+ startIndex := nearestCoordIndex(parsed, matchedOfficial.StartCoord)
+ endIndex := nearestCoordIndex(parsed, matchedOfficial.EndCoord)
+ if startIndex < endIndex {
+ displayClimb = parsed.ClimbFromIndexes(startIndex, endIndex)
+ displayClimb.Name = matchedOfficial.Name
+ } else {
+ officialFound = false
+ matchedOfficial = official_climb.OfficialClimb{}
+ }
}
profile.Climbs = append(profile.Climbs, RideProfileClimb{
- StartKm: climbs[i].StartDistanceM() / 1000,
- EndKm: climbs[i].EndDistanceM() / 1000,
- TopKm: climbs[i].TopDistanceM() / 1000,
- TopElevationM: climbs[i].TopElevationM(),
- Name: climbs[i].Name,
- Score: climbs[i].Score(),
- Category: ride.Category(climbs[i].Score()),
+ StartKm: displayClimb.StartDistanceM() / 1000,
+ EndKm: displayClimb.EndDistanceM() / 1000,
+ TopKm: displayClimb.TopDistanceM() / 1000,
+ TopElevationM: displayClimb.TopElevationM(),
+ Name: displayClimb.Name,
+ Score: displayClimb.Score(),
+ Category: ride.Category(displayClimb.Score()),
+ DistanceKm: (displayClimb.EndDistanceM() - displayClimb.StartDistanceM()) / 1000,
+ SlopePercent: ride.Slope(parsed, displayClimb.StartIndex(), displayClimb.EndIndex()) * 100,
+ Cotacol: displayClimb.DifficultyScore(),
+ OfficialClimbID: officialClimbID(matchedOfficial, officialFound),
+ OfficialName: officialClimbName(matchedOfficial, officialFound),
+ StartIndex: displayClimb.StartIndex(),
+ EndIndex: displayClimb.EndIndex(),
})
}
for _, crossing := range mountain_pass.DetectCrossings(parsed, passes, 100, 25) {
@@ -251,6 +287,33 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass) Rid
return profile
}
+func nearestCoordIndex(parsed ride.Ride, target geodist.Coord) int {
+ bestIndex := 0
+ bestDistance := math.Inf(1)
+ for index := 0; index < parsed.Len(); index++ {
+ _, distanceKm := geodist.HaversineDistance(parsed.Coord(index), target)
+ if distanceKm < bestDistance {
+ bestDistance = distanceKm
+ bestIndex = index
+ }
+ }
+ return bestIndex
+}
+
+func officialClimbID(climb official_climb.OfficialClimb, found bool) int64 {
+ if !found {
+ return 0
+ }
+ return climb.ID
+}
+
+func officialClimbName(climb official_climb.OfficialClimb, found bool) string {
+ if !found {
+ return ""
+ }
+ return climb.Name
+}
+
type SyncPageData struct {
From string
To string
@@ -295,3 +358,19 @@ func formatCotacolPer100Km(score, distanceM float64) string {
}
return fmt.Sprintf("%.1f", score*100/distanceKm)
}
+
+func formatRouteIndex(index int) string {
+ return fmt.Sprintf("%d", index)
+}
+
+func formatClimbNumber(index int) string {
+ return fmt.Sprintf("%d", index+1)
+}
+
+func formatProfileDistance(distanceKm float64) string {
+ return fmt.Sprintf("%.1f km", distanceKm)
+}
+
+func formatSlope(slopePercent float64) string {
+ return fmt.Sprintf("%.1f%%", slopePercent)
+}