summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--rides/rides.go17
-rw-r--r--rides/rides_test.go17
-rw-r--r--strava/sync.go6
-rw-r--r--strava/sync_test.go3
-rw-r--r--web/server.go57
-rw-r--r--web/server_test.go60
7 files changed, 136 insertions, 26 deletions
diff --git a/README.md b/README.md
index 6b7de0a..aa55cca 100644
--- a/README.md
+++ b/README.md
@@ -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)