aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cli/cli.go8
-rw-r--r--cli/cli_test.go20
-rw-r--r--ride/store.go12
-rw-r--r--ride/store_test.go12
-rw-r--r--web/rides.go33
-rw-r--r--web/server_test.go11
6 files changed, 73 insertions, 23 deletions
diff --git a/cli/cli.go b/cli/cli.go
index c71fd9e..672cc34 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -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
}