diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-03 21:53:04 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-03 21:53:04 +0200 |
| commit | 69f317d8cb51b911732712a9fcc8ac49563326c6 (patch) | |
| tree | f8afcada62a43326c7d3fda34e4cd4627b7b0d1c | |
| parent | 9707f9ad09db935af7d06b5de08bc52de13ba6a7 (diff) | |
feat: Sync Strava rides as GPX files
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | env.go | 50 | ||||
| -rw-r--r-- | main.go | 158 | ||||
| -rw-r--r-- | strava/strava.go | 146 | ||||
| -rw-r--r-- | strava/strava_test.go | 108 | ||||
| -rw-r--r-- | strava/sync.go | 110 | ||||
| -rw-r--r-- | strava/sync_test.go | 100 |
7 files changed, 674 insertions, 0 deletions
@@ -1,6 +1,8 @@ .env +.strava-last-sync *.db *.png +rides/ debug_department_*.csv france-latest.osm.pbf biking_home @@ -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") +} @@ -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) +} |