aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 19:03:34 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 19:03:34 +0200
commitd7b93325d4f70eeab369fa8a3c4cce9f0c4e0081 (patch)
tree76a913248fa04325e8e85f34df0cd03654e1ca76 /web
parent558f755f5746a4ee819de87579951b7533688e5d (diff)
Add dual GPX/FIT exporter and backfill for rides
Phase 1 writes both .gpx and .fit on Strava import, adds fit_path column, renames storage.gpx_dir to storage.rides_dir, and backfills missing FIT files from existing GPX without breaking CLI progress. FIT files are gitignored.
Diffstat (limited to 'web')
-rw-r--r--web/server.go2
-rw-r--r--web/server_test.go13
-rw-r--r--web/sync.go33
3 files changed, 33 insertions, 15 deletions
diff --git a/web/server.go b/web/server.go
index 06b4ace..bde45ca 100644
--- a/web/server.go
+++ b/web/server.go
@@ -29,7 +29,7 @@ type Server struct {
type syncClient interface {
List(from, to time.Time) ([]strava.Activity, error)
- Get(id int64) (strava.Activity, []byte, error)
+ Get(id int64) (strava.Activity, []byte, []byte, error)
}
func NewServer(db *sql.DB, configPath string) *Server {
diff --git a/web/server_test.go b/web/server_test.go
index 7137031..e9444c7 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -53,6 +53,7 @@ type fakeSyncClient struct {
type fakeSyncResult struct {
activity strava.Activity
data []byte
+ fitData []byte
err error
}
@@ -60,12 +61,16 @@ func (c fakeSyncClient) List(time.Time, time.Time) ([]strava.Activity, error) {
return c.activities, nil
}
-func (c fakeSyncClient) Get(id int64) (strava.Activity, []byte, error) {
+func (c fakeSyncClient) Get(id int64) (strava.Activity, []byte, []byte, error) {
result, ok := c.results[id]
if !ok {
- return strava.Activity{}, nil, fmt.Errorf("missing fake activity %d", id)
+ return strava.Activity{}, nil, nil, fmt.Errorf("missing fake activity %d", id)
}
- return result.activity, result.data, result.err
+ fitData := result.fitData
+ if fitData == nil && result.data != nil {
+ fitData = result.data
+ }
+ return result.activity, result.data, fitData, result.err
}
const emptyTrackGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg></trkseg></trk></gpx>`
@@ -684,7 +689,7 @@ func (c countingSyncClient) List(from, to time.Time) ([]strava.Activity, error)
return c.inner.List(from, to)
}
-func (c countingSyncClient) Get(id int64) (strava.Activity, []byte, error) {
+func (c countingSyncClient) Get(id int64) (strava.Activity, []byte, []byte, error) {
*c.getCalled = true
return c.inner.Get(id)
}
diff --git a/web/sync.go b/web/sync.go
index f6dc7da..8ed306f 100644
--- a/web/sync.go
+++ b/web/sync.go
@@ -78,7 +78,7 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/event-stream")
- progress, err := s.syncRides(client, appConfig.Storage.GPXDir, from, to, func(progress SyncProgress) error {
+ progress, err := s.syncRides(client, appConfig.Storage.RidesDir, from, to, func(progress SyncProgress) error {
enrichProgressRateLimit(&progress, client)
return writeSyncEvent(w, flusher, "progress", progress)
})
@@ -104,7 +104,7 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
_ = writeSyncEvent(w, flusher, "complete", progress)
}
-func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, report func(SyncProgress) error) (SyncProgress, error) {
+func (s *Server) syncRides(client syncClient, ridesDir string, from, to time.Time, report func(SyncProgress) error) (SyncProgress, error) {
activities, err := client.List(from, to)
if err != nil {
if rateErr, ok := errors.AsType[*strava.RateLimitError](err); ok {
@@ -117,8 +117,8 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time,
return progress, err
}
slog.Info("Started Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total)
- if err := os.MkdirAll(gpxDir, 0o755); err != nil {
- return progress, fmt.Errorf("create GPX directory: %w", err)
+ if err := os.MkdirAll(ridesDir, 0o755); err != nil {
+ return progress, fmt.Errorf("create rides directory: %w", err)
}
for _, summary := range activities {
externalID := fmt.Sprintf("strava:%d", summary.ID)
@@ -146,15 +146,16 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time,
}
continue
}
- activity, gpxData, err := client.Get(summary.ID)
+ activity, gpxData, fitData, err := client.Get(summary.ID)
if err != nil {
if rateErr, ok := errors.AsType[*strava.RateLimitError](err); ok {
slog.Warn("Strava sync rate limited", "activity", summary.ID, "name", summary.Name, "retryAfter", rateErr.RetryAfter, "isDaily", rateErr.IsDaily, "completed", progress.Completed, "total", progress.Total)
return progress, err
}
if errors.Is(err, strava.ErrNoTrackPoints) {
- _ = os.Remove(filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", summary.ID)))
- if saveErr := ride.Save(s.db, rideFromActivity(externalID, "", activity)); saveErr != nil {
+ _ = os.Remove(filepath.Join(ridesDir, fmt.Sprintf("activity_%d.gpx", summary.ID)))
+ _ = os.Remove(filepath.Join(ridesDir, fmt.Sprintf("activity_%d.fit", summary.ID)))
+ if saveErr := ride.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
}
@@ -173,16 +174,27 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time,
}
continue
}
- gpxPath := filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", activity.ID))
+ gpxPath := filepath.Join(ridesDir, fmt.Sprintf("activity_%d.gpx", activity.ID))
+ fitPath := filepath.Join(ridesDir, fmt.Sprintf("activity_%d.fit", activity.ID))
if err := os.WriteFile(gpxPath, gpxData, 0o644); err != nil {
_ = os.Remove(gpxPath)
+ _ = os.Remove(fitPath)
if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("write GPX: %w", err)); reportErr != nil {
return progress, reportErr
}
continue
}
- if err := ride.Save(s.db, rideFromActivity(externalID, gpxPath, activity)); err != nil {
+ if err := os.WriteFile(fitPath, fitData, 0o644); err != nil {
_ = os.Remove(gpxPath)
+ _ = os.Remove(fitPath)
+ if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("write FIT: %w", err)); reportErr != nil {
+ return progress, reportErr
+ }
+ continue
+ }
+ if err := ride.Save(s.db, rideFromActivity(externalID, gpxPath, fitPath, activity)); err != nil {
+ _ = os.Remove(gpxPath)
+ _ = os.Remove(fitPath)
if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save activity: %w", err)); reportErr != nil {
return progress, reportErr
}
@@ -198,7 +210,7 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time,
return progress, nil
}
-func rideFromActivity(externalID, gpxPath string, activity strava.Activity) ride.StoredRide {
+func rideFromActivity(externalID, gpxPath, fitPath string, activity strava.Activity) ride.StoredRide {
activityType := activity.SportType
if activityType == "" {
activityType = activity.Type
@@ -206,6 +218,7 @@ func rideFromActivity(externalID, gpxPath string, activity strava.Activity) ride
return ride.StoredRide{
ExternalID: externalID,
GPXPath: gpxPath,
+ FitPath: fitPath,
Name: activity.Name,
Type: activityType,
StartDate: activity.StartDate,