summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-03 21:53:04 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-03 21:53:04 +0200
commit69f317d8cb51b911732712a9fcc8ac49563326c6 (patch)
treef8afcada62a43326c7d3fda34e4cd4627b7b0d1c
parent9707f9ad09db935af7d06b5de08bc52de13ba6a7 (diff)
feat: Sync Strava rides as GPX files
-rw-r--r--.gitignore2
-rw-r--r--env.go50
-rw-r--r--main.go158
-rw-r--r--strava/strava.go146
-rw-r--r--strava/strava_test.go108
-rw-r--r--strava/sync.go110
-rw-r--r--strava/sync_test.go100
7 files changed, 674 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index 25407bf..c96fd10 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,8 @@
.env
+.strava-last-sync
*.db
*.png
+rides/
debug_department_*.csv
france-latest.osm.pbf
biking_home
diff --git a/env.go b/env.go
new file mode 100644
index 0000000..e1ec826
--- /dev/null
+++ b/env.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+ "os"
+ "strings"
+
+ "github.com/martinlehoux/kagamigo/kcore"
+)
+
+func loadEnv() map[string]string {
+ values := map[string]string{}
+ data, err := os.ReadFile(".env")
+ if err != nil {
+ return values
+ }
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ key, value, found := strings.Cut(line, "=")
+ if found {
+ values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"`)
+ }
+ }
+ return values
+}
+
+func updateEnv(updates map[string]string) {
+ data, err := os.ReadFile(".env")
+ kcore.Expect(err, "failed to read .env")
+ lines := strings.Split(string(data), "\n")
+ for key, value := range updates {
+ key = strings.TrimSpace(key)
+ found := false
+ for i, line := range lines {
+ existingKey, _, cut := strings.Cut(strings.TrimSpace(line), "=")
+ if cut && strings.TrimSpace(existingKey) == key {
+ lines[i] = key + "=" + value
+ found = true
+ break
+ }
+ }
+ if !found {
+ lines = append(lines, key+"="+value)
+ }
+ }
+ err = os.WriteFile(".env", []byte(strings.Join(lines, "\n")+"\n"), 0o600)
+ kcore.Expect(err, "failed to write .env")
+}
diff --git a/main.go b/main.go
index 032c55e..89f4098 100644
--- a/main.go
+++ b/main.go
@@ -3,17 +3,22 @@ package main
import (
"context"
"database/sql"
+ "errors"
"flag"
+ "fmt"
"log/slog"
+ "net/http"
"os"
"path/filepath"
"runtime/pprof"
+ "strconv"
"strings"
"time"
"github.com/martinlehoux/biking_home/mountain_pass"
"github.com/martinlehoux/biking_home/osmpass"
"github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/biking_home/strava"
"github.com/martinlehoux/kagamigo/kcore"
_ "github.com/mattn/go-sqlite3"
)
@@ -27,6 +32,9 @@ var (
enrich = flag.Bool("enrich", false, "backfill mountain pass coordinates from OSM data")
demo = flag.Bool("demo", false, "run the climb similarity demo")
chartFile = flag.String("chart", "", "render a climb/pass chart for a GPX file")
+ stravaLogin = flag.Bool("strava-login", false, "authorize the Strava API and store tokens in .env")
+ stravaSync = flag.Bool("strava-sync", false, "download Strava rides as GPX files into rides/")
+ stravaSince = flag.Int("strava-since", 30, "days of history to fetch on the first sync (before any .strava-last-sync)")
cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
parser = ride.GPXRideParser{}
)
@@ -54,11 +62,161 @@ func main() {
kcore.Expect(err, "failed to enrich mountain passes")
case *demo:
runDemo(db)
+ case *stravaLogin:
+ runStravaLogin()
+ case *stravaSync:
+ runStravaSync()
case *chartFile != "":
runChart(db, *chartFile)
}
}
+func runStravaLogin() {
+ clientID, clientSecret, _ := stravaCredentials(loadEnv())
+ if clientID == "" || clientSecret == "" {
+ kcore.Expect(errors.New("missing STRAVA_CLIENT_ID / STRAVA_CLIENT_SECRET"), "set both in .env")
+ }
+ redirectURI := fmt.Sprintf("http://localhost:%d/callback", strava.LoginPort)
+ codeCh := make(chan string, 1)
+ serverErrCh := make(chan error, 1)
+ mux := http.NewServeMux()
+ mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
+ code := r.URL.Query().Get("code")
+ if code == "" {
+ serverErrCh <- fmt.Errorf("no code in callback: %s", r.URL.RawQuery)
+ http.Error(w, "Authorization failed", http.StatusBadRequest)
+ return
+ }
+ codeCh <- code
+ fmt.Fprint(w, "Authorization successful — you can close this tab.")
+ })
+ server := &http.Server{Addr: fmt.Sprintf(":%d", strava.LoginPort), Handler: mux}
+ go func() {
+ if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ serverErrCh <- err
+ }
+ }()
+ defer server.Close()
+
+ slog.Info("Open the authorization URL in your browser", "url", strava.AuthorizeURL(clientID, redirectURI))
+ var token strava.Token
+ select {
+ case code := <-codeCh:
+ var err error
+ token, err = strava.ExchangeCode(clientID, clientSecret, code, redirectURI)
+ kcore.Expect(err, "failed to exchange authorization code")
+ slog.Info("Login successful, tokens stored in .env")
+ case err := <-serverErrCh:
+ kcore.Expect(err, "authorization server failed")
+ case <-time.After(5 * time.Minute):
+ kcore.Expect(errors.New("timeout"), "no authorization code received")
+ }
+
+ validateStrava(clientID, clientSecret, token)
+}
+
+func stravaCredentials(env map[string]string) (clientID, clientSecret string, token strava.Token) {
+ expiresAt, _ := strconv.ParseInt(env["STRAVA_EXPIRES_AT"], 10, 64)
+ return env["STRAVA_CLIENT_ID"], env["STRAVA_CLIENT_SECRET"], strava.Token{
+ AccessToken: env["STRAVA_ACCESS_TOKEN"],
+ RefreshToken: env["STRAVA_REFRESH_TOKEN"],
+ ExpiresAt: time.Unix(expiresAt, 0),
+ }
+}
+
+func validateStrava(clientID, clientSecret string, token strava.Token) {
+ client := strava.NewClient(clientID, clientSecret, token)
+ if refreshed, err := client.RefreshIfNeeded(); err != nil {
+ kcore.Expect(err, "failed to refresh access token")
+ } else if refreshed {
+ slog.Info("Access token refreshed")
+ }
+ storeStravaTokens(client.Tokens())
+ var athlete struct {
+ Firstname string `json:"firstname"`
+ Lastname string `json:"lastname"`
+ }
+ err := client.GetJSON("/athlete", &athlete)
+ kcore.Expect(err, "failed to validate token against /athlete")
+ slog.Info("Authenticated as", "athlete", athlete.Firstname+" "+athlete.Lastname)
+}
+
+func runStravaSync() {
+ clientID, clientSecret, token := stravaCredentials(loadEnv())
+ if token.AccessToken == "" || token.RefreshToken == "" {
+ kcore.Expect(errors.New("missing STRAVA tokens"), "run -strava-login first")
+ }
+ client := strava.NewClient(clientID, clientSecret, token)
+ if refreshed, err := client.RefreshIfNeeded(); err != nil {
+ kcore.Expect(err, "failed to refresh access token")
+ } else if refreshed {
+ storeStravaTokens(client.Tokens())
+ slog.Info("Access token refreshed")
+ }
+
+ after := lastStravaSync()
+ if after.IsZero() {
+ after = time.Now().AddDate(0, 0, -*stravaSince)
+ }
+ slog.Info("Syncing Strava rides", "since", after.Format("2006-01-02"))
+ activities, err := client.ListActivities(after, "Ride")
+ kcore.Expect(err, "failed to list Strava activities")
+ slog.Info("Rides found", "count", len(activities))
+
+ kcore.Expect(os.MkdirAll("rides", 0o755), "failed to create rides/ directory")
+ downloaded := 0
+ for _, activity := range activities {
+ file := filepath.Join("rides", fmt.Sprintf("activity_%d.gpx", activity.ID))
+ if _, err := os.Stat(file); err == nil {
+ continue
+ }
+ latlng, altitude, seconds, err := client.ActivityStreams(activity.ID)
+ if err != nil {
+ slog.Warn("Failed to fetch streams, skipping", "activity", activity.ID, "error", err)
+ continue
+ }
+ if len(latlng) == 0 || len(altitude) == 0 {
+ slog.Warn("Missing latlng or altitude stream, skipping", "activity", activity.ID, "name", activity.Name)
+ continue
+ }
+ err = strava.WriteActivityGPX(file, activity, latlng, altitude, seconds)
+ if err != nil {
+ slog.Warn("Failed to write GPX, skipping", "activity", activity.ID, "error", err)
+ continue
+ }
+ downloaded++
+ slog.Info("Downloaded", "activity", activity.ID, "name", activity.Name, "points", len(latlng))
+ }
+ updateLastStravaSync(time.Now())
+ slog.Info("Sync complete", "downloaded", downloaded, "seen", len(activities))
+}
+
+const lastSyncFile = ".strava-last-sync"
+
+func lastStravaSync() time.Time {
+ data, err := os.ReadFile(lastSyncFile)
+ if err != nil {
+ return time.Time{}
+ }
+ unix, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
+ if err != nil {
+ return time.Time{}
+ }
+ return time.Unix(unix, 0)
+}
+
+func updateLastStravaSync(t time.Time) {
+ kcore.Expect(os.WriteFile(lastSyncFile, []byte(fmt.Sprintf("%d\n", t.Unix())), 0o644), "failed to write last sync marker")
+}
+
+func storeStravaTokens(token strava.Token) {
+ updateEnv(map[string]string{
+ "STRAVA_ACCESS_TOKEN": token.AccessToken,
+ "STRAVA_REFRESH_TOKEN": token.RefreshToken,
+ "STRAVA_EXPIRES_AT": fmt.Sprintf("%d", token.ExpiresAt.Unix()),
+ })
+}
+
func runChart(db *sql.DB, filename string) {
r, err := ride.ParseFile(parser, filename)
kcore.Expect(err, "failed to parse ride")
diff --git a/strava/strava.go b/strava/strava.go
new file mode 100644
index 0000000..d394791
--- /dev/null
+++ b/strava/strava.go
@@ -0,0 +1,146 @@
+package strava
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/martinlehoux/kagamigo/kcore"
+)
+
+const (
+ AuthURL = "https://www.strava.com/oauth/authorize"
+ TokenURL = "https://www.strava.com/oauth/token"
+ APIURL = "https://www.strava.com/api/v3"
+ LoginPort = 8787
+)
+
+var (
+ tokenEndpoint = TokenURL
+ apiEndpoint = APIURL
+)
+
+type Token struct {
+ AccessToken string
+ RefreshToken string
+ ExpiresAt time.Time
+}
+
+func AuthorizeURL(clientID, redirectURI string) string {
+ q := url.Values{}
+ q.Set("client_id", clientID)
+ q.Set("response_type", "code")
+ q.Set("redirect_uri", redirectURI)
+ q.Set("approval_prompt", "auto")
+ q.Set("scope", "activity:read_all")
+ return AuthURL + "?" + q.Encode()
+}
+
+type tokenResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresAt int64 `json:"expires_at"`
+}
+
+func ExchangeCode(clientID, clientSecret, code, redirectURI string) (Token, error) {
+ form := url.Values{}
+ form.Set("client_id", clientID)
+ form.Set("client_secret", clientSecret)
+ form.Set("code", code)
+ form.Set("grant_type", "authorization_code")
+ form.Set("redirect_uri", redirectURI)
+ return requestToken(form)
+}
+
+func Refresh(clientID, clientSecret, refreshToken string) (Token, error) {
+ form := url.Values{}
+ form.Set("client_id", clientID)
+ form.Set("client_secret", clientSecret)
+ form.Set("grant_type", "refresh_token")
+ form.Set("refresh_token", refreshToken)
+ return requestToken(form)
+}
+
+func requestToken(form url.Values) (Token, error) {
+ resp, err := http.PostForm(tokenEndpoint, form)
+ if err != nil {
+ return Token{}, kcore.Wrap(err, "failed to request Strava token")
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return Token{}, kcore.Wrap(err, "failed to read Strava token response")
+ }
+ if resp.StatusCode != http.StatusOK {
+ return Token{}, fmt.Errorf("strava token request failed (HTTP %d): %s", resp.StatusCode, body)
+ }
+ var parsed tokenResponse
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return Token{}, kcore.Wrap(err, "failed to decode Strava token response")
+ }
+ if parsed.AccessToken == "" {
+ return Token{}, fmt.Errorf("strava token response missing access_token: %s", body)
+ }
+ return Token{
+ AccessToken: parsed.AccessToken,
+ RefreshToken: parsed.RefreshToken,
+ ExpiresAt: time.Unix(parsed.ExpiresAt, 0),
+ }, nil
+}
+
+type Client struct {
+ ClientID string
+ ClientSecret string
+ HTTP *http.Client
+ tokens Token
+}
+
+func NewClient(clientID, clientSecret string, tokens Token) *Client {
+ return &Client{ClientID: clientID, ClientSecret: clientSecret, HTTP: http.DefaultClient, tokens: tokens}
+}
+
+func (c *Client) Tokens() Token {
+ return c.tokens
+}
+
+func (c *Client) RefreshIfNeeded() (bool, error) {
+ if time.Until(c.tokens.ExpiresAt) > 5*time.Minute {
+ return false, nil
+ }
+ refreshed, err := Refresh(c.ClientID, c.ClientSecret, c.tokens.RefreshToken)
+ if err != nil {
+ return false, kcore.Wrap(err, "failed to refresh Strava access token")
+ }
+ c.tokens = refreshed
+ return true, nil
+}
+
+func (c *Client) GetJSON(path string, out any) error {
+ if _, err := c.RefreshIfNeeded(); err != nil {
+ return err
+ }
+ req, err := http.NewRequest(http.MethodGet, apiEndpoint+path, nil)
+ if err != nil {
+ return kcore.Wrap(err, "failed to build Strava request")
+ }
+ req.Header.Set("Authorization", "Bearer "+c.tokens.AccessToken)
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ return kcore.Wrap(err, "failed to call Strava API")
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return kcore.Wrap(err, "failed to read Strava API response")
+ }
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("strava API %s failed (HTTP %d): %s", path, resp.StatusCode, body)
+ }
+ if err := json.Unmarshal(body, out); err != nil {
+ return kcore.Wrap(err, "failed to decode Strava API response")
+ }
+ return nil
+}
diff --git a/strava/strava_test.go b/strava/strava_test.go
new file mode 100644
index 0000000..7120434
--- /dev/null
+++ b/strava/strava_test.go
@@ -0,0 +1,108 @@
+package strava
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAuthorizeURL(t *testing.T) {
+ u := AuthorizeURL("12345", "http://localhost:8787/callback")
+ parsed, err := url.Parse(u)
+ require.NoError(t, err)
+ assert.Equal(t, "https", parsed.Scheme)
+ assert.Equal(t, "www.strava.com", parsed.Host)
+ assert.Equal(t, "/oauth/authorize", parsed.Path)
+ q := parsed.Query()
+ assert.Equal(t, "12345", q.Get("client_id"))
+ assert.Equal(t, "http://localhost:8787/callback", q.Get("redirect_uri"))
+ assert.Equal(t, "code", q.Get("response_type"))
+ assert.Equal(t, "activity:read_all", q.Get("scope"))
+}
+
+func TestExchangeCode(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ require.NoError(t, err)
+ assert.Equal(t, "authorization_code", r.Form.Get("grant_type"))
+ assert.Equal(t, "secret", r.Form.Get("client_secret"))
+ assert.Equal(t, "the-code", r.Form.Get("code"))
+ w.Header().Set("Content-Type", "application/json")
+ _, err = w.Write([]byte(`{"access_token":"access-1","refresh_token":"refresh-1","expires_at":2000000000}`))
+ require.NoError(t, err)
+ }))
+ defer server.Close()
+ tokenEndpoint = server.URL
+
+ token, err := ExchangeCode("12345", "secret", "the-code", "http://localhost:8787/callback")
+ require.NoError(t, err)
+ assert.Equal(t, "access-1", token.AccessToken)
+ assert.Equal(t, "refresh-1", token.RefreshToken)
+ assert.Equal(t, time.Unix(2000000000, 0), token.ExpiresAt)
+}
+
+func TestRequestTokenError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, `{"message":"bad request"}`, http.StatusBadRequest)
+ }))
+ defer server.Close()
+ tokenEndpoint = server.URL
+
+ _, err := ExchangeCode("12345", "secret", "the-code", "http://localhost:8787/callback")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "400")
+}
+
+func TestRefreshIfNeeded(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ require.NoError(t, err)
+ assert.Equal(t, "refresh_token", r.Form.Get("grant_type"))
+ assert.Equal(t, "old-refresh", r.Form.Get("refresh_token"))
+ w.Header().Set("Content-Type", "application/json")
+ _, err = w.Write([]byte(`{"access_token":"new-access","refresh_token":"new-refresh","expires_at":2000000000}`))
+ require.NoError(t, err)
+ }))
+ defer server.Close()
+ tokenEndpoint = server.URL
+
+ client := NewClient("12345", "secret", Token{
+ AccessToken: "expired-access",
+ RefreshToken: "old-refresh",
+ ExpiresAt: time.Now().Add(-time.Hour),
+ })
+ refreshed, err := client.RefreshIfNeeded()
+ require.NoError(t, err)
+ assert.True(t, refreshed)
+ assert.Equal(t, "new-access", client.Tokens().AccessToken)
+ assert.Equal(t, "new-refresh", client.Tokens().RefreshToken)
+}
+
+func TestGetJSON(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "Bearer fresh-access", r.Header.Get("Authorization"))
+ assert.Equal(t, "/athlete", r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ _, err := w.Write([]byte(`{"firstname":"Martin","lastname":"Lehoux"}`))
+ require.NoError(t, err)
+ }))
+ defer server.Close()
+ apiEndpoint = server.URL
+
+ client := NewClient("12345", "secret", Token{
+ AccessToken: "fresh-access",
+ RefreshToken: "refresh",
+ ExpiresAt: time.Now().Add(time.Hour),
+ })
+ var athlete struct {
+ Firstname string `json:"firstname"`
+ }
+ err := client.GetJSON("/athlete", &athlete)
+ require.NoError(t, err)
+ assert.Equal(t, "Martin", athlete.Firstname)
+}
diff --git a/strava/sync.go b/strava/sync.go
new file mode 100644
index 0000000..f104585
--- /dev/null
+++ b/strava/sync.go
@@ -0,0 +1,110 @@
+package strava
+
+import (
+ "fmt"
+ "net/url"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/martinlehoux/kagamigo/kcore"
+ "github.com/tkrajina/gpxgo/gpx"
+)
+
+type Activity struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ SportType string `json:"sport_type"`
+ StartDate time.Time `json:"start_date"`
+}
+
+func (c *Client) ListActivities(after time.Time, types ...string) ([]Activity, error) {
+ allowed := map[string]bool{}
+ for _, t := range types {
+ allowed[t] = true
+ }
+ var all []Activity
+ for page := 1; ; page++ {
+ q := url.Values{}
+ q.Set("per_page", "200")
+ q.Set("page", strconv.Itoa(page))
+ q.Set("after", strconv.FormatInt(after.Unix(), 10))
+ var batch []Activity
+ err := c.GetJSON("/athlete/activities?"+q.Encode(), &batch)
+ if err != nil {
+ return nil, kcore.Wrap(err, "failed to list Strava activities")
+ }
+ if len(batch) == 0 {
+ break
+ }
+ for _, activity := range batch {
+ if len(allowed) == 0 || allowed[activity.Type] || allowed[activity.SportType] {
+ all = append(all, activity)
+ }
+ }
+ if len(batch) < 200 {
+ break
+ }
+ }
+ return all, nil
+}
+
+func (c *Client) ActivityStreams(id int64) (latlng [][2]float64, altitude []float64, seconds []float64, err error) {
+ path := fmt.Sprintf("/activities/%d/streams?keys=latlng,altitude,time&key_by_type=true", id)
+ var streams struct {
+ LatLng *struct {
+ Data [][2]float64 `json:"data"`
+ } `json:"latlng"`
+ Altitude *struct {
+ Data []float64 `json:"data"`
+ } `json:"altitude"`
+ Time *struct {
+ Data []float64 `json:"data"`
+ } `json:"time"`
+ }
+ err = c.GetJSON(path, &streams)
+ if err != nil {
+ return nil, nil, nil, kcore.Wrap(err, "failed to fetch Strava streams")
+ }
+ if streams.LatLng != nil {
+ latlng = streams.LatLng.Data
+ }
+ if streams.Altitude != nil {
+ altitude = streams.Altitude.Data
+ }
+ if streams.Time != nil {
+ seconds = streams.Time.Data
+ }
+ return latlng, altitude, seconds, nil
+}
+
+func WriteActivityGPX(file string, activity Activity, latlng [][2]float64, altitude, seconds []float64) error {
+ points := make([]gpx.GPXPoint, len(latlng))
+ for i := range latlng {
+ point := gpx.GPXPoint{}
+ point.Latitude = latlng[i][0]
+ point.Longitude = latlng[i][1]
+ if i < len(altitude) {
+ point.Elevation = *gpx.NewNullableFloat64(altitude[i])
+ }
+ if i < len(seconds) {
+ point.Timestamp = activity.StartDate.Add(time.Duration(seconds[i]) * time.Second)
+ }
+ points[i] = point
+ }
+ doc := gpx.GPX{
+ Version: "1.1",
+ Creator: "biking_home",
+ Name: activity.Name,
+ Tracks: []gpx.GPXTrack{{
+ Name: activity.Name,
+ Segments: []gpx.GPXTrackSegment{{Points: points}},
+ }},
+ }
+ xmlData, err := doc.ToXml(gpx.ToXmlParams{})
+ if err != nil {
+ return kcore.Wrap(err, "failed to build GPX XML")
+ }
+ return os.WriteFile(file, xmlData, 0o644)
+}
diff --git a/strava/sync_test.go b/strava/sync_test.go
new file mode 100644
index 0000000..b74d1ab
--- /dev/null
+++ b/strava/sync_test.go
@@ -0,0 +1,100 @@
+package strava
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/tkrajina/gpxgo/gpx"
+)
+
+func newTestClient(t *testing.T, handler http.HandlerFunc) *Client {
+ t.Helper()
+ server := httptest.NewServer(handler)
+ t.Cleanup(server.Close)
+ client := NewClient("12345", "secret", Token{
+ AccessToken: "fresh-access",
+ RefreshToken: "refresh",
+ ExpiresAt: time.Now().Add(time.Hour),
+ })
+ apiEndpoint = server.URL
+ return client
+}
+
+func TestListActivities(t *testing.T) {
+ calls := 0
+ client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ require.Equal(t, "/athlete/activities", r.URL.Path)
+ q := r.URL.Query()
+ assert.Equal(t, "200", q.Get("per_page"))
+ assert.Equal(t, fmt.Sprint(calls), q.Get("page"))
+ assert.Equal(t, "1600000000", q.Get("after"))
+ w.Header().Set("Content-Type", "application/json")
+ body := `[{"id":1,"name":"Ride A","type":"Ride","sport_type":"Ride","start_date":"2026-01-01T10:00:00Z"},{"id":2,"name":"Run","type":"Run","sport_type":"Run","start_date":"2026-01-01T11:00:00Z"}]`
+ if calls == 2 {
+ body = `[]`
+ }
+ _, err := w.Write([]byte(body))
+ require.NoError(t, err)
+ })
+
+ activities, err := client.ListActivities(time.Unix(1600000000, 0), "Ride")
+ require.NoError(t, err)
+ require.Len(t, activities, 1)
+ assert.Equal(t, int64(1), activities[0].ID)
+ assert.Equal(t, "Ride A", activities[0].Name)
+ assert.Equal(t, time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC), activities[0].StartDate)
+}
+
+func TestActivityStreams(t *testing.T) {
+ client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/activities/42/streams", r.URL.Path)
+ assert.Equal(t, "latlng,altitude,time", r.URL.Query().Get("keys"))
+ w.Header().Set("Content-Type", "application/json")
+ _, err := w.Write([]byte(`{
+ "latlng":{"type":"latlng","data":[[43.0,5.0],[43.1,5.1],[43.2,5.2]]},
+ "altitude":{"type":"altitude","data":[100,120,140]},
+ "time":{"type":"time","data":[0,10,25]}
+ }`))
+ require.NoError(t, err)
+ })
+
+ latlng, altitude, seconds, err := client.ActivityStreams(42)
+ require.NoError(t, err)
+ require.Len(t, latlng, 3)
+ assert.Equal(t, [2]float64{43.0, 5.0}, latlng[0])
+ assert.Equal(t, []float64{100, 120, 140}, altitude)
+ assert.Equal(t, []float64{0, 10, 25}, seconds)
+}
+
+func TestWriteActivityGPX(t *testing.T) {
+ activity := Activity{ID: 42, Name: "Ride A", StartDate: time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)}
+ latlng := [][2]float64{{43.0, 5.0}, {43.1, 5.1}}
+ altitude := []float64{100, 120}
+ seconds := []float64{0, 10}
+
+ file := filepath.Join(t.TempDir(), "activity_42.gpx")
+ err := WriteActivityGPX(file, activity, latlng, altitude, seconds)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(file)
+ require.NoError(t, err)
+ parsed, err := gpx.ParseBytes(data)
+ require.NoError(t, err)
+ require.Len(t, parsed.Tracks, 1)
+ require.Len(t, parsed.Tracks[0].Segments, 1)
+ points := parsed.Tracks[0].Segments[0].Points
+ require.Len(t, points, 2)
+ assert.Equal(t, 43.0, points[0].Latitude)
+ assert.Equal(t, 5.1, points[1].Longitude)
+ assert.Equal(t, 100.0, points[0].Elevation.Value())
+ assert.Equal(t, activity.StartDate.Add(10*time.Second), points[1].Timestamp)
+ assert.Equal(t, "Ride A", parsed.Name)
+}