From abbbf17bcfe6dfa663b7019c4892db09e27b3808 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 27 Aug 2026 13:37:26 +0200 Subject: 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. --- web/frontend/sync.ts | 6 ++-- web/frontend/types.d.ts | 1 + web/server_test.go | 78 +++++++++++++++++++++++++++++++++++++++++++++++-- web/sync.go | 20 ++++++++++--- web/views.go | 1 + 5 files changed, 98 insertions(+), 8 deletions(-) (limited to 'web') 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"` } -- cgit v1.3