aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--db/migrations/20260831000000_rename_type_to_external_sport_type.sql5
-rw-r--r--db/schema.sql5
-rw-r--r--ride/category.go11
-rw-r--r--ride/store.go17
-rw-r--r--ride/store_test.go4
-rw-r--r--strava/sync.go2
-rw-r--r--web/categories.go21
-rw-r--r--web/ride-filters_test.go94
-rw-r--r--web/server_test.go242
-rw-r--r--web/sync.go8
-rw-r--r--web/templates.templ6
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>