From 827b55f610119d553b965ceb4d3631f0481bbb3b Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Fri, 28 Aug 2026 22:38:57 +0200 Subject: Rename rides.type to external_sport_type, infer bike type from Strava sport type and display categories in UI --- ...31000000_rename_type_to_external_sport_type.sql | 5 + db/schema.sql | 5 +- ride/category.go | 11 + ride/store.go | 17 +- ride/store_test.go | 4 +- strava/sync.go | 2 +- web/categories.go | 21 ++ web/ride-filters_test.go | 94 ++++---- web/server_test.go | 242 ++++++++++----------- web/sync.go | 8 +- web/templates.templ | 6 +- 11 files changed, 231 insertions(+), 184 deletions(-) create mode 100644 db/migrations/20260831000000_rename_type_to_external_sport_type.sql diff --git a/db/migrations/20260831000000_rename_type_to_external_sport_type.sql b/db/migrations/20260831000000_rename_type_to_external_sport_type.sql new file mode 100644 index 0000000..5a734c1 --- /dev/null +++ b/db/migrations/20260831000000_rename_type_to_external_sport_type.sql @@ -0,0 +1,5 @@ +-- migrate:up +ALTER TABLE rides RENAME COLUMN type TO external_sport_type; + +-- migrate:down +ALTER TABLE rides RENAME COLUMN external_sport_type TO type; diff --git a/db/schema.sql b/db/schema.sql index 79f4c62..a97b7f1 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -4,7 +4,7 @@ CREATE TABLE rides ( external_id text unique not null, gpx_path text not null, name text not null, - type text not null, + external_sport_type text not null, start_date text not null, distance_m real not null, external_moving_time_s integer not null, @@ -44,4 +44,5 @@ INSERT INTO "schema_migrations" (version) VALUES ('20260823000000'), ('20260827000000'), ('20260828000000'), - ('20260829000000'); + ('20260829000000'), + ('20260831000000'); diff --git a/ride/category.go b/ride/category.go index 6a3431c..3f6cf89 100644 --- a/ride/category.go +++ b/ride/category.go @@ -7,6 +7,17 @@ const ( BikeTypeMTB BikeType = "mtb" ) +func BikeTypeForExternalSportType(value string) (BikeType, bool) { + switch value { + case "Ride": + return BikeTypeRoad, true + case "MountainBikeRide": + return BikeTypeMTB, true + default: + return "", false + } +} + type RideType string const ( diff --git a/ride/store.go b/ride/store.go index 9429acf..cc053ba 100644 --- a/ride/store.go +++ b/ride/store.go @@ -19,7 +19,7 @@ type StoredRide struct { GPXPath string FitPath string Name string - Type string + ExternalSportType string StartDate time.Time DistanceM float64 ExternalMovingTimeS int64 @@ -49,7 +49,7 @@ const ( SortCotacolKm SortColumn = "cotacol_100km" ) -const columns = "id, external_id, gpx_path, fit_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, bike_type, ride_type, created_at, updated_at" +const columns = "id, external_id, gpx_path, fit_path, name, external_sport_type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, bike_type, ride_type, created_at, updated_at" func Save(db *sql.DB, r StoredRide) error { var cotacolScore any @@ -83,13 +83,13 @@ func Save(db *sql.DB, r StoredRide) error { return fmt.Errorf("invalid ride_type %q", r.RideType) } _, err := db.Exec(` - INSERT INTO rides (external_id, gpx_path, fit_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, bike_type, ride_type) + INSERT INTO rides (external_id, gpx_path, fit_path, name, external_sport_type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, bike_type, ride_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(external_id) DO UPDATE SET gpx_path = excluded.gpx_path, fit_path = excluded.fit_path, name = excluded.name, - type = excluded.type, + external_sport_type = excluded.external_sport_type, start_date = excluded.start_date, distance_m = excluded.distance_m, external_moving_time_s = excluded.external_moving_time_s, @@ -103,7 +103,7 @@ func Save(db *sql.DB, r StoredRide) error { bike_type = CASE WHEN excluded.bike_type = '' THEN rides.bike_type ELSE excluded.bike_type END, ride_type = CASE WHEN excluded.ride_type = '' THEN rides.ride_type ELSE excluded.ride_type END, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - `, r.ExternalID, r.GPXPath, r.FitPath, r.Name, r.Type, r.StartDate.UTC().Format(time.RFC3339), r.DistanceM, r.ExternalMovingTimeS, movingTimeS, movingTimeAlgorithmVersion, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps, cotacolScore, cotacolAlgorithmVersion, string(r.BikeType), string(r.RideType)) + `, r.ExternalID, r.GPXPath, r.FitPath, r.Name, r.ExternalSportType, r.StartDate.UTC().Format(time.RFC3339), r.DistanceM, r.ExternalMovingTimeS, movingTimeS, movingTimeAlgorithmVersion, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps, cotacolScore, cotacolAlgorithmVersion, string(r.BikeType), string(r.RideType)) return err } @@ -173,6 +173,11 @@ func Backfill(db *sql.DB, reporters ...func(current, total int)) (int, error) { slog.Warn("Failed to stat FIT file", "ride", item.ExternalID, "file", fitPath, "error", err) } } + if item.RideType == "" { + if inferred, ok := BikeTypeForExternalSportType(item.ExternalSportType); ok { + item.BikeType = inferred + } + } if err := Save(db, item); err != nil { slog.Warn("Failed to backfill ride values", "ride", item.ExternalID, "file", item.GPXPath, "error", err) continue @@ -276,7 +281,7 @@ func scanRide(s scanner) (StoredRide, error) { createdAt string updatedAt string ) - err := s.Scan(&ride.ID, &ride.ExternalID, &ride.GPXPath, &ride.FitPath, &ride.Name, &ride.Type, &startDate, &ride.DistanceM, &ride.ExternalMovingTimeS, &movingTimeS, &movingTimeAlgoVer, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &cotacolScore, &cotacolAlgoVersion, &bikeType, &rideType, &createdAt, &updatedAt) + err := s.Scan(&ride.ID, &ride.ExternalID, &ride.GPXPath, &ride.FitPath, &ride.Name, &ride.ExternalSportType, &startDate, &ride.DistanceM, &ride.ExternalMovingTimeS, &movingTimeS, &movingTimeAlgoVer, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &cotacolScore, &cotacolAlgoVersion, &bikeType, &rideType, &createdAt, &updatedAt) if err != nil { return StoredRide{}, err } diff --git a/ride/store_test.go b/ride/store_test.go index 1fca056..a629798 100644 --- a/ride/store_test.go +++ b/ride/store_test.go @@ -35,7 +35,7 @@ func sampleRide(t *testing.T) StoredRide { GPXPath: gpxPath, FitPath: fitPath, Name: "Morning Ride", - Type: "Ride", + ExternalSportType: "Ride", StartDate: time.Date(2026, 8, 1, 7, 30, 0, 0, time.UTC), DistanceM: 42_195, ExternalMovingTimeS: 7_200, @@ -225,7 +225,7 @@ func TestBackfillReportsProgress(t *testing.T) { func TestBackfillSkipsInvalidRide(t *testing.T) { db := newTestDB(t) _, err := db.Exec(` - INSERT INTO rides (external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps) + INSERT INTO rides (external_id, gpx_path, name, external_sport_type, start_date, distance_m, external_moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, "strava:550e8400-e29b-41d4-a716-446655440000", "missing.gpx", "Broken Ride", "Ride", "2026-08-01T07:00:00Z", 20_000, 0, 0, 0, 0) require.NoError(t, err) diff --git a/strava/sync.go b/strava/sync.go index 34559de..b88c799 100644 --- a/strava/sync.go +++ b/strava/sync.go @@ -32,7 +32,7 @@ type Activity struct { } func (c *Client) List(from, to time.Time) ([]Activity, error) { - return c.list(from, to, "Ride") + return c.list(from, to, "Ride", "MountainBikeRide") } func (c *Client) list(from, to time.Time, types ...string) ([]Activity, error) { diff --git a/web/categories.go b/web/categories.go index 74d871c..6ef8c10 100644 --- a/web/categories.go +++ b/web/categories.go @@ -74,3 +74,24 @@ func formatCategoryOrEmpty(value string) string { } return value } + +func formatRideCategories(r ride.StoredRide) string { + bikeLabel, hasBike := bikeTypeLabels[r.BikeType] + rideLabel, hasRide := rideTypeLabels[r.RideType] + switch { + case hasBike && hasRide: + return bikeLabel + " · " + rideLabel + case hasBike: + return bikeLabel + case hasRide: + return rideLabel + default: + if r.BikeType != "" { + return string(r.BikeType) + } + if r.RideType != "" { + return string(r.RideType) + } + return "—" + } +} diff --git a/web/ride-filters_test.go b/web/ride-filters_test.go index b7abcf4..5075925 100644 --- a/web/ride-filters_test.go +++ b/web/ride-filters_test.go @@ -169,33 +169,33 @@ func TestFilterRidesByRideType(t *testing.T) { func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) { _, db := newWebTestServer(t) current := ride.StoredRide{ - ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", - 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, + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", + GPXPath: testGPXPath(t, "current-cotacol.gpx"), + Name: "Current score", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, } stale := ride.StoredRide{ - ExternalID: "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", - 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, + ExternalID: "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", + GPXPath: testGPXPath(t, "stale-cotacol.gpx"), + Name: "Stale score", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, } unavailable := ride.StoredRide{ - 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, + ExternalID: "strava:7ba7b810-9dad-41d1-80b4-00c04fd430c8", + Name: "No score", + ExternalSportType: "VirtualRide", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 3, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, } require.NoError(t, ride.Save(db, current)) require.NoError(t, ride.Save(db, stale)) @@ -248,14 +248,14 @@ 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, + ExternalID: externalID, + GPXPath: testGPXPath(t, externalID+".gpx"), + Name: name, + ExternalSportType: "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) @@ -297,14 +297,14 @@ func TestHandlerFiltersRidesBeforeRenderingSortedResults(t *testing.T) { server, db := newWebTestServer(t) save := func(externalID, name, path string, distance float64) { require.NoError(t, ride.Save(db, ride.StoredRide{ - ExternalID: externalID, - 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, + ExternalID: externalID, + GPXPath: testGPXPath(t, path), + Name: name, + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: distance, })) } save("strava:550e8400-e29b-41d4-a716-446655440000", "Long Ride", "long-filtered.gpx", 40_000) @@ -324,14 +324,14 @@ func TestHandlerFiltersRidesBeforeRenderingSortedResults(t *testing.T) { func TestHandlerRendersFilteredEmptyState(t *testing.T) { server, db := newWebTestServer(t) require.NoError(t, ride.Save(db, ride.StoredRide{ - ExternalID: "strava:8ba7b810-9dad-41d1-80b4-00c04fd430c8", - 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, + ExternalID: "strava:8ba7b810-9dad-41d1-80b4-00c04fd430c8", + GPXPath: testGPXPath(t, "empty-filter.gpx"), + Name: "Short Ride", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, })) request := httptest.NewRequest(http.MethodGet, "/?distance_min=300&distance_max=300", nil) diff --git a/web/server_test.go b/web/server_test.go index d75628d..2e9a332 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -89,24 +89,24 @@ const officialClimbTrackGPX = ` for _, item := range data.Items { - { item.Ride.Name }
{ item.Ride.Type }{ formatRideDate(item.Ride.StartDate) }{ formatDistance(item.Ride.DistanceM) }{ item.MovingTime }{ formatSpeed(item.Ride.AverageSpeedMps) }{ formatElevation(item.Ride.TotalElevationGainM) }{ item.Cotacol }{ item.CotacolPer100Km } + { item.Ride.Name }
{ formatRideCategories(item.Ride) }{ formatRideDate(item.Ride.StartDate) }{ formatDistance(item.Ride.DistanceM) }{ item.MovingTime }{ formatSpeed(item.Ride.AverageSpeedMps) }{ formatElevation(item.Ride.TotalElevationGainM) }{ item.Cotacol }{ item.CotacolPer100Km } } @@ -206,7 +206,7 @@ templ RideDetailPage(data RideDetailView) { templ RideDetailContent(data RideDetailView) {
-

{ data.Ride.Name }

{ data.Ride.Type } · { formatRideDate(data.Ride.StartDate) }

+

{ data.Ride.Name }

{ formatRideCategories(data.Ride) } · { formatRideDate(data.Ride.StartDate) }

if data.ExternalURL != "" { View on Strava @@ -448,7 +448,7 @@ templ DataQualityContent(data DataQualityPageData) { for _, item := range data.Items { - { item.Ride.Name }
{ item.Ride.Type } + { item.Ride.Name }
{ formatRideCategories(item.Ride) } { formatRideDate(item.Ride.StartDate) } { formatDistance(item.Ride.DistanceM) } { formatCategoryOrEmpty(string(item.Ride.BikeType)) } -- cgit v1.3