diff options
| -rw-r--r-- | db/migrations/20260831000000_rename_type_to_external_sport_type.sql | 5 | ||||
| -rw-r--r-- | db/schema.sql | 5 | ||||
| -rw-r--r-- | ride/category.go | 11 | ||||
| -rw-r--r-- | ride/store.go | 17 | ||||
| -rw-r--r-- | ride/store_test.go | 4 | ||||
| -rw-r--r-- | strava/sync.go | 2 | ||||
| -rw-r--r-- | web/categories.go | 21 | ||||
| -rw-r--r-- | web/ride-filters_test.go | 94 | ||||
| -rw-r--r-- | web/server_test.go | 242 | ||||
| -rw-r--r-- | web/sync.go | 8 | ||||
| -rw-r--r-- | web/templates.templ | 6 |
11 files changed, 231 insertions, 184 deletions
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 = `<?xml version="1.0"?><gpx xmlns="http://www.topog func TestHandlerRendersRidesPage(t *testing.T) { server, db := newWebTestServer(t) require.NoError(t, ride.Save(db, ride.StoredRide{ - ExternalID: "strava:1234", - 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, + ExternalID: "strava:1234", + GPXPath: testGPXPath(t, "long.gpx"), + Name: "Long Ride", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, })) require.NoError(t, ride.Save(db, ride.StoredRide{ - ExternalID: "strava:5678", - 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, + ExternalID: "strava:5678", + GPXPath: testGPXPath(t, "short.gpx"), + Name: "Short Ride", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), + DistanceM: 9_999, })) req := httptest.NewRequest(http.MethodGet, "/", nil) response := httptest.NewRecorder() @@ -126,14 +126,14 @@ func TestHandlerRendersRidesPage(t *testing.T) { func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { server, db := newWebTestServer(t) require.NoError(t, ride.Save(db, ride.StoredRide{ - ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", - 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, + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", + GPXPath: testGPXPath(t, "detail.gpx"), + Name: "Detailed Ride", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, })) _, err := db.Exec(` INSERT INTO mountain_passes (osm_id, name, elevation, latitude, longitude) @@ -275,14 +275,14 @@ func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) { routePath := filepath.Join(t.TempDir(), "official-climb.gpx") require.NoError(t, os.WriteFile(routePath, []byte(officialClimbTrackGPX), 0o600)) require.NoError(t, ride.Save(db, ride.StoredRide{ - ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", - 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, + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", + GPXPath: routePath, + Name: "Climb Candidate", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 2_000, })) item, found, err := ride.GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000") require.NoError(t, err) @@ -334,13 +334,13 @@ func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) { func TestHandlerRendersRideDetailWithoutRoute(t *testing.T) { server, db := newWebTestServer(t) item := ride.StoredRide{ - 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, + ExternalID: "strava:14701658670", + Name: "Indoor Ride", + ExternalSportType: "VirtualRide", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, } require.NoError(t, ride.Save(db, item)) stored, found, err := ride.GetByExternalID(db, item.ExternalID) @@ -361,22 +361,22 @@ func TestHandlerRendersRideDetailWithoutRoute(t *testing.T) { func TestHandlerRendersAnalyticsChart(t *testing.T) { server, db := newWebTestServer(t) first := ride.StoredRide{ - 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, + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440001", + Name: "Fastish Ride", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 40_000, } second := ride.StoredRide{ - 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, + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440002", + Name: "Climbing Ride", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), + DistanceM: 40_000, } require.NoError(t, ride.Save(db, first)) require.NoError(t, ride.Save(db, second)) @@ -407,22 +407,22 @@ func TestHandlerRendersAnalyticsChart(t *testing.T) { func TestHandlerFiltersAnalyticsByDistance(t *testing.T) { server, db := newWebTestServer(t) short := ride.StoredRide{ - 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, + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440003", + Name: "Short Ride", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 40_000, } long := ride.StoredRide{ - 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, + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440004", + Name: "Long Ride", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), + DistanceM: 100_000, } require.NoError(t, ride.Save(db, short)) require.NoError(t, ride.Save(db, long)) @@ -462,7 +462,7 @@ func TestHandlerReturnsNotFoundForUnknownRide(t *testing.T) { func TestHandlerShowsErrorForUnavailableRideRoute(t *testing.T) { server, db := newWebTestServer(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:6ba7b810-9dad-41d1-80b4-00c04fd430c8", "missing.gpx", "Unavailable Route", "Ride", "2026-08-01T07:00:00Z", 20_000, 0, 0, 0, 0) require.NoError(t, err) @@ -634,12 +634,12 @@ func TestSyncRidesResyncsTitleFromList(t *testing.T) { startDate := time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC) gpxPath := testGPXPath(t, "existing.gpx") require.NoError(t, ride.Save(db, ride.StoredRide{ - ExternalID: "strava:789", - GPXPath: gpxPath, - Name: "Old Title", - Type: "Ride", - StartDate: startDate, - DistanceM: 20_000, + ExternalID: "strava:789", + GPXPath: gpxPath, + Name: "Old Title", + ExternalSportType: "Ride", + StartDate: startDate, + DistanceM: 20_000, })) // Verify the title was corrected on Strava (ride #789). client := fakeSyncClient{ @@ -666,12 +666,12 @@ func TestSyncRidesSkipsWhenTitleUnchanged(t *testing.T) { server, db := newWebTestServer(t) startDate := time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC) require.NoError(t, ride.Save(db, ride.StoredRide{ - ExternalID: "strava:789", - GPXPath: testGPXPath(t, "existing2.gpx"), - Name: "Same Title", - Type: "Ride", - StartDate: startDate, - DistanceM: 20_000, + ExternalID: "strava:789", + GPXPath: testGPXPath(t, "existing2.gpx"), + Name: "Same Title", + ExternalSportType: "Ride", + StartDate: startDate, + DistanceM: 20_000, })) client := fakeSyncClient{ activities: []strava.Activity{ @@ -805,14 +805,14 @@ func TestRideSortHeadersToggleDirection(t *testing.T) { func TestBuildRideViewsUsesStoredCotacol(t *testing.T) { _, db := newWebTestServer(t) stored := ride.StoredRide{ - ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", - 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, + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", + GPXPath: testGPXPath(t, "stored.gpx"), + Name: "Stored Ride", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, } require.NoError(t, ride.Save(db, stored)) _, err := db.Exec("UPDATE rides SET gpx_path = ? WHERE external_id = ?", "missing.gpx", stored.ExternalID) @@ -831,7 +831,7 @@ func TestBuildRideViewMovingTimePrefersCanonical(t *testing.T) { ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", GPXPath: testGPXPath(t, "moving.gpx"), Name: "Moving Ride", - Type: "Ride", + ExternalSportType: "Ride", StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, ExternalMovingTimeS: 7_200, @@ -847,24 +847,24 @@ func TestBuildRideViewMovingTimePrefersCanonical(t *testing.T) { func TestHandlerSortsRidesByDistance(t *testing.T) { server, db := newWebTestServer(t) require.NoError(t, ride.Save(db, ride.StoredRide{ - ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", - 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, + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", + GPXPath: testGPXPath(t, "near.gpx"), + Name: "Near Ride", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, })) require.NoError(t, ride.Save(db, ride.StoredRide{ - ExternalID: "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", - 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, + ExternalID: "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", + GPXPath: testGPXPath(t, "far.gpx"), + Name: "Far Ride", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), + DistanceM: 40_000, })) req := httptest.NewRequest(http.MethodGet, "/?sort=distance&dir=asc", nil) @@ -880,24 +880,24 @@ func TestHandlerSortsRidesByDistance(t *testing.T) { func TestHandlerSortsRidesByCotacol(t *testing.T) { server, db := newWebTestServer(t) first := ride.StoredRide{ - ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", - 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, + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", + GPXPath: testGPXPath(t, "first.gpx"), + Name: "Low Cotacol", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, } second := ride.StoredRide{ - ExternalID: "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", - 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, + ExternalID: "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", + GPXPath: testGPXPath(t, "second.gpx"), + Name: "High Cotacol", + ExternalSportType: "Ride", + BikeType: ride.BikeTypeRoad, + RideType: ride.RideTypeTraining, + StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), + DistanceM: 40_000, } require.NoError(t, ride.Save(db, first)) require.NoError(t, ride.Save(db, second)) diff --git a/web/sync.go b/web/sync.go index 8ed306f..73e8146 100644 --- a/web/sync.go +++ b/web/sync.go @@ -215,12 +215,12 @@ func rideFromActivity(externalID, gpxPath, fitPath string, activity strava.Activ if activityType == "" { activityType = activity.Type } - return ride.StoredRide{ + stored := ride.StoredRide{ ExternalID: externalID, GPXPath: gpxPath, FitPath: fitPath, Name: activity.Name, - Type: activityType, + ExternalSportType: activityType, StartDate: activity.StartDate, DistanceM: activity.DistanceM, ExternalMovingTimeS: activity.MovingTimeS, @@ -228,6 +228,10 @@ func rideFromActivity(externalID, gpxPath, fitPath string, activity strava.Activ TotalElevationGainM: activity.TotalElevationGainM, AverageSpeedMps: activity.AverageSpeedMps, } + if bikeType, ok := ride.BikeTypeForExternalSportType(activityType); ok { + stored.BikeType = bikeType + } + return stored } func skipSyncActivity(progress *SyncProgress, report func(SyncProgress) error, activity strava.Activity, err error) error { diff --git a/web/templates.templ b/web/templates.templ index db60ca9..5bf6b15 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -91,7 +91,7 @@ templ RidesContent(data RidesPageData) { </tr></thead> <tbody> for _, item := range data.Items { - <tr><td><strong><a href={ templ.URL(rideDetailURL(item.Ride.ID)) }>{ item.Ride.Name }</a></strong><br/><small>{ item.Ride.Type }</small></td><td>{ formatRideDate(item.Ride.StartDate) }</td><td class="numeric">{ formatDistance(item.Ride.DistanceM) }</td><td class="numeric">{ item.MovingTime }</td><td class="numeric">{ formatSpeed(item.Ride.AverageSpeedMps) }</td><td class="numeric">{ formatElevation(item.Ride.TotalElevationGainM) }</td><td class="numeric">{ item.Cotacol }</td><td class="numeric">{ item.CotacolPer100Km }</td></tr> + <tr><td><strong><a href={ templ.URL(rideDetailURL(item.Ride.ID)) }>{ item.Ride.Name }</a></strong><br/><small>{ formatRideCategories(item.Ride) }</small></td><td>{ formatRideDate(item.Ride.StartDate) }</td><td class="numeric">{ formatDistance(item.Ride.DistanceM) }</td><td class="numeric">{ item.MovingTime }</td><td class="numeric">{ formatSpeed(item.Ride.AverageSpeedMps) }</td><td class="numeric">{ formatElevation(item.Ride.TotalElevationGainM) }</td><td class="numeric">{ item.Cotacol }</td><td class="numeric">{ item.CotacolPer100Km }</td></tr> } </tbody> </table> @@ -206,7 +206,7 @@ templ RideDetailPage(data RideDetailView) { templ RideDetailContent(data RideDetailView) { <div class="toolbar"> - <div><h1 class="ride-detail-title">{ data.Ride.Name }</h1><p class="lead">{ data.Ride.Type } · { formatRideDate(data.Ride.StartDate) }</p></div> + <div><h1 class="ride-detail-title">{ data.Ride.Name }</h1><p class="lead">{ formatRideCategories(data.Ride) } · { formatRideDate(data.Ride.StartDate) }</p></div> <div class="toolbar-actions"> if data.ExternalURL != "" { <a class="button secondary" href={ templ.URL(data.ExternalURL) } target="_blank" rel="noopener">View on Strava</a> @@ -448,7 +448,7 @@ templ DataQualityContent(data DataQualityPageData) { for _, item := range data.Items { <tr> <td><input type="checkbox" name="ride_ids" value={ formatRideID(item.Ride.ID) } class="data-quality-checkbox"/></td> - <td><strong><a href={ templ.URL(rideDetailURL(item.Ride.ID)) }>{ item.Ride.Name }</a></strong><br/><small>{ item.Ride.Type }</small></td> + <td><strong><a href={ templ.URL(rideDetailURL(item.Ride.ID)) }>{ item.Ride.Name }</a></strong><br/><small>{ formatRideCategories(item.Ride) }</small></td> <td>{ formatRideDate(item.Ride.StartDate) }</td> <td class="numeric">{ formatDistance(item.Ride.DistanceM) }</td> <td>{ formatCategoryOrEmpty(string(item.Ride.BikeType)) }</td> |
