diff options
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | rides/rides.go | 17 | ||||
| -rw-r--r-- | rides/rides_test.go | 17 | ||||
| -rw-r--r-- | strava/sync.go | 6 | ||||
| -rw-r--r-- | strava/sync_test.go | 3 | ||||
| -rw-r--r-- | web/server.go | 57 | ||||
| -rw-r--r-- | web/server_test.go | 60 |
7 files changed, 136 insertions, 26 deletions
@@ -14,6 +14,7 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c - **Web ride library** — starts a local web server by default, imports Strava rides over a date range, stores metadata in SQLite, and keeps their GPX files on disk - **Materialized ride values** — computes Cotacol on import, stores its algorithm version in SQLite, and refreshes all computed values with `-backfill` - **Strava stream metrics** — preserves heart rate, cadence, and power in Garmin-compatible GPX files and in-memory ride columns +- **Metadata-only ride imports** — stores Strava activity statistics for rides without GPS tracks, without Cotacol or route data - **Ride table sorting** — sorts every ride-library column through clickable server-side headers and query parameters - **Ride detail maps** — opens a stored ride with its recorded route on an interactive OpenStreetMap map - **Strava import progress** — streams live import progress with an aggregate progress bar and prevents overlapping syncs @@ -138,7 +139,6 @@ mise run build - Plot speed and slope per segment, colored by heart rate - Plot speed vs ctc/100km - Persist the chosen climb variant across activities -- Handle historical data - Blog with pictures and markdown - Cotacol with a different step size - Cotacol with a variable step size (constant slope is the best?) diff --git a/rides/rides.go b/rides/rides.go index 8112c63..f68952c 100644 --- a/rides/rides.go +++ b/rides/rides.go @@ -43,12 +43,17 @@ const ( const columns = "id, external_id, gpx_path, name, type, start_date, distance_m, moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, created_at, updated_at" func Save(db *sql.DB, r Ride) error { - parsed, err := ride.ParseFile(ride.GPXRideParser{}, r.GPXPath) - if err != nil { - return fmt.Errorf("compute Cotacol for ride %q: %w", r.ExternalID, err) + var cotacolScore any + var cotacolAlgorithmVersion any + if r.GPXPath != "" { + parsed, err := ride.ParseFile(ride.GPXRideParser{}, r.GPXPath) + if err != nil { + return fmt.Errorf("compute Cotacol for ride %q: %w", r.ExternalID, err) + } + cotacolScore = ride.Cotacol(parsed) + cotacolAlgorithmVersion = ride.CotacolAlgorithmVersion } - cotacolScore := ride.Cotacol(parsed) - _, err = db.Exec(` + _, err := db.Exec(` INSERT INTO rides (external_id, gpx_path, name, type, start_date, distance_m, moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(external_id) DO UPDATE SET @@ -64,7 +69,7 @@ func Save(db *sql.DB, r Ride) error { cotacol_score = excluded.cotacol_score, cotacol_algo_version = excluded.cotacol_algo_version, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - `, r.ExternalID, r.GPXPath, r.Name, r.Type, r.StartDate.UTC().Format(time.RFC3339), r.DistanceM, r.MovingTimeS, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps, cotacolScore, ride.CotacolAlgorithmVersion) + `, r.ExternalID, r.GPXPath, r.Name, r.Type, r.StartDate.UTC().Format(time.RFC3339), r.DistanceM, r.MovingTimeS, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps, cotacolScore, cotacolAlgorithmVersion) return err } diff --git a/rides/rides_test.go b/rides/rides_test.go index 1c382b0..eb705fa 100644 --- a/rides/rides_test.go +++ b/rides/rides_test.go @@ -117,6 +117,23 @@ func TestSaveComputesCotacol(t *testing.T) { assert.Equal(t, ride.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) } +func TestSaveAllowsRideWithoutGPX(t *testing.T) { + db := newTestDB(t) + indoor := sampleRide(t) + indoor.ExternalID = "strava:14701658670" + indoor.GPXPath = "" + + require.NoError(t, Save(db, indoor)) + + got, found, err := GetByExternalID(db, indoor.ExternalID) + require.NoError(t, err) + require.True(t, found) + assert.Empty(t, got.GPXPath) + _, ready := got.CotacolScore() + assert.False(t, ready) + assert.Empty(t, got.CotacolAlgorithmVersion()) +} + func TestBackfill(t *testing.T) { db := newTestDB(t) require.NoError(t, Save(db, sampleRide(t))) diff --git a/strava/sync.go b/strava/sync.go index 45b13d3..28ba925 100644 --- a/strava/sync.go +++ b/strava/sync.go @@ -13,6 +13,8 @@ import ( const garminTrackPointExtensionNamespace = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1" +var ErrNoTrackPoints = errors.New("activity has no track points") + type Activity struct { ID int64 `json:"id"` Name string `json:"name"` @@ -77,7 +79,7 @@ func (c *Client) Get(id int64) (Activity, []byte, error) { } gpxData, err := activityGPX(activity, streams) if err != nil { - return Activity{}, nil, err + return activity, nil, err } return activity, gpxData, nil } @@ -139,7 +141,7 @@ func (c *Client) activityStreams(id int64) (activityStreams, error) { func activityGPX(activity Activity, streams activityStreams) ([]byte, error) { if len(streams.LatLng) == 0 { - return nil, errors.New("activity has no track points") + return nil, ErrNoTrackPoints } points := make([]gpx.GPXPoint, len(streams.LatLng)) for i := range streams.LatLng { diff --git a/strava/sync_test.go b/strava/sync_test.go index c55f97b..9223c95 100644 --- a/strava/sync_test.go +++ b/strava/sync_test.go @@ -128,7 +128,8 @@ func TestGetRejectsActivityWithoutTrackPoints(t *testing.T) { } }) - _, _, err := client.Get(14701658670) + activity, _, err := client.Get(14701658670) + assert.Equal(t, int64(14701658670), activity.ID) require.EqualError(t, err, "activity has no track points") } diff --git a/web/server.go b/web/server.go index 6f222ef..46fe66b 100644 --- a/web/server.go +++ b/web/server.go @@ -89,6 +89,11 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } + if item.GPXPath == "" { + slog.Info("Loaded ride detail without route", "ride_id", id) + kcore.RenderPage(r.Context(), RideDetailPage(RideDetailView{RideView: buildRideView(item)}), w) + return + } parsed, err := ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) if err != nil { slog.Error("Failed to load ride route", "ride_id", id, "file", item.GPXPath, "error", err) @@ -297,6 +302,22 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, } activity, gpxData, err := client.Get(summary.ID) if err != nil { + if errors.Is(err, strava.ErrNoTrackPoints) { + _ = os.Remove(filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", summary.ID))) + if saveErr := rides.Save(s.db, rideFromActivity(externalID, "", activity)); saveErr != nil { + if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save metadata: %w", saveErr)); reportErr != nil { + return progress, reportErr + } + continue + } + progress.Imported++ + progress.Completed++ + if reportErr := reportSyncProgress(report, progress); reportErr != nil { + return progress, reportErr + } + slog.Info("Imported Strava activity without route", "activity", activity.ID, "name", activity.Name) + continue + } if err := skipSyncActivity(&progress, report, summary, err); err != nil { return progress, err } @@ -310,22 +331,7 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, } continue } - activityType := activity.SportType - if activityType == "" { - activityType = activity.Type - } - if err := rides.Save(s.db, rides.Ride{ - ExternalID: externalID, - GPXPath: gpxPath, - Name: activity.Name, - Type: activityType, - StartDate: activity.StartDate, - DistanceM: activity.DistanceM, - MovingTimeS: activity.MovingTimeS, - ElapsedTimeS: activity.ElapsedTimeS, - TotalElevationGainM: activity.TotalElevationGainM, - AverageSpeedMps: activity.AverageSpeedMps, - }); err != nil { + if err := rides.Save(s.db, rideFromActivity(externalID, gpxPath, activity)); err != nil { _ = os.Remove(gpxPath) if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save activity: %w", err)); reportErr != nil { return progress, reportErr @@ -342,6 +348,25 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, return progress, nil } +func rideFromActivity(externalID, gpxPath string, activity strava.Activity) rides.Ride { + activityType := activity.SportType + if activityType == "" { + activityType = activity.Type + } + return rides.Ride{ + ExternalID: externalID, + GPXPath: gpxPath, + Name: activity.Name, + Type: activityType, + StartDate: activity.StartDate, + DistanceM: activity.DistanceM, + MovingTimeS: activity.MovingTimeS, + ElapsedTimeS: activity.ElapsedTimeS, + TotalElevationGainM: activity.TotalElevationGainM, + AverageSpeedMps: activity.AverageSpeedMps, + } +} + func skipSyncActivity(progress *SyncProgress, report func(SyncProgress) error, activity strava.Activity, err error) error { progress.Skipped++ progress.Completed++ diff --git a/web/server_test.go b/web/server_test.go index 46828d1..5964c5a 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -150,6 +150,31 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { assert.Contains(t, body, `"coordinates":[[5,43]`) } +func TestHandlerRendersRideDetailWithoutRoute(t *testing.T) { + server, db := newWebTestServer(t) + item := rides.Ride{ + ExternalID: "strava:14701658670", + Name: "Indoor Ride", + Type: "VirtualRide", + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, + } + require.NoError(t, rides.Save(db, item)) + stored, found, err := rides.GetByExternalID(db, item.ExternalID) + require.NoError(t, err) + require.True(t, found) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d", stored.ID), nil) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, req) + + body := response.Body.String() + assert.Equal(t, http.StatusOK, response.Code) + assert.Contains(t, body, "Indoor Ride") + assert.NotContains(t, body, "The recorded route is unavailable.") + assert.NotContains(t, body, `id="ride-map"`) +} + func TestHandlerReturnsNotFoundForUnknownRide(t *testing.T) { server, _ := newWebTestServer(t) req := httptest.NewRequest(http.MethodGet, "/rides/999999", nil) @@ -286,6 +311,41 @@ func TestSyncRidesContinuesAfterInvalidActivity(t *testing.T) { assert.NoFileExists(t, filepath.Join(gpxDir, "activity_14701658670.gpx")) } +func TestSyncRidesSavesActivityWithoutRoute(t *testing.T) { + server, db := newWebTestServer(t) + indoorID := int64(14701658670) + validID := int64(14701658671) + startDate := time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC) + client := fakeSyncClient{ + activities: []strava.Activity{ + {ID: indoorID, Name: "Indoor activity", Type: "VirtualRide", StartDate: startDate, DistanceM: 20_000}, + {ID: validID, Name: "Valid activity", Type: "Ride", StartDate: startDate}, + }, + results: map[int64]fakeSyncResult{ + indoorID: {activity: strava.Activity{ID: indoorID, Name: "Indoor activity", Type: "VirtualRide", StartDate: startDate, DistanceM: 20_000}, err: strava.ErrNoTrackPoints}, + validID: {activity: strava.Activity{ID: validID, Name: "Valid activity", Type: "Ride", StartDate: startDate}, data: []byte(validTrackGPX)}, + }, + } + gpxDir := t.TempDir() + staleGPXPath := filepath.Join(gpxDir, "activity_14701658670.gpx") + require.NoError(t, os.WriteFile(staleGPXPath, []byte(emptyTrackGPX), 0o600)) + + progress, err := server.syncRides(client, gpxDir, startDate, startDate.AddDate(0, 0, 1), nil) + + require.NoError(t, err) + assert.Equal(t, SyncProgress{Total: 2, Completed: 2, Imported: 2}, progress) + indoor, found, err := rides.GetByExternalID(db, "strava:14701658670") + require.NoError(t, err) + require.True(t, found) + assert.Empty(t, indoor.GPXPath) + _, ready := indoor.CotacolScore() + assert.False(t, ready) + assert.NoFileExists(t, staleGPXPath) + _, found, err = rides.GetByExternalID(db, "strava:14701658671") + require.NoError(t, err) + assert.True(t, found) +} + func TestSyncRejectsConcurrentImport(t *testing.T) { server, _ := newWebTestServer(t) appConfig, err := config.Load(server.configPath) |