aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-21 06:56:12 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-21 06:56:12 +0200
commit9da52d7355f5a77f0adc9a658619b3bfef03ba3c (patch)
treeb9e5f803d0c9787a84696b9e38b06b6721c9ded3
parent13aeedfbb9271444ca42efb45bbe0986ae7610ce (diff)
feat(ride): persist canonical moving time, preserve Strava reference
-rw-r--r--README.md4
-rw-r--r--cli/cli.go6
-rw-r--r--cli/cli_test.go10
-rw-r--r--db/migrations/20260820184307_ride_moving_time.sql9
-rw-r--r--db/schema.sql7
-rw-r--r--rides/rides.go84
-rw-r--r--rides/rides_test.go58
-rw-r--r--web/ride-filters.go4
-rw-r--r--web/ride-filters_test.go10
-rw-r--r--web/server.go4
-rw-r--r--web/server_test.go21
-rw-r--r--web/templates.templ4
-rw-r--r--web/templates_templ.go14
-rw-r--r--web/views.go1
14 files changed, 179 insertions, 57 deletions
diff --git a/README.md b/README.md
index 6dbdcd4..d4153a7 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c
- **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 library filters** — filters rides by distance, moving duration, start date, Cotacol score, and route availability through shareable query URLs
-- **Computed moving time** — computes moving time from the GPX track with a speed-threshold algorithm, evaluated against Strava's reference via `-backtest`
+- **Computed moving time** — computes moving time from the GPX track with a speed-threshold algorithm, persists it as the canonical value (keeping Strava's as `external_moving_time_s`), and benchmarks against that reference via `-backtest`
- **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
@@ -152,6 +152,8 @@ TypeScript checks can also be run directly with `npm run check`, covering format
- Destination via Start
- Planning rides
- Mountain pass from OSM
+- performance/profile tracking
+- progress bar cli
## Resources
diff --git a/cli/cli.go b/cli/cli.go
index bf67638..3a04b8b 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -101,7 +101,7 @@ func runBacktest(db *sql.DB) error {
slog.Warn("Failed to parse ride GPX", "ride", item.ExternalID, "file", item.GPXPath, "error", err)
continue
}
- reference := time.Duration(item.MovingTimeS) * time.Second
+ reference := time.Duration(item.ExternalMovingTimeS) * time.Second
computed := ride.MovingTime(parsed)
samples = append(samples, backtestSample{
externalID: item.ExternalID,
@@ -148,9 +148,9 @@ func runBacktest(db *sql.DB) error {
func isBacktestEligible(item rides.Ride) bool {
return item.GPXPath != "" &&
item.DistanceM >= backtestMinimumDistanceM &&
- item.MovingTimeS > 0 &&
+ item.ExternalMovingTimeS > 0 &&
item.ElapsedTimeS > 0 &&
- float64(item.MovingTimeS)/float64(item.ElapsedTimeS) >= backtestMinimumMovingRatio
+ float64(item.ExternalMovingTimeS)/float64(item.ElapsedTimeS) >= backtestMinimumMovingRatio
}
func meanAbsErr(absErrors []float64) float64 {
diff --git a/cli/cli_test.go b/cli/cli_test.go
index 59dec79..7964387 100644
--- a/cli/cli_test.go
+++ b/cli/cli_test.go
@@ -8,17 +8,17 @@ import (
)
func TestBacktestEligibilityExcludesShortDistances(t *testing.T) {
- short := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000 - 1, MovingTimeS: 8_000, ElapsedTimeS: 10_000}
- minimum := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000, MovingTimeS: 8_000, ElapsedTimeS: 10_000}
+ short := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000 - 1, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000}
+ minimum := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000}
assert.False(t, isBacktestEligible(short))
assert.True(t, isBacktestEligible(minimum))
}
func TestBacktestEligibilityExcludesLowMovingRatio(t *testing.T) {
- low := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000, MovingTimeS: 7_999, ElapsedTimeS: 10_000}
- boundary := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000, MovingTimeS: 8_000, ElapsedTimeS: 10_000}
- noElapsed := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000, MovingTimeS: 1}
+ low := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 7_999, ElapsedTimeS: 10_000}
+ boundary := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000}
+ noElapsed := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 1}
assert.False(t, isBacktestEligible(low))
assert.True(t, isBacktestEligible(boundary))
diff --git a/db/migrations/20260820184307_ride_moving_time.sql b/db/migrations/20260820184307_ride_moving_time.sql
new file mode 100644
index 0000000..6505609
--- /dev/null
+++ b/db/migrations/20260820184307_ride_moving_time.sql
@@ -0,0 +1,9 @@
+-- migrate:up
+alter table rides rename column moving_time_s to external_moving_time_s;
+alter table rides add column moving_time_s integer;
+alter table rides add column moving_time_algo_version text;
+
+-- migrate:down
+alter table rides drop column moving_time_algo_version;
+alter table rides drop column moving_time_s;
+alter table rides rename column external_moving_time_s to moving_time_s; \ No newline at end of file
diff --git a/db/schema.sql b/db/schema.sql
index 6c8f8ac..4c03183 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -23,13 +23,13 @@ CREATE TABLE rides (
type text not null,
start_date text not null,
distance_m real not null,
- moving_time_s integer not null,
+ external_moving_time_s integer not null,
elapsed_time_s integer not null,
total_elevation_gain_m real not null,
average_speed_mps real not null,
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
-, cotacol_score real, cotacol_algo_version text);
+, cotacol_score real, cotacol_algo_version text, moving_time_s integer, moving_time_algo_version text);
CREATE TABLE official_climbs (
id integer primary key,
name text not null,
@@ -46,4 +46,5 @@ INSERT INTO "schema_migrations" (version) VALUES
('20260802090000'),
('20260804000000'),
('20260807000000'),
- ('20260813000000');
+ ('20260813000000'),
+ ('20260820184307');
diff --git a/rides/rides.go b/rides/rides.go
index f68952c..b0ce5fb 100644
--- a/rides/rides.go
+++ b/rides/rides.go
@@ -11,21 +11,23 @@ import (
)
type Ride struct {
- ID int64
- ExternalID string
- GPXPath string
- Name string
- Type string
- StartDate time.Time
- DistanceM float64
- MovingTimeS int64
- ElapsedTimeS int64
- TotalElevationGainM float64
- AverageSpeedMps float64
- cotacolScore *float64
- cotacolAlgoVersion string
- CreatedAt time.Time
- UpdatedAt time.Time
+ ID int64
+ ExternalID string
+ GPXPath string
+ Name string
+ Type string
+ StartDate time.Time
+ DistanceM float64
+ ExternalMovingTimeS int64
+ movingTimeS *int64
+ movingTimeAlgoVersion string
+ ElapsedTimeS int64
+ TotalElevationGainM float64
+ AverageSpeedMps float64
+ cotacolScore *float64
+ cotacolAlgoVersion string
+ CreatedAt time.Time
+ UpdatedAt time.Time
}
type SortColumn string
@@ -40,36 +42,42 @@ const (
SortCotacolKm SortColumn = "cotacol_100km"
)
-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"
+const columns = "id, external_id, gpx_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, created_at, updated_at"
func Save(db *sql.DB, r Ride) error {
var cotacolScore any
var cotacolAlgorithmVersion any
+ var movingTimeS any
+ var movingTimeAlgorithmVersion 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)
+ return fmt.Errorf("compute ride values for %q: %w", r.ExternalID, err)
}
cotacolScore = ride.Cotacol(parsed)
cotacolAlgorithmVersion = ride.CotacolAlgorithmVersion
+ movingTimeS = int64(ride.MovingTime(parsed) / time.Second)
+ movingTimeAlgorithmVersion = ride.MovingTimeAlgorithmVersion
}
_, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO rides (external_id, gpx_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)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(external_id) DO UPDATE SET
gpx_path = excluded.gpx_path,
name = excluded.name,
type = excluded.type,
start_date = excluded.start_date,
distance_m = excluded.distance_m,
+ external_moving_time_s = excluded.external_moving_time_s,
moving_time_s = excluded.moving_time_s,
+ moving_time_algo_version = excluded.moving_time_algo_version,
elapsed_time_s = excluded.elapsed_time_s,
total_elevation_gain_m = excluded.total_elevation_gain_m,
average_speed_mps = excluded.average_speed_mps,
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, cotacolAlgorithmVersion)
+ `, r.ExternalID, r.GPXPath, r.Name, r.Type, r.StartDate.UTC().Format(time.RFC3339), r.DistanceM, r.ExternalMovingTimeS, movingTimeS, movingTimeAlgorithmVersion, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps, cotacolScore, cotacolAlgorithmVersion)
return err
}
@@ -120,7 +128,9 @@ func ListSorted(db *sql.DB, column SortColumn, descending bool) ([]Ride, error)
}
query := "SELECT " + columns + " FROM rides ORDER BY "
args := []any{}
- if isComputedSort(column) {
+ if column == SortMovingTime {
+ args = append(args, ride.MovingTimeAlgorithmVersion)
+ } else if isComputedSort(column) {
query += "(cotacol_score IS NULL OR cotacol_algo_version IS NULL OR cotacol_algo_version <> ? OR distance_m <= 0) ASC, "
args = append(args, ride.CotacolAlgorithmVersion)
}
@@ -145,7 +155,7 @@ var sortExpressions = map[SortColumn]string{
SortName: "name COLLATE NOCASE",
SortStartDate: "start_date",
SortDistance: "distance_m",
- SortMovingTime: "moving_time_s",
+ SortMovingTime: "CASE WHEN moving_time_algo_version = ? THEN COALESCE(moving_time_s, external_moving_time_s) ELSE external_moving_time_s END",
SortElevation: "total_elevation_gain_m",
SortCotacol: "cotacol_score",
SortCotacolKm: "cotacol_score * 100000.0 / NULLIF(distance_m, 0)",
@@ -187,15 +197,23 @@ func scanRide(s scanner) (Ride, error) {
var (
ride Ride
startDate string
+ movingTimeS sql.NullInt64
+ movingTimeAlgoVer sql.NullString
cotacolScore sql.NullFloat64
cotacolAlgoVersion sql.NullString
createdAt string
updatedAt string
)
- err := s.Scan(&ride.ID, &ride.ExternalID, &ride.GPXPath, &ride.Name, &ride.Type, &startDate, &ride.DistanceM, &ride.MovingTimeS, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &cotacolScore, &cotacolAlgoVersion, &createdAt, &updatedAt)
+ err := s.Scan(&ride.ID, &ride.ExternalID, &ride.GPXPath, &ride.Name, &ride.Type, &startDate, &ride.DistanceM, &ride.ExternalMovingTimeS, &movingTimeS, &movingTimeAlgoVer, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &cotacolScore, &cotacolAlgoVersion, &createdAt, &updatedAt)
if err != nil {
return Ride{}, err
}
+ if movingTimeS.Valid {
+ ride.movingTimeS = &movingTimeS.Int64
+ }
+ if movingTimeAlgoVer.Valid {
+ ride.movingTimeAlgoVersion = movingTimeAlgoVer.String
+ }
if cotacolScore.Valid {
ride.cotacolScore = &cotacolScore.Float64
}
@@ -221,6 +239,26 @@ func (r Ride) CotacolAlgorithmVersion() string {
return r.cotacolAlgoVersion
}
+func (r Ride) MovingTimeS() (int64, bool) {
+ if r.movingTimeS == nil {
+ return 0, false
+ }
+ return *r.movingTimeS, true
+}
+
+func (r Ride) MovingTimeAlgorithmVersion() string {
+ return r.movingTimeAlgoVersion
+}
+
+// EffectiveMovingTimeS is the canonical moving time when the stored value
+// matches the current algorithm version, falling back to Strava's value.
+func (r Ride) EffectiveMovingTimeS() int64 {
+ if moving, ok := r.MovingTimeS(); ok && r.movingTimeAlgoVersion == ride.MovingTimeAlgorithmVersion {
+ return moving
+ }
+ return r.ExternalMovingTimeS
+}
+
func (r Ride) CotacolScore() (float64, bool) {
if r.cotacolScore == nil {
return 0, false
diff --git a/rides/rides_test.go b/rides/rides_test.go
index 90998cd..fc8ff0d 100644
--- a/rides/rides_test.go
+++ b/rides/rides_test.go
@@ -29,14 +29,14 @@ func sampleRide(t *testing.T) Ride {
Type: "Ride",
StartDate: time.Date(2026, 8, 1, 7, 30, 0, 0, time.UTC),
DistanceM: 42_195,
- MovingTimeS: 7_200,
+ ExternalMovingTimeS: 7_200,
ElapsedTimeS: 7_800,
TotalElevationGainM: 850,
AverageSpeedMps: 5.86,
}
}
-const testGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg><trkpt lat="43.0" lon="5.0"><ele>100</ele></trkpt><trkpt lat="43.001" lon="5.001"><ele>200</ele></trkpt></trkseg></trk></gpx>`
+const testGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg><trkpt lat="43.0" lon="5.0"><ele>100</ele><time>2026-08-01T07:30:00Z</time></trkpt><trkpt lat="43.001" lon="5.001"><ele>200</ele><time>2026-08-01T07:31:00Z</time></trkpt></trkseg></trk></gpx>`
func TestUpsertAndGet(t *testing.T) {
db := newTestDB(t)
@@ -93,6 +93,20 @@ func TestSaveComputesCotacol(t *testing.T) {
assert.Equal(t, ride.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
}
+func TestSaveComputesMovingTimeAndPreservesExternal(t *testing.T) {
+ db := newTestDB(t)
+ require.NoError(t, Save(db, sampleRide(t)))
+
+ got, ok, err := GetByExternalID(db, "strava:1234")
+ require.NoError(t, err)
+ require.True(t, ok)
+ moving, found := got.MovingTimeS()
+ require.True(t, found)
+ assert.Equal(t, int64(60), moving)
+ assert.Equal(t, ride.MovingTimeAlgorithmVersion, got.MovingTimeAlgorithmVersion())
+ assert.Equal(t, int64(7_200), got.ExternalMovingTimeS)
+}
+
func TestSaveAllowsRideWithoutGPX(t *testing.T) {
db := newTestDB(t)
indoor := sampleRide(t)
@@ -108,6 +122,44 @@ func TestSaveAllowsRideWithoutGPX(t *testing.T) {
_, ready := got.CotacolScore()
assert.False(t, ready)
assert.Empty(t, got.CotacolAlgorithmVersion())
+ _, ready = got.MovingTimeS()
+ assert.False(t, ready)
+ assert.Empty(t, got.MovingTimeAlgorithmVersion())
+}
+
+func TestEffectiveMovingTimePrefersCanonical(t *testing.T) {
+ db := newTestDB(t)
+ require.NoError(t, Save(db, sampleRide(t)))
+ got, ok, err := GetByExternalID(db, "strava:1234")
+ require.NoError(t, err)
+ require.True(t, ok)
+ assert.Equal(t, int64(60), got.EffectiveMovingTimeS())
+
+ _, err = db.Exec("UPDATE rides SET moving_time_algo_version = 'old' WHERE external_id = ?", got.ExternalID)
+ require.NoError(t, err)
+ got, ok, err = GetByExternalID(db, "strava:1234")
+ require.NoError(t, err)
+ require.True(t, ok)
+ assert.Equal(t, int64(7_200), got.EffectiveMovingTimeS())
+}
+
+func TestListSortedByMovingTimeUsesCanonicalWithExternalFallback(t *testing.T) {
+ db := newTestDB(t)
+ first := sampleRide(t)
+ first.ExternalID = "strava:550e8400-e29b-41d4-a716-446655440000"
+ second := sampleRide(t)
+ second.ExternalID = "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8"
+ require.NoError(t, Save(db, first))
+ require.NoError(t, Save(db, second))
+ _, err := db.Exec("UPDATE rides SET external_moving_time_s = 1000, moving_time_s = 500 WHERE external_id = ?", first.ExternalID)
+ require.NoError(t, err)
+ _, err = db.Exec("UPDATE rides SET external_moving_time_s = 300, moving_time_s = 500, moving_time_algo_version = 'old' WHERE external_id = ?", second.ExternalID)
+ require.NoError(t, err)
+
+ items, err := ListSorted(db, SortMovingTime, false)
+ require.NoError(t, err)
+ assert.Equal(t, second.ExternalID, items[0].ExternalID)
+ assert.Equal(t, first.ExternalID, items[1].ExternalID)
}
func TestBackfill(t *testing.T) {
@@ -132,7 +184,7 @@ func TestBackfill(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, moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps)
+ 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)
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/web/ride-filters.go b/web/ride-filters.go
index 189a72e..3b567f5 100644
--- a/web/ride-filters.go
+++ b/web/ride-filters.go
@@ -271,10 +271,10 @@ func rideMatchesFilters(item rides.Ride, filters RideFilters) bool {
if filters.DistanceMaxKm != nil && item.DistanceM > float64(*filters.DistanceMaxKm)*1000 {
return false
}
- if filters.DurationMinMinutes != nil && item.MovingTimeS < int64(*filters.DurationMinMinutes)*60 {
+ if filters.DurationMinMinutes != nil && item.EffectiveMovingTimeS() < int64(*filters.DurationMinMinutes)*60 {
return false
}
- if filters.DurationMaxMinutes != nil && item.MovingTimeS > int64(*filters.DurationMaxMinutes)*60 {
+ if filters.DurationMaxMinutes != nil && item.EffectiveMovingTimeS() > int64(*filters.DurationMaxMinutes)*60 {
return false
}
startDate := item.StartDate.UTC()
diff --git a/web/ride-filters_test.go b/web/ride-filters_test.go
index c7b9133..8b470b6 100644
--- a/web/ride-filters_test.go
+++ b/web/ride-filters_test.go
@@ -89,11 +89,11 @@ func TestParseRideFiltersTreatsFullRangesAsInactive(t *testing.T) {
func TestFilterRidesUsesInclusiveBoundsAndMovingTime(t *testing.T) {
items := []rides.Ride{
- {Name: "lower boundary", DistanceM: 20_000, MovingTimeS: 1_800, StartDate: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), GPXPath: "lower.gpx"},
- {Name: "upper boundary", DistanceM: 100_000, MovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "upper.gpx"},
- {Name: "outside distance", DistanceM: 110_000, MovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-distance.gpx"},
- {Name: "outside moving time", DistanceM: 100_000, MovingTimeS: 10_801, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-duration.gpx"},
- {Name: "outside date", DistanceM: 100_000, MovingTimeS: 10_800, StartDate: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC), GPXPath: "outside-date.gpx"},
+ {Name: "lower boundary", DistanceM: 20_000, ExternalMovingTimeS: 1_800, StartDate: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), GPXPath: "lower.gpx"},
+ {Name: "upper boundary", DistanceM: 100_000, ExternalMovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "upper.gpx"},
+ {Name: "outside distance", DistanceM: 110_000, ExternalMovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-distance.gpx"},
+ {Name: "outside moving time", DistanceM: 100_000, ExternalMovingTimeS: 10_801, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-duration.gpx"},
+ {Name: "outside date", DistanceM: 100_000, ExternalMovingTimeS: 10_800, StartDate: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC), GPXPath: "outside-date.gpx"},
}
filters := parseRideFilters(url.Values{
rideFilterDistanceMinParam: {"20"},
diff --git a/web/server.go b/web/server.go
index 4bc74e3..06ee425 100644
--- a/web/server.go
+++ b/web/server.go
@@ -228,7 +228,7 @@ func buildRideViews(items []rides.Ride) []RideView {
}
func buildRideView(item rides.Ride) RideView {
- view := RideView{Ride: item, Cotacol: "-", CotacolPer100Km: "-"}
+ view := RideView{Ride: item, Cotacol: "-", CotacolPer100Km: "-", MovingTime: formatDuration(item.EffectiveMovingTimeS())}
cotacol, ready := item.CotacolScore()
if ready && item.CotacolAlgorithmVersion() == ride.CotacolAlgorithmVersion && item.DistanceM > 0 {
view.Cotacol = formatCotacol(cotacol)
@@ -459,7 +459,7 @@ func rideFromActivity(externalID, gpxPath string, activity strava.Activity) ride
Type: activityType,
StartDate: activity.StartDate,
DistanceM: activity.DistanceM,
- MovingTimeS: activity.MovingTimeS,
+ ExternalMovingTimeS: activity.MovingTimeS,
ElapsedTimeS: activity.ElapsedTimeS,
TotalElevationGainM: activity.TotalElevationGainM,
AverageSpeedMps: activity.AverageSpeedMps,
diff --git a/web/server_test.go b/web/server_test.go
index 823e4a8..68928fe 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -361,7 +361,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, moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps)
+ 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)
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)
@@ -648,6 +648,25 @@ func TestBuildRideViewsUsesStoredCotacol(t *testing.T) {
assert.NotEqual(t, "-", views[0].Cotacol)
}
+func TestBuildRideViewMovingTimePrefersCanonical(t *testing.T) {
+ _, db := newWebTestServer(t)
+ ride := rides.Ride{
+ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000",
+ GPXPath: testGPXPath(t, "moving.gpx"),
+ Name: "Moving Ride",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
+ DistanceM: 20_000,
+ ExternalMovingTimeS: 7_200,
+ }
+ require.NoError(t, rides.Save(db, ride))
+ items, err := rides.List(db)
+ require.NoError(t, err)
+ require.Len(t, items, 1)
+
+ assert.NotEqual(t, "2h 00m", buildRideView(items[0]).MovingTime)
+}
+
func TestHandlerSortsRidesByDistance(t *testing.T) {
server, db := newWebTestServer(t)
require.NoError(t, rides.Save(db, rides.Ride{
diff --git a/web/templates.templ b/web/templates.templ
index 8a987fc..88f17d1 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -108,7 +108,7 @@ templ RidesContent(data RidesPageData) {
</tr></thead>
<tbody>
for _, item := range data.Items {
- <tr><td><strong><a href={ templ.URL(rideDetailURL(item.ID)) }>{ item.Name }</a></strong><br/><small>{ item.Type }</small></td><td>{ formatRideDate(item.StartDate) }</td><td class="numeric">{ formatDistance(item.DistanceM) }</td><td class="numeric">{ formatDuration(item.MovingTimeS) }</td><td class="numeric">{ formatElevation(item.TotalElevationGainM) }</td><td class="numeric">{ item.Cotacol }</td><td class="numeric">{ item.CotacolPer100Km }</td></tr>
+ <tr><td><strong><a href={ templ.URL(rideDetailURL(item.ID)) }>{ item.Name }</a></strong><br/><small>{ item.Type }</small></td><td>{ formatRideDate(item.StartDate) }</td><td class="numeric">{ formatDistance(item.DistanceM) }</td><td class="numeric">{ item.MovingTime }</td><td class="numeric">{ formatElevation(item.TotalElevationGainM) }</td><td class="numeric">{ item.Cotacol }</td><td class="numeric">{ item.CotacolPer100Km }</td></tr>
}
</tbody>
</table>
@@ -139,7 +139,7 @@ templ RideDetailContent(data RideDetailView) {
<section class="panel summary-panel">
<dl>
<div><dt>Distance</dt><dd>{ formatDistance(data.Ride.DistanceM) }</dd></div>
- <div><dt>Moving time</dt><dd>{ formatDuration(data.Ride.MovingTimeS) }</dd></div>
+ <div><dt>Moving time</dt><dd>{ data.MovingTime }</dd></div>
<div><dt>Elevation</dt><dd>{ formatElevation(data.Ride.TotalElevationGainM) }</dd></div>
</dl>
</section>
diff --git a/web/templates_templ.go b/web/templates_templ.go
index 05ae20a..a1cf867 100644
--- a/web/templates_templ.go
+++ b/web/templates_templ.go
@@ -821,9 +821,9 @@ func RidesContent(data RidesPageData) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var56 string
- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(formatDuration(item.MovingTimeS))
+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(item.MovingTime)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 111, Col: 288}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 111, Col: 271}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
if templ_7745c5c3_Err != nil {
@@ -836,7 +836,7 @@ func RidesContent(data RidesPageData) templ.Component {
var templ_7745c5c3_Var57 string
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(formatElevation(item.TotalElevationGainM))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 111, Col: 358}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 111, Col: 341}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
if templ_7745c5c3_Err != nil {
@@ -849,7 +849,7 @@ func RidesContent(data RidesPageData) templ.Component {
var templ_7745c5c3_Var58 string
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(item.Cotacol)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 111, Col: 399}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 111, Col: 382}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
if templ_7745c5c3_Err != nil {
@@ -862,7 +862,7 @@ func RidesContent(data RidesPageData) templ.Component {
var templ_7745c5c3_Var59 string
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(item.CotacolPer100Km)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 111, Col: 448}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 111, Col: 431}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
if templ_7745c5c3_Err != nil {
@@ -1054,9 +1054,9 @@ func RideDetailContent(data RideDetailView) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var69 string
- templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(formatDuration(data.Ride.MovingTimeS))
+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(data.MovingTime)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 142, Col: 72}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 142, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
if templ_7745c5c3_Err != nil {
diff --git a/web/views.go b/web/views.go
index de4d38f..1bf1782 100644
--- a/web/views.go
+++ b/web/views.go
@@ -16,6 +16,7 @@ type RideView struct {
rides.Ride
Cotacol string
CotacolPer100Km string
+ MovingTime string
}
type RidesPageData struct {