summaryrefslogtreecommitdiff
path: root/rides
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-08 10:05:05 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-08 10:05:05 +0200
commit2c00b74cc02b55e26d68fef108596aae56080d5a (patch)
tree793b37a40979b13f05135151814783003ba6bf44 /rides
parent7aa64b0917130fcc1dc9f3da30d8f825136fe541 (diff)
feat: Persist Cotacol ride values
Diffstat (limited to 'rides')
-rw-r--r--rides/rides.go78
-rw-r--r--rides/rides_test.go63
2 files changed, 123 insertions, 18 deletions
diff --git a/rides/rides.go b/rides/rides.go
index f9d33da..619fefa 100644
--- a/rides/rides.go
+++ b/rides/rides.go
@@ -5,6 +5,7 @@ import (
"fmt"
"time"
+ "github.com/martinlehoux/biking_home/ride"
"github.com/martinlehoux/kagamigo/kcore"
)
@@ -20,6 +21,8 @@ type Ride struct {
ElapsedTimeS int64
TotalElevationGainM float64
AverageSpeedMps float64
+ cotacolScore *float64
+ cotacolAlgoVersion string
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -34,12 +37,17 @@ const (
SortElevation SortColumn = "elevation"
)
-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"
+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, cotacol_score, cotacol_algo_version, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ parsed, err := ride.ParseFile(ride.GPXRideParser{}, r.GPXPath)
+ if err != nil {
+ return fmt.Errorf("compute Cotacol for ride %q: %w", r.ExternalID, err)
+ }
+ cotacolScore := ride.Cotacol(parsed)
+ _, 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, cotacol_score, cotacol_algo_version)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(external_id) DO UPDATE SET
gpx_path = excluded.gpx_path,
name = excluded.name,
@@ -50,11 +58,42 @@ func Save(db *sql.DB, r Ride) error {
elapsed_time_s = excluded.elapsed_time_s,
total_elevation_gain_m = excluded.total_elevation_gain_m,
average_speed_mps = excluded.average_speed_mps,
+ cotacol_score = excluded.cotacol_score,
+ cotacol_algo_version = excluded.cotacol_algo_version,
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)
+ `, r.ExternalID, r.GPXPath, r.Name, r.Type, r.StartDate.UTC().Format(time.RFC3339), r.DistanceM, r.MovingTimeS, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps, cotacolScore, ride.CotacolAlgorithmVersion)
return err
}
+func Backfill(db *sql.DB) (int, error) {
+ rows, err := db.Query("SELECT " + columns + " FROM rides ORDER BY id")
+ if err != nil {
+ return 0, err
+ }
+ var pending []Ride
+ for rows.Next() {
+ item, err := scanRide(rows)
+ if err != nil {
+ rows.Close()
+ return 0, err
+ }
+ pending = append(pending, item)
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return 0, err
+ }
+ if err := rows.Close(); err != nil {
+ return 0, err
+ }
+ for i, item := range pending {
+ if err := Save(db, item); err != nil {
+ return i, fmt.Errorf("backfill ride %q: %w", item.ExternalID, err)
+ }
+ }
+ return len(pending), nil
+}
+
func List(db *sql.DB) ([]Ride, error) {
return ListSorted(db, SortStartDate, true)
}
@@ -110,15 +149,23 @@ type scanner interface {
func scanRide(s scanner) (Ride, error) {
var (
- ride Ride
- startDate string
- createdAt string
- updatedAt string
+ ride Ride
+ startDate string
+ cotacolScore sql.NullFloat64
+ cotacolAlgoVersion sql.NullString
+ 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)
+ err := s.Scan(&ride.ID, &ride.ExternalID, &ride.GPXPath, &ride.Name, &ride.Type, &startDate, &ride.DistanceM, &ride.MovingTimeS, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &cotacolScore, &cotacolAlgoVersion, &createdAt, &updatedAt)
if err != nil {
return Ride{}, err
}
+ if cotacolScore.Valid {
+ ride.cotacolScore = &cotacolScore.Float64
+ }
+ if cotacolAlgoVersion.Valid {
+ ride.cotacolAlgoVersion = cotacolAlgoVersion.String
+ }
ride.StartDate, err = time.Parse(time.RFC3339, startDate)
if err != nil {
return Ride{}, kcore.Wrap(err, "invalid start_date in rides row")
@@ -133,3 +180,14 @@ func scanRide(s scanner) (Ride, error) {
}
return ride, nil
}
+
+func (r Ride) CotacolAlgorithmVersion() string {
+ return r.cotacolAlgoVersion
+}
+
+func (r Ride) CotacolScore() (float64, bool) {
+ if r.cotacolScore == nil {
+ return 0, false
+ }
+ return *r.cotacolScore, true
+}
diff --git a/rides/rides_test.go b/rides/rides_test.go
index 1ba23f9..cbe5670 100644
--- a/rides/rides_test.go
+++ b/rides/rides_test.go
@@ -2,9 +2,12 @@ package rides
import (
"database/sql"
+ "os"
+ "path/filepath"
"testing"
"time"
+ "github.com/martinlehoux/biking_home/ride"
"github.com/martinlehoux/kagamigo/kcore"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
@@ -29,6 +32,8 @@ func newTestDB(t *testing.T) *sql.DB {
elapsed_time_s integer not null,
total_elevation_gain_m real not null,
average_speed_mps real not null,
+ cotacol_score real,
+ cotacol_algo_version text,
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'))
)
@@ -37,10 +42,13 @@ func newTestDB(t *testing.T) *sql.DB {
return db
}
-func sampleRide() Ride {
+func sampleRide(t *testing.T) Ride {
+ t.Helper()
+ gpxPath := filepath.Join(t.TempDir(), "ride.gpx")
+ require.NoError(t, os.WriteFile(gpxPath, []byte(testGPX), 0o600))
return Ride{
ExternalID: "strava:1234",
- GPXPath: "rides/activity_1234.gpx",
+ GPXPath: gpxPath,
Name: "Morning Ride",
Type: "Ride",
StartDate: time.Date(2026, 8, 1, 7, 30, 0, 0, time.UTC),
@@ -52,27 +60,66 @@ func sampleRide() Ride {
}
}
+const testGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg><trkpt lat="43.0" lon="5.0"><ele>100</ele></trkpt><trkpt lat="43.001" lon="5.001"><ele>200</ele></trkpt></trkseg></trk></gpx>`
+
func TestUpsertAndGet(t *testing.T) {
db := newTestDB(t)
- err := Save(db, sampleRide())
+ sample := sampleRide(t)
+ err := Save(db, sample)
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, sample.StartDate, got.StartDate)
assert.Equal(t, 42_195.0, got.DistanceM)
- assert.Equal(t, "rides/activity_1234.gpx", got.GPXPath)
+ assert.Equal(t, sample.GPXPath, got.GPXPath)
+ score, found := got.CotacolScore()
+ require.True(t, found)
+ assert.Greater(t, score, 0.0)
+ assert.Equal(t, ride.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
_, ok, err = GetByExternalID(db, "strava:9999")
require.NoError(t, err)
assert.False(t, ok)
}
+func TestSaveComputesCotacol(t *testing.T) {
+ db := newTestDB(t)
+ require.NoError(t, Save(db, sampleRide(t)))
+
+ got, ok, err := GetByExternalID(db, "strava:1234")
+ require.NoError(t, err)
+ require.True(t, ok)
+ score, found := got.CotacolScore()
+ require.True(t, found)
+ assert.Greater(t, score, 0.0)
+ assert.Equal(t, ride.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
+}
+
+func TestBackfill(t *testing.T) {
+ db := newTestDB(t)
+ require.NoError(t, Save(db, sampleRide(t)))
+
+ count, err := Backfill(db)
+ require.NoError(t, err)
+ assert.Equal(t, 1, count)
+ got, ok, err := GetByExternalID(db, "strava:1234")
+ require.NoError(t, err)
+ require.True(t, ok)
+ _, found := got.CotacolScore()
+ assert.True(t, found)
+ assert.Equal(t, ride.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
+
+ count, err = Backfill(db)
+ require.NoError(t, err)
+ assert.Equal(t, 1, count)
+}
+
func TestUpsertUpdatesExisting(t *testing.T) {
db := newTestDB(t)
- ride := sampleRide()
+ ride := sampleRide(t)
require.NoError(t, Save(db, ride))
ride.Name = "Renamed Ride"
ride.DistanceM = 50_000
@@ -87,9 +134,9 @@ func TestUpsertUpdatesExisting(t *testing.T) {
func TestList(t *testing.T) {
db := newTestDB(t)
- first := sampleRide()
+ first := sampleRide(t)
first.StartDate = time.Date(2026, 7, 1, 7, 0, 0, 0, time.UTC)
- second := sampleRide()
+ second := sampleRide(t)
second.ExternalID = "strava:5678"
second.Name = "Evening Ride"
require.NoError(t, Save(db, first))