diff options
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | cli/cli.go | 6 | ||||
| -rw-r--r-- | cli/cli_test.go | 10 | ||||
| -rw-r--r-- | db/migrations/20260820184307_ride_moving_time.sql | 9 | ||||
| -rw-r--r-- | db/schema.sql | 7 | ||||
| -rw-r--r-- | rides/rides.go | 84 | ||||
| -rw-r--r-- | rides/rides_test.go | 58 | ||||
| -rw-r--r-- | web/ride-filters.go | 4 | ||||
| -rw-r--r-- | web/ride-filters_test.go | 10 | ||||
| -rw-r--r-- | web/server.go | 4 | ||||
| -rw-r--r-- | web/server_test.go | 21 | ||||
| -rw-r--r-- | web/templates.templ | 4 | ||||
| -rw-r--r-- | web/templates_templ.go | 14 | ||||
| -rw-r--r-- | web/views.go | 1 |
14 files changed, 179 insertions, 57 deletions
@@ -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 @@ -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 { |
