From 53615a05708ff852af42905e2b5ced66ba70c43d Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 23 Aug 2026 11:09:07 +0200 Subject: Merge ride and rides packages into ride/store following mountain_pass structure Consolidate persistence (Save, List, Backfill) into ride/store.go alongside ride.go/climb.go/moving_time.go, rename rides.Ride to ride.StoredRide to avoid collision with parsed ride.Ride, update all web/cli consumers and RideView to use named Ride field, adjust templates and regenerate templ files, and remove rides directory. --- README.md | 13 +- cli/cli.go | 7 +- cli/cli_test.go | 12 +- ride/store.go | 268 +++++++++++++++++++++++++++++++++++++++++ ride/store_test.go | 303 ++++++++++++++++++++++++++++++++++++++++++++++ rides/rides.go | 269 ----------------------------------------- rides/rides_test.go | 304 ----------------------------------------------- web/ride-filters.go | 7 +- web/ride-filters_test.go | 23 ++-- web/rides.go | 17 ++- web/server_test.go | 71 ++++++----- web/sync.go | 12 +- web/templates.templ | 2 +- web/views.go | 27 ++--- 14 files changed, 664 insertions(+), 671 deletions(-) create mode 100644 ride/store.go create mode 100644 ride/store_test.go delete mode 100644 rides/rides.go delete mode 100644 rides/rides_test.go diff --git a/README.md b/README.md index 9341208..b108792 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,9 @@ Standard apps are rate-limited to 100 calls per 15 minutes and 1,000 per day. ## Architecture - Go 1.27; SQLite via `mattn/go-sqlite3` -- `ride` — GPX parsing, climb detection, difficulty scores (KOM + Cotacol), similarity index +- `ride` — GPX parsing, climb detection, difficulty scores (KOM + Cotacol), and SQLite persistence for imported ride metadata and versioned computed values - `mountain_pass` — OSM PBF extraction (`mountain_pass=yes` / `natural=mountain_pass` nodes), pass storage and querying, and crossings/climb matching - `strava` — OAuth2 client returning activity metadata and GPX data -- `rides` — SQLite persistence for imported ride metadata and versioned computed values - `config` — typed YAML configuration and atomic persistence - `web` — HTTP server, OAuth callback, sync orchestration, and templ pages - `cli` — command handlers for data import, backfill, and benchmarking @@ -75,10 +74,9 @@ flowchart TB main["main (bootstrap: main.go)"] - ride["ride (GPX parsing, climbs, Cotacol)"] + ride["ride (GPX parsing, climbs, Cotacol, persistence)"] mpass["mountain_pass (OSM PBF, crossings)"] strava["strava (OAuth, List, Get)"] - rides["rides (SQLite persistence)"] config["config (YAML)"] web["web (HTTP + templ)"] cli["cli (import, backfill, benchmark)"] @@ -89,7 +87,7 @@ flowchart TB cli --> web mpass --> ride web --> strava - web --> rides + web --> ride web --> config ``` @@ -111,9 +109,12 @@ TypeScript checks can also be run directly with `npm run check`, covering format - http://localhost:8080/rides/368: does not contain Pont Rout - http://localhost:8080/rides/6: Cat 4 inside Chemin des Lauriers (embrassé) - Compute estimated power, speed +- Plot de difficultés normalisées contre moving duration +- Categorised rides like to-dos +- Compact binary or vectorized storage for root +- For a ride, scatter, one point per 100m segment. Slope vs speed - Plot speed and slope per segment, colored by heart rate - http://localhost:8080/rides/378: Repentance partial climb -- regroup ride and rides packages - screaming archi + hexagon - Blog with pictures and markdown - Road quality (Arbois = 2/5, Roquefavour = 4/5) diff --git a/cli/cli.go b/cli/cli.go index 52b5a8c..68a86a6 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -16,7 +16,6 @@ import ( "github.com/martinlehoux/biking_home/config" "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/ride" - "github.com/martinlehoux/biking_home/rides" "github.com/martinlehoux/biking_home/web" "github.com/martinlehoux/kagamigo/kcore" ) @@ -42,7 +41,7 @@ func Run(db *sql.DB, configPath string, appConfig config.Config) { _, err := mountain_pass.ExtractMountainPasses(context.Background(), *extractOSM, db) kcore.Expect(err, "failed to extract mountain passes from OSM") case *backfill: - count, err := rides.Backfill(db) + count, err := ride.Backfill(db) kcore.Expect(err, "failed to backfill ride values") slog.Info("Backfilled ride values", "rides", count) case *backtest: @@ -67,7 +66,7 @@ type backtestSample struct { } func runBacktest(db *sql.DB) error { - items, err := rides.List(db) + items, err := ride.List(db) if err != nil { return err } @@ -128,7 +127,7 @@ func runBacktest(db *sql.DB) error { return nil } -func isBacktestEligible(item rides.Ride) bool { +func isBacktestEligible(item ride.StoredRide) bool { return item.GPXPath != "" && item.DistanceM >= backtestMinimumDistanceM && item.ExternalMovingTimeS > 0 && diff --git a/cli/cli_test.go b/cli/cli_test.go index 7964387..3e2afe2 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -3,22 +3,22 @@ package cli import ( "testing" - "github.com/martinlehoux/biking_home/rides" + "github.com/martinlehoux/biking_home/ride" "github.com/stretchr/testify/assert" ) func TestBacktestEligibilityExcludesShortDistances(t *testing.T) { - short := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000 - 1, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000} - minimum := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000} + short := ride.StoredRide{GPXPath: "ride.gpx", DistanceM: 10_000 - 1, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000} + minimum := ride.StoredRide{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000} assert.False(t, isBacktestEligible(short)) assert.True(t, isBacktestEligible(minimum)) } func TestBacktestEligibilityExcludesLowMovingRatio(t *testing.T) { - low := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 7_999, ElapsedTimeS: 10_000} - boundary := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000} - noElapsed := rides.Ride{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 1} + low := ride.StoredRide{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 7_999, ElapsedTimeS: 10_000} + boundary := ride.StoredRide{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000} + noElapsed := ride.StoredRide{GPXPath: "ride.gpx", DistanceM: 10_000, ExternalMovingTimeS: 1} assert.False(t, isBacktestEligible(low)) assert.True(t, isBacktestEligible(boundary)) diff --git a/ride/store.go b/ride/store.go new file mode 100644 index 0000000..af598e8 --- /dev/null +++ b/ride/store.go @@ -0,0 +1,268 @@ +package ride + +import ( + "database/sql" + "fmt" + "log/slog" + "time" + + "github.com/martinlehoux/kagamigo/kcore" +) + +type StoredRide struct { + ID int64 + ExternalID string + GPXPath string + Name string + Type string + StartDate time.Time + DistanceM float64 + ExternalMovingTimeS int64 + movingTimeS *int64 + movingTimeAlgoVersion string + ElapsedTimeS int64 + TotalElevationGainM float64 + AverageSpeedMps float64 + cotacolScore *float64 + cotacolAlgoVersion string + CreatedAt time.Time + UpdatedAt time.Time +} + +type SortColumn string + +const ( + SortName SortColumn = "name" + SortStartDate SortColumn = "started" + SortDistance SortColumn = "distance" + SortMovingTime SortColumn = "moving_time" + SortSpeed SortColumn = "speed" + SortElevation SortColumn = "elevation" + SortCotacol SortColumn = "cotacol" + SortCotacolKm SortColumn = "cotacol_100km" +) + +const columns = "id, external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, 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 StoredRide) error { + var cotacolScore any + var cotacolAlgorithmVersion any + var movingTimeS any + var movingTimeAlgorithmVersion any + if r.GPXPath != "" { + parsed, err := ParseFile(GPXRideParser{}, r.GPXPath) + if err != nil { + return fmt.Errorf("compute ride values for %q: %w", r.ExternalID, err) + } + cotacolScore = Cotacol(parsed) + cotacolAlgorithmVersion = CotacolAlgorithmVersion + movingTimeS = int64(MovingTime(parsed) / time.Second) + movingTimeAlgorithmVersion = MovingTimeAlgorithmVersion + } + _, err := db.Exec(` + INSERT INTO rides (external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, 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, + type = excluded.type, + start_date = excluded.start_date, + distance_m = excluded.distance_m, + external_moving_time_s = excluded.external_moving_time_s, + moving_time_s = excluded.moving_time_s, + moving_time_algo_version = excluded.moving_time_algo_version, + 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.ExternalMovingTimeS, movingTimeS, movingTimeAlgorithmVersion, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps, cotacolScore, 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 []StoredRide + 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 + } + backfilled := 0 + for _, item := range pending { + if err := Save(db, item); err != nil { + slog.Warn("Failed to backfill ride values", "ride", item.ExternalID, "file", item.GPXPath, "error", err) + continue + } + backfilled++ + } + return backfilled, nil +} + +func List(db *sql.DB) ([]StoredRide, error) { + return ListSorted(db, SortStartDate, true) +} + +func ListSorted(db *sql.DB, column SortColumn, descending bool) ([]StoredRide, error) { + expression, ok := sortExpressions[column] + if !ok { + return nil, fmt.Errorf("invalid ride sort column %q", column) + } + direction := "ASC" + if descending { + direction = "DESC" + } + query := "SELECT " + columns + " FROM rides ORDER BY " + args := []any{} + if column == SortMovingTime { + args = append(args, MovingTimeAlgorithmVersion) + } else if isComputedSort(column) { + query += "(cotacol_score IS NULL OR cotacol_algo_version IS NULL OR cotacol_algo_version <> ? OR distance_m <= 0) ASC, " + args = append(args, CotacolAlgorithmVersion) + } + query += expression + " " + direction + ", id DESC" + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var rides []StoredRide + for rows.Next() { + ride, err := scanRide(rows) + if err != nil { + return nil, err + } + rides = append(rides, ride) + } + return rides, rows.Err() +} + +var sortExpressions = map[SortColumn]string{ + SortName: "name COLLATE NOCASE", + SortStartDate: "start_date", + SortDistance: "distance_m", + SortMovingTime: "CASE WHEN moving_time_algo_version = ? THEN COALESCE(moving_time_s, external_moving_time_s) ELSE external_moving_time_s END", + SortSpeed: "average_speed_mps", + SortElevation: "total_elevation_gain_m", + SortCotacol: "cotacol_score", + SortCotacolKm: "cotacol_score * 100000.0 / NULLIF(distance_m, 0)", +} + +func isComputedSort(column SortColumn) bool { + return column == SortCotacol || column == SortCotacolKm +} + +func GetByExternalID(db *sql.DB, externalID string) (StoredRide, bool, error) { + row := db.QueryRow("SELECT "+columns+" FROM rides WHERE external_id = ?", externalID) + ride, err := scanRide(row) + if err == sql.ErrNoRows { + return StoredRide{}, false, nil + } + if err != nil { + return StoredRide{}, false, err + } + return ride, true, nil +} + +func GetByID(db *sql.DB, id int64) (StoredRide, bool, error) { + row := db.QueryRow("SELECT "+columns+" FROM rides WHERE id = ?", id) + ride, err := scanRide(row) + if err == sql.ErrNoRows { + return StoredRide{}, false, nil + } + if err != nil { + return StoredRide{}, false, err + } + return ride, true, nil +} + +type scanner interface { + Scan(dest ...any) error +} + +func scanRide(s scanner) (StoredRide, error) { + var ( + ride StoredRide + startDate string + movingTimeS sql.NullInt64 + movingTimeAlgoVer sql.NullString + 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.ExternalMovingTimeS, &movingTimeS, &movingTimeAlgoVer, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &cotacolScore, &cotacolAlgoVersion, &createdAt, &updatedAt) + if err != nil { + return StoredRide{}, err + } + if movingTimeS.Valid { + ride.movingTimeS = &movingTimeS.Int64 + } + if movingTimeAlgoVer.Valid { + ride.movingTimeAlgoVersion = movingTimeAlgoVer.String + } + 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 StoredRide{}, kcore.Wrap(err, "invalid start_date in rides row") + } + ride.CreatedAt, err = time.Parse(time.RFC3339, createdAt) + if err != nil { + return StoredRide{}, kcore.Wrap(err, "invalid created_at in rides row") + } + ride.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt) + if err != nil { + return StoredRide{}, kcore.Wrap(err, "invalid updated_at in rides row") + } + return ride, nil +} + +func (r StoredRide) CotacolAlgorithmVersion() string { + return r.cotacolAlgoVersion +} + +func (r StoredRide) MovingTimeS() (int64, bool) { + if r.movingTimeS == nil { + return 0, false + } + return *r.movingTimeS, true +} + +func (r StoredRide) MovingTimeAlgorithmVersion() string { + return r.movingTimeAlgoVersion +} + +// EffectiveMovingTimeS is the canonical moving time when the stored value +// matches the current algorithm version, falling back to Strava's value. +func (r StoredRide) EffectiveMovingTimeS() int64 { + if moving, ok := r.MovingTimeS(); ok && r.movingTimeAlgoVersion == MovingTimeAlgorithmVersion { + return moving + } + return r.ExternalMovingTimeS +} + +func (r StoredRide) CotacolScore() (float64, bool) { + if r.cotacolScore == nil { + return 0, false + } + return *r.cotacolScore, true +} diff --git a/ride/store_test.go b/ride/store_test.go new file mode 100644 index 0000000..877f4e5 --- /dev/null +++ b/ride/store_test.go @@ -0,0 +1,303 @@ +package ride + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + "time" + + "github.com/martinlehoux/biking_home/internal/dbtest" + "github.com/martinlehoux/kagamigo/kcore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestDB(t *testing.T) *sql.DB { + return dbtest.New(t) +} + +func sampleRide(t *testing.T) StoredRide { + t.Helper() + gpxPath := filepath.Join(t.TempDir(), "ride.gpx") + require.NoError(t, os.WriteFile(gpxPath, []byte(testGPX), 0o600)) + return StoredRide{ + ExternalID: "strava:1234", + GPXPath: gpxPath, + Name: "Morning Ride", + Type: "Ride", + StartDate: time.Date(2026, 8, 1, 7, 30, 0, 0, time.UTC), + DistanceM: 42_195, + ExternalMovingTimeS: 7_200, + ElapsedTimeS: 7_800, + TotalElevationGainM: 850, + AverageSpeedMps: 5.86, + } +} + +const testGPX = `100200` + +func TestUpsertAndGet(t *testing.T) { + db := newTestDB(t) + 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, sample.StartDate, got.StartDate) + assert.Equal(t, 42_195.0, got.DistanceM) + assert.Equal(t, sample.GPXPath, got.GPXPath) + score, found := got.CotacolScore() + require.True(t, found) + assert.Greater(t, score, 0.0) + assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) + + _, ok, err = GetByExternalID(db, "strava:9999") + require.NoError(t, err) + assert.False(t, ok) +} + +func TestGetByID(t *testing.T) { + db := newTestDB(t) + sample := sampleRide(t) + require.NoError(t, Save(db, sample)) + stored, found, err := GetByExternalID(db, sample.ExternalID) + require.NoError(t, err) + require.True(t, found) + + got, found, err := GetByID(db, stored.ID) + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, sample.ExternalID, got.ExternalID) + assert.Equal(t, sample.Name, got.Name) + + _, found, err = GetByID(db, 999999) + require.NoError(t, err) + assert.False(t, found) +} + +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, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) +} + +func TestSaveComputesMovingTimeAndPreservesExternal(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) + moving, found := got.MovingTimeS() + require.True(t, found) + assert.Equal(t, int64(60), moving) + assert.Equal(t, MovingTimeAlgorithmVersion, got.MovingTimeAlgorithmVersion()) + assert.Equal(t, int64(7_200), got.ExternalMovingTimeS) +} + +func TestSaveAllowsRideWithoutGPX(t *testing.T) { + db := newTestDB(t) + indoor := sampleRide(t) + indoor.ExternalID = "strava:14701658670" + indoor.GPXPath = "" + + require.NoError(t, Save(db, indoor)) + + got, found, err := GetByExternalID(db, indoor.ExternalID) + require.NoError(t, err) + require.True(t, found) + assert.Empty(t, got.GPXPath) + _, ready := got.CotacolScore() + assert.False(t, ready) + assert.Empty(t, got.CotacolAlgorithmVersion()) + _, ready = got.MovingTimeS() + assert.False(t, ready) + assert.Empty(t, got.MovingTimeAlgorithmVersion()) +} + +func TestEffectiveMovingTimePrefersCanonical(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) + assert.Equal(t, int64(60), got.EffectiveMovingTimeS()) + + _, err = db.Exec("UPDATE rides SET moving_time_algo_version = 'old' WHERE external_id = ?", got.ExternalID) + require.NoError(t, err) + got, ok, err = GetByExternalID(db, "strava:1234") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, int64(7_200), got.EffectiveMovingTimeS()) +} + +func TestListSortedByMovingTimeUsesCanonicalWithExternalFallback(t *testing.T) { + db := newTestDB(t) + first := sampleRide(t) + first.ExternalID = "strava:550e8400-e29b-41d4-a716-446655440000" + second := sampleRide(t) + second.ExternalID = "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8" + require.NoError(t, Save(db, first)) + require.NoError(t, Save(db, second)) + _, err := db.Exec("UPDATE rides SET external_moving_time_s = 1000, moving_time_s = 500 WHERE external_id = ?", first.ExternalID) + require.NoError(t, err) + _, err = db.Exec("UPDATE rides SET external_moving_time_s = 300, moving_time_s = 500, moving_time_algo_version = 'old' WHERE external_id = ?", second.ExternalID) + require.NoError(t, err) + + items, err := ListSorted(db, SortMovingTime, false) + require.NoError(t, err) + assert.Equal(t, second.ExternalID, items[0].ExternalID) + assert.Equal(t, first.ExternalID, items[1].ExternalID) +} + +func TestListSortedBySpeed(t *testing.T) { + db := newTestDB(t) + slow := sampleRide(t) + slow.ExternalID = "strava:550e8400-e29b-41d4-a716-446655440000" + slow.AverageSpeedMps = 4.2 + fast := sampleRide(t) + fast.ExternalID = "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8" + fast.AverageSpeedMps = 9.8 + require.NoError(t, Save(db, slow)) + require.NoError(t, Save(db, fast)) + + items, err := ListSorted(db, SortSpeed, false) + require.NoError(t, err) + assert.Equal(t, slow.ExternalID, items[0].ExternalID) + assert.Equal(t, fast.ExternalID, items[1].ExternalID) +} + +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, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) + + count, err = Backfill(db) + require.NoError(t, err) + assert.Equal(t, 1, count) +} + +func TestBackfillSkipsInvalidRide(t *testing.T) { + db := newTestDB(t) + _, err := db.Exec(` + INSERT INTO rides (external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "strava:550e8400-e29b-41d4-a716-446655440000", "missing.gpx", "Broken Ride", "Ride", "2026-08-01T07:00:00Z", 20_000, 0, 0, 0, 0) + require.NoError(t, err) + + count, err := Backfill(db) + require.NoError(t, err) + assert.Zero(t, count) + + got, ok, err := GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000") + require.NoError(t, err) + require.True(t, ok) + _, found := got.CotacolScore() + assert.False(t, found) +} + +func TestUpsertUpdatesExisting(t *testing.T) { + db := newTestDB(t) + ride := sampleRide(t) + 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(t) + first.StartDate = time.Date(2026, 7, 1, 7, 0, 0, 0, time.UTC) + second := sampleRide(t) + 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") +} + +func TestListSortedByCotacolKeepsStaleLast(t *testing.T) { + db := newTestDB(t) + first := sampleRide(t) + first.ExternalID = "strava:550e8400-e29b-41d4-a716-446655440000" + first.DistanceM = 20_000 + second := sampleRide(t) + second.ExternalID = "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8" + second.DistanceM = 40_000 + require.NoError(t, Save(db, first)) + require.NoError(t, Save(db, second)) + _, err := db.Exec(` + UPDATE rides + SET cotacol_score = CASE external_id WHEN ? THEN 4 ELSE 6 END, + cotacol_algo_version = ? + `, first.ExternalID, CotacolAlgorithmVersion) + require.NoError(t, err) + + items, err := ListSorted(db, SortCotacol, false) + require.NoError(t, err) + assert.Equal(t, first.ExternalID, items[0].ExternalID) + assert.Equal(t, second.ExternalID, items[1].ExternalID) + + _, err = db.Exec("UPDATE rides SET cotacol_algo_version = ? WHERE external_id = ?", "old", first.ExternalID) + require.NoError(t, err) + items, err = ListSorted(db, SortCotacol, false) + require.NoError(t, err) + assert.Equal(t, second.ExternalID, items[0].ExternalID) + assert.Equal(t, first.ExternalID, items[1].ExternalID) +} + +func TestListSortedByCotacolPer100Km(t *testing.T) { + db := newTestDB(t) + first := sampleRide(t) + first.ExternalID = "strava:550e8400-e29b-41d4-a716-446655440000" + first.DistanceM = 20_000 + second := sampleRide(t) + second.ExternalID = "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8" + second.DistanceM = 40_000 + require.NoError(t, Save(db, first)) + require.NoError(t, Save(db, second)) + _, err := db.Exec(` + UPDATE rides + SET cotacol_score = CASE external_id WHEN ? THEN 4 ELSE 6 END, + cotacol_algo_version = ? + `, first.ExternalID, CotacolAlgorithmVersion) + require.NoError(t, err) + + items, err := ListSorted(db, SortCotacolKm, false) + require.NoError(t, err) + assert.Equal(t, second.ExternalID, items[0].ExternalID) + assert.Equal(t, first.ExternalID, items[1].ExternalID) +} diff --git a/rides/rides.go b/rides/rides.go deleted file mode 100644 index 94fbc64..0000000 --- a/rides/rides.go +++ /dev/null @@ -1,269 +0,0 @@ -package rides - -import ( - "database/sql" - "fmt" - "log/slog" - "time" - - "github.com/martinlehoux/biking_home/ride" - "github.com/martinlehoux/kagamigo/kcore" -) - -type Ride struct { - ID int64 - ExternalID string - GPXPath string - Name string - Type string - StartDate time.Time - DistanceM float64 - ExternalMovingTimeS int64 - movingTimeS *int64 - movingTimeAlgoVersion string - ElapsedTimeS int64 - TotalElevationGainM float64 - AverageSpeedMps float64 - cotacolScore *float64 - cotacolAlgoVersion string - CreatedAt time.Time - UpdatedAt time.Time -} - -type SortColumn string - -const ( - SortName SortColumn = "name" - SortStartDate SortColumn = "started" - SortDistance SortColumn = "distance" - SortMovingTime SortColumn = "moving_time" - SortSpeed SortColumn = "speed" - SortElevation SortColumn = "elevation" - SortCotacol SortColumn = "cotacol" - SortCotacolKm SortColumn = "cotacol_100km" -) - -const columns = "id, external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, 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 { - var cotacolScore any - var cotacolAlgorithmVersion any - var movingTimeS any - var movingTimeAlgorithmVersion any - if r.GPXPath != "" { - parsed, err := ride.ParseFile(ride.GPXRideParser{}, r.GPXPath) - if err != nil { - return fmt.Errorf("compute ride values for %q: %w", r.ExternalID, err) - } - cotacolScore = ride.Cotacol(parsed) - cotacolAlgorithmVersion = ride.CotacolAlgorithmVersion - movingTimeS = int64(ride.MovingTime(parsed) / time.Second) - movingTimeAlgorithmVersion = ride.MovingTimeAlgorithmVersion - } - _, err := db.Exec(` - INSERT INTO rides (external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, 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, - type = excluded.type, - start_date = excluded.start_date, - distance_m = excluded.distance_m, - external_moving_time_s = excluded.external_moving_time_s, - moving_time_s = excluded.moving_time_s, - moving_time_algo_version = excluded.moving_time_algo_version, - 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.ExternalMovingTimeS, movingTimeS, movingTimeAlgorithmVersion, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps, cotacolScore, 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 - } - backfilled := 0 - for _, item := range pending { - if err := Save(db, item); err != nil { - slog.Warn("Failed to backfill ride values", "ride", item.ExternalID, "file", item.GPXPath, "error", err) - continue - } - backfilled++ - } - return backfilled, nil -} - -func List(db *sql.DB) ([]Ride, error) { - return ListSorted(db, SortStartDate, true) -} - -func ListSorted(db *sql.DB, column SortColumn, descending bool) ([]Ride, error) { - expression, ok := sortExpressions[column] - if !ok { - return nil, fmt.Errorf("invalid ride sort column %q", column) - } - direction := "ASC" - if descending { - direction = "DESC" - } - query := "SELECT " + columns + " FROM rides ORDER BY " - args := []any{} - if column == SortMovingTime { - args = append(args, ride.MovingTimeAlgorithmVersion) - } else if isComputedSort(column) { - query += "(cotacol_score IS NULL OR cotacol_algo_version IS NULL OR cotacol_algo_version <> ? OR distance_m <= 0) ASC, " - args = append(args, ride.CotacolAlgorithmVersion) - } - query += expression + " " + direction + ", id DESC" - rows, err := db.Query(query, args...) - 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() -} - -var sortExpressions = map[SortColumn]string{ - SortName: "name COLLATE NOCASE", - SortStartDate: "start_date", - SortDistance: "distance_m", - SortMovingTime: "CASE WHEN moving_time_algo_version = ? THEN COALESCE(moving_time_s, external_moving_time_s) ELSE external_moving_time_s END", - SortSpeed: "average_speed_mps", - SortElevation: "total_elevation_gain_m", - SortCotacol: "cotacol_score", - SortCotacolKm: "cotacol_score * 100000.0 / NULLIF(distance_m, 0)", -} - -func isComputedSort(column SortColumn) bool { - return column == SortCotacol || column == SortCotacolKm -} - -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 -} - -func GetByID(db *sql.DB, id int64) (Ride, bool, error) { - row := db.QueryRow("SELECT "+columns+" FROM rides WHERE id = ?", id) - 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 - movingTimeS sql.NullInt64 - movingTimeAlgoVer sql.NullString - 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.ExternalMovingTimeS, &movingTimeS, &movingTimeAlgoVer, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &cotacolScore, &cotacolAlgoVersion, &createdAt, &updatedAt) - if err != nil { - return Ride{}, err - } - if movingTimeS.Valid { - ride.movingTimeS = &movingTimeS.Int64 - } - if movingTimeAlgoVer.Valid { - ride.movingTimeAlgoVersion = movingTimeAlgoVer.String - } - 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") - } - 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 -} - -func (r Ride) CotacolAlgorithmVersion() string { - return r.cotacolAlgoVersion -} - -func (r Ride) MovingTimeS() (int64, bool) { - if r.movingTimeS == nil { - return 0, false - } - return *r.movingTimeS, true -} - -func (r Ride) MovingTimeAlgorithmVersion() string { - return r.movingTimeAlgoVersion -} - -// EffectiveMovingTimeS is the canonical moving time when the stored value -// matches the current algorithm version, falling back to Strava's value. -func (r Ride) EffectiveMovingTimeS() int64 { - if moving, ok := r.MovingTimeS(); ok && r.movingTimeAlgoVersion == ride.MovingTimeAlgorithmVersion { - return moving - } - return r.ExternalMovingTimeS -} - -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 deleted file mode 100644 index ea87172..0000000 --- a/rides/rides_test.go +++ /dev/null @@ -1,304 +0,0 @@ -package rides - -import ( - "database/sql" - "os" - "path/filepath" - "testing" - "time" - - "github.com/martinlehoux/biking_home/internal/dbtest" - "github.com/martinlehoux/biking_home/ride" - "github.com/martinlehoux/kagamigo/kcore" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newTestDB(t *testing.T) *sql.DB { - return dbtest.New(t) -} - -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: gpxPath, - Name: "Morning Ride", - Type: "Ride", - StartDate: time.Date(2026, 8, 1, 7, 30, 0, 0, time.UTC), - DistanceM: 42_195, - ExternalMovingTimeS: 7_200, - ElapsedTimeS: 7_800, - TotalElevationGainM: 850, - AverageSpeedMps: 5.86, - } -} - -const testGPX = `100200` - -func TestUpsertAndGet(t *testing.T) { - db := newTestDB(t) - 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, sample.StartDate, got.StartDate) - assert.Equal(t, 42_195.0, got.DistanceM) - 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 TestGetByID(t *testing.T) { - db := newTestDB(t) - sample := sampleRide(t) - require.NoError(t, Save(db, sample)) - stored, found, err := GetByExternalID(db, sample.ExternalID) - require.NoError(t, err) - require.True(t, found) - - got, found, err := GetByID(db, stored.ID) - require.NoError(t, err) - require.True(t, found) - assert.Equal(t, sample.ExternalID, got.ExternalID) - assert.Equal(t, sample.Name, got.Name) - - _, found, err = GetByID(db, 999999) - require.NoError(t, err) - assert.False(t, found) -} - -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 TestSaveComputesMovingTimeAndPreservesExternal(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) - moving, found := got.MovingTimeS() - require.True(t, found) - assert.Equal(t, int64(60), moving) - assert.Equal(t, ride.MovingTimeAlgorithmVersion, got.MovingTimeAlgorithmVersion()) - assert.Equal(t, int64(7_200), got.ExternalMovingTimeS) -} - -func TestSaveAllowsRideWithoutGPX(t *testing.T) { - db := newTestDB(t) - indoor := sampleRide(t) - indoor.ExternalID = "strava:14701658670" - indoor.GPXPath = "" - - require.NoError(t, Save(db, indoor)) - - got, found, err := GetByExternalID(db, indoor.ExternalID) - require.NoError(t, err) - require.True(t, found) - assert.Empty(t, got.GPXPath) - _, ready := got.CotacolScore() - assert.False(t, ready) - assert.Empty(t, got.CotacolAlgorithmVersion()) - _, ready = got.MovingTimeS() - assert.False(t, ready) - assert.Empty(t, got.MovingTimeAlgorithmVersion()) -} - -func TestEffectiveMovingTimePrefersCanonical(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) - assert.Equal(t, int64(60), got.EffectiveMovingTimeS()) - - _, err = db.Exec("UPDATE rides SET moving_time_algo_version = 'old' WHERE external_id = ?", got.ExternalID) - require.NoError(t, err) - got, ok, err = GetByExternalID(db, "strava:1234") - require.NoError(t, err) - require.True(t, ok) - assert.Equal(t, int64(7_200), got.EffectiveMovingTimeS()) -} - -func TestListSortedByMovingTimeUsesCanonicalWithExternalFallback(t *testing.T) { - db := newTestDB(t) - first := sampleRide(t) - first.ExternalID = "strava:550e8400-e29b-41d4-a716-446655440000" - second := sampleRide(t) - second.ExternalID = "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8" - require.NoError(t, Save(db, first)) - require.NoError(t, Save(db, second)) - _, err := db.Exec("UPDATE rides SET external_moving_time_s = 1000, moving_time_s = 500 WHERE external_id = ?", first.ExternalID) - require.NoError(t, err) - _, err = db.Exec("UPDATE rides SET external_moving_time_s = 300, moving_time_s = 500, moving_time_algo_version = 'old' WHERE external_id = ?", second.ExternalID) - require.NoError(t, err) - - items, err := ListSorted(db, SortMovingTime, false) - require.NoError(t, err) - assert.Equal(t, second.ExternalID, items[0].ExternalID) - assert.Equal(t, first.ExternalID, items[1].ExternalID) -} - -func TestListSortedBySpeed(t *testing.T) { - db := newTestDB(t) - slow := sampleRide(t) - slow.ExternalID = "strava:550e8400-e29b-41d4-a716-446655440000" - slow.AverageSpeedMps = 4.2 - fast := sampleRide(t) - fast.ExternalID = "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8" - fast.AverageSpeedMps = 9.8 - require.NoError(t, Save(db, slow)) - require.NoError(t, Save(db, fast)) - - items, err := ListSorted(db, SortSpeed, false) - require.NoError(t, err) - assert.Equal(t, slow.ExternalID, items[0].ExternalID) - assert.Equal(t, fast.ExternalID, items[1].ExternalID) -} - -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 TestBackfillSkipsInvalidRide(t *testing.T) { - db := newTestDB(t) - _, err := db.Exec(` - INSERT INTO rides (external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, "strava:550e8400-e29b-41d4-a716-446655440000", "missing.gpx", "Broken Ride", "Ride", "2026-08-01T07:00:00Z", 20_000, 0, 0, 0, 0) - require.NoError(t, err) - - count, err := Backfill(db) - require.NoError(t, err) - assert.Zero(t, count) - - got, ok, err := GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000") - require.NoError(t, err) - require.True(t, ok) - _, found := got.CotacolScore() - assert.False(t, found) -} - -func TestUpsertUpdatesExisting(t *testing.T) { - db := newTestDB(t) - ride := sampleRide(t) - 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(t) - first.StartDate = time.Date(2026, 7, 1, 7, 0, 0, 0, time.UTC) - second := sampleRide(t) - 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") -} - -func TestListSortedByCotacolKeepsStaleLast(t *testing.T) { - db := newTestDB(t) - first := sampleRide(t) - first.ExternalID = "strava:550e8400-e29b-41d4-a716-446655440000" - first.DistanceM = 20_000 - second := sampleRide(t) - second.ExternalID = "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8" - second.DistanceM = 40_000 - require.NoError(t, Save(db, first)) - require.NoError(t, Save(db, second)) - _, err := db.Exec(` - UPDATE rides - SET cotacol_score = CASE external_id WHEN ? THEN 4 ELSE 6 END, - cotacol_algo_version = ? - `, first.ExternalID, ride.CotacolAlgorithmVersion) - require.NoError(t, err) - - items, err := ListSorted(db, SortCotacol, false) - require.NoError(t, err) - assert.Equal(t, first.ExternalID, items[0].ExternalID) - assert.Equal(t, second.ExternalID, items[1].ExternalID) - - _, err = db.Exec("UPDATE rides SET cotacol_algo_version = ? WHERE external_id = ?", "old", first.ExternalID) - require.NoError(t, err) - items, err = ListSorted(db, SortCotacol, false) - require.NoError(t, err) - assert.Equal(t, second.ExternalID, items[0].ExternalID) - assert.Equal(t, first.ExternalID, items[1].ExternalID) -} - -func TestListSortedByCotacolPer100Km(t *testing.T) { - db := newTestDB(t) - first := sampleRide(t) - first.ExternalID = "strava:550e8400-e29b-41d4-a716-446655440000" - first.DistanceM = 20_000 - second := sampleRide(t) - second.ExternalID = "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8" - second.DistanceM = 40_000 - require.NoError(t, Save(db, first)) - require.NoError(t, Save(db, second)) - _, err := db.Exec(` - UPDATE rides - SET cotacol_score = CASE external_id WHEN ? THEN 4 ELSE 6 END, - cotacol_algo_version = ? - `, first.ExternalID, ride.CotacolAlgorithmVersion) - require.NoError(t, err) - - items, err := ListSorted(db, SortCotacolKm, false) - require.NoError(t, err) - assert.Equal(t, second.ExternalID, items[0].ExternalID) - assert.Equal(t, first.ExternalID, items[1].ExternalID) -} diff --git a/web/ride-filters.go b/web/ride-filters.go index 1fb647b..64bade9 100644 --- a/web/ride-filters.go +++ b/web/ride-filters.go @@ -6,7 +6,6 @@ import ( "time" "github.com/martinlehoux/biking_home/ride" - "github.com/martinlehoux/biking_home/rides" ) const ( @@ -321,11 +320,11 @@ func (filters RideFilters) Active() bool { filters.Route != rideRouteFilterAny } -func filterRides(items []rides.Ride, filters RideFilters) []rides.Ride { +func filterRides(items []ride.StoredRide, filters RideFilters) []ride.StoredRide { if !filters.Active() { return items } - filtered := make([]rides.Ride, 0, len(items)) + filtered := make([]ride.StoredRide, 0, len(items)) for _, item := range items { if rideMatchesFilters(item, filters) { filtered = append(filtered, item) @@ -334,7 +333,7 @@ func filterRides(items []rides.Ride, filters RideFilters) []rides.Ride { return filtered } -func rideMatchesFilters(item rides.Ride, filters RideFilters) bool { +func rideMatchesFilters(item ride.StoredRide, filters RideFilters) bool { if filters.DistanceMinKm != nil && item.DistanceM < float64(*filters.DistanceMinKm)*1000 { return false } diff --git a/web/ride-filters_test.go b/web/ride-filters_test.go index 8b470b6..bf1cc6f 100644 --- a/web/ride-filters_test.go +++ b/web/ride-filters_test.go @@ -9,7 +9,6 @@ import ( "time" "github.com/martinlehoux/biking_home/ride" - "github.com/martinlehoux/biking_home/rides" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -88,7 +87,7 @@ func TestParseRideFiltersTreatsFullRangesAsInactive(t *testing.T) { } func TestFilterRidesUsesInclusiveBoundsAndMovingTime(t *testing.T) { - items := []rides.Ride{ + items := []ride.StoredRide{ {Name: "lower boundary", DistanceM: 20_000, ExternalMovingTimeS: 1_800, StartDate: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), GPXPath: "lower.gpx"}, {Name: "upper boundary", DistanceM: 100_000, ExternalMovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "upper.gpx"}, {Name: "outside distance", DistanceM: 110_000, ExternalMovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-distance.gpx"}, @@ -112,7 +111,7 @@ func TestFilterRidesUsesInclusiveBoundsAndMovingTime(t *testing.T) { } func TestFilterRidesByRouteAvailability(t *testing.T) { - items := []rides.Ride{ + items := []ride.StoredRide{ {Name: "recorded", GPXPath: "recorded.gpx"}, {Name: "metadata only"}, } @@ -128,7 +127,7 @@ func TestFilterRidesByRouteAvailability(t *testing.T) { func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) { _, db := newWebTestServer(t) - current := rides.Ride{ + current := ride.StoredRide{ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", GPXPath: testGPXPath(t, "current-cotacol.gpx"), Name: "Current score", @@ -136,7 +135,7 @@ func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) { StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } - stale := rides.Ride{ + stale := ride.StoredRide{ ExternalID: "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", GPXPath: testGPXPath(t, "stale-cotacol.gpx"), Name: "Stale score", @@ -144,16 +143,16 @@ func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) { StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } - unavailable := rides.Ride{ + unavailable := ride.StoredRide{ ExternalID: "strava:7ba7b810-9dad-41d1-80b4-00c04fd430c8", Name: "No score", Type: "VirtualRide", StartDate: time.Date(2026, 8, 3, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } - require.NoError(t, rides.Save(db, current)) - require.NoError(t, rides.Save(db, stale)) - require.NoError(t, rides.Save(db, unavailable)) + require.NoError(t, ride.Save(db, current)) + require.NoError(t, ride.Save(db, stale)) + require.NoError(t, ride.Save(db, unavailable)) _, err := db.Exec(` UPDATE rides SET cotacol_score = CASE external_id @@ -167,7 +166,7 @@ func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) { WHERE external_id IN (?, ?) `, current.ExternalID, stale.ExternalID, current.ExternalID, ride.CotacolAlgorithmVersion, stale.ExternalID, current.ExternalID, stale.ExternalID) require.NoError(t, err) - items, err := rides.List(db) + items, err := ride.List(db) require.NoError(t, err) filters := parseRideFilters(url.Values{rideFilterCotacolMinParam: {"200"}, rideFilterCotacolMaxParam: {"300"}}) @@ -210,7 +209,7 @@ func TestRideSortHeadersPreserveFilters(t *testing.T) { func TestHandlerFiltersRidesBeforeRenderingSortedResults(t *testing.T) { server, db := newWebTestServer(t) save := func(externalID, name, path string, distance float64) { - require.NoError(t, rides.Save(db, rides.Ride{ + require.NoError(t, ride.Save(db, ride.StoredRide{ ExternalID: externalID, GPXPath: testGPXPath(t, path), Name: name, @@ -235,7 +234,7 @@ func TestHandlerFiltersRidesBeforeRenderingSortedResults(t *testing.T) { func TestHandlerRendersFilteredEmptyState(t *testing.T) { server, db := newWebTestServer(t) - require.NoError(t, rides.Save(db, rides.Ride{ + require.NoError(t, ride.Save(db, ride.StoredRide{ ExternalID: "strava:8ba7b810-9dad-41d1-80b4-00c04fd430c8", GPXPath: testGPXPath(t, "empty-filter.gpx"), Name: "Short Ride", diff --git a/web/rides.go b/web/rides.go index cb38b3c..477f066 100644 --- a/web/rides.go +++ b/web/rides.go @@ -9,7 +9,6 @@ import ( "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" - "github.com/martinlehoux/biking_home/rides" "github.com/martinlehoux/kagamigo/kcore" ) @@ -41,7 +40,7 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - item, found, err := rides.GetByID(s.db, id) + item, found, err := ride.GetByID(s.db, id) if err != nil { slog.Error("Failed to load ride", "ride_id", id, "error", err) http.Error(w, "failed to load ride", http.StatusInternalServerError) @@ -103,7 +102,7 @@ func (s *Server) handleAnalytics(w http.ResponseWriter, r *http.Request) { if filters.DistanceMaxKm != nil { maxKm = *filters.DistanceMaxKm } - eligible := make([]rides.Ride, 0, len(items)) + eligible := make([]ride.StoredRide, 0, len(items)) for _, item := range items { distanceKm := int(item.DistanceM / 1000) if distanceKm < minKm || (maxKm != 0 && distanceKm > maxKm) { @@ -130,7 +129,7 @@ func (s *Server) handleCreateOfficialClimb(w http.ResponseWriter, r *http.Reques http.NotFound(w, r) return } - item, found, err := rides.GetByID(s.db, id) + item, found, err := ride.GetByID(s.db, id) if err != nil { slog.Error("Failed to load ride for official climb creation", "ride_id", id, "error", err) http.Error(w, "failed to load ride", http.StatusInternalServerError) @@ -190,18 +189,18 @@ func parseRouteIndex(value string) (int, error) { return index, nil } -func (s *Server) listRides(rideSort RideSort) ([]rides.Ride, error) { +func (s *Server) listRides(rideSort RideSort) ([]ride.StoredRide, error) { column, found := rideSort.databaseColumn() if !found { - return rides.List(s.db) + return ride.List(s.db) } - return rides.ListSorted(s.db, column, rideSort.Descending) + return ride.ListSorted(s.db, column, rideSort.Descending) } const minimumDisplayedDistanceM = 10_000 const rideDetailMountainPassMarginM = 10_000 -func buildRideViews(items []rides.Ride) []RideView { +func buildRideViews(items []ride.StoredRide) []RideView { views := make([]RideView, 0, len(items)) for _, item := range items { if item.DistanceM < minimumDisplayedDistanceM { @@ -212,7 +211,7 @@ func buildRideViews(items []rides.Ride) []RideView { return views } -func buildRideView(item rides.Ride) RideView { +func buildRideView(item ride.StoredRide) RideView { view := RideView{Ride: item, Cotacol: "-", CotacolPer100Km: "-", MovingTime: formatDuration(item.EffectiveMovingTimeS())} cotacol, ready := item.CotacolScore() if ready && item.CotacolAlgorithmVersion() == ride.CotacolAlgorithmVersion && item.DistanceM > 0 { diff --git a/web/server_test.go b/web/server_test.go index 0514dfc..ebedcd6 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -20,7 +20,6 @@ import ( "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" - "github.com/martinlehoux/biking_home/rides" "github.com/martinlehoux/biking_home/strava" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -73,7 +72,7 @@ const officialClimbTrackGPX = ` for _, item := range data.Items { - { item.Name }
{ item.Type }{ formatRideDate(item.StartDate) }{ formatDistance(item.DistanceM) }{ item.MovingTime }{ formatSpeed(item.AverageSpeedMps) }{ formatElevation(item.TotalElevationGainM) }{ item.Cotacol }{ item.CotacolPer100Km } + { item.Ride.Name }
{ item.Ride.Type }{ formatRideDate(item.Ride.StartDate) }{ formatDistance(item.Ride.DistanceM) }{ item.MovingTime }{ formatSpeed(item.Ride.AverageSpeedMps) }{ formatElevation(item.Ride.TotalElevationGainM) }{ item.Cotacol }{ item.CotacolPer100Km } } diff --git a/web/views.go b/web/views.go index 7a5a461..64afb82 100644 --- a/web/views.go +++ b/web/views.go @@ -9,11 +9,10 @@ import ( "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/biking_home/rideanalysis" - "github.com/martinlehoux/biking_home/rides" ) type RideView struct { - rides.Ride + Ride ride.StoredRide Cotacol string CotacolPer100Km string MovingTime string @@ -39,7 +38,7 @@ type AnalyticsPoint struct { CotacolPer100Km float64 `json:"cotacolPer100Km"` } -func buildAnalyticsPoints(items []rides.Ride) []AnalyticsPoint { +func buildAnalyticsPoints(items []ride.StoredRide) []AnalyticsPoint { points := make([]AnalyticsPoint, 0, len(items)) for _, item := range items { cotacol, ready := item.CotacolScore() @@ -194,16 +193,16 @@ func parseRideSort(query url.Values) RideSort { return RideSort{Column: column, Descending: direction != "asc"} } -func (s RideSort) databaseColumn() (rides.SortColumn, bool) { - columns := map[string]rides.SortColumn{ - rideSortName: rides.SortName, - rideSortStarted: rides.SortStartDate, - rideSortDistance: rides.SortDistance, - rideSortMovingTime: rides.SortMovingTime, - rideSortSpeed: rides.SortSpeed, - rideSortElevation: rides.SortElevation, - rideSortCotacol: rides.SortCotacol, - rideSortCotacolKm: rides.SortCotacolKm, +func (s RideSort) databaseColumn() (ride.SortColumn, bool) { + columns := map[string]ride.SortColumn{ + rideSortName: ride.SortName, + rideSortStarted: ride.SortStartDate, + rideSortDistance: ride.SortDistance, + rideSortMovingTime: ride.SortMovingTime, + rideSortSpeed: ride.SortSpeed, + rideSortElevation: ride.SortElevation, + rideSortCotacol: ride.SortCotacol, + rideSortCotacolKm: ride.SortCotacolKm, } column, found := columns[s.Column] return column, found @@ -277,7 +276,7 @@ func officialClimbCreateURL(id int64) string { return fmt.Sprintf("/rides/%d/official-climbs", id) } -func buildRideDetailView(item rides.Ride, parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideDetailView { +func buildRideDetailView(item ride.StoredRide, parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideDetailView { coordinates := make([][]float64, parsed.Len()) for i := 0; i < parsed.Len(); i++ { coordinate := parsed.Coord(i) -- cgit v1.3