From 4dcc0a4e655b0178a7733954bf75df3cff9ef07a Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Mon, 24 Aug 2026 13:36:41 +0200 Subject: Ride type multiselect filter with a checkbox group across library and analytics --- README.md | 1 + web/ride-filters.go | 83 ++++++++++++++++++++++++++++++++++++------------ web/ride-filters_test.go | 78 +++++++++++++++++++++++++++++++++++++++++++-- web/static/style.css | 4 +++ web/templates.templ | 40 +++++++---------------- 5 files changed, 155 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 9b24b65..40e9346 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c - **Strava import progress** — streams live import progress with an aggregate progress bar and prevents overlapping syncs - **Analytics second chart** — plots normalized difficulty (Cotacol per 100 km) against moving duration alongside speed vs. Cotacol - **Ride categorisation** — mandatory bike type (road/mtb) and ride type (training/indoor/race/transport) per ride, bulk-categorised via a data-quality todo, filtered on library and analytics, and benchmarked excluding indoor/transport +- **Ride type multiselect filter** — pick multiple ride types at once via a checkbox group on the library and analytics, defaulting to all except transport ## Getting started diff --git a/web/ride-filters.go b/web/ride-filters.go index de8e242..2282b8d 100644 --- a/web/ride-filters.go +++ b/web/ride-filters.go @@ -43,7 +43,7 @@ type RideFilters struct { CotacolMinPoints *int CotacolMaxPoints *int BikeType *ride.BikeType - RideType *ride.RideType + RideTypes []ride.RideType } type RideFilterView struct { @@ -57,7 +57,7 @@ type RideFilterView struct { CotacolMinPoints int CotacolMaxPoints int BikeType string - RideType string + RideTypes []ride.RideType ResetURL string } @@ -140,7 +140,7 @@ func parseRideFilters(query url.Values) RideFilters { CotacolMinPoints: parseRideFilterInt(query.Get(rideFilterCotacolMinParam), rideFilterCotacolMinPoints, rideFilterCotacolMaxPoints, rideFilterCotacolStepPoints), CotacolMaxPoints: parseRideFilterInt(query.Get(rideFilterCotacolMaxParam), rideFilterCotacolMinPoints, rideFilterCotacolMaxPoints, rideFilterCotacolStepPoints), BikeType: parseRideBikeTypeFilter(query.Get(rideFilterBikeTypeParam)), - RideType: parseRideRideTypeFilter(query.Get(rideFilterRideTypeParam)), + RideTypes: parseRideRideTypesFilter(query[rideFilterRideTypeParam]), DateFrom: parseRideFilterDate(query.Get(rideFilterDateFromParam), false), DateToExclusive: parseRideFilterDate(query.Get(rideFilterDateToParam), true)} @@ -212,15 +212,60 @@ func parseRideBikeTypeFilter(value string) *ride.BikeType { return &bikeType } -func parseRideRideTypeFilter(value string) *ride.RideType { - if value == "" { - return nil +func parseRideRideTypesFilter(values []string) []ride.RideType { + set := make([]ride.RideType, 0, len(values)) + for _, value := range values { + if !ride.IsValidRideType(value) { + continue + } + rideType := ride.RideType(value) + if containsRideType(set, rideType) { + continue + } + set = append(set, rideType) } - if !ride.IsValidRideType(value) { - return nil + if len(set) == 0 { + return defaultRideTypes() + } + return set +} + +// defaultRideTypes is the ride type set represented by an absent ride_type +// filter: every ride type except transport. +func defaultRideTypes() []ride.RideType { + return []ride.RideType{ + ride.RideTypeTraining, + ride.RideTypeIndoor, + ride.RideTypeRace, + } +} + +func containsRideType(types []ride.RideType, target ride.RideType) bool { + for _, candidate := range types { + if candidate == target { + return true + } } - rideType := ride.RideType(value) - return &rideType + return false +} + +func rideTypesEqual(a, b []ride.RideType) bool { + if len(a) != len(b) { + return false + } + for _, candidate := range a { + if !containsRideType(b, candidate) { + return false + } + } + return true +} + +func rideTypeFilterActive(types []ride.RideType) bool { + if len(types) == 0 { + return false + } + return !rideTypesEqual(types, defaultRideTypes()) } func buildRideFilterView(filters RideFilters, sort RideSort) RideFilterView { @@ -236,10 +281,6 @@ func buildRideFilterView(filters RideFilters, sort RideSort) RideFilterView { 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), @@ -251,7 +292,7 @@ func buildRideFilterView(filters RideFilters, sort RideSort) RideFilterView { CotacolMinPoints: rideFilterValue(filters.CotacolMinPoints, rideFilterCotacolMinPoints), CotacolMaxPoints: rideFilterValue(filters.CotacolMaxPoints, rideFilterCotacolMaxPoints), BikeType: bikeType, - RideType: rideType, + RideTypes: filters.RideTypes, ResetURL: rideFiltersResetURL(sort), } } @@ -317,8 +358,10 @@ func rideFilterQuery(filters RideFilters) url.Values { if filters.BikeType != nil { query.Set(rideFilterBikeTypeParam, string(*filters.BikeType)) } - if filters.RideType != nil { - query.Set(rideFilterRideTypeParam, string(*filters.RideType)) + if len(filters.RideTypes) > 0 && !rideTypesEqual(filters.RideTypes, defaultRideTypes()) { + for _, rideType := range filters.RideTypes { + query.Add(rideFilterRideTypeParam, string(rideType)) + } } return query } @@ -343,7 +386,7 @@ func (filters RideFilters) Active() bool { filters.CotacolMinPoints != nil || filters.CotacolMaxPoints != nil || filters.BikeType != nil || - filters.RideType != nil + rideTypeFilterActive(filters.RideTypes) } func filterRides(items []ride.StoredRide, filters RideFilters) []ride.StoredRide { @@ -352,7 +395,7 @@ func filterRides(items []ride.StoredRide, filters RideFilters) []ride.StoredRide if !item.IsCategorised() { continue } - if filters.Active() && !rideMatchesFilters(item, filters) { + if !rideMatchesFilters(item, filters) { continue } filtered = append(filtered, item) @@ -395,7 +438,7 @@ func rideMatchesFilters(item ride.StoredRide, filters RideFilters) bool { if filters.BikeType != nil && item.BikeType != *filters.BikeType { return false } - if filters.RideType != nil && item.RideType != *filters.RideType { + if len(filters.RideTypes) > 0 && !containsRideType(filters.RideTypes, item.RideType) { return false } return true diff --git a/web/ride-filters_test.go b/web/ride-filters_test.go index fd3ee13..e4efc02 100644 --- a/web/ride-filters_test.go +++ b/web/ride-filters_test.go @@ -38,7 +38,7 @@ func TestParseRideFilters(t *testing.T) { assert.Equal(t, 50, *filters.CotacolMinPoints) assert.Equal(t, 500, *filters.CotacolMaxPoints) assert.Equal(t, ride.BikeTypeRoad, *filters.BikeType) - assert.Equal(t, ride.RideTypeTraining, *filters.RideType) + assert.Equal(t, []ride.RideType{ride.RideTypeTraining}, filters.RideTypes) assert.True(t, filters.Active()) } @@ -68,7 +68,7 @@ func TestParseRideFiltersIgnoresInvalidValues(t *testing.T) { assert.Nil(t, filters.CotacolMinPoints) assert.Nil(t, filters.CotacolMaxPoints) assert.Nil(t, filters.BikeType) - assert.Nil(t, filters.RideType) + assert.Equal(t, defaultRideTypes(), filters.RideTypes) } func TestParseRideFiltersTreatsFullRangesAsInactive(t *testing.T) { @@ -114,7 +114,40 @@ func TestFilterRidesUsesInclusiveBoundsAndMovingTime(t *testing.T) { assert.Equal(t, "upper boundary", filtered[1].Name) } -func TestFilterRidesByBikeAndRideType(t *testing.T) { +func TestFilterRidesDefaultsToAllRideTypesExceptTransport(t *testing.T) { + items := []ride.StoredRide{ + {Name: "training", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining, GPXPath: "a.gpx"}, + {Name: "race", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeRace, GPXPath: "b.gpx"}, + {Name: "indoor", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeIndoor, GPXPath: "c.gpx"}, + {Name: "transport", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTransport, GPXPath: "d.gpx"}, + } + + filtered := filterRides(items, parseRideFilters(url.Values{})) + + require.Len(t, filtered, 3) + assert.False(t, parseRideFilters(url.Values{}).Active()) + for _, item := range filtered { + assert.NotEqual(t, ride.RideTypeTransport, item.RideType) + } +} + +func TestFilterRidesByMultipleRideTypes(t *testing.T) { + items := []ride.StoredRide{ + {Name: "training", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining, GPXPath: "a.gpx"}, + {Name: "race", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeRace, GPXPath: "b.gpx"}, + {Name: "indoor", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeIndoor, GPXPath: "c.gpx"}, + } + filters := parseRideFilters(url.Values{rideFilterRideTypeParam: {string(ride.RideTypeTraining), string(ride.RideTypeRace)}}) + + filtered := filterRides(items, filters) + + require.Len(t, filtered, 2) + assert.True(t, filters.Active()) + assert.Equal(t, ride.RideTypeTraining, filtered[0].RideType) + assert.Equal(t, ride.RideTypeRace, filtered[1].RideType) +} + +func TestFilterRidesByRideType(t *testing.T) { items := []ride.StoredRide{ {Name: "road training", BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining, GPXPath: "a.gpx"}, {Name: "mtb race", BikeType: ride.BikeTypeMTB, RideType: ride.RideTypeRace, GPXPath: "b.gpx"}, @@ -204,9 +237,40 @@ func TestHandlerRendersRideFilterControls(t *testing.T) { assert.Contains(t, body, `name="cotacol_min"`) assert.Contains(t, body, `name="bike_type"`) assert.Contains(t, body, `name="ride_type"`) + assert.Contains(t, body, `value="training" checked`) + assert.NotContains(t, body, `value="transport" checked`) assert.Contains(t, body, ``) } +func TestHandlerHidesTransportRidesByDefault(t *testing.T) { + server, db := newWebTestServer(t) + save := func(externalID, name string, rideType ride.RideType) { + require.NoError(t, ride.Save(db, ride.StoredRide{ + ExternalID: externalID, + GPXPath: testGPXPath(t, externalID+".gpx"), + Name: name, + Type: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: rideType, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, + })) + } + save("strava:550e8400-e29b-41d4-a716-446655440000", "Training Ride", ride.RideTypeTraining) + save("strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", "Race Ride", ride.RideTypeRace) + save("strava:7ba7b810-9dad-41d1-80b4-00c04fd430c8", "Transport Ride", ride.RideTypeTransport) + + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + + body := response.Body.String() + assert.Equal(t, http.StatusOK, response.Code) + assert.Contains(t, body, "Training Ride") + assert.Contains(t, body, "Race Ride") + assert.NotContains(t, body, "Transport Ride") +} + func TestRideSortHeadersPreserveFilters(t *testing.T) { filters := parseRideFilters(url.Values{ rideFilterDistanceMinParam: {"50"}, @@ -219,6 +283,14 @@ func TestRideSortHeadersPreserveFilters(t *testing.T) { assert.Equal(t, "/?bike_type=road&dir=asc&distance_max=150&distance_min=50&sort=name", headers[0].URL) } +func TestRideSortHeadersPreserveRideTypeMultiselect(t *testing.T) { + filters := parseRideFilters(url.Values{rideFilterRideTypeParam: {string(ride.RideTypeRace), string(ride.RideTypeTransport)}}) + + headers := rideSortHeadersWithFilters(RideSort{Column: rideSortDistance, Descending: true}, filters) + + assert.Equal(t, "/?dir=asc&ride_type=race&ride_type=transport&sort=name", headers[0].URL) +} + func TestHandlerFiltersRidesBeforeRenderingSortedResults(t *testing.T) { server, db := newWebTestServer(t) save := func(externalID, name, path string, distance float64) { diff --git a/web/static/style.css b/web/static/style.css index 4ada7b1..5442ea8 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -112,6 +112,10 @@ th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text- .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); } +.ride-filter-meta select:focus-visible { outline: .2rem solid var(--color-focus); outline-offset: .1rem; } +.ride-type-options { display: grid; gap: .3rem; margin-top: .5rem; } +.ride-type-option { display: flex; align-items: center; gap: .5rem; color: var(--color-foreground); cursor: pointer; } +.ride-type-option input { accent-color: var(--color-accent); } .ride-filter-actions { display: flex; justify-content: flex-end; gap: .75rem; flex-wrap: wrap; } .map-panel { padding: 0; overflow: hidden; } .ride-map { min-height: 24rem; height: min(65vh, 40rem); } diff --git a/web/templates.templ b/web/templates.templ index 15e3466..c0e6e18 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -62,22 +62,14 @@ templ RidesContent(data RidesPageData) { } - } - - + + @@ -140,22 +132,14 @@ templ AnalyticsContent(data AnalyticsPageData) { } - } - - + + -- cgit v1.3