aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-24 09:27:29 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-24 09:27:29 +0200
commit8b921624666141c09cf20e2e0ee3cbe260a6412d (patch)
tree7f708dfe7807dda1f54e4d010d58858d5e7c5999 /web
parent34b8ae8f4c16b86281aa578e8f6891fca3666d37 (diff)
Add type filtering, drop hard-coded filters and route filter, tune backtest
Diffstat (limited to 'web')
-rw-r--r--web/ride-filters.go86
-rw-r--r--web/ride-filters_test.go59
-rw-r--r--web/rides.go29
-rw-r--r--web/server_test.go30
-rw-r--r--web/static/style.css3
-rw-r--r--web/templates.templ71
6 files changed, 187 insertions, 91 deletions
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, `<script type="module" src="/static/ride-filters.js"></script>`)
}
@@ -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) {
<legend>Started</legend>
<div class="date-filter-inputs"><label for="ride-date-from">From<input id="ride-date-from" type="date" name="date_from" value={ data.Filters.DateFrom }/></label><label for="ride-date-to">To<input id="ride-date-to" type="date" name="date_to" value={ data.Filters.DateTo }/></label></div>
</fieldset>
- <label class="route-filter" for="ride-route">Route
- <select id="ride-route" name="route">
- if data.Filters.Route == rideRouteFilterAny {
- <option value="" selected>Any route</option>
+ <label for="ride-bike-type-filter">Bike
+ <select id="ride-bike-type-filter" name="bike_type">
+ if data.Filters.BikeType == "" {
+ <option value="" selected>Any bike</option>
} else {
- <option value="">Any route</option>
+ <option value="">Any bike</option>
}
- if data.Filters.Route == rideRouteFilterAvailable {
- <option value={ rideRouteFilterAvailable } selected>Recorded route</option>
- } else {
- <option value={ rideRouteFilterAvailable }>Recorded route</option>
+ for _, opt := range bikeTypeOptions {
+ if data.Filters.BikeType == string(opt) {
+ <option value={ string(opt) } selected>{ bikeTypeLabels[opt] }</option>
+ } else {
+ <option value={ string(opt) }>{ bikeTypeLabels[opt] }</option>
+ }
}
- if data.Filters.Route == rideRouteFilterMissing {
- <option value={ rideRouteFilterMissing } selected>Metadata only</option>
+ </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={ rideRouteFilterMissing }>Metadata only</option>
+ <option value="">Any type</option>
+ }
+ 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>
+ }
}
</select>
</label>
@@ -110,6 +123,40 @@ templ AnalyticsContent(data AnalyticsPageData) {
<div class="ride-filter-ranges">
@RideRangeFilter(rideDistanceFilterConfig(data.Filter))
</div>
+ <div class="ride-filter-meta">
+ <label for="analytics-bike-type">Bike
+ <select id="analytics-bike-type" name="bike_type">
+ if data.Filter.BikeType == "" {
+ <option value="" selected>Any bike</option>
+ } else {
+ <option value="">Any bike</option>
+ }
+ for _, opt := range bikeTypeOptions {
+ if data.Filter.BikeType == string(opt) {
+ <option value={ string(opt) } selected>{ bikeTypeLabels[opt] }</option>
+ } else {
+ <option value={ string(opt) }>{ bikeTypeLabels[opt] }</option>
+ }
+ }
+ </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>
+ }
+ 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>
+ }
+ }
+ </select>
+ </label>
+ </div>
</form>
</section>
<section class="panel analytics-panel">