diff options
| -rw-r--r-- | cli/cli.go | 8 | ||||
| -rw-r--r-- | cli/cli_test.go | 20 | ||||
| -rw-r--r-- | ride/store.go | 12 | ||||
| -rw-r--r-- | ride/store_test.go | 12 | ||||
| -rw-r--r-- | web/rides.go | 33 | ||||
| -rw-r--r-- | web/server_test.go | 11 |
6 files changed, 73 insertions, 23 deletions
@@ -145,12 +145,12 @@ func runBacktest(db *sql.DB) error { if !isBacktestEligible(item) { continue } - parsed, err := ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) + parsed, err := ride.ParseFile(ride.FITRideParser{}, item.FitPath) if err != nil { if errors.Is(err, ride.ErrNoTrackPoints) { continue } - slog.Warn("Failed to parse ride GPX", "ride", item.ExternalID, "file", item.GPXPath, "error", err) + slog.Warn("Failed to parse ride FIT", "ride", item.ExternalID, "file", item.FitPath, "error", err) continue } reference := time.Duration(item.ExternalMovingTimeS) * time.Second @@ -164,7 +164,7 @@ func runBacktest(db *sql.DB) error { }) } if len(samples) == 0 { - slog.Info("No rides with GPX to backtest", "rides", len(items)) + slog.Info("No rides with FIT to backtest", "rides", len(items)) return nil } sort.Slice(samples, func(i, j int) bool { @@ -195,7 +195,7 @@ func runBacktest(db *sql.DB) error { } func isBacktestEligible(item ride.StoredRide) bool { - if item.GPXPath == "" || item.ExternalMovingTimeS <= 0 || item.ElapsedTimeS <= 0 { + if item.FitPath == "" || item.ExternalMovingTimeS <= 0 || item.ElapsedTimeS <= 0 { return false } if !item.IsCategorised() { diff --git a/cli/cli_test.go b/cli/cli_test.go index 28260e0..e12ff7a 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -8,17 +8,17 @@ import ( ) func TestBacktestEligibilityExcludesShortDistances(t *testing.T) { - short := ride.StoredRide{GPXPath: "ride.gpx", DistanceM: 10_000 - 1, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} - minimum := ride.StoredRide{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} + short := ride.StoredRide{FitPath: "ride.fit", DistanceM: 10_000 - 1, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} + minimum := ride.StoredRide{FitPath: "ride.fit", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} assert.True(t, isBacktestEligible(short)) assert.True(t, isBacktestEligible(minimum)) } func TestBacktestEligibilityExcludesLowMovingRatio(t *testing.T) { - low := ride.StoredRide{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 7_999, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} - boundary := ride.StoredRide{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} - noElapsed := ride.StoredRide{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 1, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} + low := ride.StoredRide{FitPath: "ride.fit", DistanceM: 10_000, ExternalMovingTimeS: 7_999, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} + boundary := ride.StoredRide{FitPath: "ride.fit", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} + noElapsed := ride.StoredRide{FitPath: "ride.fit", DistanceM: 10_000, ExternalMovingTimeS: 1, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} assert.True(t, isBacktestEligible(low)) assert.True(t, isBacktestEligible(boundary)) @@ -26,11 +26,11 @@ func TestBacktestEligibilityExcludesLowMovingRatio(t *testing.T) { } func TestBacktestEligibilityExcludesUncategorisedAndIndoorTransport(t *testing.T) { - base := ride.StoredRide{GPXPath: "ride.gpx", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} - uncategorised := ride.StoredRide{GPXPath: "ride.gpx", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000} - indoor := ride.StoredRide{GPXPath: "ride.gpx", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeIndoor} - transport := ride.StoredRide{GPXPath: "ride.gpx", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTransport} - race := ride.StoredRide{GPXPath: "ride.gpx", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeMTB, RideType: ride.RideTypeRace} + base := ride.StoredRide{FitPath: "ride.fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} + uncategorised := ride.StoredRide{FitPath: "ride.fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000} + indoor := ride.StoredRide{FitPath: "ride.fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeIndoor} + transport := ride.StoredRide{FitPath: "ride.fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTransport} + race := ride.StoredRide{FitPath: "ride.fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeMTB, RideType: ride.RideTypeRace} assert.True(t, isBacktestEligible(base)) assert.False(t, isBacktestEligible(uncategorised)) diff --git a/ride/store.go b/ride/store.go index f6de535..9429acf 100644 --- a/ride/store.go +++ b/ride/store.go @@ -56,7 +56,17 @@ func Save(db *sql.DB, r StoredRide) error { var cotacolAlgorithmVersion any var movingTimeS any var movingTimeAlgorithmVersion any - if r.GPXPath != "" { + if r.FitPath != "" { + parsed, err := ParseFile(FITRideParser{}, r.FitPath) + if err != nil { + return fmt.Errorf("compute ride values for %q: %w", r.ExternalID, err) + } + cotacolScore = climb.Cotacol(parsed.Distances, parsed.Elevations) + cotacolAlgorithmVersion = climb.CotacolAlgorithmVersion + movingTimeS = int64(MovingTime(parsed) / time.Second) + movingTimeAlgorithmVersion = MovingTimeAlgorithmVersion + } else if r.GPXPath != "" { + // Legacy fallback for rides created before FIT (e.g. tests). parsed, err := ParseFile(GPXRideParser{}, r.GPXPath) if err != nil { return fmt.Errorf("compute ride values for %q: %w", r.ExternalID, err) diff --git a/ride/store_test.go b/ride/store_test.go index 1ee3c69..1fca056 100644 --- a/ride/store_test.go +++ b/ride/store_test.go @@ -20,11 +20,20 @@ func newTestDB(t *testing.T) *sql.DB { func sampleRide(t *testing.T) StoredRide { t.Helper() - gpxPath := filepath.Join(t.TempDir(), "ride.gpx") + dir := t.TempDir() + gpxPath := filepath.Join(dir, "ride.gpx") require.NoError(t, os.WriteFile(gpxPath, []byte(testGPX), 0o600)) + fitPath := filepath.Join(dir, "ride.fit") + parsed, err := ParseFile(GPXRideParser{}, gpxPath) + require.NoError(t, err) + f, err := os.Create(fitPath) + require.NoError(t, err) + require.NoError(t, EncodeFIT(parsed, f)) + require.NoError(t, f.Close()) return StoredRide{ ExternalID: "strava:1234", GPXPath: gpxPath, + FitPath: fitPath, Name: "Morning Ride", Type: "Ride", StartDate: time.Date(2026, 8, 1, 7, 30, 0, 0, time.UTC), @@ -112,6 +121,7 @@ func TestSaveAllowsRideWithoutGPX(t *testing.T) { indoor := sampleRide(t) indoor.ExternalID = "strava:14701658670" indoor.GPXPath = "" + indoor.FitPath = "" require.NoError(t, Save(db, indoor)) diff --git a/web/rides.go b/web/rides.go index d3f5634..f59c0a4 100644 --- a/web/rides.go +++ b/web/rides.go @@ -51,14 +51,25 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - if item.GPXPath == "" { + if item.FitPath == "" && item.GPXPath == "" { slog.Info("Loaded ride detail without route", "ride_id", id) kcore.RenderPage(r.Context(), RideDetailPage(RideDetailView{RideView: buildRideView(item), Notice: rideDetailNotice(r)}), w) return } - parsed, err := ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) + // Prefer FIT; fallback to GPX for legacy rides pending backfill. + var parsed ride.Ride + if item.FitPath != "" { + parsed, err = ride.ParseFile(ride.FITRideParser{}, item.FitPath) + if err != nil { + slog.Error("Failed to load ride route", "ride_id", id, "file", item.FitPath, "error", err) + } + } else { + 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) + } + } if err != nil { - slog.Error("Failed to load ride route", "ride_id", id, "file", item.GPXPath, "error", err) kcore.RenderPage(r.Context(), RideDetailPage(RideDetailView{ RideView: buildRideView(item), RouteError: "The recorded route is unavailable.", @@ -121,7 +132,7 @@ func (s *Server) handleCreateOfficialClimb(w http.ResponseWriter, r *http.Reques http.NotFound(w, r) return } - if item.GPXPath == "" { + if item.FitPath == "" && item.GPXPath == "" { http.Error(w, "a recorded route is required to create an official climb", http.StatusBadRequest) return } @@ -139,9 +150,19 @@ func (s *Server) handleCreateOfficialClimb(w http.ResponseWriter, r *http.Reques http.Error(w, "a valid end route point is required", http.StatusBadRequest) return } - parsed, err := ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) + var parsed ride.Ride + if item.FitPath != "" { + parsed, err = ride.ParseFile(ride.FITRideParser{}, item.FitPath) + if err != nil { + slog.Error("Failed to load ride route for official climb creation", "ride_id", id, "file", item.FitPath, "error", err) + } + } else { + parsed, err = ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) + if err != nil { + slog.Error("Failed to load ride route for official climb creation", "ride_id", id, "file", item.GPXPath, "error", err) + } + } if err != nil { - slog.Error("Failed to load ride route for official climb creation", "ride_id", id, "file", item.GPXPath, "error", err) http.Error(w, "the recorded route is unavailable", http.StatusBadRequest) return } diff --git a/web/server_test.go b/web/server_test.go index e9444c7..d75628d 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -66,9 +66,18 @@ func (c fakeSyncClient) Get(id int64) (strava.Activity, []byte, []byte, error) { if !ok { return strava.Activity{}, nil, nil, fmt.Errorf("missing fake activity %d", id) } + if result.err != nil { + return result.activity, nil, nil, result.err + } fitData := result.fitData if fitData == nil && result.data != nil { - fitData = result.data + parsed, err := (ride.GPXRideParser{}).Parse(bytes.NewReader(result.data)) + if err == nil { + var buf bytes.Buffer + if err := ride.EncodeFIT(parsed, &buf); err == nil { + fitData = buf.Bytes() + } + } } return result.activity, result.data, fitData, result.err } |
