From e3d0beee5be3dd2854b0f4fc10905a4fcdc08f47 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 13 Sep 2026 21:55:59 +0200 Subject: Persist official climb attempts with climbs pages Record one attempt row per (ride, climb, occurrence) when a ride detail loads, snapshotting distance, elevation gain, slope, cotacol and attempt timestamps; a climbs-backfill CLI command seeds all existing rides. New /climbs list page aggregates attempts (medians computed in Go, best time as min duration) with sortable columns, and /climbs/{id} shows the median scalar stats, a median normalized elevation profile (50 steps over each attempt's own length), the full-precision recorded route of the median-distance attempt on an OSM map, and every attempt with time at second precision. --- official_climb/aggregate_test.go | 76 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 official_climb/aggregate_test.go (limited to 'official_climb/aggregate_test.go') 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) +} -- cgit v1.3