From bef31e1d35fe88f2698cf0e3191f26a79623f66f Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Wed, 5 Aug 2026 11:21:55 +0200 Subject: feat: Add web ride library with Strava sync --- rides/rides.go | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++ rides/rides_test.go | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 rides/rides.go create mode 100644 rides/rides_test.go (limited to 'rides') diff --git a/rides/rides.go b/rides/rides.go new file mode 100644 index 0000000..1958d20 --- /dev/null +++ b/rides/rides.go @@ -0,0 +1,104 @@ +package rides + +import ( + "database/sql" + "time" + + "github.com/martinlehoux/kagamigo/kcore" +) + +type Ride struct { + ID int64 + ExternalID string + GPXPath string + Name string + Type string + StartDate time.Time + DistanceM float64 + MovingTimeS int64 + ElapsedTimeS int64 + TotalElevationGainM float64 + AverageSpeedMps float64 + CreatedAt time.Time + UpdatedAt time.Time +} + +const columns = "id, external_id, gpx_path, name, type, start_date, distance_m, moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps, created_at, updated_at" + +func Save(db *sql.DB, r Ride) error { + _, err := db.Exec(` + INSERT INTO rides (external_id, gpx_path, name, type, start_date, distance_m, moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(external_id) DO UPDATE SET + gpx_path = excluded.gpx_path, + name = excluded.name, + type = excluded.type, + start_date = excluded.start_date, + distance_m = excluded.distance_m, + moving_time_s = excluded.moving_time_s, + elapsed_time_s = excluded.elapsed_time_s, + total_elevation_gain_m = excluded.total_elevation_gain_m, + average_speed_mps = excluded.average_speed_mps, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + `, r.ExternalID, r.GPXPath, r.Name, r.Type, r.StartDate.UTC().Format(time.RFC3339), r.DistanceM, r.MovingTimeS, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps) + return err +} + +func List(db *sql.DB) ([]Ride, error) { + rows, err := db.Query("SELECT " + columns + " FROM rides ORDER BY start_date DESC") + if err != nil { + return nil, err + } + defer rows.Close() + var rides []Ride + for rows.Next() { + ride, err := scanRide(rows) + if err != nil { + return nil, err + } + rides = append(rides, ride) + } + return rides, rows.Err() +} + +func GetByExternalID(db *sql.DB, externalID string) (Ride, bool, error) { + row := db.QueryRow("SELECT "+columns+" FROM rides WHERE external_id = ?", externalID) + ride, err := scanRide(row) + if err == sql.ErrNoRows { + return Ride{}, false, nil + } + if err != nil { + return Ride{}, false, err + } + return ride, true, nil +} + +type scanner interface { + Scan(dest ...any) error +} + +func scanRide(s scanner) (Ride, error) { + var ( + ride Ride + startDate string + createdAt string + updatedAt string + ) + err := s.Scan(&ride.ID, &ride.ExternalID, &ride.GPXPath, &ride.Name, &ride.Type, &startDate, &ride.DistanceM, &ride.MovingTimeS, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &createdAt, &updatedAt) + if err != nil { + return Ride{}, err + } + ride.StartDate, err = time.Parse(time.RFC3339, startDate) + if err != nil { + return Ride{}, kcore.Wrap(err, "invalid start_date in rides row") + } + ride.CreatedAt, err = time.Parse(time.RFC3339, createdAt) + if err != nil { + return Ride{}, kcore.Wrap(err, "invalid created_at in rides row") + } + ride.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt) + if err != nil { + return Ride{}, kcore.Wrap(err, "invalid updated_at in rides row") + } + return ride, nil +} diff --git a/rides/rides_test.go b/rides/rides_test.go new file mode 100644 index 0000000..1ba23f9 --- /dev/null +++ b/rides/rides_test.go @@ -0,0 +1,104 @@ +package rides + +import ( + "database/sql" + "testing" + "time" + + "github.com/martinlehoux/kagamigo/kcore" + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { db.Close() }) + _, err = db.Exec(` + create table rides ( + id integer primary key, + external_id text unique not null, + gpx_path text not null, + name text not null, + type text not null, + start_date text not null, + distance_m real not null, + moving_time_s integer not null, + elapsed_time_s integer not null, + total_elevation_gain_m real not null, + average_speed_mps real not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ) + `) + require.NoError(t, err) + return db +} + +func sampleRide() Ride { + return Ride{ + ExternalID: "strava:1234", + GPXPath: "rides/activity_1234.gpx", + Name: "Morning Ride", + Type: "Ride", + StartDate: time.Date(2026, 8, 1, 7, 30, 0, 0, time.UTC), + DistanceM: 42_195, + MovingTimeS: 7_200, + ElapsedTimeS: 7_800, + TotalElevationGainM: 850, + AverageSpeedMps: 5.86, + } +} + +func TestUpsertAndGet(t *testing.T) { + db := newTestDB(t) + err := Save(db, sampleRide()) + require.NoError(t, err) + + got, ok, err := GetByExternalID(db, "strava:1234") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "Morning Ride", got.Name) + assert.Equal(t, sampleRide().StartDate, got.StartDate) + assert.Equal(t, 42_195.0, got.DistanceM) + assert.Equal(t, "rides/activity_1234.gpx", got.GPXPath) + + _, ok, err = GetByExternalID(db, "strava:9999") + require.NoError(t, err) + assert.False(t, ok) +} + +func TestUpsertUpdatesExisting(t *testing.T) { + db := newTestDB(t) + ride := sampleRide() + require.NoError(t, Save(db, ride)) + ride.Name = "Renamed Ride" + ride.DistanceM = 50_000 + require.NoError(t, Save(db, ride)) + + got, ok, err := GetByExternalID(db, "strava:1234") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "Renamed Ride", got.Name) + assert.Equal(t, 50_000.0, got.DistanceM) +} + +func TestList(t *testing.T) { + db := newTestDB(t) + first := sampleRide() + first.StartDate = time.Date(2026, 7, 1, 7, 0, 0, 0, time.UTC) + second := sampleRide() + second.ExternalID = "strava:5678" + second.Name = "Evening Ride" + require.NoError(t, Save(db, first)) + require.NoError(t, Save(db, second)) + + rides, err := List(db) + require.NoError(t, err) + require.Len(t, rides, 2) + assert.Equal(t, "strava:5678", rides[0].ExternalID) + assert.Equal(t, "strava:1234", rides[1].ExternalID) + kcore.Assert(len(rides) == 2, "two rides") +} -- cgit v1.2.3