diff options
| -rw-r--r-- | db/migrations/20260913000000_ride_official_climb_attempts.sql | 18 | ||||
| -rw-r--r-- | db/schema.sql | 17 | ||||
| -rw-r--r-- | main.go | 3 | ||||
| -rw-r--r-- | official_climb/aggregate.go | 147 | ||||
| -rw-r--r-- | official_climb/aggregate_test.go | 76 | ||||
| -rw-r--r-- | official_climb/attempt.go | 158 | ||||
| -rw-r--r-- | official_climb/attempt_test.go | 128 | ||||
| -rw-r--r-- | official_climb/list.go | 133 | ||||
| -rw-r--r-- | official_climb/list_test.go | 52 | ||||
| -rw-r--r-- | ride/cli.go | 6 | ||||
| -rw-r--r-- | web/climb-backfill.go | 81 | ||||
| -rw-r--r-- | web/climbs.go | 398 | ||||
| -rw-r--r-- | web/climbs_test.go | 97 | ||||
| -rw-r--r-- | web/frontend/climb-map.ts | 65 | ||||
| -rw-r--r-- | web/frontend/climb-profile.ts | 110 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile.ts | 413 | ||||
| -rw-r--r-- | web/official-climbs.go | 60 | ||||
| -rw-r--r-- | web/official-climbs_test.go | 81 | ||||
| -rw-r--r-- | web/rides.go | 1 | ||||
| -rw-r--r-- | web/server.go | 4 | ||||
| -rw-r--r-- | web/templates.templ | 88 | ||||
| -rw-r--r-- | web/views.go | 6 |
22 files changed, 1952 insertions, 190 deletions
diff --git a/db/migrations/20260913000000_ride_official_climb_attempts.sql b/db/migrations/20260913000000_ride_official_climb_attempts.sql new file mode 100644 index 0000000..880f81b --- /dev/null +++ b/db/migrations/20260913000000_ride_official_climb_attempts.sql @@ -0,0 +1,18 @@ +-- migrate:up +CREATE TABLE ride_official_climb_attempts ( + ride_id integer not null references rides(id), + climb_id integer not null references official_climbs(id), + occurrence integer not null, + distance_m real not null, + elevation_gain_m real not null, + slope_percent real not null, + cotacol real not null, + cotacol_algo_version text not null, + started_at text not null, + ended_at text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + primary key (ride_id, climb_id, occurrence) +); + +-- migrate:down +DROP TABLE ride_official_climb_attempts; diff --git a/db/schema.sql b/db/schema.sql index a97b7f1..e149762 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -24,6 +24,20 @@ CREATE TABLE official_climbs ( 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')) ); +CREATE TABLE ride_official_climb_attempts ( + ride_id integer not null references rides(id), + climb_id integer not null references official_climbs(id), + occurrence integer not null, + distance_m real not null, + elevation_gain_m real not null, + slope_percent real not null, + cotacol real not null, + cotacol_algo_version text not null, + started_at text not null, + ended_at text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + primary key (ride_id, climb_id, occurrence) +); CREATE TABLE IF NOT EXISTS "mountain_passes" ( id integer primary key, osm_id integer unique not null, @@ -45,4 +59,5 @@ INSERT INTO "schema_migrations" (version) VALUES ('20260827000000'), ('20260828000000'), ('20260829000000'), - ('20260831000000'); + ('20260831000000'), + ('20260913000000'); @@ -56,6 +56,8 @@ func runCommand(db *sql.DB, configPath string, appConfig config.Config, args []s mountain_pass.ExtractOSM(db, args[1]) case "backfill": ride.BackfillCmd(db) + case "climbs-backfill": + web.BackfillOfficialClimbAttemptsCmd(db, configPath) case "backtest": ride.BacktestCmd(db) case "elevation": @@ -88,6 +90,7 @@ Commands: fetch-osm download the France OSM PBF into france-latest.osm.pbf extract-osm <path> extract mountain passes from an OSM PBF file backfill recompute all stored ride values + climbs-backfill record official climb attempts for every stored ride backtest run data benchmarks comparing computed values against stored reference values elevation analyse detect elevation outliers (tunnel spikes) elevation fix linearly fix elevation between anchors diff --git a/official_climb/aggregate.go b/official_climb/aggregate.go new file mode 100644 index 0000000..c2bd75d --- /dev/null +++ b/official_climb/aggregate.go @@ -0,0 +1,147 @@ +package official_climb + +import ( + "sort" + "time" + + "github.com/martinlehoux/biking_home/climb" +) + +const profileSteps = 50 + +// Aggregate is the climb-level summary derived from its recorded attempts. +type Aggregate struct { + Attempts int + DistanceM float64 + ElevationGainM float64 + SlopePercent float64 + Cotacol float64 + Category climb.Category + BestDuration time.Duration + HasBest bool +} + +// AggregateAttempts summarizes a climb from its attempts: every scalar is the +// median across attempts so one noisy ride cannot define the climb. +func AggregateAttempts(attempts []Attempt) Aggregate { + if len(attempts) == 0 { + return Aggregate{} + } + distances := make([]float64, len(attempts)) + gains := make([]float64, len(attempts)) + slopes := make([]float64, len(attempts)) + cotacols := make([]float64, len(attempts)) + aggregate := Aggregate{Attempts: len(attempts)} + for i, attempt := range attempts { + distances[i] = attempt.DistanceM + gains[i] = attempt.ElevationGainM + slopes[i] = attempt.SlopePercent + cotacols[i] = attempt.Cotacol + duration := attempt.EndedAt.Sub(attempt.StartedAt) + if duration > 0 && (!aggregate.HasBest || duration < aggregate.BestDuration) { + aggregate.BestDuration = duration + aggregate.HasBest = true + } + } + aggregate.DistanceM = Median(distances) + aggregate.ElevationGainM = Median(gains) + aggregate.SlopePercent = Median(slopes) + aggregate.Cotacol = Median(cotacols) + aggregate.Category = climb.CategoryOf(aggregate.Cotacol) + return aggregate +} + +// Median returns the median of values; for an even count it averages the two +// middle values. +func Median(values []float64) float64 { + if len(values) == 0 { + return 0 + } + sorted := make([]float64, len(values)) + copy(sorted, values) + sort.Float64s(sorted) + middle := len(sorted) / 2 + if len(sorted)%2 == 1 { + return sorted[middle] + } + return (sorted[middle-1] + sorted[middle]) / 2 +} + +// normalizedSample locates one normalized step inside a segment: the index of +// the recorded point preceding the target distance and the interpolation +// fraction toward the next point. +type normalizedSample struct { + index int + fraction float64 +} + +// normalizedSamples locates profileSteps equally spaced fractions of the +// segment length, interpolating between recorded points. Distances are +// relative: segments of different lengths stay comparable. +func normalizedSamples(distances []float64) []normalizedSample { + if len(distances) < 2 { + return nil + } + start, end := distances[0], distances[len(distances)-1] + span := end - start + if span <= 0 { + return nil + } + samples := make([]normalizedSample, profileSteps) + i := 0 + for step := range profileSteps { + target := start + span*float64(step)/float64(profileSteps-1) + for i < len(distances)-2 && distances[i+1] < target { + i++ + } + segmentSpan := distances[i+1] - distances[i] + fraction := 0.0 + if segmentSpan > 0 { + fraction = (target - distances[i]) / segmentSpan + } + samples[step] = normalizedSample{index: i, fraction: fraction} + } + return samples +} + +// NormalizeProfile samples a climb segment's elevation at profileSteps +// equally spaced fractions of the segment length, interpolating between +// recorded points. +func NormalizeProfile(segment climb.Climb) []float64 { + samples := normalizedSamples(segment.Distances) + if samples == nil { + return nil + } + elevations := make([]float64, len(samples)) + for step, sample := range samples { + elevations[step] = sampleElevation(segment.Distances, segment.Elevations, sample) + } + return elevations +} + +func sampleElevation(distances, elevations []float64, sample normalizedSample) float64 { + i := sample.index + if i+1 >= len(distances) { + return elevations[len(elevations)-1] + } + return elevations[i] + sample.fraction*(elevations[i+1]-elevations[i]) +} + +// MedianProfile merges per-attempt normalized elevation samples into one +// canonical profile: the median elevation at every step. +func MedianProfile(samples [][]float64) []float64 { + median := make([]float64, 0, profileSteps) + for step := range profileSteps { + values := make([]float64, 0, len(samples)) + for _, sample := range samples { + if step < len(sample) { + values = append(values, sample[step]) + } + } + if len(values) == 0 { + continue + } + median = append(median, Median(values)) + } + return median +} diff --git a/official_climb/aggregate_test.go b/official_climb/aggregate_test.go new file mode 100644 index 0000000..2e051ce --- /dev/null +++ b/official_climb/aggregate_test.go @@ -0,0 +1,76 @@ +package official_climb + +import ( + "testing" + "time" + + "github.com/martinlehoux/biking_home/climb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMedianOddAndEven(t *testing.T) { + assert.Equal(t, 3.0, Median([]float64{5, 1, 3})) + assert.Equal(t, 2.5, Median([]float64{1, 2, 3, 4})) + assert.Equal(t, 0.0, Median(nil)) +} + +func TestAggregateAttemptsUsesMedians(t *testing.T) { + attempts := []Attempt{ + {DistanceM: 3000, ElevationGainM: 200, SlopePercent: 6.7, Cotacol: 80, StartedAt: time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC), EndedAt: time.Date(2026, 1, 1, 8, 20, 0, 0, time.UTC)}, + {DistanceM: 3200, ElevationGainM: 210, SlopePercent: 6.6, Cotacol: 84, StartedAt: time.Date(2026, 2, 1, 8, 0, 0, 0, time.UTC), EndedAt: time.Date(2026, 2, 1, 8, 15, 0, 0, time.UTC)}, + {DistanceM: 6000, ElevationGainM: 900, SlopePercent: 15, Cotacol: 400, StartedAt: time.Date(2026, 3, 1, 8, 0, 0, 0, time.UTC), EndedAt: time.Date(2026, 3, 1, 8, 40, 0, 0, time.UTC)}, + } + aggregate := AggregateAttempts(attempts) + assert.Equal(t, 3, aggregate.Attempts) + assert.Equal(t, 3200.0, aggregate.DistanceM) + assert.Equal(t, 210.0, aggregate.ElevationGainM) + assert.Equal(t, 6.7, aggregate.SlopePercent) + assert.Equal(t, 84.0, aggregate.Cotacol) + require.True(t, aggregate.HasBest) + assert.Equal(t, 15*time.Minute, aggregate.BestDuration) +} + +func TestAggregateAttemptsIgnoresBrokenDurations(t *testing.T) { + attempts := []Attempt{ + {DistanceM: 3000, StartedAt: time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC), EndedAt: time.Date(2026, 1, 1, 7, 0, 0, 0, time.UTC)}, + {DistanceM: 3200, StartedAt: time.Date(2026, 2, 1, 8, 0, 0, 0, time.UTC), EndedAt: time.Date(2026, 2, 1, 8, 15, 0, 0, time.UTC)}, + } + aggregate := AggregateAttempts(attempts) + require.True(t, aggregate.HasBest) + assert.Equal(t, 15*time.Minute, aggregate.BestDuration) +} + +func TestAggregateAttemptsEmpty(t *testing.T) { + aggregate := AggregateAttempts(nil) + assert.Equal(t, 0, aggregate.Attempts) + assert.False(t, aggregate.HasBest) + assert.Equal(t, climb.CategoryNo, aggregate.Category) +} + +func TestNormalizeProfileLinearSegment(t *testing.T) { + segment := climb.Climb{Route: climb.Route{ + Distances: []float64{0, 500, 1000}, + Elevations: []float64{100, 150, 200}, + }} + samples := NormalizeProfile(segment) + require.Len(t, samples, profileSteps) + assert.InDelta(t, 100, samples[0], 0.01) + assert.InDelta(t, 200, samples[profileSteps-1], 0.01) + assert.InDelta(t, 100+100*float64(profileSteps/2)/float64(profileSteps-1), samples[profileSteps/2], 0.01) +} + +func TestMedianProfileRobustToOutlier(t *testing.T) { + straight := make([]float64, profileSteps) + for i := range straight { + straight[i] = float64(i) + } + spiked := make([]float64, profileSteps) + for i := range spiked { + spiked[i] = float64(i) + 500 + } + median := MedianProfile([][]float64{straight, spiked, straight}) + require.Len(t, median, profileSteps) + assert.InDelta(t, 10, median[10], 0.01) + assert.InDelta(t, 49, median[49], 0.01) +} diff --git a/official_climb/attempt.go b/official_climb/attempt.go new file mode 100644 index 0000000..43778f0 --- /dev/null +++ b/official_climb/attempt.go @@ -0,0 +1,158 @@ +package official_climb + +import ( + "database/sql" + "fmt" + "time" + + "github.com/martinlehoux/biking_home/climb" +) + +// Attempt is one recorded passage of an official climb during one ride, +// identified by (rideID, climbID, occurrence). Scalar stats are snapshotted at +// recording time so climb listings never re-parse ride files. +type Attempt struct { + RideID int64 + ClimbID int64 + Occurrence int + DistanceM float64 + ElevationGainM float64 + SlopePercent float64 + Cotacol float64 + CotacolAlgoVersion string + StartedAt time.Time + EndedAt time.Time + CreatedAt time.Time +} + +const attemptColumns = "a.ride_id, a.climb_id, a.occurrence, a.distance_m, a.elevation_gain_m, a.slope_percent, a.cotacol, a.cotacol_algo_version, a.started_at, a.ended_at, a.created_at" + +// AttemptsForClimb returns every recorded attempt of one official climb, +// oldest ride first. +func AttemptsForClimb(db *sql.DB, climbID int64) ([]Attempt, error) { + return listAttempts(db, "climb_id = ?", climbID, "r.start_date, a.ride_id, a.occurrence") +} + +// AttemptsForRide returns every recorded attempt within one ride, in route +// order. +func AttemptsForRide(db *sql.DB, rideID int64) ([]Attempt, error) { + return listAttempts(db, "ride_id = ?", rideID, "a.occurrence") +} + +func listAttempts(db *sql.DB, where string, arg any, order string) ([]Attempt, error) { + rows, err := db.Query(` + SELECT `+attemptColumns+` + FROM ride_official_climb_attempts a + JOIN rides r ON r.id = a.ride_id + WHERE `+where+` + ORDER BY `+order, arg) + if err != nil { + return nil, fmt.Errorf("list official climb attempts: %w", err) + } + defer rows.Close() + attempts := make([]Attempt, 0) + for rows.Next() { + attempt, err := scanAttempt(rows) + if err != nil { + return nil, err + } + attempts = append(attempts, attempt) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate official climb attempts: %w", err) + } + return attempts, nil +} + +// ReplaceRideAttempts atomically rewrites every attempt row of one ride, +// dropping stale matches and inserting the freshly analyzed ones. +func ReplaceRideAttempts(db *sql.DB, rideID int64, attempts []Attempt) error { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("replace ride attempts: %w", err) + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.Exec("DELETE FROM ride_official_climb_attempts WHERE ride_id = ?", rideID); err != nil { + return fmt.Errorf("delete ride attempts: %w", err) + } + for _, attempt := range attempts { + if attempt.RideID != rideID { + return fmt.Errorf("replace ride attempts: attempt references ride %d, expected %d", attempt.RideID, rideID) + } + if _, err := tx.Exec(` + INSERT INTO ride_official_climb_attempts (ride_id, climb_id, occurrence, distance_m, elevation_gain_m, slope_percent, cotacol, cotacol_algo_version, started_at, ended_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, attempt.RideID, attempt.ClimbID, attempt.Occurrence, attempt.DistanceM, attempt.ElevationGainM, attempt.SlopePercent, attempt.Cotacol, attempt.CotacolAlgoVersion, + attempt.StartedAt.UTC().Format(time.RFC3339), attempt.EndedAt.UTC().Format(time.RFC3339)); err != nil { + return fmt.Errorf("insert ride attempt: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit ride attempts: %w", err) + } + return nil +} + +type attemptScanner interface { + Scan(dest ...any) error +} + +func scanAttempt(s attemptScanner) (Attempt, error) { + var ( + attempt Attempt + startedAt, endedAt string + createdAt sql.NullString + ) + if err := s.Scan(&attempt.RideID, &attempt.ClimbID, &attempt.Occurrence, &attempt.DistanceM, &attempt.ElevationGainM, &attempt.SlopePercent, &attempt.Cotacol, &attempt.CotacolAlgoVersion, &startedAt, &endedAt, &createdAt); err != nil { + return Attempt{}, err + } + var err error + if attempt.StartedAt, err = time.Parse(time.RFC3339, startedAt); err != nil { + return Attempt{}, fmt.Errorf("invalid ride_official_climb_attempts.started_at: %w", err) + } + if attempt.EndedAt, err = time.Parse(time.RFC3339, endedAt); err != nil { + return Attempt{}, fmt.Errorf("invalid ride_official_climb_attempts.ended_at: %w", err) + } + if createdAt.Valid { + if attempt.CreatedAt, err = time.Parse(time.RFC3339, createdAt.String); err != nil { + return Attempt{}, fmt.Errorf("invalid ride_official_climb_attempts.created_at: %w", err) + } + } + return attempt, nil +} + +// AttemptStats carries the analyzed values snapshotted into an attempt row. +type AttemptStats struct { + OfficialClimbID int64 + DistanceM float64 + ElevationGainM float64 + SlopePercent float64 + Cotacol float64 + StartIndex int + EndIndex int +} + +// AttemptFromStats builds the persisted snapshot of one analyzed climb +// occurrence. Timestamps come from the ride's recorded points bounding the +// climb interval. +func AttemptFromStats(rideID int64, occurrence int, stats AttemptStats, timestamps []time.Time) Attempt { + startedAt, endedAt := time.Time{}, time.Time{} + if stats.StartIndex < len(timestamps) { + startedAt = timestamps[stats.StartIndex] + } + if stats.EndIndex < len(timestamps) { + endedAt = timestamps[stats.EndIndex] + } + return Attempt{ + RideID: rideID, + ClimbID: stats.OfficialClimbID, + Occurrence: occurrence, + DistanceM: stats.DistanceM, + ElevationGainM: stats.ElevationGainM, + SlopePercent: stats.SlopePercent, + Cotacol: stats.Cotacol, + CotacolAlgoVersion: climb.CotacolAlgorithmVersion, + StartedAt: startedAt, + EndedAt: endedAt, + } +} diff --git a/official_climb/attempt_test.go b/official_climb/attempt_test.go new file mode 100644 index 0000000..147c80b --- /dev/null +++ b/official_climb/attempt_test.go @@ -0,0 +1,128 @@ +package official_climb_test + +import ( + "database/sql" + "testing" + "time" + + "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/internal/dbtest" + "github.com/martinlehoux/biking_home/official_climb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func coord(lat, lon float64) geodist.Coord { + return geodist.Coord{Lat: lat, Lon: lon} +} + +func insertRide(t *testing.T, db *sql.DB, opts ...func(*rideInsert)) int64 { + t.Helper() + insert := rideInsert{startDate: "2026-09-01T08:00:00Z"} + for _, opt := range opts { + opt(&insert) + } + result, err := db.Exec(` + INSERT INTO rides (external_id, gpx_path, name, external_sport_type, start_date, distance_m, external_moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "strava:test-ride-"+insert.startDate, "missing.gpx", "Test Ride", "Ride", insert.startDate, 20_000, 0, 0, 0, 0) + require.NoError(t, err) + id, err := result.LastInsertId() + require.NoError(t, err) + return id +} + +type rideInsert struct { + startDate string +} + +func withStartDate(startDate string) func(*rideInsert) { + return func(r *rideInsert) { r.startDate = startDate } +} + +func attemptFixture(rideID, climbID int64, occurrence int) official_climb.Attempt { + return official_climb.Attempt{ + RideID: rideID, + ClimbID: climbID, + Occurrence: occurrence, + DistanceM: 3200, + ElevationGainM: 210, + SlopePercent: 6.5, + Cotacol: 84.2, + CotacolAlgoVersion: "v1", + StartedAt: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC), + EndedAt: time.Date(2026, 9, 1, 10, 18, 30, 0, time.UTC), + } +} + +func TestReplaceRideAttemptsRoundTrip(t *testing.T) { + db := dbtest.New(t) + created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + require.NoError(t, err) + rideID := insertRide(t, db) + + require.NoError(t, official_climb.ReplaceRideAttempts(db, rideID, []official_climb.Attempt{ + attemptFixture(rideID, created.ID, 0), + attemptFixture(rideID, created.ID, 1), + })) + + attempts, err := official_climb.AttemptsForClimb(db, created.ID) + require.NoError(t, err) + require.Len(t, attempts, 2) + assert.Equal(t, 0, attempts[0].Occurrence) + assert.Equal(t, 1, attempts[1].Occurrence) + assert.Equal(t, 3200.0, attempts[0].DistanceM) + assert.Equal(t, "v1", attempts[0].CotacolAlgoVersion) + assert.Equal(t, time.Date(2026, 9, 1, 10, 18, 30, 0, time.UTC), attempts[0].EndedAt) + + rideAttempts, err := official_climb.AttemptsForRide(db, rideID) + require.NoError(t, err) + require.Len(t, rideAttempts, 2) +} + +func TestReplaceRideAttemptsDropsStaleRows(t *testing.T) { + db := dbtest.New(t) + first, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col A", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + require.NoError(t, err) + second, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col B", StartCoord: coord(43.7, 5.5), EndCoord: coord(43.72, 5.52)}) + require.NoError(t, err) + rideID := insertRide(t, db) + + require.NoError(t, official_climb.ReplaceRideAttempts(db, rideID, []official_climb.Attempt{attemptFixture(rideID, first.ID, 0)})) + require.NoError(t, official_climb.ReplaceRideAttempts(db, rideID, []official_climb.Attempt{attemptFixture(rideID, second.ID, 0)})) + + attempts, err := official_climb.AttemptsForClimb(db, first.ID) + require.NoError(t, err) + assert.Empty(t, attempts) + attempts, err = official_climb.AttemptsForClimb(db, second.ID) + require.NoError(t, err) + require.Len(t, attempts, 1) +} + +func TestReplaceRideAttemptsRejectsForeignRide(t *testing.T) { + db := dbtest.New(t) + created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + require.NoError(t, err) + rideID := insertRide(t, db) + + attempt := attemptFixture(rideID+1, created.ID, 0) + err = official_climb.ReplaceRideAttempts(db, rideID, []official_climb.Attempt{attempt}) + assert.Error(t, err) +} + +func TestAttemptsForClimbOrdersByRideDate(t *testing.T) { + db := dbtest.New(t) + created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + require.NoError(t, err) + newer := insertRide(t, db, withStartDate("2026-09-10T08:00:00Z")) + older := insertRide(t, db, withStartDate("2026-09-02T08:00:00Z")) + + require.NoError(t, official_climb.ReplaceRideAttempts(db, newer, []official_climb.Attempt{attemptFixture(newer, created.ID, 0)})) + require.NoError(t, official_climb.ReplaceRideAttempts(db, older, []official_climb.Attempt{attemptFixture(older, created.ID, 0)})) + + attempts, err := official_climb.AttemptsForClimb(db, created.ID) + require.NoError(t, err) + require.Len(t, attempts, 2) + assert.Equal(t, older, attempts[0].RideID) + assert.Equal(t, newer, attempts[1].RideID) +} diff --git a/official_climb/list.go b/official_climb/list.go new file mode 100644 index 0000000..33b12e8 --- /dev/null +++ b/official_climb/list.go @@ -0,0 +1,133 @@ +package official_climb + +import ( + "database/sql" + "fmt" + "time" + + "github.com/martinlehoux/biking_home/climb" +) + +// SortColumn is a climbs-list ordering key. +type SortColumn string + +const ( + SortName SortColumn = "name" + SortAttempts SortColumn = "attempts" + SortDistance SortColumn = "distance" + SortSlope SortColumn = "slope" + SortCotacol SortColumn = "cotacol" + SortBestTime SortColumn = "best_time" +) + +// ListedClimb is one climbs-list row: official climb identity plus aggregates +// over its recorded attempts. Medians are computed in Go from the attempt +// rows; best time is the minimum attempt duration. +type ListedClimb struct { + ID int64 + Name string + Attempts int + DistanceM float64 + ElevationGainM float64 + SlopePercent float64 + Cotacol float64 + BestDurationS float64 + HasBest bool +} + +func (c ListedClimb) Category() string { + return climb.CategoryOf(c.Cotacol).String() +} + +// ListWithAggregates returns every official climb with aggregates over its +// attempts. Attempts default to zero for climbs never ridden; climbs without +// attempts show no distance/slope stats. +func ListWithAggregates(db *sql.DB) ([]ListedClimb, error) { + climbRows, err := db.Query("SELECT id, name FROM official_climbs ORDER BY name ASC") + if err != nil { + return nil, fmt.Errorf("list climbs: %w", err) + } + defer climbRows.Close() + climbs := make([]ListedClimb, 0) + index := make(map[int64]int) + for climbRows.Next() { + var row ListedClimb + if err := climbRows.Scan(&row.ID, &row.Name); err != nil { + return nil, err + } + index[row.ID] = len(climbs) + climbs = append(climbs, row) + } + if err := climbRows.Err(); err != nil { + return nil, fmt.Errorf("iterate climbs: %w", err) + } + + attemptRows, err := db.Query(` + SELECT climb_id, distance_m, elevation_gain_m, slope_percent, cotacol, started_at, ended_at + FROM ride_official_climb_attempts`) + if err != nil { + return nil, fmt.Errorf("list climb attempts: %w", err) + } + defer attemptRows.Close() + samples := make(map[int64]*attemptSamples) + for attemptRows.Next() { + var ( + climbID int64 + distanceM float64 + gainM float64 + slope float64 + cotacol float64 + startedAt string + endedAt string + ) + if err := attemptRows.Scan(&climbID, &distanceM, &gainM, &slope, &cotacol, &startedAt, &endedAt); err != nil { + return nil, err + } + sample, known := samples[climbID] + if !known { + sample = &attemptSamples{} + samples[climbID] = sample + } + sample.distances = append(sample.distances, distanceM) + sample.gains = append(sample.gains, gainM) + sample.slopes = append(sample.slopes, slope) + sample.cotacols = append(sample.cotacols, cotacol) + start, err := time.Parse(time.RFC3339, startedAt) + if err != nil { + return nil, fmt.Errorf("invalid ride_official_climb_attempts.started_at: %w", err) + } + end, err := time.Parse(time.RFC3339, endedAt) + if err != nil { + return nil, fmt.Errorf("invalid ride_official_climb_attempts.ended_at: %w", err) + } + durationS := end.Sub(start).Seconds() + if durationS > 0 && (sample.bestDurationS == 0 || durationS < sample.bestDurationS) { + sample.bestDurationS = durationS + } + } + if err := attemptRows.Err(); err != nil { + return nil, fmt.Errorf("iterate climb attempts: %w", err) + } + + for id, sample := range samples { + row := &climbs[index[id]] + row.Attempts = len(sample.distances) + row.DistanceM = Median(sample.distances) + row.ElevationGainM = Median(sample.gains) + row.SlopePercent = Median(sample.slopes) + row.Cotacol = Median(sample.cotacols) + if sample.bestDurationS > 0 { + row.BestDurationS = sample.bestDurationS + row.HasBest = true + } + } + return climbs, nil +} + +type attemptSamples struct { + distances []float64 + gains []float64 + slopes []float64 + cotacols []float64 + bestDurationS float64 +} diff --git a/official_climb/list_test.go b/official_climb/list_test.go new file mode 100644 index 0000000..6a65f80 --- /dev/null +++ b/official_climb/list_test.go @@ -0,0 +1,52 @@ +package official_climb_test + +import ( + "testing" + + "github.com/martinlehoux/biking_home/internal/dbtest" + "github.com/martinlehoux/biking_home/official_climb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListWithAggregatesMediansAndUnridden(t *testing.T) { + db := dbtest.New(t) + unridden, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col Fantome", StartCoord: coord(43.9, 5.9), EndCoord: coord(43.92, 5.92)}) + require.NoError(t, err) + climbed, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col Fréquente", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + require.NoError(t, err) + rideID := insertRide(t, db) + + attempt := attemptFixture(rideID, climbed.ID, 0) + attempt.DistanceM = 3000 + attempt.Cotacol = 80 + medianAttempt := attempt + medianAttempt.Occurrence = 1 + medianAttempt.DistanceM = 3200 + medianAttempt.Cotacol = 84 + outlier := attempt + outlier.Occurrence = 2 + outlier.DistanceM = 9000 + outlier.Cotacol = 500 + require.NoError(t, official_climb.ReplaceRideAttempts(db, rideID, []official_climb.Attempt{attempt, medianAttempt, outlier})) + + climbs, err := official_climb.ListWithAggregates(db) + require.NoError(t, err) + require.Len(t, climbs, 2) + + byID := make(map[int64]official_climb.ListedClimb) + for _, climb := range climbs { + byID[climb.ID] = climb + } + frequente := byID[climbed.ID] + assert.Equal(t, 3, frequente.Attempts) + assert.Equal(t, 3200.0, frequente.DistanceM, "median ignores the 9 km outlier") + assert.Equal(t, 84.0, frequente.Cotacol) + assert.True(t, frequente.HasBest) + assert.Positive(t, frequente.BestDurationS) + + fantome := byID[unridden.ID] + assert.Equal(t, 0, fantome.Attempts) + assert.Equal(t, 0.0, fantome.DistanceM) + assert.False(t, fantome.HasBest) +} diff --git a/ride/cli.go b/ride/cli.go index a460260..c148b38 100644 --- a/ride/cli.go +++ b/ride/cli.go @@ -209,6 +209,12 @@ func parseStoredRide(item StoredRide) (Ride, error) { return ParseFile(GPXRideParser{}, item.GPXPath) } +// ParseStoredRide parses a stored ride's route files, preferring FIT and +// falling back to GPX when FIT parsing fails or is missing. +func ParseStoredRide(item StoredRide) (Ride, error) { + return parseStoredRide(item) +} + type elevationAnalyseResult struct { RideID int64 `json:"rideId"` Name string `json:"name"` diff --git a/web/climb-backfill.go b/web/climb-backfill.go new file mode 100644 index 0000000..13a8c02 --- /dev/null +++ b/web/climb-backfill.go @@ -0,0 +1,81 @@ +package web + +import ( + "database/sql" + "log/slog" + + "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/rideanalysis" + "github.com/martinlehoux/kagamigo/kcore" + "github.com/schollz/progressbar/v3" +) + +// BackfillOfficialClimbAttempts parses every route-bearing ride, matches its +// official climbs, and persists the resulting attempts. An optional reporter +// is invoked with progress for CLI rendering. +func BackfillOfficialClimbAttempts(db *sql.DB, configPath string, reporters ...func(current, total int)) (int, error) { + appConfig, err := config.Load(configPath) + if err != nil { + return 0, err + } + matchPolicy := official_climb.DefaultMatchPolicy() + matchPolicy.EndpointRadiusM = appConfig.OfficialClimb.MatchRadiusM + items, err := ride.List(db) + if err != nil { + return 0, err + } + officialClimbs, err := official_climb.List(db) + if err != nil { + return 0, err + } + var bar *progressbar.ProgressBar + report := func(int, int) {} + if len(reporters) > 0 { + report = reporters[0] + } + backfilled := 0 + for i, item := range items { + report(i+1, len(items)) + if item.FitPath == "" && item.GPXPath == "" { + continue + } + parsed, err := ride.ParseStoredRide(item) + if err != nil { + slog.Warn("Failed to parse ride for climb backfill", "ride_id", item.ID, "error", err) + continue + } + passes, err := mountain_pass.LoadMountainPassesAroundRide(db, parsed.Coords, rideDetailMountainPassMarginM) + if err != nil { + slog.Warn("Failed to load passes for climb backfill", "ride_id", item.ID, "error", err) + } + analysis := rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy) + attempts := attemptsFromAnalysis(item.ID, analysis, parsed) + if err := official_climb.ReplaceRideAttempts(db, item.ID, attempts); err != nil { + slog.Warn("Failed to record climb attempts", "ride_id", item.ID, "error", err) + continue + } + backfilled++ + } + if bar != nil { + bar.Finish() + } + return backfilled, nil +} + +// BackfillOfficialClimbAttemptsCmd runs the climb attempt backfill from the CLI. +func BackfillOfficialClimbAttemptsCmd(db *sql.DB, configPath string) { + var bar *progressbar.ProgressBar + count, err := BackfillOfficialClimbAttempts(db, configPath, func(current, total int) { + if bar == nil && mountain_pass.ProgressBarEnabled() { + bar = progressbar.Default(int64(total), "Backfilling climb attempts") + } + if bar != nil { + bar.Set(current) + } + }) + kcore.Expect(err, "failed to backfill climb attempts") + slog.Info("Backfilled climb attempts", "rides", count) +} diff --git a/web/climbs.go b/web/climbs.go new file mode 100644 index 0000000..6859545 --- /dev/null +++ b/web/climbs.go @@ -0,0 +1,398 @@ +package web + +import ( + "database/sql" + "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" + "log/slog" + "math" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + + "github.com/martinlehoux/biking_home/official_climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/kagamigo/kcore" +) + +func climbDetailURL(id int64) string { + return "/climbs/" + strconv.FormatInt(id, 10) +} + +type routeCandidate struct { + coords []geodist.Coord + distance float64 +} + +type ClimbProfileData struct { + DistanceKm float64 `json:"distanceKm"` + Elevations []float64 `json:"elevations"` + Route [][]float64 `json:"route,omitempty"` +} +type ClimbAttemptView struct { + RideID int64 + RideName string + Date string + Duration string + Distance string + Cotacol string +} + +type ClimbStatsView struct { + Attempts string + Distance string + Slope string + Cotacol string + Category string + BestTime string + ElevationG string +} + +type ClimbDetailPageData struct { + Climb official_climb.OfficialClimb + Stats ClimbStatsView + Attempts []ClimbAttemptView + Profile ClimbProfileData + Endpoints ClimbMapEndpoints +} + +type ClimbMapEndpoints struct { + Start []float64 `json:"start"` + End []float64 `json:"end"` + Name string `json:"name"` +} + +func (s *Server) handleClimb(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 + } + climb, found, err := official_climb.GetByID(s.db, id) + if err != nil { + slog.Error("Failed to load official climb", "climb_id", id, "error", err) + http.Error(w, "failed to load climb", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + attempts, err := official_climb.AttemptsForClimb(s.db, id) + if err != nil { + slog.Error("Failed to load climb attempts", "climb_id", id, "error", err) + http.Error(w, "failed to load climb attempts", http.StatusInternalServerError) + return + } + aggregate := official_climb.AggregateAttempts(attempts) + profile := s.buildClimbProfile(climb, aggregate, attempts) + kcore.RenderPage(r.Context(), ClimbDetailPage(ClimbDetailPageData{ + Climb: climb, + Stats: buildClimbStatsView(aggregate), + Attempts: buildClimbAttemptViews(s.db, attempts), + Profile: profile, + Endpoints: ClimbMapEndpoints{ + Start: []float64{climb.StartCoord.Lat, climb.StartCoord.Lon}, + End: []float64{climb.EndCoord.Lat, climb.EndCoord.Lon}, + Name: climb.Name, + }, + }), w) +} + +// buildClimbProfile computes the median normalized elevation profile from the +// linked rides: each ride is parsed, its climb occurrences matched, and every +// occurrence resampled onto the shared normalized axis. +func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, aggregate official_climb.Aggregate, attempts []official_climb.Attempt) ClimbProfileData { + profile := ClimbProfileData{DistanceKm: aggregate.DistanceM / 1000} + if len(attempts) == 0 { + return profile + } + matchPolicy := s.officialClimbMatchPolicy() + elevationSamples := make([][]float64, 0, len(attempts)) + routeCandidates := make([]routeCandidate, 0, len(attempts)) + seen := make(map[int64]bool) + for _, attempt := range attempts { + if seen[attempt.RideID] { + continue + } + seen[attempt.RideID] = true + item, found, err := ride.GetByID(s.db, attempt.RideID) + if err != nil || !found { + continue + } + parsed, err := ride.ParseStoredRide(item) + if err != nil { + slog.Warn("Failed to parse ride for climb profile", "climb_id", officialClimb.ID, "ride_id", attempt.RideID, "error", err) + continue + } + for _, matched := range official_climb.MatchClimbs(parsed.Coords, officialClimb, matchPolicy.EndpointRadiusM) { + segment := climb.Climb{Route: climb.Route{ + Distances: parsed.Distances[matched.StartIndex : matched.EndIndex+1], + Elevations: parsed.Elevations[matched.StartIndex : matched.EndIndex+1], + Coords: parsed.Coords[matched.StartIndex : matched.EndIndex+1], + }} + if sample := official_climb.NormalizeProfile(segment); len(sample) > 0 { + elevationSamples = append(elevationSamples, sample) + } + routeCandidates = append(routeCandidates, routeCandidate{ + coords: segment.Coords, + distance: segment.Distances[len(segment.Distances)-1] - segment.Distances[0], + }) + } + } + profile.Elevations = official_climb.MedianProfile(elevationSamples) + if route := medianRoute(routeCandidates); len(route) > 1 { + profile.Route = make([][]float64, len(route)) + for i, coord := range route { + profile.Route[i] = []float64{coord.Lon, coord.Lat} + } + } + return profile +} + +// medianRoute picks the recorded route of the attempt whose distance is the +// median across occurrences: full recorded precision, no resampling, and +// robust to one GPS-drifted ride. +func medianRoute(candidates []routeCandidate) []geodist.Coord { + if len(candidates) == 0 { + return nil + } + distances := make([]float64, len(candidates)) + for i, candidate := range candidates { + distances[i] = candidate.distance + } + median := official_climb.Median(distances) + best := candidates[0] + bestDiff := math.Abs(best.distance - median) + for _, candidate := range candidates[1:] { + if diff := math.Abs(candidate.distance - median); diff < bestDiff { + best = candidate + bestDiff = diff + } + } + return best.coords +} + +func buildClimbStatsView(aggregate official_climb.Aggregate) ClimbStatsView { + stats := ClimbStatsView{ + Attempts: strconv.Itoa(aggregate.Attempts), + Distance: formatDistance(aggregate.DistanceM), + Slope: formatSlope(aggregate.SlopePercent), + Cotacol: formatCotacol(aggregate.Cotacol), + Category: aggregate.Category.String(), + ElevationG: formatElevation(aggregate.ElevationGainM), + BestTime: "-", + } + if aggregate.HasBest { + stats.BestTime = formatDuration(int64(aggregate.BestDuration.Seconds())) + } + return stats +} + +func buildClimbAttemptViews(db *sql.DB, attempts []official_climb.Attempt) []ClimbAttemptView { + names := make(map[int64]string) + views := make([]ClimbAttemptView, len(attempts)) + for i, attempt := range attempts { + name, known := names[attempt.RideID] + if !known { + if item, found, err := ride.GetByID(db, attempt.RideID); err == nil && found { + name = item.Name + } + names[attempt.RideID] = name + } + views[i] = ClimbAttemptView{ + RideID: attempt.RideID, + RideName: name, + Date: formatRideDate(attempt.StartedAt), + Duration: formatDuration(int64(attempt.EndedAt.Sub(attempt.StartedAt).Seconds())), + Distance: formatDistance(attempt.DistanceM), + Cotacol: formatCotacol(attempt.Cotacol), + } + } + return views +} + +func climbsListURL() string { + return "/climbs" +} + +type ClimbSort struct { + Column string + Descending bool +} + +const ( + climbSortName = "name" + climbSortCategory = "category" + climbSortAttempts = "attempts" + climbSortDistance = "distance" + climbSortSlope = "slope" + climbSortCotacol = "cotacol" + climbSortBestTime = "best_time" +) + +var climbSortColumns = map[string]bool{ + climbSortName: true, + climbSortCategory: true, + climbSortAttempts: true, + climbSortDistance: true, + climbSortSlope: true, + climbSortCotacol: true, + climbSortBestTime: true, +} + +func parseClimbSort(query url.Values) ClimbSort { + column := query.Get("sort") + if !climbSortColumns[column] { + column = climbSortCotacol + } + direction := query.Get("dir") + return ClimbSort{Column: column, Descending: direction != "asc"} +} + +func sortListedClimbs(climbs []official_climb.ListedClimb, sort ClimbSort) []official_climb.ListedClimb { + ordered := make([]official_climb.ListedClimb, len(climbs)) + copy(ordered, climbs) + slices.SortStableFunc(ordered, func(a, b official_climb.ListedClimb) int { + var cmp int + switch sort.Column { + case climbSortName: + cmp = strings.Compare(a.Name, b.Name) + case climbSortCategory: + cmp = int(climb.CategoryOf(a.Cotacol)) - int(climb.CategoryOf(b.Cotacol)) + case climbSortAttempts: + cmp = a.Attempts - b.Attempts + case climbSortDistance: + cmp = compareFloat(a.DistanceM, b.DistanceM) + case climbSortSlope: + cmp = compareFloat(a.SlopePercent, b.SlopePercent) + case climbSortBestTime: + cmp = compareFloat(bestTimeOrInfinity(a), bestTimeOrInfinity(b)) + default: + cmp = compareFloat(a.Cotacol, b.Cotacol) + } + if sort.Descending { + return -cmp + } + return cmp + }) + return ordered +} + +func bestTimeOrInfinity(climb official_climb.ListedClimb) float64 { + if !climb.HasBest { + return math.Inf(1) + } + return climb.BestDurationS +} + +func compareFloat(a, b float64) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} + +type ClimbListView struct { + Climb official_climb.ListedClimb + Category string + Distance string + Slope string + Cotacol string + BestTime string + URL string +} + +type ClimbsPageData struct { + Items []ClimbListView + Headers []RideSortHeader +} + +func buildClimbListViews(climbs []official_climb.ListedClimb) []ClimbListView { + views := make([]ClimbListView, len(climbs)) + for i, listed := range climbs { + bestTime := "-" + if listed.HasBest { + bestTime = formatDuration(int64(listed.BestDurationS)) + } + views[i] = ClimbListView{ + Climb: listed, + Category: listed.Category(), + Distance: formatDistance(listed.DistanceM), + Slope: formatSlope(listed.SlopePercent), + Cotacol: formatCotacol(listed.Cotacol), + BestTime: bestTime, + URL: climbDetailURL(listed.ID), + } + } + return views +} + +func (s *Server) handleClimbs(w http.ResponseWriter, r *http.Request) { + sort := parseClimbSort(r.URL.Query()) + climbs, err := official_climb.ListWithAggregates(s.db) + if err != nil { + slog.Error("Failed to load climbs", "error", err) + http.Error(w, "failed to load climbs", http.StatusInternalServerError) + return + } + climbs = sortListedClimbs(climbs, sort) + kcore.RenderPage(r.Context(), ClimbsPage(ClimbsPageData{ + Items: buildClimbListViews(climbs), + Headers: climbSortHeaders(sort), + }), w) +} + +func climbSortHeaders(current ClimbSort) []RideSortHeader { + columns := []struct { + key string + label string + class string + }{ + {key: climbSortName, label: "Climb"}, + {key: climbSortCategory, label: "Category"}, + {key: climbSortAttempts, label: "Attempts", class: "numeric"}, + {key: climbSortDistance, label: "Distance", class: "numeric"}, + {key: climbSortSlope, label: "Avg slope", class: "numeric"}, + {key: climbSortCotacol, label: "Cotacol", class: "numeric"}, + {key: climbSortBestTime, label: "Best time", class: "numeric"}, + } + headers := make([]RideSortHeader, 0, len(columns)) + for _, column := range columns { + descending := false + active := current.Column == column.key + if active { + descending = !current.Descending + } + query := url.Values{} + query.Set("sort", column.key) + query.Set("dir", rideSortDirection(RideSort{Column: column.key, Descending: descending})) + headerClass := column.class + ariaSort := "none" + indicator := "" + if active { + headerClass += " active" + if current.Descending { + ariaSort = "descending" + indicator = "↓" + } else { + ariaSort = "ascending" + indicator = "↑" + } + } + headers = append(headers, RideSortHeader{ + Label: column.label, + Class: headerClass, + URL: climbsListURL() + "?" + query.Encode(), + AriaSort: ariaSort, + Indicator: indicator, + }) + } + return headers +} diff --git a/web/climbs_test.go b/web/climbs_test.go new file mode 100644 index 0000000..fbaf527 --- /dev/null +++ b/web/climbs_test.go @@ -0,0 +1,97 @@ +package web + +import ( + "database/sql" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/martinlehoux/biking_home/official_climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/rideanalysis" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func recordFixtureAttempts(t *testing.T, server *Server, rideID int64, climb official_climb.OfficialClimb) { + t.Helper() + parsed, _ := testRideForClimb(t) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{climb}, official_climb.DefaultMatchPolicy()) + server.recordOfficialClimbAttempts(rideID, parsed, analysis) +} +func storeRideFixtureFile(t *testing.T, db *sql.DB, rideID int64) { + t.Helper() + parsed, _ := testRideForClimb(t) + fitPath := filepath.Join(t.TempDir(), "ride.fit") + fitFile, err := os.Create(fitPath) + require.NoError(t, err) + require.NoError(t, ride.EncodeFIT(parsed, fitFile)) + require.NoError(t, fitFile.Close()) + _, err = db.Exec("UPDATE rides SET fit_path = ? WHERE id = ?", fitPath, rideID) + require.NoError(t, err) +} + +func TestHandleClimbRendersAttemptsAndProfile(t *testing.T) { + server, db := newWebTestServer(t) + _, climbFixture := testRideForClimb(t) + created, err := official_climb.Create(db, climbFixture) + require.NoError(t, err) + rideID := insertTestRide(t, db) + storeRideFixtureFile(t, db, rideID) + recordFixtureAttempts(t, server, rideID, created) + + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, climbDetailURL(created.ID), nil)) + require.Equal(t, http.StatusOK, recorder.Code) + body := recorder.Body.String() + assert.Contains(t, body, "Col de Test") + assert.Contains(t, body, "climb-profile-data") + assert.Contains(t, body, "Test Ride") +} + +func TestHandleClimbNotFound(t *testing.T) { + server, _ := newWebTestServer(t) + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/climbs/999", nil)) + assert.Equal(t, http.StatusNotFound, recorder.Code) +} + +func TestBuildClimbProfileMediansAcrossRides(t *testing.T) { + server, db := newWebTestServer(t) + _, climbFixture := testRideForClimb(t) + created, err := official_climb.Create(db, climbFixture) + require.NoError(t, err) + rideID := insertTestRide(t, db) + storeRideFixtureFile(t, db, rideID) + parsed, _ := testRideForClimb(t) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) + attempts := attemptsFromAnalysis(rideID, analysis, parsed) + + profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) + assert.InDelta(t, 1.0, profile.DistanceKm, 0.01) + require.Len(t, profile.Elevations, 50) + assert.InDelta(t, 300, profile.Elevations[0], 1) + assert.InDelta(t, 447, profile.Elevations[49], 2) +} + +func TestBuildClimbProfileRouteKeepsRecordedPoints(t *testing.T) { + server, db := newWebTestServer(t) + parsed, climbFixture := testRideForClimb(t) + created, err := official_climb.Create(db, climbFixture) + require.NoError(t, err) + rideID := insertTestRide(t, db) + storeRideFixtureFile(t, db, rideID) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) + attempts := attemptsFromAnalysis(rideID, analysis, parsed) + + profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) + require.NotEmpty(t, profile.Route) + // Full recorded precision: every matched segment point is present, not a + // fixed 50-step resample. + assert.InDelta(t, parsed.Coords[0].Lon, profile.Route[0][0], 1e-6) + assert.InDelta(t, parsed.Coords[0].Lat, profile.Route[0][1], 1e-6) + assert.InDelta(t, parsed.Coords[1].Lon, profile.Route[1][0], 1e-6) + assert.InDelta(t, parsed.Coords[1].Lat, profile.Route[1][1], 1e-6) +} diff --git a/web/frontend/climb-map.ts b/web/frontend/climb-map.ts new file mode 100644 index 0000000..909614d --- /dev/null +++ b/web/frontend/climb-map.ts @@ -0,0 +1,65 @@ +import type { Map as LeafletMap } from "leaflet"; + +interface ClimbMapPayload { + distanceKm: number; + elevations: number[]; + route?: [number, number][]; +} + +interface ClimbMapEndpoints { + start: [number, number]; + end: [number, number]; + name: string; +} + +const mountClimbMap = (): void => { + const element = document.getElementById("climb-map"); + const endpointsElement = document.getElementById("climb-endpoints"); + const profileElement = document.getElementById("climb-profile-data"); + const leaflet = window.L; + if (!element || !endpointsElement || !profileElement || !leaflet) return; + const endpoints = JSON.parse(endpointsElement.textContent ?? "null") as ClimbMapEndpoints | null; + const profile = JSON.parse(profileElement.textContent ?? "null") as ClimbMapPayload | null; + if (!endpoints) return; + const route = (profile?.route ?? []).map(([lon, lat]) => [lat, lon] as [number, number]); + const line: [number, number][] = route.length > 1 ? route : [endpoints.start, endpoints.end]; + + const map: LeafletMap = leaflet.map(element); + leaflet + .tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', + }) + .addTo(map); + + const startIcon = leaflet.divIcon({ + className: "ride-map-climb-start-icon", + html: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="6" fill="currentColor"/></svg>', + iconSize: [26, 26], + iconAnchor: [13, 13], + }); + const endIcon = leaflet.divIcon({ + className: "ride-map-climb-end-icon", + html: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3v18" stroke="currentColor" stroke-width="2"/><path d="M6 4h13v8H6Z" fill="currentColor"/><path d="M6 4h3.25v4H6ZM12.5 4h3.25v4H12.5ZM9.25 8h3.25v4H9.25ZM15.75 8H19v4h-3.25Z" fill="var(--color-forest)"/></svg>', + iconSize: [26, 26], + iconAnchor: [13, 13], + }); + leaflet + .polyline(line, { + color: "var(--color-climb-route)", + weight: 4, + opacity: 0.9, + }) + .addTo(map); + leaflet + .marker(endpoints.start, { icon: startIcon, alt: `${endpoints.name} start` }) + .addTo(map) + .bindTooltip("Start"); + leaflet + .marker(endpoints.end, { icon: endIcon, alt: `${endpoints.name} end` }) + .addTo(map) + .bindTooltip("End"); + map.fitBounds(leaflet.latLngBounds(line), { padding: [40, 40], maxZoom: 16 }); +}; + +mountClimbMap(); diff --git a/web/frontend/climb-profile.ts b/web/frontend/climb-profile.ts new file mode 100644 index 0000000..8910349 --- /dev/null +++ b/web/frontend/climb-profile.ts @@ -0,0 +1,110 @@ +import { drawOfficialSections, officialProfilePlot, resolveOfficialProfileColors } from "./official-climb-profile.js"; +import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js"; +import type { OfficialProfileSection } from "./official-climb-profile-logic.js"; +import type { ProfilePoint } from "./types.js"; + +interface ClimbProfilePayload { + distanceKm: number; + elevations: number[]; +} + +interface ClimbProfileState { + canvas: HTMLCanvasElement; + payload: ClimbProfilePayload; + sections: OfficialProfileSection[]; + hoverOutput: HTMLOutputElement; +} + +const climbProfilePoints = (payload: ClimbProfilePayload): ProfilePoint[] => + payload.elevations.map((elevationM, index) => ({ + distanceKm: (payload.distanceKm * index) / (payload.elevations.length - 1), + elevationM, + })); + +const climbProfileSections = (payload: ClimbProfilePayload): OfficialProfileSection[] => { + const points = climbProfilePoints(payload); + if (points.length < 2) return []; + return officialProfileSections(points, 0, points.length - 1, displayStepForLength(payload.distanceKm * 1000)); +}; + +const elevationAtDistance = (sections: OfficialProfileSection[], distanceKm: number): number | null => { + for (const section of sections) { + if (distanceKm >= section.startDistanceKm && distanceKm <= section.endDistanceKm) { + const span = section.endDistanceKm - section.startDistanceKm; + const fraction = span > 0 ? (distanceKm - section.startDistanceKm) / span : 0; + return section.startElevation + fraction * (section.endElevation - section.startElevation); + } + } + return null; +}; + +const drawHoverMarker = (state: ClimbProfileState, distanceKm: number): void => { + const colors = resolveOfficialProfileColors(); + const rect = state.canvas.getBoundingClientRect(); + const context = state.canvas.getContext("2d"); + if (!context) return; + const plot = officialProfilePlot(state.sections, rect.width, rect.height); + const elevationSpan = Math.max(plot.maxElevation - plot.minElevation, 1); + const x = plot.left + ((distanceKm - plot.minDistanceKm) / plot.distanceSpan) * (plot.right - plot.left); + const elevation = elevationAtDistance(state.sections, distanceKm); + if (elevation === null) return; + const y = plot.bottom - ((elevation - plot.minElevation) / elevationSpan) * (plot.bottom - plot.top); + context.strokeStyle = colors.subtle; + context.lineWidth = 1; + context.setLineDash([3, 3]); + context.beginPath(); + context.moveTo(x, plot.top); + context.lineTo(x, plot.bottom); + context.stroke(); + context.setLineDash([]); + context.fillStyle = colors.accent; + context.beginPath(); + context.arc(x, y, 5, 0, 2 * Math.PI); + context.fill(); + state.hoverOutput.textContent = `${distanceKm.toFixed(2)} km · ${Math.round(elevation)} m`; +}; + +const clearHover = (state: ClimbProfileState): void => { + drawClimbProfile(state.canvas, state.payload, state.sections); + state.hoverOutput.textContent = ""; +}; + +const drawClimbProfile = (canvas: HTMLCanvasElement, payload: ClimbProfilePayload, sections?: OfficialProfileSection[]): void => { + const profileSections = sections ?? climbProfileSections(payload); + if (profileSections.length === 0) return; + drawOfficialSections(canvas, profileSections, resolveOfficialProfileColors()); +}; + +const bindHover = (state: ClimbProfileState): void => { + state.canvas.addEventListener("pointermove", (event: PointerEvent) => { + const rect = state.canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const plot = officialProfilePlot(state.sections, rect.width, rect.height); + if (x < plot.left || x > plot.right) { + clearHover(state); + return; + } + const distanceKm = plot.minDistanceKm + ((x - plot.left) / (plot.right - plot.left)) * plot.distanceSpan; + drawClimbProfile(state.canvas, state.payload, state.sections); + drawHoverMarker(state, distanceKm); + }); + state.canvas.addEventListener("pointerleave", () => clearHover(state)); + state.canvas.addEventListener("pointercancel", () => clearHover(state)); +}; + +const init = (): void => { + const payloadElement = document.getElementById("climb-profile-data"); + const canvas = document.querySelector<HTMLCanvasElement>("[data-climb-profile]"); + if (!payloadElement || !canvas) return; + const payload = JSON.parse(payloadElement.textContent ?? "null") as ClimbProfilePayload | null; + if (!payload || payload.elevations.length < 2) return; + const sections = climbProfileSections(payload); + if (sections.length === 0) return; + const hoverOutput = document.querySelector<HTMLOutputElement>("[data-climb-profile-hover]"); + if (!hoverOutput) return; + const state: ClimbProfileState = { canvas, payload, sections, hoverOutput }; + drawClimbProfile(canvas, payload, sections); + bindHover(state); +}; + +init(); diff --git a/web/frontend/official-climb-profile.ts b/web/frontend/official-climb-profile.ts index bc851f5..ac4d591 100644 --- a/web/frontend/official-climb-profile.ts +++ b/web/frontend/official-climb-profile.ts @@ -1,6 +1,6 @@ import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js"; +import type { OfficialProfileSection } from "./official-climb-profile-logic.js"; import type { OfficialProfileColors, RideProfilePoint } from "./types.js"; - export class OfficialClimbProfileController { readonly points: RideProfilePoint[]; readonly colors: OfficialProfileColors; @@ -12,43 +12,19 @@ export class OfficialClimbProfileController { this.cards = [...document.querySelectorAll("[data-official-climb-card]")].map((card) => card as HTMLDetailsElement); for (const card of this.cards) { card.addEventListener("toggle", () => { - if (!card.open) return; - for (const other of this.cards) { - if (other !== card) other.open = false; + if (card.open) { + this.redrawOpen(); } - const profileCanvas = card.querySelector<HTMLCanvasElement>("[data-official-profile]"); - if (profileCanvas) this.drawOfficialProfile(profileCanvas); }); } } resolveColors(): OfficialProfileColors { - const colorProbe = document.createElement("span"); - colorProbe.hidden = true; - document.body.append(colorProbe); - const resolveColor = (name: string): string => { - colorProbe.style.color = `var(${name})`; - return getComputedStyle(colorProbe).color; - }; - const colors = { - downhill: resolveColor("--color-profile-downhill"), - "0-3": resolveColor("--color-profile-0-3"), - "3-6": resolveColor("--color-profile-3-6"), - "6-9": resolveColor("--color-profile-6-9"), - "9-plus": resolveColor("--color-profile-9-plus"), - plotSurface: resolveColor("--color-plot-surface"), - grid: resolveColor("--color-plot-grid"), - xGrid: resolveColor("--color-profile-grid-x"), - yGrid: resolveColor("--color-profile-grid-y"), - subtle: resolveColor("--color-subtle"), - accent: resolveColor("--color-accent"), - }; - colorProbe.remove(); - return colors; + return resolveOfficialProfileColors(); } drawOfficialProfile(profileCanvas: HTMLCanvasElement): void { - const { points, colors } = this; + const { points } = this; const startIndex = Number.parseInt(profileCanvas.dataset.profileStart ?? "", 10); const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd ?? "", 10); if ( @@ -59,175 +35,240 @@ export class OfficialClimbProfileController { startIndex >= endIndex ) return; - const rect = profileCanvas.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) return; - const context = profileCanvas.getContext("2d"); - if (!context) return; - const ratio = window.devicePixelRatio || 1; - profileCanvas.width = Math.floor(rect.width * ratio); - profileCanvas.height = Math.floor(rect.height * ratio); - context.setTransform(ratio, 0, 0, ratio, 0, 0); - const plot = { left: 10, right: Math.max(11, rect.width - 44), top: 16, bottom: rect.height - 34 }; const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000; const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM)); if (sections.length === 0) return; - let minElevation = sections[0].startElevation; - let maxElevation = minElevation; - for (const section of sections) { - minElevation = Math.min(minElevation, section.startElevation, section.endElevation); - maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation); - } - const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8); - minElevation -= elevationPadding; - maxElevation += elevationPadding; - const minDistance = sections[0].startDistanceKm; - const maxDistance = sections[sections.length - 1].endDistanceKm; - const distanceSpan = Math.max(maxDistance - minDistance, 0.1); - const elevationSpan = Math.max(maxElevation - minElevation, 1); - const xForDistance = (distanceKm: number) => plot.left + ((distanceKm - minDistance) / distanceSpan) * (plot.right - plot.left); - const yForElevation = (elevationM: number) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (plot.bottom - plot.top); - context.clearRect(0, 0, rect.width, rect.height); - context.fillStyle = colors.plotSurface; - context.fillRect(0, 0, rect.width, rect.height); - context.font = "9px system-ui, sans-serif"; - context.lineWidth = 1; - context.setLineDash([4, 4]); - context.strokeStyle = colors.yGrid; - const yGridElevations: number[] = []; - for (let elevationM = Math.ceil(minElevation / 100) * 100; elevationM <= maxElevation; elevationM += 100) { - yGridElevations.push(elevationM); - } - context.save(); - context.beginPath(); - context.moveTo(plot.left, plot.bottom); - context.lineTo(plot.left, yForElevation(sections[0].startElevation)); - for (const section of sections) { - context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation)); - } - context.lineTo(plot.right, plot.bottom); - context.closePath(); - context.clip(); - for (const elevationM of yGridElevations) { - const y = yForElevation(elevationM); - context.beginPath(); - context.moveTo(plot.left, y); - context.lineTo(plot.right, y); - context.stroke(); - } - context.restore(); - context.fillStyle = colors.yGrid; - context.textAlign = "left"; - context.textBaseline = "middle"; - for (const elevationM of yGridElevations) { - context.fillText(`${elevationM} m`, plot.right + 6, yForElevation(elevationM)); + drawOfficialSections(profileCanvas, sections, this.colors); + } + + redrawOpen() { + const profileCanvases = document.querySelectorAll<HTMLCanvasElement>("[data-official-profile]"); + if (profileCanvases.length === 0) return; + for (const profileCanvas of profileCanvases) { + if (profileCanvas.closest("details")?.open) { + this.drawOfficialProfile(profileCanvas); + } } - context.setLineDash([]); - context.strokeStyle = colors.grid; + } +} + +export function resolveOfficialProfileColors(): OfficialProfileColors { + const colorProbe = document.createElement("span"); + colorProbe.hidden = true; + document.body.append(colorProbe); + const resolveColor = (name: string): string => { + colorProbe.style.color = `var(${name})`; + return getComputedStyle(colorProbe).color; + }; + const colors = { + downhill: resolveColor("--color-profile-downhill"), + "0-3": resolveColor("--color-profile-0-3"), + "3-6": resolveColor("--color-profile-3-6"), + "6-9": resolveColor("--color-profile-6-9"), + "9-plus": resolveColor("--color-profile-9-plus"), + plotSurface: resolveColor("--color-plot-surface"), + grid: resolveColor("--color-plot-grid"), + xGrid: resolveColor("--color-profile-grid-x"), + yGrid: resolveColor("--color-profile-grid-y"), + subtle: resolveColor("--color-subtle"), + accent: resolveColor("--color-accent"), + }; + colorProbe.remove(); + return colors; +} + +export interface OfficialProfilePlot { + left: number; + right: number; + top: number; + bottom: number; + minElevation: number; + maxElevation: number; + minDistanceKm: number; + maxDistanceKm: number; + distanceSpan: number; +} + +export const officialProfilePlot = (sections: OfficialProfileSection[], width: number, height: number): OfficialProfilePlot => { + let minElevation = sections[0].startElevation; + let maxElevation = minElevation; + for (const section of sections) { + minElevation = Math.min(minElevation, section.startElevation, section.endElevation); + maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation); + } + const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8); + minElevation -= elevationPadding; + maxElevation += elevationPadding; + const minDistanceKm = sections[0].startDistanceKm; + const maxDistanceKm = sections[sections.length - 1].endDistanceKm; + return { + left: 10, + right: Math.max(11, width - 44), + top: 16, + bottom: height - 34, + minElevation, + maxElevation, + minDistanceKm, + maxDistanceKm, + distanceSpan: Math.max(maxDistanceKm - minDistanceKm, 0.1), + }; +}; + +// drawOfficialSections renders precomputed profile sections onto a canvas. +// Shared by the ride detail official-climb cards and the climb detail page. +export function drawOfficialSections( + profileCanvas: HTMLCanvasElement, + sections: OfficialProfileSection[], + colors: OfficialProfileColors, +): void { + const rect = profileCanvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const context = profileCanvas.getContext("2d"); + if (!context) return; + const ratio = window.devicePixelRatio || 1; + profileCanvas.width = Math.floor(rect.width * ratio); + profileCanvas.height = Math.floor(rect.height * ratio); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + const plot = officialProfilePlot(sections, rect.width, rect.height); + const minElevation = plot.minElevation; + const maxElevation = plot.maxElevation; + const minDistance = plot.minDistanceKm; + const maxDistance = plot.maxDistanceKm; + const elevationSpan = Math.max(maxElevation - minElevation, 1); + const xForDistance = (distanceKm: number) => plot.left + ((distanceKm - minDistance) / plot.distanceSpan) * (plot.right - plot.left); + const yForElevation = (elevationM: number) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (plot.bottom - plot.top); + context.clearRect(0, 0, rect.width, rect.height); + context.fillStyle = colors.plotSurface; + context.fillRect(0, 0, rect.width, rect.height); + context.font = "9px system-ui, sans-serif"; + context.lineWidth = 1; + context.setLineDash([4, 4]); + context.strokeStyle = colors.yGrid; + const yGridElevations: number[] = []; + for (let elevationM = Math.ceil(minElevation / 100) * 100; elevationM <= maxElevation; elevationM += 100) { + yGridElevations.push(elevationM); + } + context.save(); + context.beginPath(); + context.moveTo(plot.left, plot.bottom); + context.lineTo(plot.left, yForElevation(sections[0].startElevation)); + for (const section of sections) { + context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation)); + } + context.lineTo(plot.right, plot.bottom); + context.closePath(); + context.clip(); + for (const elevationM of yGridElevations) { + const y = yForElevation(elevationM); context.beginPath(); - context.moveTo(plot.left, plot.bottom); - context.lineTo(plot.right, plot.bottom); + context.moveTo(plot.left, y); + context.lineTo(plot.right, y); context.stroke(); - for (const section of sections) { - const startX = xForDistance(section.startDistanceKm); - const endX = xForDistance(section.endDistanceKm); - context.beginPath(); - context.moveTo(startX, plot.bottom); - context.lineTo(startX, yForElevation(section.startElevation)); - context.lineTo(endX, yForElevation(section.endElevation)); - context.lineTo(endX, plot.bottom); - context.closePath(); - context.globalAlpha = 0.25; - context.fillStyle = colors[section.band]; - context.fill(); - context.globalAlpha = 1; - context.beginPath(); - context.moveTo(startX, yForElevation(section.startElevation)); - context.lineTo(endX, yForElevation(section.endElevation)); - context.strokeStyle = colors[section.band]; - context.lineWidth = 2.5; - context.stroke(); - } - context.save(); + } + context.restore(); + context.fillStyle = colors.yGrid; + context.textAlign = "left"; + context.textBaseline = "middle"; + for (const elevationM of yGridElevations) { + context.fillText(`${elevationM} m`, plot.right + 6, yForElevation(elevationM)); + } + context.setLineDash([]); + context.strokeStyle = colors.grid; + context.beginPath(); + context.moveTo(plot.left, plot.bottom); + context.lineTo(plot.right, plot.bottom); + context.stroke(); + for (const section of sections) { + const startX = xForDistance(section.startDistanceKm); + const endX = xForDistance(section.endDistanceKm); context.beginPath(); - context.moveTo(plot.left, plot.bottom); - context.lineTo(plot.left, yForElevation(sections[0].startElevation)); - for (const section of sections) { - context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation)); - } - context.lineTo(plot.right, plot.bottom); + context.moveTo(startX, plot.bottom); + context.lineTo(startX, yForElevation(section.startElevation)); + context.lineTo(endX, yForElevation(section.endElevation)); + context.lineTo(endX, plot.bottom); context.closePath(); - context.clip(); - context.strokeStyle = colors.xGrid; - context.lineWidth = 1; - context.setLineDash([4, 4]); - for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) { - const distanceKm = sectionIndex === sections.length ? maxDistance : sections[sectionIndex].startDistanceKm; - const x = xForDistance(distanceKm); - context.beginPath(); - context.moveTo(x, plot.top); - context.lineTo(x, plot.bottom); - context.stroke(); - } - context.restore(); - context.setLineDash([]); - context.font = "9px system-ui, sans-serif"; - context.fillStyle = colors.subtle; - context.textBaseline = "bottom"; - context.textAlign = "center"; - context.save(); + context.globalAlpha = 0.25; + context.fillStyle = colors[section.band]; + context.fill(); + context.globalAlpha = 1; context.beginPath(); - context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top); - context.clip(); - for (const section of sections) { - const startX = xForDistance(section.startDistanceKm); - const endX = xForDistance(section.endDistanceKm); - const label = `${section.slopePercent.toFixed(1)}%`; - context.fillText(label, (startX + endX) / 2, plot.bottom - 4); - } - context.restore(); - let topElevation = sections[0].startElevation; - let topDistance = sections[0].startDistanceKm; - for (const section of sections) { - if (section.startElevation > topElevation) { - topElevation = section.startElevation; - topDistance = section.startDistanceKm; - } - if (section.endElevation > topElevation) { - topElevation = section.endElevation; - topDistance = section.endDistanceKm; - } - } - context.fillStyle = colors.accent; + context.moveTo(startX, yForElevation(section.startElevation)); + context.lineTo(endX, yForElevation(section.endElevation)); + context.strokeStyle = colors[section.band]; + context.lineWidth = 2.5; + context.stroke(); + } + context.save(); + context.beginPath(); + context.moveTo(plot.left, plot.bottom); + context.lineTo(plot.left, yForElevation(sections[0].startElevation)); + for (const section of sections) { + context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation)); + } + context.lineTo(plot.right, plot.bottom); + context.closePath(); + context.clip(); + context.strokeStyle = colors.xGrid; + context.lineWidth = 1; + context.setLineDash([4, 4]); + for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) { + const distanceKm = sectionIndex === sections.length ? maxDistance : sections[sectionIndex].startDistanceKm; + const x = xForDistance(distanceKm); context.beginPath(); - context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI); - context.fill(); - context.font = "9px system-ui, sans-serif"; - context.fillStyle = colors.subtle; - context.textBaseline = "top"; - context.strokeStyle = colors.grid; - context.lineWidth = 1; - for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) { - const distanceKm = - sectionIndex === sections.length ? maxDistance - minDistance : sections[sectionIndex].startDistanceKm - minDistance; - const x = xForDistance(minDistance + distanceKm); - context.beginPath(); - context.moveTo(x, plot.bottom); - context.lineTo(x, plot.bottom + 3); - context.stroke(); - context.textAlign = sectionIndex === 0 ? "left" : sectionIndex === sections.length ? "right" : "center"; - context.fillText(this.formatDistance(distanceKm), x, plot.bottom + 5); - } + context.moveTo(x, plot.top); + context.lineTo(x, plot.bottom); + context.stroke(); } - - formatDistance(distance: number): string { - return `${distance.toFixed(distance < 10 ? 1 : 0)} km`; + context.restore(); + context.setLineDash([]); + context.font = "9px system-ui, sans-serif"; + context.fillStyle = colors.subtle; + context.textBaseline = "bottom"; + context.textAlign = "center"; + context.save(); + context.beginPath(); + context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top); + context.clip(); + for (const section of sections) { + const startX = xForDistance(section.startDistanceKm); + const endX = xForDistance(section.endDistanceKm); + const label = `${section.slopePercent.toFixed(1)}%`; + context.fillText(label, (startX + endX) / 2, plot.bottom - 4); } - - redrawOpen() { - const profileCanvases = document.querySelectorAll<HTMLCanvasElement>("[data-official-profile]"); - for (const profileCanvas of profileCanvases) { - const card = profileCanvas.closest<HTMLDetailsElement>("[data-official-climb-card]"); - if (card?.open) this.drawOfficialProfile(profileCanvas); + context.restore(); + let topElevation = sections[0].startElevation; + let topDistance = sections[0].startDistanceKm; + for (const section of sections) { + if (section.startElevation > topElevation) { + topElevation = section.startElevation; + topDistance = section.startDistanceKm; } + if (section.endElevation > topElevation) { + topElevation = section.endElevation; + topDistance = section.endDistanceKm; + } + } + context.fillStyle = colors.accent; + context.beginPath(); + context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI); + context.fill(); + context.font = "9px system-ui, sans-serif"; + context.fillStyle = colors.subtle; + context.textBaseline = "top"; + context.strokeStyle = colors.grid; + context.lineWidth = 1; + for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) { + const distanceKm = sectionIndex === sections.length ? maxDistance - minDistance : sections[sectionIndex].startDistanceKm - minDistance; + const x = xForDistance(minDistance + distanceKm); + context.beginPath(); + context.moveTo(x, plot.bottom); + context.lineTo(x, plot.bottom + 3); + context.stroke(); + context.textAlign = sectionIndex === 0 ? "left" : sectionIndex === sections.length ? "right" : "center"; + context.fillText(formatClimbDistance(distanceKm), x, plot.bottom + 5); } } + +function formatClimbDistance(distance: number): string { + return `${distance.toFixed(distance < 10 ? 1 : 0)} km`; +} diff --git a/web/official-climbs.go b/web/official-climbs.go new file mode 100644 index 0000000..ce9d7cc --- /dev/null +++ b/web/official-climbs.go @@ -0,0 +1,60 @@ +package web + +import ( + "log/slog" + + "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/rideanalysis" +) + +// analysisFor runs the ride analysis pipeline the same way the ride profile +// does, so recorded attempts and displayed climbs always agree. +func analysisFor(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) rideanalysis.Result { + return rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy) +} + +// attemptsFromAnalysis extracts one attempt per official-climb occurrence. +// Occurrences are numbered per climb in route order (0, 1, … for laps). +func attemptsFromAnalysis(rideID int64, analysis rideanalysis.Result, parsed ride.Ride) []official_climb.Attempt { + occurrences := make(map[int64]int) + attempts := make([]official_climb.Attempt, 0) + for _, analyzed := range analysis.Climbs { + if analyzed.OfficialClimbID == 0 { + continue + } + occurrence := occurrences[analyzed.OfficialClimbID] + occurrences[analyzed.OfficialClimbID] = occurrence + 1 + attempts = append(attempts, official_climb.AttemptFromStats(rideID, occurrence, official_climb.AttemptStats{ + OfficialClimbID: analyzed.OfficialClimbID, + DistanceM: analyzed.DistanceM, + ElevationGainM: elevationGain(analyzed), + SlopePercent: analyzed.SlopePercent, + Cotacol: analyzed.Cotacol, + StartIndex: analyzed.Interval.Start, + EndIndex: analyzed.Interval.End, + }, parsed.Timestamps())) + } + return attempts +} + +func elevationGain(analyzed rideanalysis.AnalyzedClimb) float64 { + gain := 0.0 + for i := 1; i < len(analyzed.Segment.Elevations); i++ { + if diff := analyzed.Segment.Elevations[i] - analyzed.Segment.Elevations[i-1]; diff > 0 { + gain += diff + } + } + return gain +} + +// recordOfficialClimbAttempts persists the ride's official-climb attempts, +// replacing any previously recorded ones. Failures are logged, never fatal: +// attempts are an optimization over on-the-fly matching. +func (s *Server) recordOfficialClimbAttempts(rideID int64, parsed ride.Ride, analysis rideanalysis.Result) { + attempts := attemptsFromAnalysis(rideID, analysis, parsed) + if err := official_climb.ReplaceRideAttempts(s.db, rideID, attempts); err != nil { + slog.Warn("Failed to record official climb attempts", "ride_id", rideID, "error", err) + } +} diff --git a/web/official-climbs_test.go b/web/official-climbs_test.go new file mode 100644 index 0000000..ba71f19 --- /dev/null +++ b/web/official-climbs_test.go @@ -0,0 +1,81 @@ +package web + +import ( + "database/sql" + "github.com/martinlehoux/biking_home/internal/ridetest" + "testing" + "time" + + "github.com/martinlehoux/biking_home/official_climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/rideanalysis" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testRideStart = time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) + +func testRideForClimb(t *testing.T) (ride.Ride, official_climb.OfficialClimb) { + t.Helper() + parsed := ridetest.MustRide("1km at 14.7% | 1km at -6.7%", ridetest.StartCoord(43.61, 5.42), ridetest.StartElevation(300), ridetest.Sampling(1000), ridetest.StartTime(testRideStart)) + return parsed, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[1]} +} + +func insertTestRide(t *testing.T, db *sql.DB) int64 { + t.Helper() + result, err := db.Exec(` + INSERT INTO rides (external_id, gpx_path, name, external_sport_type, start_date, distance_m, external_moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "strava:climb-test", "missing.gpx", "Test Ride", "Ride", "2026-09-01T08:00:00Z", 20_000, 0, 0, 0, 0) + require.NoError(t, err) + id, err := result.LastInsertId() + require.NoError(t, err) + return id +} + +func TestAttemptsFromAnalysisSnapshotsOfficialClimbs(t *testing.T) { + parsed, climb := testRideForClimb(t) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{{ID: 42, Name: climb.Name, StartCoord: climb.StartCoord, EndCoord: climb.EndCoord}}, official_climb.DefaultMatchPolicy()) + + attempts := attemptsFromAnalysis(7, analysis, parsed) + require.Len(t, attempts, 1) + assert.Equal(t, int64(7), attempts[0].RideID) + assert.Equal(t, int64(42), attempts[0].ClimbID) + assert.Equal(t, 0, attempts[0].Occurrence) + assert.InDelta(t, 1000, attempts[0].DistanceM, 1) + assert.InDelta(t, 147, attempts[0].ElevationGainM, 1) + assert.InDelta(t, 14.7, attempts[0].SlopePercent, 0.1) + assert.Positive(t, attempts[0].Cotacol) + assert.Equal(t, "v1", attempts[0].CotacolAlgoVersion) + assert.Equal(t, testRideStart, attempts[0].StartedAt.UTC()) + assert.False(t, attempts[0].EndedAt.IsZero(), "attempt end must come from ride timestamps") +} + +func TestAttemptsFromAnalysisSkipsUnmatchedClimbs(t *testing.T) { + parsed, _ := testRideForClimb(t) + analysis := rideanalysis.Analyze(parsed, nil, nil, official_climb.DefaultMatchPolicy()) + + attempts := attemptsFromAnalysis(7, analysis, parsed) + assert.Empty(t, attempts) +} + +func TestRecordOfficialClimbAttemptsPersistsAndReplaces(t *testing.T) { + server, db := newWebTestServer(t) + rideID := insertTestRide(t, db) + parsed, climb := testRideForClimb(t) + created, err := official_climb.Create(db, climb) + require.NoError(t, err) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) + + server.recordOfficialClimbAttempts(rideID, parsed, analysis) + attempts, err := official_climb.AttemptsForClimb(db, created.ID) + require.NoError(t, err) + require.Len(t, attempts, 1) + assert.Equal(t, rideID, attempts[0].RideID) + + // Re-recording replaces, never duplicates. + server.recordOfficialClimbAttempts(rideID, parsed, analysis) + attempts, err = official_climb.AttemptsForClimb(db, created.ID) + require.NoError(t, err) + require.Len(t, attempts, 1) +} diff --git a/web/rides.go b/web/rides.go index 37340b8..10fbb75 100644 --- a/web/rides.go +++ b/web/rides.go @@ -87,6 +87,7 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) { slog.Warn("Failed to load official climbs for ride detail", "ride_id", id, "error", err) } matchPolicy := s.officialClimbMatchPolicy() + s.recordOfficialClimbAttempts(id, parsed, analysisFor(parsed, passes, officialClimbs, matchPolicy)) slog.Info("Loaded ride detail", "ride_id", id) view := buildRideDetailView(buildRideView(item), parsed, passes, officialClimbs, matchPolicy) view.Notice = rideDetailNotice(r) diff --git a/web/server.go b/web/server.go index c588696..fc0e526 100644 --- a/web/server.go +++ b/web/server.go @@ -50,8 +50,10 @@ func (s *Server) Handler() http.Handler { handleRoute(mux, "POST /preview/official-climbs", s.handlePreviewOfficialClimb) handleRoute(mux, "GET /rides/{id}", s.handleRide) handleRoute(mux, "GET /analytics", s.handleAnalytics) - handleRoute(mux, "POST /rides/{id}/official-climbs", s.handleCreateOfficialClimb) + handleRoute(mux, "GET /climbs", s.handleClimbs) + handleRoute(mux, "GET /climbs/{id}", s.handleClimb) handleRoute(mux, "POST /rides/{id}/categories", s.handleUpdateRideCategories) + handleRoute(mux, "POST /rides/{id}/official-climbs", s.handleCreateOfficialClimb) handleRoute(mux, "GET /data-quality", s.handleDataQuality) handleRoute(mux, "POST /data-quality/categories", s.handleBulkUpdateCategories) handleRoute(mux, "GET /sync", s.handleSyncForm) diff --git a/web/templates.templ b/web/templates.templ index 14e9aab..d41dd39 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -15,7 +15,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="/analytics">Analytics</a><a href="/preview">Preview</a><a href="/data-quality">Data quality</a><a href="/sync">Sync Strava</a></nav> + <nav class="nav"><a href="/">Rides</a><a href="/climbs">Climbs</a><a href="/analytics">Analytics</a><a href="/preview">Preview</a><a href="/data-quality">Data quality</a><a href="/sync">Sync Strava</a></nav> </header> <main class="container"> @content @@ -485,3 +485,89 @@ templ DataQualityContent(data DataQualityPageData) { } </section> } +templ ClimbsPage(data ClimbsPageData) { + @Layout("Climbs", ClimbsContent(data)) +} + +templ ClimbsContent(data ClimbsPageData) { + <p class="eyebrow">Official climbs</p> + <section class="panel"> + if len(data.Items) == 0 { + <div class="empty">No official climbs yet. Create one from a ride detail page.</div> + } else { + <table> + <thead><tr> + for _, header := range data.Headers { + <th class={ header.Class } scope="col" aria-sort={ header.AriaSort }><a class="sort-link" href={ templ.URL(header.URL) }>{ header.Label }<span class="sort-indicator" aria-hidden="true">{ header.Indicator }</span></a></th> + } + </tr></thead> + <tbody> + for _, item := range data.Items { + <tr><td><strong><a href={ templ.URL(item.URL) }>{ item.Climb.Name }</a></strong></td><td>{ item.Category }</td><td class="numeric">{ formatClimbCount(item.Climb.Attempts) }</td><td class="numeric">{ item.Distance }</td><td class="numeric">{ item.Slope }</td><td class="numeric">{ item.Cotacol }</td><td class="numeric">{ item.BestTime }</td></tr> + } + </tbody> + </table> + } + </section> +} + +templ ClimbDetailPage(data ClimbDetailPageData) { + @Layout("Climb", ClimbDetailContent(data)) +} + +templ ClimbDetailContent(data ClimbDetailPageData) { + <div class="toolbar"> + <div><h1 class="ride-detail-title">{ data.Climb.Name }</h1><p class="lead">Official climb · { data.Stats.Category }</p></div> + <div class="toolbar-actions"><a class="button secondary" href="/climbs">Back to climbs</a></div> + </div> + <section class="panel summary-panel"> + <dl> + <div><dt>Attempts</dt><dd>{ data.Stats.Attempts }</dd></div> + <div><dt>Distance</dt><dd>{ data.Stats.Distance }</dd></div> + <div><dt>Elevation</dt><dd>{ data.Stats.ElevationG }</dd></div> + <div><dt>Avg slope</dt><dd>{ data.Stats.Slope }</dd></div> + <div><dt>Cotacol</dt><dd>{ data.Stats.Cotacol }</dd></div> + <div><dt>Best time</dt><dd>{ data.Stats.BestTime }</dd></div> + </dl> + </section> + <section class="panel map-panel"> + <div id="climb-map" class="ride-map" role="img" aria-label="Official climb map"></div> + </section> + if len(data.Profile.Elevations) > 0 { + <section class="panel"> + <div class="section-heading"><div><p class="eyebrow">Profile</p><h2>Median profile</h2></div></div> + <div class="profile-chart"><canvas id="climb-profile" data-climb-profile tabindex="0" role="img" aria-label={ officialProfileLabel(data.Climb.Name) }></canvas></div> + <p class="profile-hover" data-climb-profile-hover aria-live="polite">Hover or focus the profile to inspect the climb at a distance.</p> + <div class="official-climb-profile-legend" role="list" aria-label="Average slope color scale"> + <span role="listitem"><i class="profile-band-swatch profile-band-downhill" aria-hidden="true"></i><=0%</span> + <span role="listitem"><i class="profile-band-swatch profile-band-0-3" aria-hidden="true"></i>0–3%</span> + <span role="listitem"><i class="profile-band-swatch profile-band-3-6" aria-hidden="true"></i>3–6%</span> + <span role="listitem"><i class="profile-band-swatch profile-band-6-9" aria-hidden="true"></i>6–9%</span> + <span role="listitem"><i class="profile-band-swatch profile-band-9-plus" aria-hidden="true"></i>9%+</span> + </div> + </section> + } + <section class="panel"> + <div class="section-heading"> + <div><p class="eyebrow">History</p><h2>Attempts</h2></div> + <span class="section-count">{ formatClimbCount(len(data.Attempts)) }</span> + </div> + if len(data.Attempts) == 0 { + <div class="empty">No attempts recorded yet. Open a ride containing this climb to record one.</div> + } else { + <table> + <thead><tr><th>Date</th><th>Ride</th><th class="numeric">Time</th><th class="numeric">Distance</th><th class="numeric">Cotacol</th></tr></thead> + <tbody> + for _, attempt := range data.Attempts { + <tr><td>{ attempt.Date }</td><td><a href={ templ.URL(rideDetailURL(attempt.RideID)) }>{ attempt.RideName }</a></td><td class="numeric">{ attempt.Duration }</td><td class="numeric">{ attempt.Distance }</td><td class="numeric">{ attempt.Cotacol }</td></tr> + } + </tbody> + </table> + } + </section> + @templ.JSONScript("climb-profile-data", data.Profile) + @templ.JSONScript("climb-endpoints", data.Endpoints) + <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> + <script type="module" src="/static/climb-profile.js"></script> + <script type="module" src="/static/climb-map.js"></script> +} diff --git a/web/views.go b/web/views.go index bfbfe09..5dfc0e6 100644 --- a/web/views.go +++ b/web/views.go @@ -437,7 +437,11 @@ func formatSpeed(metersPerSecond float64) string { func formatDuration(seconds int64) string { hours := seconds / 3600 minutes := (seconds % 3600) / 60 - return fmt.Sprintf("%dh %02dm", hours, minutes) + secondsOnly := seconds % 60 + if hours > 0 { + return fmt.Sprintf("%dh %02dm %02ds", hours, minutes, secondsOnly) + } + return fmt.Sprintf("%dm %02ds", minutes, secondsOnly) } func formatElevation(meters float64) string { |
