aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-24 13:36:41 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-24 13:36:41 +0200
commit4dcc0a4e655b0178a7733954bf75df3cff9ef07a (patch)
tree6acca3eba2b94dbed9d759295149cd51551ed9cf
parente85150b7e1f2191e9ef39c969f041d27aec33643 (diff)
Ride type multiselect filter with a checkbox group across library and analytics
-rw-r--r--README.md1
-rw-r--r--web/ride-filters.go83
-rw-r--r--web/ride-filters_test.go78
-rw-r--r--web/static/style.css4
-rw-r--r--web/templates.templ40
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, `<script type="module" src="/static/ride-filters.js"></script>`)
}
+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) {
}
</select>
</label>
- <label for="ride-type-filter">Ride type
- <select id="ride-type-filter" name="ride_type">
- if data.Filters.RideType == "" {
- <option value="" selected>Any type</option>
- } else {
- <option value="">Any type</option>
- }
+ <fieldset class="ride-filter-group">
+ <legend>Ride type</legend>
+ <div class="ride-type-options">
for _, opt := range rideTypeOptions {
- if data.Filters.RideType == string(opt) {
- <option value={ string(opt) } selected>{ rideTypeLabels[opt] }</option>
- } else {
- <option value={ string(opt) }>{ rideTypeLabels[opt] }</option>
- }
+ <label class="ride-type-option"><input type="checkbox" name="ride_type" value={ string(opt) } checked?={ containsRideType(data.Filters.RideTypes, opt) }/><span>{ rideTypeLabels[opt] }</span></label>
}
- </select>
- </label>
+ </div>
+ </fieldset>
</div>
</form>
</section>
@@ -140,22 +132,14 @@ templ AnalyticsContent(data AnalyticsPageData) {
}
</select>
</label>
- <label for="analytics-ride-type">Ride type
- <select id="analytics-ride-type" name="ride_type">
- if data.Filter.RideType == "" {
- <option value="" selected>Any type</option>
- } else {
- <option value="">Any type</option>
- }
+ <fieldset class="ride-filter-group">
+ <legend>Ride type</legend>
+ <div class="ride-type-options">
for _, opt := range rideTypeOptions {
- if data.Filter.RideType == string(opt) {
- <option value={ string(opt) } selected>{ rideTypeLabels[opt] }</option>
- } else {
- <option value={ string(opt) }>{ rideTypeLabels[opt] }</option>
- }
+ <label class="ride-type-option"><input type="checkbox" name="ride_type" value={ string(opt) } checked?={ containsRideType(data.Filter.RideTypes, opt) }/><span>{ rideTypeLabels[opt] }</span></label>
}
- </select>
- </label>
+ </div>
+ </fieldset>
</div>
</form>
</section>