From 8b921624666141c09cf20e2e0ee3cbe260a6412d Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Mon, 24 Aug 2026 09:27:29 +0200 Subject: Add type filtering, drop hard-coded filters and route filter, tune backtest --- web/ride-filters.go | 86 +++++++++++++++++++++++++++++++----------------- web/ride-filters_test.go | 59 +++++++++++++++++++++------------ web/rides.go | 29 ++-------------- web/server_test.go | 30 ++++++++++++++++- web/static/style.css | 3 +- web/templates.templ | 71 ++++++++++++++++++++++++++++++++------- 6 files changed, 187 insertions(+), 91 deletions(-) (limited to 'web') diff --git a/web/ride-filters.go b/web/ride-filters.go index 64bade9..de8e242 100644 --- a/web/ride-filters.go +++ b/web/ride-filters.go @@ -29,13 +29,8 @@ const ( rideFilterDateToParam = "date_to" rideFilterCotacolMinParam = "cotacol_min" rideFilterCotacolMaxParam = "cotacol_max" - rideFilterRouteParam = "route" -) - -const ( - rideRouteFilterAny = "" - rideRouteFilterAvailable = "available" - rideRouteFilterMissing = "missing" + rideFilterBikeTypeParam = "bike_type" + rideFilterRideTypeParam = "ride_type" ) type RideFilters struct { @@ -47,7 +42,8 @@ type RideFilters struct { DateToExclusive *time.Time CotacolMinPoints *int CotacolMaxPoints *int - Route string + BikeType *ride.BikeType + RideType *ride.RideType } type RideFilterView struct { @@ -60,7 +56,8 @@ type RideFilterView struct { DateTo string CotacolMinPoints int CotacolMaxPoints int - Route string + BikeType string + RideType string ResetURL string } @@ -142,7 +139,8 @@ func parseRideFilters(query url.Values) RideFilters { DurationMaxMinutes: parseRideFilterInt(query.Get(rideFilterDurationMaxParam), rideFilterDurationMinMinutes, rideFilterDurationMaxMinutes, rideFilterDurationStepMinutes), CotacolMinPoints: parseRideFilterInt(query.Get(rideFilterCotacolMinParam), rideFilterCotacolMinPoints, rideFilterCotacolMaxPoints, rideFilterCotacolStepPoints), CotacolMaxPoints: parseRideFilterInt(query.Get(rideFilterCotacolMaxParam), rideFilterCotacolMinPoints, rideFilterCotacolMaxPoints, rideFilterCotacolStepPoints), - Route: parseRideRouteFilter(query.Get(rideFilterRouteParam)), + BikeType: parseRideBikeTypeFilter(query.Get(rideFilterBikeTypeParam)), + RideType: parseRideRideTypeFilter(query.Get(rideFilterRideTypeParam)), DateFrom: parseRideFilterDate(query.Get(rideFilterDateFromParam), false), DateToExclusive: parseRideFilterDate(query.Get(rideFilterDateToParam), true)} @@ -203,11 +201,26 @@ func parseRideFilterDate(value string, end bool) *time.Time { return &parsed } -func parseRideRouteFilter(value string) string { - if value == rideRouteFilterAvailable || value == rideRouteFilterMissing { - return value +func parseRideBikeTypeFilter(value string) *ride.BikeType { + if value == "" { + return nil + } + if !ride.IsValidBikeType(value) { + return nil + } + bikeType := ride.BikeType(value) + return &bikeType +} + +func parseRideRideTypeFilter(value string) *ride.RideType { + if value == "" { + return nil + } + if !ride.IsValidRideType(value) { + return nil } - return rideRouteFilterAny + rideType := ride.RideType(value) + return &rideType } func buildRideFilterView(filters RideFilters, sort RideSort) RideFilterView { @@ -219,6 +232,14 @@ func buildRideFilterView(filters RideFilters, sort RideSort) RideFilterView { if filters.DateToExclusive != nil { dateTo = filters.DateToExclusive.AddDate(0, 0, -1).Format(dateFormat) } + bikeType := "" + if filters.BikeType != nil { + bikeType = string(*filters.BikeType) + } + rideType := "" + if filters.RideType != nil { + rideType = string(*filters.RideType) + } return RideFilterView{ Active: filters.Active(), DistanceMinKm: rideFilterValue(filters.DistanceMinKm, rideFilterDistanceMinKm), @@ -229,7 +250,8 @@ func buildRideFilterView(filters RideFilters, sort RideSort) RideFilterView { DateTo: dateTo, CotacolMinPoints: rideFilterValue(filters.CotacolMinPoints, rideFilterCotacolMinPoints), CotacolMaxPoints: rideFilterValue(filters.CotacolMaxPoints, rideFilterCotacolMaxPoints), - Route: filters.Route, + BikeType: bikeType, + RideType: rideType, ResetURL: rideFiltersResetURL(sort), } } @@ -292,8 +314,11 @@ func rideFilterQuery(filters RideFilters) url.Values { if filters.CotacolMaxPoints != nil { query.Set(rideFilterCotacolMaxParam, formatRideFilterInt(*filters.CotacolMaxPoints)) } - if filters.Route != rideRouteFilterAny { - query.Set(rideFilterRouteParam, filters.Route) + if filters.BikeType != nil { + query.Set(rideFilterBikeTypeParam, string(*filters.BikeType)) + } + if filters.RideType != nil { + query.Set(rideFilterRideTypeParam, string(*filters.RideType)) } return query } @@ -317,18 +342,20 @@ func (filters RideFilters) Active() bool { filters.DateToExclusive != nil || filters.CotacolMinPoints != nil || filters.CotacolMaxPoints != nil || - filters.Route != rideRouteFilterAny + filters.BikeType != nil || + filters.RideType != nil } func filterRides(items []ride.StoredRide, filters RideFilters) []ride.StoredRide { - if !filters.Active() { - return items - } filtered := make([]ride.StoredRide, 0, len(items)) for _, item := range items { - if rideMatchesFilters(item, filters) { - filtered = append(filtered, item) + if !item.IsCategorised() { + continue + } + if filters.Active() && !rideMatchesFilters(item, filters) { + continue } + filtered = append(filtered, item) } return filtered } @@ -365,12 +392,11 @@ func rideMatchesFilters(item ride.StoredRide, filters RideFilters) bool { return false } } - switch filters.Route { - case rideRouteFilterAvailable: - return item.GPXPath != "" - case rideRouteFilterMissing: - return item.GPXPath == "" - default: - return true + if filters.BikeType != nil && item.BikeType != *filters.BikeType { + return false + } + if filters.RideType != nil && item.RideType != *filters.RideType { + return false } + return true } diff --git a/web/ride-filters_test.go b/web/ride-filters_test.go index bf1cc6f..fd3ee13 100644 --- a/web/ride-filters_test.go +++ b/web/ride-filters_test.go @@ -23,7 +23,8 @@ func TestParseRideFilters(t *testing.T) { rideFilterDateToParam: {"2026-08-31"}, rideFilterCotacolMinParam: {"50"}, rideFilterCotacolMaxParam: {"500"}, - rideFilterRouteParam: {rideRouteFilterAvailable}, + rideFilterBikeTypeParam: {string(ride.BikeTypeRoad)}, + rideFilterRideTypeParam: {string(ride.RideTypeTraining)}, } filters := parseRideFilters(query) @@ -36,7 +37,8 @@ func TestParseRideFilters(t *testing.T) { assert.Equal(t, "2026-09-01T00:00:00Z", filters.DateToExclusive.Format(time.RFC3339)) assert.Equal(t, 50, *filters.CotacolMinPoints) assert.Equal(t, 500, *filters.CotacolMaxPoints) - assert.Equal(t, rideRouteFilterAvailable, filters.Route) + assert.Equal(t, ride.BikeTypeRoad, *filters.BikeType) + assert.Equal(t, ride.RideTypeTraining, *filters.RideType) assert.True(t, filters.Active()) } @@ -50,7 +52,8 @@ func TestParseRideFiltersIgnoresInvalidValues(t *testing.T) { rideFilterDateToParam: {"2026-08-01"}, rideFilterCotacolMinParam: {"25"}, rideFilterCotacolMaxParam: {"25"}, - rideFilterRouteParam: {"unknown"}, + rideFilterBikeTypeParam: {"unknown"}, + rideFilterRideTypeParam: {"unknown"}, } filters := parseRideFilters(query) @@ -64,7 +67,8 @@ func TestParseRideFiltersIgnoresInvalidValues(t *testing.T) { assert.Nil(t, filters.DateToExclusive) assert.Nil(t, filters.CotacolMinPoints) assert.Nil(t, filters.CotacolMaxPoints) - assert.Empty(t, filters.Route) + assert.Nil(t, filters.BikeType) + assert.Nil(t, filters.RideType) } func TestParseRideFiltersTreatsFullRangesAsInactive(t *testing.T) { @@ -88,11 +92,11 @@ func TestParseRideFiltersTreatsFullRangesAsInactive(t *testing.T) { func TestFilterRidesUsesInclusiveBoundsAndMovingTime(t *testing.T) { items := []ride.StoredRide{ - {Name: "lower boundary", DistanceM: 20_000, ExternalMovingTimeS: 1_800, StartDate: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), GPXPath: "lower.gpx"}, - {Name: "upper boundary", DistanceM: 100_000, ExternalMovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "upper.gpx"}, - {Name: "outside distance", DistanceM: 110_000, ExternalMovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-distance.gpx"}, - {Name: "outside moving time", DistanceM: 100_000, ExternalMovingTimeS: 10_801, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-duration.gpx"}, - {Name: "outside date", DistanceM: 100_000, ExternalMovingTimeS: 10_800, StartDate: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC), GPXPath: "outside-date.gpx"}, + {Name: "lower boundary", DistanceM: 20_000, ExternalMovingTimeS: 1_800, StartDate: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), GPXPath: "lower.gpx", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining}, + {Name: "upper boundary", DistanceM: 100_000, ExternalMovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "upper.gpx", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining}, + {Name: "outside distance", DistanceM: 110_000, ExternalMovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-distance.gpx", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining}, + {Name: "outside moving time", DistanceM: 100_000, ExternalMovingTimeS: 10_801, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-duration.gpx", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining}, + {Name: "outside date", DistanceM: 100_000, ExternalMovingTimeS: 10_800, StartDate: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC), GPXPath: "outside-date.gpx", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining}, } filters := parseRideFilters(url.Values{ rideFilterDistanceMinParam: {"20"}, @@ -110,19 +114,21 @@ func TestFilterRidesUsesInclusiveBoundsAndMovingTime(t *testing.T) { assert.Equal(t, "upper boundary", filtered[1].Name) } -func TestFilterRidesByRouteAvailability(t *testing.T) { +func TestFilterRidesByBikeAndRideType(t *testing.T) { items := []ride.StoredRide{ - {Name: "recorded", GPXPath: "recorded.gpx"}, - {Name: "metadata only"}, + {Name: "road training", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining, GPXPath: "a.gpx"}, + {Name: "mtb race", BikeType: ride.BikeTypeMTB, RideType: ride.RideTypeRace, GPXPath: "b.gpx"}, + {Name: "road race", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeRace, GPXPath: "c.gpx"}, } - available := filterRides(items, parseRideFilters(url.Values{rideFilterRouteParam: {rideRouteFilterAvailable}})) - missing := filterRides(items, parseRideFilters(url.Values{rideFilterRouteParam: {rideRouteFilterMissing}})) + road := filterRides(items, parseRideFilters(url.Values{rideFilterBikeTypeParam: {string(ride.BikeTypeRoad)}})) + race := filterRides(items, parseRideFilters(url.Values{rideFilterRideTypeParam: {string(ride.RideTypeRace)}})) + roadRace := filterRides(items, parseRideFilters(url.Values{rideFilterBikeTypeParam: {string(ride.BikeTypeRoad)}, rideFilterRideTypeParam: {string(ride.RideTypeRace)}})) - require.Len(t, available, 1) - assert.Equal(t, "recorded", available[0].Name) - require.Len(t, missing, 1) - assert.Equal(t, "metadata only", missing[0].Name) + require.Len(t, road, 2) + require.Len(t, race, 2) + require.Len(t, roadRace, 1) + assert.Equal(t, "road race", roadRace[0].Name) } func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) { @@ -132,6 +138,8 @@ func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) { GPXPath: testGPXPath(t, "current-cotacol.gpx"), Name: "Current score", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } @@ -140,6 +148,8 @@ func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) { GPXPath: testGPXPath(t, "stale-cotacol.gpx"), Name: "Stale score", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } @@ -147,6 +157,8 @@ func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) { ExternalID: "strava:7ba7b810-9dad-41d1-80b4-00c04fd430c8", Name: "No score", Type: "VirtualRide", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 3, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } @@ -190,7 +202,8 @@ func TestHandlerRendersRideFilterControls(t *testing.T) { assert.Contains(t, body, `name="duration_min"`) assert.Contains(t, body, `name="date_from"`) assert.Contains(t, body, `name="cotacol_min"`) - assert.Contains(t, body, `name="route"`) + assert.Contains(t, body, `name="bike_type"`) + assert.Contains(t, body, `name="ride_type"`) assert.Contains(t, body, ``) } @@ -198,12 +211,12 @@ func TestRideSortHeadersPreserveFilters(t *testing.T) { filters := parseRideFilters(url.Values{ rideFilterDistanceMinParam: {"50"}, rideFilterDistanceMaxParam: {"150"}, - rideFilterRouteParam: {rideRouteFilterAvailable}, + rideFilterBikeTypeParam: {string(ride.BikeTypeRoad)}, }) headers := rideSortHeadersWithFilters(RideSort{Column: rideSortDistance, Descending: true}, filters) - assert.Equal(t, "/?dir=asc&distance_max=150&distance_min=50&route=available&sort=name", headers[0].URL) + assert.Equal(t, "/?bike_type=road&dir=asc&distance_max=150&distance_min=50&sort=name", headers[0].URL) } func TestHandlerFiltersRidesBeforeRenderingSortedResults(t *testing.T) { @@ -214,6 +227,8 @@ func TestHandlerFiltersRidesBeforeRenderingSortedResults(t *testing.T) { GPXPath: testGPXPath(t, path), Name: name, Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: distance, })) @@ -239,6 +254,8 @@ func TestHandlerRendersFilteredEmptyState(t *testing.T) { GPXPath: testGPXPath(t, "empty-filter.gpx"), Name: "Short Ride", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, })) diff --git a/web/rides.go b/web/rides.go index 477f066..81017d9 100644 --- a/web/rides.go +++ b/web/rides.go @@ -94,28 +94,9 @@ func (s *Server) handleAnalytics(w http.ResponseWriter, r *http.Request) { return } filters := parseRideFilters(r.URL.Query()) - minKm := minimumDisplayedDistanceM / 1000 - if filters.DistanceMinKm != nil && *filters.DistanceMinKm > minKm { - minKm = *filters.DistanceMinKm - } - maxKm := 0 - if filters.DistanceMaxKm != nil { - maxKm = *filters.DistanceMaxKm - } - eligible := make([]ride.StoredRide, 0, len(items)) - for _, item := range items { - distanceKm := int(item.DistanceM / 1000) - if distanceKm < minKm || (maxKm != 0 && distanceKm > maxKm) { - continue - } - eligible = append(eligible, item) - } - filterView := RideFilterView{ - DistanceMinKm: rideFilterValue(filters.DistanceMinKm, rideFilterDistanceMinKm), - DistanceMaxKm: rideFilterValue(filters.DistanceMaxKm, rideFilterDistanceMaxKm), - Active: filters.Active(), - ResetURL: "/analytics", - } + eligible := filterRides(items, filters) + filterView := buildRideFilterView(filters, RideSort{}) + filterView.ResetURL = "/analytics" slog.Info("Loaded ride analytics", "rides", len(eligible)) kcore.RenderPage(r.Context(), AnalyticsPage(AnalyticsPageData{ Points: buildAnalyticsPoints(eligible), @@ -197,15 +178,11 @@ func (s *Server) listRides(rideSort RideSort) ([]ride.StoredRide, error) { return ride.ListSorted(s.db, column, rideSort.Descending) } -const minimumDisplayedDistanceM = 10_000 const rideDetailMountainPassMarginM = 10_000 func buildRideViews(items []ride.StoredRide) []RideView { views := make([]RideView, 0, len(items)) for _, item := range items { - if item.DistanceM < minimumDisplayedDistanceM { - continue - } views = append(views, buildRideView(item)) } return views diff --git a/web/server_test.go b/web/server_test.go index ebedcd6..b3f6028 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -77,6 +77,8 @@ func TestHandlerRendersRidesPage(t *testing.T) { GPXPath: testGPXPath(t, "long.gpx"), Name: "Long Ride", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, })) @@ -85,6 +87,8 @@ func TestHandlerRendersRidesPage(t *testing.T) { GPXPath: testGPXPath(t, "short.gpx"), Name: "Short Ride", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), DistanceM: 9_999, })) @@ -97,7 +101,7 @@ func TestHandlerRendersRidesPage(t *testing.T) { assert.NotContains(t, response.Body.String(), "All rides") assert.NotContains(t, response.Body.String(), "Your imported rides, ready for climb analysis.") assert.Contains(t, response.Body.String(), "Long Ride") - assert.NotContains(t, response.Body.String(), "Short Ride") + assert.Contains(t, response.Body.String(), "Short Ride") assert.Contains(t, response.Body.String(), "Cotacol") assert.Contains(t, response.Body.String(), "Cotacol / 100 km") assert.Contains(t, response.Body.String(), `href="/rides/1"`) @@ -110,6 +114,8 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { GPXPath: testGPXPath(t, "detail.gpx"), Name: "Detailed Ride", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, })) @@ -272,6 +278,8 @@ func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) { GPXPath: routePath, Name: "Climb Candidate", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 2_000, })) @@ -328,6 +336,8 @@ func TestHandlerRendersRideDetailWithoutRoute(t *testing.T) { ExternalID: "strava:14701658670", Name: "Indoor Ride", Type: "VirtualRide", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } @@ -353,6 +363,8 @@ func TestHandlerRendersAnalyticsChart(t *testing.T) { ExternalID: "strava:550e8400-e29b-41d4-a716-446655440001", Name: "Fastish Ride", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 40_000, } @@ -360,6 +372,8 @@ func TestHandlerRendersAnalyticsChart(t *testing.T) { ExternalID: "strava:550e8400-e29b-41d4-a716-446655440002", Name: "Climbing Ride", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), DistanceM: 40_000, } @@ -395,6 +409,8 @@ func TestHandlerFiltersAnalyticsByDistance(t *testing.T) { ExternalID: "strava:550e8400-e29b-41d4-a716-446655440003", Name: "Short Ride", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 40_000, } @@ -402,6 +418,8 @@ func TestHandlerFiltersAnalyticsByDistance(t *testing.T) { ExternalID: "strava:550e8400-e29b-41d4-a716-446655440004", Name: "Long Ride", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), DistanceM: 100_000, } @@ -716,6 +734,8 @@ func TestBuildRideViewsUsesStoredCotacol(t *testing.T) { GPXPath: testGPXPath(t, "stored.gpx"), Name: "Stored Ride", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } @@ -756,6 +776,8 @@ func TestHandlerSortsRidesByDistance(t *testing.T) { GPXPath: testGPXPath(t, "near.gpx"), Name: "Near Ride", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, })) @@ -764,6 +786,8 @@ func TestHandlerSortsRidesByDistance(t *testing.T) { GPXPath: testGPXPath(t, "far.gpx"), Name: "Far Ride", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), DistanceM: 40_000, })) @@ -785,6 +809,8 @@ func TestHandlerSortsRidesByCotacol(t *testing.T) { GPXPath: testGPXPath(t, "first.gpx"), Name: "Low Cotacol", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } @@ -793,6 +819,8 @@ func TestHandlerSortsRidesByCotacol(t *testing.T) { GPXPath: testGPXPath(t, "second.gpx"), Name: "High Cotacol", Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), DistanceM: 40_000, } diff --git a/web/static/style.css b/web/static/style.css index 491a54e..4ada7b1 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -107,7 +107,8 @@ th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text- .range-input::-moz-range-thumb { width: 1rem; height: 1rem; border: 2px solid var(--color-surface); border-radius: 50%; background: var(--color-accent); box-shadow: 0 .15rem .4rem var(--color-panel-shadow); appearance: none; pointer-events: auto; cursor: pointer; } .range-input:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 .2rem var(--color-focus); } .range-input:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 .2rem var(--color-focus); } -.ride-filter-meta { display: grid; grid-template-columns: minmax(0, 2fr) minmax(12rem, 1fr); align-items: end; gap: 1rem; } +.ride-filter-meta { display: grid; grid-template-columns: minmax(0, 1.6fr) repeat(2, minmax(8.5rem, 1fr)); align-items: end; gap: 1rem; } +.analytics-filters-panel .ride-filter-meta { grid-template-columns: repeat(2, minmax(9rem, 1fr)); max-width: 22rem; } .date-filter-inputs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; margin-top: .5rem; } .route-filter { align-self: end; } select { border: 1px solid var(--color-input-border); border-radius: .55rem; padding: .65rem .7rem; font: inherit; color: inherit; background: var(--color-surface); } diff --git a/web/templates.templ b/web/templates.templ index 6c3b09d..15e3466 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -46,22 +46,35 @@ templ RidesContent(data RidesPageData) { Started
- + @@ -110,6 +123,40 @@ templ AnalyticsContent(data AnalyticsPageData) {
@RideRangeFilter(rideDistanceFilterConfig(data.Filter))
+
+ + +
-- cgit v1.3