diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-05 11:21:55 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-05 11:21:55 +0200 |
| commit | bef31e1d35fe88f2698cf0e3191f26a79623f66f (patch) | |
| tree | 8d8e10b2a58fc4ca557f0312cfda60adfc308c30 /strava | |
| parent | 69f317d8cb51b911732712a9fcc8ac49563326c6 (diff) | |
feat: Add web ride library with Strava sync
Diffstat (limited to 'strava')
| -rw-r--r-- | strava/strava.go | 7 | ||||
| -rw-r--r-- | strava/sync.go | 60 | ||||
| -rw-r--r-- | strava/sync_test.go | 31 |
3 files changed, 87 insertions, 11 deletions
diff --git a/strava/strava.go b/strava/strava.go index d394791..c7a84d0 100644 --- a/strava/strava.go +++ b/strava/strava.go @@ -30,12 +30,19 @@ type Token struct { } func AuthorizeURL(clientID, redirectURI string) string { + return AuthorizeURLWithState(clientID, redirectURI, "") +} + +func AuthorizeURLWithState(clientID, redirectURI, state 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") + if state != "" { + q.Set("state", state) + } return AuthURL + "?" + q.Encode() } diff --git a/strava/sync.go b/strava/sync.go index f104585..2044f28 100644 --- a/strava/sync.go +++ b/strava/sync.go @@ -12,14 +12,23 @@ import ( ) 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"` + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + SportType string `json:"sport_type"` + StartDate time.Time `json:"start_date"` + DistanceM float64 `json:"distance"` + MovingTimeS int64 `json:"moving_time"` + ElapsedTimeS int64 `json:"elapsed_time"` + TotalElevationGainM float64 `json:"total_elevation_gain"` + AverageSpeedMps float64 `json:"average_speed"` } -func (c *Client) ListActivities(after time.Time, types ...string) ([]Activity, error) { +func (c *Client) List(from, to time.Time) ([]Activity, error) { + return c.list(from, to, "Ride") +} + +func (c *Client) list(from, to time.Time, types ...string) ([]Activity, error) { allowed := map[string]bool{} for _, t := range types { allowed[t] = true @@ -29,7 +38,12 @@ func (c *Client) ListActivities(after time.Time, types ...string) ([]Activity, e q := url.Values{} q.Set("per_page", "200") q.Set("page", strconv.Itoa(page)) - q.Set("after", strconv.FormatInt(after.Unix(), 10)) + if !from.IsZero() { + q.Set("after", strconv.FormatInt(from.Unix(), 10)) + } + if !to.IsZero() { + q.Set("before", strconv.FormatInt(to.Unix(), 10)) + } var batch []Activity err := c.GetJSON("/athlete/activities?"+q.Encode(), &batch) if err != nil { @@ -50,6 +64,26 @@ func (c *Client) ListActivities(after time.Time, types ...string) ([]Activity, e return all, nil } +func (c *Client) ListActivities(after time.Time, types ...string) ([]Activity, error) { + return c.list(after, time.Time{}, types...) +} + +func (c *Client) Get(id int64) (Activity, []byte, error) { + var activity Activity + if err := c.GetJSON(fmt.Sprintf("/activities/%d", id), &activity); err != nil { + return Activity{}, nil, kcore.Wrap(err, "failed to get Strava activity") + } + latlng, altitude, seconds, err := c.ActivityStreams(id) + if err != nil { + return Activity{}, nil, err + } + gpxData, err := ActivityGPX(activity, latlng, altitude, seconds) + if err != nil { + return Activity{}, nil, err + } + return activity, gpxData, 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 { @@ -79,7 +113,7 @@ func (c *Client) ActivityStreams(id int64) (latlng [][2]float64, altitude []floa return latlng, altitude, seconds, nil } -func WriteActivityGPX(file string, activity Activity, latlng [][2]float64, altitude, seconds []float64) error { +func ActivityGPX(activity Activity, latlng [][2]float64, altitude, seconds []float64) ([]byte, error) { points := make([]gpx.GPXPoint, len(latlng)) for i := range latlng { point := gpx.GPXPoint{} @@ -104,7 +138,15 @@ func WriteActivityGPX(file string, activity Activity, latlng [][2]float64, altit } xmlData, err := doc.ToXml(gpx.ToXmlParams{}) if err != nil { - return kcore.Wrap(err, "failed to build GPX XML") + return nil, kcore.Wrap(err, "failed to build GPX XML") + } + return xmlData, nil +} + +func WriteActivityGPX(file string, activity Activity, latlng [][2]float64, altitude, seconds []float64) error { + xmlData, err := ActivityGPX(activity, latlng, altitude, seconds) + if err != nil { + return err } return os.WriteFile(file, xmlData, 0o644) } diff --git a/strava/sync_test.go b/strava/sync_test.go index b74d1ab..1ddf439 100644 --- a/strava/sync_test.go +++ b/strava/sync_test.go @@ -27,7 +27,7 @@ func newTestClient(t *testing.T, handler http.HandlerFunc) *Client { return client } -func TestListActivities(t *testing.T) { +func TestList(t *testing.T) { calls := 0 client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ @@ -36,6 +36,7 @@ func TestListActivities(t *testing.T) { assert.Equal(t, "200", q.Get("per_page")) assert.Equal(t, fmt.Sprint(calls), q.Get("page")) assert.Equal(t, "1600000000", q.Get("after")) + assert.Equal(t, "1700000000", q.Get("before")) 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 { @@ -45,7 +46,7 @@ func TestListActivities(t *testing.T) { require.NoError(t, err) }) - activities, err := client.ListActivities(time.Unix(1600000000, 0), "Ride") + activities, err := client.List(time.Unix(1600000000, 0), time.Unix(1700000000, 0)) require.NoError(t, err) require.Len(t, activities, 1) assert.Equal(t, int64(1), activities[0].ID) @@ -53,6 +54,32 @@ func TestListActivities(t *testing.T) { assert.Equal(t, time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC), activities[0].StartDate) } +func TestGet(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/activities/42": + _, err := w.Write([]byte(`{"id":42,"name":"Ride A","type":"Ride","sport_type":"Ride","start_date":"2026-01-01T10:00:00Z","distance":42195,"moving_time":7200,"elapsed_time":7800,"total_elevation_gain":850,"average_speed":5.86}`)) + require.NoError(t, err) + case "/activities/42/streams": + _, err := w.Write([]byte(`{"latlng":{"data":[[43.0,5.0],[43.1,5.1]]},"altitude":{"data":[100,120]},"time":{"data":[0,10]}}`)) + require.NoError(t, err) + default: + http.NotFound(w, r) + } + }) + + activity, gpxData, err := client.Get(42) + require.NoError(t, err) + assert.Equal(t, int64(42), activity.ID) + assert.Equal(t, 42195.0, activity.DistanceM) + assert.Equal(t, int64(7200), activity.MovingTimeS) + assert.NotEmpty(t, gpxData) + parsed, err := gpx.ParseBytes(gpxData) + require.NoError(t, err) + require.Len(t, parsed.Tracks[0].Segments[0].Points, 2) +} + func TestActivityStreams(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/activities/42/streams", r.URL.Path) |