summaryrefslogtreecommitdiff
path: root/strava
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-05 11:21:55 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-05 11:21:55 +0200
commitbef31e1d35fe88f2698cf0e3191f26a79623f66f (patch)
tree8d8e10b2a58fc4ca557f0312cfda60adfc308c30 /strava
parent69f317d8cb51b911732712a9fcc8ac49563326c6 (diff)
feat: Add web ride library with Strava sync
Diffstat (limited to 'strava')
-rw-r--r--strava/strava.go7
-rw-r--r--strava/sync.go60
-rw-r--r--strava/sync_test.go31
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)