aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 13:37:26 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 13:37:26 +0200
commitabbbf17bcfe6dfa663b7019c4892db09e27b3808 (patch)
tree483968e14d6e853aeb909724e7d60998f3d2a4b2
parent6ec883a7fd40a14f2882076bd1a377bbbd0438ad (diff)
Resync Strava title from list payload via Save
Context / Problem: Strava titles can be corrected after import. The sync path only handled new rides; existing rides were marked skipped without checking the cheap list metadata, so corrections like ride #789 stayed stale. What: In syncRides reuse the existing Save write path for rare title resyncs (pay GPX parse cost) when existing.Name != list.Name; track progress.Updated and surface it in SSE/TS UI; add tests for resync without stream fetch. Remove the now-obsolete TODO. QA: go test/ vet, tsc, biome/stylelint pass.
-rw-r--r--README.md3
-rw-r--r--web/frontend/sync.ts6
-rw-r--r--web/frontend/types.d.ts1
-rw-r--r--web/server_test.go78
-rw-r--r--web/sync.go20
-rw-r--r--web/views.go1
6 files changed, 99 insertions, 10 deletions
diff --git a/README.md b/README.md
index 35bb1c9..123b021 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,7 @@ go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap # allocs
## TODO
+- cli args instea dof flags
- screaming archi + hexagon
- Compute estimated power, speed
- For a ride, scatter, one point per 100m segment. Slope vs speed
@@ -168,8 +169,6 @@ go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap # allocs
- improve GPX parsing and Analyze
- Compact binary or vectorized storage for root
- Actually might be .fit
-- Strava
- - Resync metadata (title) from list
- OffClimb
- Keep trace appart from ride to display slope profile, compute cotacol
- Profile - Adaptative to show ramps
diff --git a/web/frontend/sync.ts b/web/frontend/sync.ts
index f58d224..9462d52 100644
--- a/web/frontend/sync.ts
+++ b/web/frontend/sync.ts
@@ -39,7 +39,8 @@ export const mountSync = (): void => {
progressBar.max = Math.max(progress.total, 1);
progressBar.value = progress.completed;
progressCount.textContent = `${progress.completed} / ${progress.total}`;
- progressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped${formatRateLimit(progress)}`;
+ const updatedPart = progress.updated ? ` · ${progress.updated} updated` : "";
+ progressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped${updatedPart}${formatRateLimit(progress)}`;
};
const handleEvent = (eventName: string, data: SyncProgress | SyncErrorEvent): boolean => {
if (eventName === "progress") {
@@ -50,7 +51,8 @@ export const mountSync = (): void => {
if (eventName === "complete") {
if (!isSyncProgress(data)) throw new Error("Invalid Strava sync completion event");
updateProgress(data);
- notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.${formatRateLimit(data)}`;
+ const updatedPart = data.updated ? `, ${data.updated} updated` : "";
+ notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped${updatedPart}.${formatRateLimit(data)}`;
notice.hidden = false;
return true;
}
diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts
index 83b3921..d4c4bdf 100644
--- a/web/frontend/types.d.ts
+++ b/web/frontend/types.d.ts
@@ -144,6 +144,7 @@ export interface SyncProgress {
completed: number;
imported: number;
skipped: number;
+ updated: number;
rateLimit?: RateLimitSnapshot;
}
diff --git a/web/server_test.go b/web/server_test.go
index a60d742..7137031 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -615,6 +615,80 @@ func TestSyncRidesSavesActivityWithoutRoute(t *testing.T) {
assert.True(t, found)
}
+func TestSyncRidesResyncsTitleFromList(t *testing.T) {
+ server, db := newWebTestServer(t)
+ startDate := time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC)
+ gpxPath := testGPXPath(t, "existing.gpx")
+ require.NoError(t, ride.Save(db, ride.StoredRide{
+ ExternalID: "strava:789",
+ GPXPath: gpxPath,
+ Name: "Old Title",
+ Type: "Ride",
+ StartDate: startDate,
+ DistanceM: 20_000,
+ }))
+ // Verify the title was corrected on Strava (ride #789).
+ client := fakeSyncClient{
+ activities: []strava.Activity{
+ {ID: 789, Name: "Corrected Title", Type: "Ride", StartDate: startDate},
+ },
+ results: map[int64]fakeSyncResult{},
+ }
+ gpxDir := t.TempDir()
+ var getCalled bool
+ wrapped := countingSyncClient{inner: client, getCalled: &getCalled}
+ progress, err := server.syncRides(wrapped, gpxDir, startDate, startDate.AddDate(0, 0, 1), nil)
+ require.NoError(t, err)
+ assert.Equal(t, SyncProgress{Total: 1, Completed: 1, Imported: 0, Skipped: 0, Updated: 1}, progress)
+ assert.False(t, getCalled, "sync should not fetch streams for metadata resync")
+ updated, found, err := ride.GetByExternalID(db, "strava:789")
+ require.NoError(t, err)
+ require.True(t, found)
+ assert.Equal(t, "Corrected Title", updated.Name)
+ assert.Equal(t, gpxPath, updated.GPXPath, "GPX path should be preserved on metadata resync")
+}
+
+func TestSyncRidesSkipsWhenTitleUnchanged(t *testing.T) {
+ server, db := newWebTestServer(t)
+ startDate := time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC)
+ require.NoError(t, ride.Save(db, ride.StoredRide{
+ ExternalID: "strava:789",
+ GPXPath: testGPXPath(t, "existing2.gpx"),
+ Name: "Same Title",
+ Type: "Ride",
+ StartDate: startDate,
+ DistanceM: 20_000,
+ }))
+ client := fakeSyncClient{
+ activities: []strava.Activity{
+ {ID: 789, Name: "Same Title", Type: "Ride", StartDate: startDate},
+ },
+ results: map[int64]fakeSyncResult{},
+ }
+ gpxDir := t.TempDir()
+ progress, err := server.syncRides(client, gpxDir, startDate, startDate.AddDate(0, 0, 1), nil)
+ require.NoError(t, err)
+ assert.Equal(t, SyncProgress{Total: 1, Completed: 1, Imported: 0, Skipped: 1, Updated: 0}, progress)
+ stored, found, err := ride.GetByExternalID(db, "strava:789")
+ require.NoError(t, err)
+ require.True(t, found)
+ assert.Equal(t, "Same Title", stored.Name)
+}
+
+type countingSyncClient struct {
+ inner fakeSyncClient
+ getCalled *bool
+}
+
+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) {
+ *c.getCalled = true
+ return c.inner.Get(id)
+}
+
func TestSyncRejectsConcurrentImport(t *testing.T) {
server, _ := newWebTestServer(t)
appConfig, err := config.Load(server.configPath)
@@ -645,8 +719,8 @@ func TestWriteSyncEvent(t *testing.T) {
require.NoError(t, writeSyncEvent(response, response, "complete", progress))
body := response.Body.String()
- assert.Contains(t, body, "event: progress\ndata: {\"total\":2,\"completed\":1,\"imported\":1,\"skipped\":0}\n\n")
- assert.Contains(t, body, "event: complete\ndata: {\"total\":2,\"completed\":1,\"imported\":1,\"skipped\":0}\n\n")
+ assert.Contains(t, body, "event: progress\ndata: {\"total\":2,\"completed\":1,\"imported\":1,\"skipped\":0,\"updated\":0}\n\n")
+ assert.Contains(t, body, "event: complete\ndata: {\"total\":2,\"completed\":1,\"imported\":1,\"skipped\":0,\"updated\":0}\n\n")
assert.Less(t, strings.Index(body, "event: progress"), strings.Index(body, "event: complete"))
}
diff --git a/web/sync.go b/web/sync.go
index 0b95b93..f6dc7da 100644
--- a/web/sync.go
+++ b/web/sync.go
@@ -100,7 +100,7 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
return
}
enrichProgressRateLimit(&progress, client)
- slog.Info("Completed Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total, "imported", progress.Imported, "skipped", progress.Skipped, "readDaily", rateLimitLogValue(progress.RateLimit, true), "overallDaily", rateLimitLogValue(progress.RateLimit, false))
+ slog.Info("Completed Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total, "imported", progress.Imported, "skipped", progress.Skipped, "updated", progress.Updated, "readDaily", rateLimitLogValue(progress.RateLimit, true), "overallDaily", rateLimitLogValue(progress.RateLimit, false))
_ = writeSyncEvent(w, flusher, "complete", progress)
}
@@ -122,12 +122,24 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time,
}
for _, summary := range activities {
externalID := fmt.Sprintf("strava:%d", summary.ID)
- _, exists, err := ride.GetByExternalID(s.db, externalID)
+ existing, exists, err := ride.GetByExternalID(s.db, externalID)
if err != nil {
return progress, err
}
if exists {
- progress.Skipped++
+ if existing.Name != summary.Name {
+ existing.Name = summary.Name
+ if err := ride.Save(s.db, existing); err != nil {
+ if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("resync metadata: %w", err)); reportErr != nil {
+ return progress, reportErr
+ }
+ continue
+ }
+ progress.Updated++
+ slog.Info("Resynced Strava ride metadata", "activity", summary.ID, "name", summary.Name)
+ } else {
+ progress.Skipped++
+ }
progress.Completed++
if err := reportSyncProgress(report, progress); err != nil {
return progress, err
@@ -297,7 +309,7 @@ func rateLimitMessage(rateErr *strava.RateLimitError, progress SyncProgress) str
progressText := ""
if progress.Total > 0 {
remaining := progress.Total - progress.Completed
- progressText = fmt.Sprintf(" Progress: %d/%d completed, %d imported, %d skipped, %d remaining.", progress.Completed, progress.Total, progress.Imported, progress.Skipped, remaining)
+ progressText = fmt.Sprintf(" Progress: %d/%d completed, %d imported, %d skipped, %d updated, %d remaining.", progress.Completed, progress.Total, progress.Imported, progress.Skipped, progress.Updated, remaining)
}
return fmt.Sprintf("Strava %s rate limit reached.%s%s%s Re-run sync after the reset time to import remaining rides.", scope, progressText, usageText, retryText)
}
diff --git a/web/views.go b/web/views.go
index 4f769cc..5c8d452 100644
--- a/web/views.go
+++ b/web/views.go
@@ -397,6 +397,7 @@ type SyncProgress struct {
Completed int `json:"completed"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
+ Updated int `json:"updated"`
RateLimit *RateLimitSnapshot `json:"rateLimit,omitempty"`
}