summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-11 14:19:01 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-11 14:19:01 +0200
commit7d13374eec8c33c4e14cc5b0c3de36e4f620ce0e (patch)
tree98a20ee73f34863ddb5938aa5eb29346e4a5db56 /web
parent1da94931188c35c06dd032aadce2799fc13db1dd (diff)
fix: Continue Strava import after invalid activities
Diffstat (limited to 'web')
-rw-r--r--web/server.go33
-rw-r--r--web/server_test.go62
-rw-r--r--web/templates.templ2
-rw-r--r--web/templates_templ.go2
4 files changed, 92 insertions, 7 deletions
diff --git a/web/server.go b/web/server.go
index 53374ce..6f222ef 100644
--- a/web/server.go
+++ b/web/server.go
@@ -37,6 +37,11 @@ type Server struct {
syncActive bool
}
+type syncClient interface {
+ List(from, to time.Time) ([]strava.Activity, error)
+ Get(id int64) (strava.Activity, []byte, error)
+}
+
func NewServer(db *sql.DB, configPath string) *Server {
return &Server{db: db, configPath: configPath}
}
@@ -263,7 +268,7 @@ func (s *Server) handleStravaCallback(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, returnTo, http.StatusSeeOther)
}
-func (s *Server) syncRides(client *strava.Client, gpxDir string, from, to time.Time, report func(SyncProgress) error) (SyncProgress, error) {
+func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, report func(SyncProgress) error) (SyncProgress, error) {
activities, err := client.List(from, to)
if err != nil {
return SyncProgress{}, err
@@ -292,11 +297,18 @@ func (s *Server) syncRides(client *strava.Client, gpxDir string, from, to time.T
}
activity, gpxData, err := client.Get(summary.ID)
if err != nil {
- return progress, err
+ if err := skipSyncActivity(&progress, report, summary, err); err != nil {
+ return progress, err
+ }
+ continue
}
gpxPath := filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", activity.ID))
if err := os.WriteFile(gpxPath, gpxData, 0o644); err != nil {
- return progress, fmt.Errorf("write GPX for activity %d: %w", activity.ID, err)
+ _ = os.Remove(gpxPath)
+ if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("write GPX: %w", err)); reportErr != nil {
+ return progress, reportErr
+ }
+ continue
}
activityType := activity.SportType
if activityType == "" {
@@ -314,7 +326,11 @@ func (s *Server) syncRides(client *strava.Client, gpxDir string, from, to time.T
TotalElevationGainM: activity.TotalElevationGainM,
AverageSpeedMps: activity.AverageSpeedMps,
}); err != nil {
- return progress, fmt.Errorf("save activity %d: %w", activity.ID, err)
+ _ = os.Remove(gpxPath)
+ if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save activity: %w", err)); reportErr != nil {
+ return progress, reportErr
+ }
+ continue
}
progress.Imported++
progress.Completed++
@@ -326,6 +342,13 @@ func (s *Server) syncRides(client *strava.Client, gpxDir string, from, to time.T
return progress, nil
}
+func skipSyncActivity(progress *SyncProgress, report func(SyncProgress) error, activity strava.Activity, err error) error {
+ progress.Skipped++
+ progress.Completed++
+ slog.Warn("Skipped Strava activity", "activity", activity.ID, "name", activity.Name, "error", err)
+ return reportSyncProgress(report, *progress)
+}
+
func reportSyncProgress(report func(SyncProgress) error, progress SyncProgress) error {
if report == nil {
return nil
@@ -469,7 +492,7 @@ func syncNotice(r *http.Request) string {
if imported == "" {
return ""
}
- return fmt.Sprintf("Sync complete: %s imported, %s already stored.", imported, r.URL.Query().Get("skipped"))
+ return fmt.Sprintf("Sync complete: %s imported, %s skipped.", imported, r.URL.Query().Get("skipped"))
}
func safeReturnTo(value string) string {
diff --git a/web/server_test.go b/web/server_test.go
index 32ab448..46828d1 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -16,6 +16,7 @@ import (
"github.com/martinlehoux/biking_home/config"
"github.com/martinlehoux/biking_home/rides"
+ "github.com/martinlehoux/biking_home/strava"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -61,6 +62,32 @@ func testGPXPath(t *testing.T, name string) string {
return path
}
+type fakeSyncClient struct {
+ activities []strava.Activity
+ results map[int64]fakeSyncResult
+}
+
+type fakeSyncResult struct {
+ activity strava.Activity
+ data []byte
+ err error
+}
+
+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) {
+ result, ok := c.results[id]
+ if !ok {
+ return strava.Activity{}, nil, fmt.Errorf("missing fake activity %d", id)
+ }
+ return result.activity, result.data, 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>`
+const validTrackGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg><trkpt lat="43.0" lon="5.0"><ele>100</ele></trkpt><trkpt lat="43.001" lon="5.001"><ele>200</ele></trkpt></trkseg></trk></gpx>`
+
func TestHandlerRendersRidesPage(t *testing.T) {
server, db := newWebTestServer(t)
require.NoError(t, rides.Save(db, rides.Ride{
@@ -224,6 +251,41 @@ func TestSyncParsesMultipartDateRange(t *testing.T) {
assert.NotContains(t, response.Body.String(), "<!doctype html>")
}
+func TestSyncRidesContinuesAfterInvalidActivity(t *testing.T) {
+ server, db := newWebTestServer(t)
+ invalidID := int64(14701658670)
+ validID := int64(14701658671)
+ startDate := time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC)
+ client := fakeSyncClient{
+ activities: []strava.Activity{
+ {ID: invalidID, Name: "Empty activity", Type: "Ride", StartDate: startDate},
+ {ID: validID, Name: "Valid activity", Type: "Ride", StartDate: startDate},
+ },
+ results: map[int64]fakeSyncResult{
+ invalidID: {activity: strava.Activity{ID: invalidID, Name: "Empty activity", Type: "Ride", StartDate: startDate}, data: []byte(emptyTrackGPX)},
+ validID: {activity: strava.Activity{ID: validID, Name: "Valid activity", Type: "Ride", StartDate: startDate}, data: []byte(validTrackGPX)},
+ },
+ }
+ var updates []SyncProgress
+ gpxDir := t.TempDir()
+
+ progress, err := server.syncRides(client, gpxDir, startDate, startDate.AddDate(0, 0, 1), func(progress SyncProgress) error {
+ updates = append(updates, progress)
+ return nil
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, SyncProgress{Total: 2, Completed: 2, Imported: 1, Skipped: 1}, progress)
+ assert.Len(t, updates, 3)
+ _, found, err := rides.GetByExternalID(db, "strava:14701658670")
+ require.NoError(t, err)
+ assert.False(t, found)
+ _, found, err = rides.GetByExternalID(db, "strava:14701658671")
+ require.NoError(t, err)
+ assert.True(t, found)
+ assert.NoFileExists(t, filepath.Join(gpxDir, "activity_14701658670.gpx"))
+}
+
func TestSyncRejectsConcurrentImport(t *testing.T) {
server, _ := newWebTestServer(t)
appConfig, err := config.Load(server.configPath)
diff --git a/web/templates.templ b/web/templates.templ
index 74b0365..78591e1 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -208,7 +208,7 @@ templ SyncContent(data SyncPageData) {
}
if (eventName === "complete") {
updateProgress(data);
- notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} already stored.`;
+ notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`;
notice.hidden = false;
return true;
}
diff --git a/web/templates_templ.go b/web/templates_templ.go
index ba6910b..b66e732 100644
--- a/web/templates_templ.go
+++ b/web/templates_templ.go
@@ -630,7 +630,7 @@ func SyncContent(data SyncPageData) templ.Component {
return templ_7745c5c3_Err
}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section><script>\n\t\t(() => {\n\t\t\tconst form = document.getElementById(\"sync-form\");\n\t\t\tif (!form) return;\n\t\t\tconst button = form.querySelector(\"button[type=submit]\");\n\t\t\tconst progressPanel = document.getElementById(\"sync-progress\");\n\t\t\tconst progressBar = document.getElementById(\"sync-progress-bar\");\n\t\t\tconst progressCount = document.getElementById(\"sync-progress-count\");\n\t\t\tconst progressDetails = document.getElementById(\"sync-progress-details\");\n\t\t\tconst notice = document.getElementById(\"sync-notice\");\n\t\t\tconst error = document.getElementById(\"sync-error\");\n\n\t\t\tconst showError = (message) => {\n\t\t\t\terror.textContent = message;\n\t\t\t\terror.hidden = false;\n\t\t\t};\n\t\t\tconst updateProgress = (progress) => {\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressBar.max = Math.max(progress.total, 1);\n\t\t\t\tprogressBar.value = progress.completed;\n\t\t\t\tprogressCount.textContent = `${progress.completed} / ${progress.total}`;\n\t\t\t\tprogressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;\n\t\t\t};\n\t\t\tconst handleEvent = (eventName, data) => {\n\t\t\t\tif (eventName === \"progress\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"complete\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\tnotice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} already stored.`;\n\t\t\t\t\tnotice.hidden = false;\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"error\") {\n\t\t\t\t\tif (data.progress) updateProgress(data.progress);\n\t\t\t\t\tthrow new Error(data.message || \"Strava sync failed\");\n\t\t\t\t}\n\t\t\t\treturn false;\n\t\t\t};\n\t\t\tconst consumeEvents = async (response) => {\n\t\t\t\tconst reader = response.body.getReader();\n\t\t\t\tconst decoder = new TextDecoder();\n\t\t\t\tlet buffer = \"\";\n\t\t\t\tlet completed = false;\n\t\t\t\tconst consumeBlock = (block) => {\n\t\t\t\t\tlet eventName = \"message\";\n\t\t\t\t\tlet data = \"\";\n\t\t\t\t\tfor (const line of block.split(/\\r?\\n/)) {\n\t\t\t\t\t\tif (line.startsWith(\"event: \")) eventName = line.slice(7);\n\t\t\t\t\t\tif (line.startsWith(\"data: \")) data += line.slice(6);\n\t\t\t\t\t}\n\t\t\t\t\tif (data) completed = handleEvent(eventName, JSON.parse(data)) || completed;\n\t\t\t\t};\n\t\t\t\twhile (true) {\n\t\t\t\t\tconst { value, done } = await reader.read();\n\t\t\t\t\tbuffer += decoder.decode(value || new Uint8Array(), { stream: !done });\n\t\t\t\t\tconst blocks = buffer.split(/\\r?\\n\\r?\\n/);\n\t\t\t\t\tbuffer = blocks.pop();\n\t\t\t\t\tfor (const block of blocks) consumeBlock(block);\n\t\t\t\t\tif (done) break;\n\t\t\t\t}\n\t\t\t\tif (buffer.trim()) consumeBlock(buffer);\n\t\t\t\tif (!completed) throw new Error(\"Strava sync ended before completion\");\n\t\t\t};\n\n\t\t\tform.addEventListener(\"submit\", async (event) => {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tbutton.disabled = true;\n\t\t\t\tbutton.textContent = \"Syncing...\";\n\t\t\t\tnotice.hidden = true;\n\t\t\t\terror.hidden = true;\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressCount.textContent = \"0 / 0\";\n\t\t\t\tprogressDetails.textContent = \"Preparing import...\";\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await fetch(form.action, {\n\t\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\t\tbody: new FormData(form),\n\t\t\t\t\t\theaders: { Accept: \"text/event-stream\" },\n\t\t\t\t\t});\n\t\t\t\t\tif (response.redirected) {\n\t\t\t\t\t\twindow.location.assign(response.url);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (!response.ok) throw new Error(await response.text() || `Sync failed (HTTP ${response.status})`);\n\t\t\t\t\tif (!response.body) throw new Error(\"The browser does not support streaming responses\");\n\t\t\t\t\tawait consumeEvents(response);\n\t\t\t\t} catch (caught) {\n\t\t\t\t\tshowError(caught instanceof Error ? caught.message : \"Strava sync failed\");\n\t\t\t\t} finally {\n\t\t\t\t\tbutton.disabled = false;\n\t\t\t\t\tbutton.textContent = \"Sync rides\";\n\t\t\t\t}\n\t\t\t});\n\t\t})();\n\t</script>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section><script>\n\t\t(() => {\n\t\t\tconst form = document.getElementById(\"sync-form\");\n\t\t\tif (!form) return;\n\t\t\tconst button = form.querySelector(\"button[type=submit]\");\n\t\t\tconst progressPanel = document.getElementById(\"sync-progress\");\n\t\t\tconst progressBar = document.getElementById(\"sync-progress-bar\");\n\t\t\tconst progressCount = document.getElementById(\"sync-progress-count\");\n\t\t\tconst progressDetails = document.getElementById(\"sync-progress-details\");\n\t\t\tconst notice = document.getElementById(\"sync-notice\");\n\t\t\tconst error = document.getElementById(\"sync-error\");\n\n\t\t\tconst showError = (message) => {\n\t\t\t\terror.textContent = message;\n\t\t\t\terror.hidden = false;\n\t\t\t};\n\t\t\tconst updateProgress = (progress) => {\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressBar.max = Math.max(progress.total, 1);\n\t\t\t\tprogressBar.value = progress.completed;\n\t\t\t\tprogressCount.textContent = `${progress.completed} / ${progress.total}`;\n\t\t\t\tprogressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;\n\t\t\t};\n\t\t\tconst handleEvent = (eventName, data) => {\n\t\t\t\tif (eventName === \"progress\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"complete\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\tnotice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`;\n\t\t\t\t\tnotice.hidden = false;\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"error\") {\n\t\t\t\t\tif (data.progress) updateProgress(data.progress);\n\t\t\t\t\tthrow new Error(data.message || \"Strava sync failed\");\n\t\t\t\t}\n\t\t\t\treturn false;\n\t\t\t};\n\t\t\tconst consumeEvents = async (response) => {\n\t\t\t\tconst reader = response.body.getReader();\n\t\t\t\tconst decoder = new TextDecoder();\n\t\t\t\tlet buffer = \"\";\n\t\t\t\tlet completed = false;\n\t\t\t\tconst consumeBlock = (block) => {\n\t\t\t\t\tlet eventName = \"message\";\n\t\t\t\t\tlet data = \"\";\n\t\t\t\t\tfor (const line of block.split(/\\r?\\n/)) {\n\t\t\t\t\t\tif (line.startsWith(\"event: \")) eventName = line.slice(7);\n\t\t\t\t\t\tif (line.startsWith(\"data: \")) data += line.slice(6);\n\t\t\t\t\t}\n\t\t\t\t\tif (data) completed = handleEvent(eventName, JSON.parse(data)) || completed;\n\t\t\t\t};\n\t\t\t\twhile (true) {\n\t\t\t\t\tconst { value, done } = await reader.read();\n\t\t\t\t\tbuffer += decoder.decode(value || new Uint8Array(), { stream: !done });\n\t\t\t\t\tconst blocks = buffer.split(/\\r?\\n\\r?\\n/);\n\t\t\t\t\tbuffer = blocks.pop();\n\t\t\t\t\tfor (const block of blocks) consumeBlock(block);\n\t\t\t\t\tif (done) break;\n\t\t\t\t}\n\t\t\t\tif (buffer.trim()) consumeBlock(buffer);\n\t\t\t\tif (!completed) throw new Error(\"Strava sync ended before completion\");\n\t\t\t};\n\n\t\t\tform.addEventListener(\"submit\", async (event) => {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tbutton.disabled = true;\n\t\t\t\tbutton.textContent = \"Syncing...\";\n\t\t\t\tnotice.hidden = true;\n\t\t\t\terror.hidden = true;\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressCount.textContent = \"0 / 0\";\n\t\t\t\tprogressDetails.textContent = \"Preparing import...\";\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await fetch(form.action, {\n\t\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\t\tbody: new FormData(form),\n\t\t\t\t\t\theaders: { Accept: \"text/event-stream\" },\n\t\t\t\t\t});\n\t\t\t\t\tif (response.redirected) {\n\t\t\t\t\t\twindow.location.assign(response.url);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (!response.ok) throw new Error(await response.text() || `Sync failed (HTTP ${response.status})`);\n\t\t\t\t\tif (!response.body) throw new Error(\"The browser does not support streaming responses\");\n\t\t\t\t\tawait consumeEvents(response);\n\t\t\t\t} catch (caught) {\n\t\t\t\t\tshowError(caught instanceof Error ? caught.message : \"Strava sync failed\");\n\t\t\t\t} finally {\n\t\t\t\t\tbutton.disabled = false;\n\t\t\t\t\tbutton.textContent = \"Sync rides\";\n\t\t\t\t}\n\t\t\t});\n\t\t})();\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}