diff options
| -rw-r--r-- | README.md | 13 | ||||
| -rw-r--r-- | cli/cli.go | 7 | ||||
| -rw-r--r-- | cli/cli_test.go | 12 | ||||
| -rw-r--r-- | ride/store.go (renamed from rides/rides.go) | 65 | ||||
| -rw-r--r-- | ride/store_test.go (renamed from rides/rides_test.go) | 19 | ||||
| -rw-r--r-- | web/ride-filters.go | 7 | ||||
| -rw-r--r-- | web/ride-filters_test.go | 23 | ||||
| -rw-r--r-- | web/rides.go | 17 | ||||
| -rw-r--r-- | web/server_test.go | 71 | ||||
| -rw-r--r-- | web/sync.go | 12 | ||||
| -rw-r--r-- | web/templates.templ | 2 | ||||
| -rw-r--r-- | web/views.go | 27 |
12 files changed, 134 insertions, 141 deletions
@@ -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) @@ -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/rides/rides.go b/ride/store.go index 94fbc64..af598e8 100644 --- a/rides/rides.go +++ b/ride/store.go @@ -1,4 +1,4 @@ -package rides +package ride import ( "database/sql" @@ -6,11 +6,10 @@ import ( "log/slog" "time" - "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/kagamigo/kcore" ) -type Ride struct { +type StoredRide struct { ID int64 ExternalID string GPXPath string @@ -45,20 +44,20 @@ const ( 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 { +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 := ride.ParseFile(ride.GPXRideParser{}, r.GPXPath) + parsed, err := ParseFile(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 + 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) @@ -87,7 +86,7 @@ func Backfill(db *sql.DB) (int, error) { if err != nil { return 0, err } - var pending []Ride + var pending []StoredRide for rows.Next() { item, err := scanRide(rows) if err != nil { @@ -114,11 +113,11 @@ func Backfill(db *sql.DB) (int, error) { return backfilled, nil } -func List(db *sql.DB) ([]Ride, error) { +func List(db *sql.DB) ([]StoredRide, error) { return ListSorted(db, SortStartDate, true) } -func ListSorted(db *sql.DB, column SortColumn, descending bool) ([]Ride, error) { +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) @@ -130,10 +129,10 @@ func ListSorted(db *sql.DB, column SortColumn, descending bool) ([]Ride, error) query := "SELECT " + columns + " FROM rides ORDER BY " args := []any{} if column == SortMovingTime { - args = append(args, ride.MovingTimeAlgorithmVersion) + 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, ride.CotacolAlgorithmVersion) + args = append(args, CotacolAlgorithmVersion) } query += expression + " " + direction + ", id DESC" rows, err := db.Query(query, args...) @@ -141,7 +140,7 @@ func ListSorted(db *sql.DB, column SortColumn, descending bool) ([]Ride, error) return nil, err } defer rows.Close() - var rides []Ride + var rides []StoredRide for rows.Next() { ride, err := scanRide(rows) if err != nil { @@ -167,26 +166,26 @@ func isComputedSort(column SortColumn) bool { return column == SortCotacol || column == SortCotacolKm } -func GetByExternalID(db *sql.DB, externalID string) (Ride, bool, error) { +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 Ride{}, false, nil + return StoredRide{}, false, nil } if err != nil { - return Ride{}, false, err + return StoredRide{}, false, err } return ride, true, nil } -func GetByID(db *sql.DB, id int64) (Ride, bool, error) { +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 Ride{}, false, nil + return StoredRide{}, false, nil } if err != nil { - return Ride{}, false, err + return StoredRide{}, false, err } return ride, true, nil } @@ -195,9 +194,9 @@ type scanner interface { Scan(dest ...any) error } -func scanRide(s scanner) (Ride, error) { +func scanRide(s scanner) (StoredRide, error) { var ( - ride Ride + ride StoredRide startDate string movingTimeS sql.NullInt64 movingTimeAlgoVer sql.NullString @@ -208,7 +207,7 @@ func scanRide(s scanner) (Ride, error) { ) 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 + return StoredRide{}, err } if movingTimeS.Valid { ride.movingTimeS = &movingTimeS.Int64 @@ -224,44 +223,44 @@ func scanRide(s scanner) (Ride, error) { } ride.StartDate, err = time.Parse(time.RFC3339, startDate) if err != nil { - return Ride{}, kcore.Wrap(err, "invalid start_date in rides row") + return StoredRide{}, 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") + return StoredRide{}, 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 StoredRide{}, kcore.Wrap(err, "invalid updated_at in rides row") } return ride, nil } -func (r Ride) CotacolAlgorithmVersion() string { +func (r StoredRide) CotacolAlgorithmVersion() string { return r.cotacolAlgoVersion } -func (r Ride) MovingTimeS() (int64, bool) { +func (r StoredRide) MovingTimeS() (int64, bool) { if r.movingTimeS == nil { return 0, false } return *r.movingTimeS, true } -func (r Ride) MovingTimeAlgorithmVersion() string { +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 Ride) EffectiveMovingTimeS() int64 { - if moving, ok := r.MovingTimeS(); ok && r.movingTimeAlgoVersion == ride.MovingTimeAlgorithmVersion { +func (r StoredRide) EffectiveMovingTimeS() int64 { + if moving, ok := r.MovingTimeS(); ok && r.movingTimeAlgoVersion == MovingTimeAlgorithmVersion { return moving } return r.ExternalMovingTimeS } -func (r Ride) CotacolScore() (float64, bool) { +func (r StoredRide) CotacolScore() (float64, bool) { if r.cotacolScore == nil { return 0, false } diff --git a/rides/rides_test.go b/ride/store_test.go index ea87172..877f4e5 100644 --- a/rides/rides_test.go +++ b/ride/store_test.go @@ -1,4 +1,4 @@ -package rides +package ride import ( "database/sql" @@ -8,7 +8,6 @@ import ( "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" @@ -18,11 +17,11 @@ func newTestDB(t *testing.T) *sql.DB { return dbtest.New(t) } -func sampleRide(t *testing.T) Ride { +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 Ride{ + return StoredRide{ ExternalID: "strava:1234", GPXPath: gpxPath, Name: "Morning Ride", @@ -54,7 +53,7 @@ func TestUpsertAndGet(t *testing.T) { score, found := got.CotacolScore() require.True(t, found) assert.Greater(t, score, 0.0) - assert.Equal(t, ride.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) + assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) _, ok, err = GetByExternalID(db, "strava:9999") require.NoError(t, err) @@ -90,7 +89,7 @@ func TestSaveComputesCotacol(t *testing.T) { score, found := got.CotacolScore() require.True(t, found) assert.Greater(t, score, 0.0) - assert.Equal(t, ride.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) + assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) } func TestSaveComputesMovingTimeAndPreservesExternal(t *testing.T) { @@ -103,7 +102,7 @@ func TestSaveComputesMovingTimeAndPreservesExternal(t *testing.T) { moving, found := got.MovingTimeS() require.True(t, found) assert.Equal(t, int64(60), moving) - assert.Equal(t, ride.MovingTimeAlgorithmVersion, got.MovingTimeAlgorithmVersion()) + assert.Equal(t, MovingTimeAlgorithmVersion, got.MovingTimeAlgorithmVersion()) assert.Equal(t, int64(7_200), got.ExternalMovingTimeS) } @@ -191,7 +190,7 @@ func TestBackfill(t *testing.T) { require.True(t, ok) _, found := got.CotacolScore() assert.True(t, found) - assert.Equal(t, ride.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) + assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) count, err = Backfill(db) require.NoError(t, err) @@ -264,7 +263,7 @@ func TestListSortedByCotacolKeepsStaleLast(t *testing.T) { UPDATE rides SET cotacol_score = CASE external_id WHEN ? THEN 4 ELSE 6 END, cotacol_algo_version = ? - `, first.ExternalID, ride.CotacolAlgorithmVersion) + `, first.ExternalID, CotacolAlgorithmVersion) require.NoError(t, err) items, err := ListSorted(db, SortCotacol, false) @@ -294,7 +293,7 @@ func TestListSortedByCotacolPer100Km(t *testing.T) { UPDATE rides SET cotacol_score = CASE external_id WHEN ? THEN 4 ELSE 6 END, cotacol_algo_version = ? - `, first.ExternalID, ride.CotacolAlgorithmVersion) + `, first.ExternalID, CotacolAlgorithmVersion) require.NoError(t, err) items, err := ListSorted(db, SortCotacolKm, false) 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 = `<?xml version="1.0"?><gpx xmlns="http://www.topog func TestHandlerRendersRidesPage(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:1234", GPXPath: testGPXPath(t, "long.gpx"), Name: "Long Ride", @@ -81,7 +80,7 @@ func TestHandlerRendersRidesPage(t *testing.T) { StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, })) - require.NoError(t, rides.Save(db, rides.Ride{ + require.NoError(t, ride.Save(db, ride.StoredRide{ ExternalID: "strava:5678", GPXPath: testGPXPath(t, "short.gpx"), Name: "Short Ride", @@ -106,7 +105,7 @@ func TestHandlerRendersRidesPage(t *testing.T) { func TestHandlerRendersRideDetailWithEmbeddedRoute(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:550e8400-e29b-41d4-a716-446655440000", GPXPath: testGPXPath(t, "detail.gpx"), Name: "Detailed Ride", @@ -122,7 +121,7 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { 2, "Distant pass", 500, 43.2, 5.2, ) require.NoError(t, err) - item, found, err := rides.GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000") + item, found, err := ride.GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000") require.NoError(t, err) require.True(t, found) @@ -268,7 +267,7 @@ func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) { server, db := newWebTestServer(t) routePath := filepath.Join(t.TempDir(), "official-climb.gpx") require.NoError(t, os.WriteFile(routePath, []byte(officialClimbTrackGPX), 0o600)) - require.NoError(t, rides.Save(db, rides.Ride{ + require.NoError(t, ride.Save(db, ride.StoredRide{ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", GPXPath: routePath, Name: "Climb Candidate", @@ -276,7 +275,7 @@ func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) { StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 2_000, })) - item, found, err := rides.GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000") + item, found, err := ride.GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000") require.NoError(t, err) require.True(t, found) @@ -325,15 +324,15 @@ func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) { func TestHandlerRendersRideDetailWithoutRoute(t *testing.T) { server, db := newWebTestServer(t) - item := rides.Ride{ + item := ride.StoredRide{ ExternalID: "strava:14701658670", Name: "Indoor Ride", Type: "VirtualRide", StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } - require.NoError(t, rides.Save(db, item)) - stored, found, err := rides.GetByExternalID(db, item.ExternalID) + require.NoError(t, ride.Save(db, item)) + stored, found, err := ride.GetByExternalID(db, item.ExternalID) require.NoError(t, err) require.True(t, found) @@ -350,22 +349,22 @@ func TestHandlerRendersRideDetailWithoutRoute(t *testing.T) { func TestHandlerRendersAnalyticsChart(t *testing.T) { server, db := newWebTestServer(t) - first := rides.Ride{ + first := ride.StoredRide{ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440001", Name: "Fastish Ride", Type: "Ride", StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 40_000, } - second := rides.Ride{ + second := ride.StoredRide{ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440002", Name: "Climbing Ride", Type: "Ride", StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), DistanceM: 40_000, } - require.NoError(t, rides.Save(db, first)) - require.NoError(t, rides.Save(db, second)) + require.NoError(t, ride.Save(db, first)) + require.NoError(t, ride.Save(db, second)) _, err := db.Exec(` UPDATE rides SET cotacol_score = CASE external_id WHEN ? THEN 20 ELSE 120 END, @@ -392,22 +391,22 @@ func TestHandlerRendersAnalyticsChart(t *testing.T) { func TestHandlerFiltersAnalyticsByDistance(t *testing.T) { server, db := newWebTestServer(t) - short := rides.Ride{ + short := ride.StoredRide{ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440003", Name: "Short Ride", Type: "Ride", StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 40_000, } - long := rides.Ride{ + long := ride.StoredRide{ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440004", Name: "Long Ride", Type: "Ride", StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), DistanceM: 100_000, } - require.NoError(t, rides.Save(db, short)) - require.NoError(t, rides.Save(db, long)) + require.NoError(t, ride.Save(db, short)) + require.NoError(t, ride.Save(db, long)) _, err := db.Exec(` UPDATE rides SET cotacol_score = CASE external_id WHEN ? THEN 20 ELSE 120 END, @@ -448,7 +447,7 @@ func TestHandlerShowsErrorForUnavailableRideRoute(t *testing.T) { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", "missing.gpx", "Unavailable Route", "Ride", "2026-08-01T07:00:00Z", 20_000, 0, 0, 0, 0) require.NoError(t, err) - item, found, err := rides.GetByExternalID(db, "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8") + item, found, err := ride.GetByExternalID(db, "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8") require.NoError(t, err) require.True(t, found) @@ -567,10 +566,10 @@ func TestSyncRidesContinuesAfterInvalidActivity(t *testing.T) { require.NoError(t, err) assert.Equal(t, SyncProgress{Total: 2, Completed: 2, Imported: 1, Skipped: 1}, progress) assert.Len(t, updates, 3) - _, found, err := rides.GetByExternalID(db, "strava:14701658670") + _, found, err := ride.GetByExternalID(db, "strava:14701658670") require.NoError(t, err) assert.False(t, found) - _, found, err = rides.GetByExternalID(db, "strava:14701658671") + _, found, err = ride.GetByExternalID(db, "strava:14701658671") require.NoError(t, err) assert.True(t, found) assert.NoFileExists(t, filepath.Join(gpxDir, "activity_14701658670.gpx")) @@ -599,14 +598,14 @@ func TestSyncRidesSavesActivityWithoutRoute(t *testing.T) { require.NoError(t, err) assert.Equal(t, SyncProgress{Total: 2, Completed: 2, Imported: 2}, progress) - indoor, found, err := rides.GetByExternalID(db, "strava:14701658670") + indoor, found, err := ride.GetByExternalID(db, "strava:14701658670") require.NoError(t, err) require.True(t, found) assert.Empty(t, indoor.GPXPath) _, ready := indoor.CotacolScore() assert.False(t, ready) assert.NoFileExists(t, staleGPXPath) - _, found, err = rides.GetByExternalID(db, "strava:14701658671") + _, found, err = ride.GetByExternalID(db, "strava:14701658671") require.NoError(t, err) assert.True(t, found) } @@ -712,7 +711,7 @@ func TestRideSortHeadersToggleDirection(t *testing.T) { func TestBuildRideViewsUsesStoredCotacol(t *testing.T) { _, db := newWebTestServer(t) - ride := rides.Ride{ + stored := ride.StoredRide{ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", GPXPath: testGPXPath(t, "stored.gpx"), Name: "Stored Ride", @@ -720,10 +719,10 @@ func TestBuildRideViewsUsesStoredCotacol(t *testing.T) { StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } - require.NoError(t, rides.Save(db, ride)) - _, err := db.Exec("UPDATE rides SET gpx_path = ? WHERE external_id = ?", "missing.gpx", ride.ExternalID) + require.NoError(t, ride.Save(db, stored)) + _, err := db.Exec("UPDATE rides SET gpx_path = ? WHERE external_id = ?", "missing.gpx", stored.ExternalID) require.NoError(t, err) - items, err := rides.List(db) + items, err := ride.List(db) require.NoError(t, err) views := buildRideViews(items) @@ -733,7 +732,7 @@ func TestBuildRideViewsUsesStoredCotacol(t *testing.T) { func TestBuildRideViewMovingTimePrefersCanonical(t *testing.T) { _, db := newWebTestServer(t) - ride := rides.Ride{ + stored := ride.StoredRide{ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", GPXPath: testGPXPath(t, "moving.gpx"), Name: "Moving Ride", @@ -742,8 +741,8 @@ func TestBuildRideViewMovingTimePrefersCanonical(t *testing.T) { DistanceM: 20_000, ExternalMovingTimeS: 7_200, } - require.NoError(t, rides.Save(db, ride)) - items, err := rides.List(db) + require.NoError(t, ride.Save(db, stored)) + items, err := ride.List(db) require.NoError(t, err) require.Len(t, items, 1) @@ -752,7 +751,7 @@ func TestBuildRideViewMovingTimePrefersCanonical(t *testing.T) { func TestHandlerSortsRidesByDistance(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:550e8400-e29b-41d4-a716-446655440000", GPXPath: testGPXPath(t, "near.gpx"), Name: "Near Ride", @@ -760,7 +759,7 @@ func TestHandlerSortsRidesByDistance(t *testing.T) { StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, })) - require.NoError(t, rides.Save(db, rides.Ride{ + require.NoError(t, ride.Save(db, ride.StoredRide{ ExternalID: "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", GPXPath: testGPXPath(t, "far.gpx"), Name: "Far Ride", @@ -781,7 +780,7 @@ func TestHandlerSortsRidesByDistance(t *testing.T) { func TestHandlerSortsRidesByCotacol(t *testing.T) { server, db := newWebTestServer(t) - first := rides.Ride{ + first := ride.StoredRide{ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", GPXPath: testGPXPath(t, "first.gpx"), Name: "Low Cotacol", @@ -789,7 +788,7 @@ func TestHandlerSortsRidesByCotacol(t *testing.T) { StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } - second := rides.Ride{ + second := ride.StoredRide{ ExternalID: "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", GPXPath: testGPXPath(t, "second.gpx"), Name: "High Cotacol", @@ -797,8 +796,8 @@ func TestHandlerSortsRidesByCotacol(t *testing.T) { StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), DistanceM: 40_000, } - require.NoError(t, rides.Save(db, first)) - require.NoError(t, rides.Save(db, second)) + require.NoError(t, ride.Save(db, first)) + require.NoError(t, ride.Save(db, second)) _, err := db.Exec(` UPDATE rides SET cotacol_score = CASE external_id WHEN ? THEN 1 ELSE 4 END diff --git a/web/sync.go b/web/sync.go index 4a6ba5f..b491ee0 100644 --- a/web/sync.go +++ b/web/sync.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "github.com/martinlehoux/biking_home/rides" + "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/biking_home/strava" "github.com/martinlehoux/kagamigo/kcore" ) @@ -104,7 +104,7 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, } for _, summary := range activities { externalID := fmt.Sprintf("strava:%d", summary.ID) - _, exists, err := rides.GetByExternalID(s.db, externalID) + _, exists, err := ride.GetByExternalID(s.db, externalID) if err != nil { return progress, err } @@ -120,7 +120,7 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, if err != nil { if errors.Is(err, strava.ErrNoTrackPoints) { _ = os.Remove(filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", summary.ID))) - if saveErr := rides.Save(s.db, rideFromActivity(externalID, "", activity)); saveErr != nil { + if saveErr := ride.Save(s.db, rideFromActivity(externalID, "", activity)); saveErr != nil { if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save metadata: %w", saveErr)); reportErr != nil { return progress, reportErr } @@ -147,7 +147,7 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, } continue } - if err := rides.Save(s.db, rideFromActivity(externalID, gpxPath, activity)); err != nil { + if err := ride.Save(s.db, rideFromActivity(externalID, gpxPath, activity)); err != nil { _ = os.Remove(gpxPath) if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save activity: %w", err)); reportErr != nil { return progress, reportErr @@ -164,12 +164,12 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, return progress, nil } -func rideFromActivity(externalID, gpxPath string, activity strava.Activity) rides.Ride { +func rideFromActivity(externalID, gpxPath string, activity strava.Activity) ride.StoredRide { activityType := activity.SportType if activityType == "" { activityType = activity.Type } - return rides.Ride{ + return ride.StoredRide{ ExternalID: externalID, GPXPath: gpxPath, Name: activity.Name, diff --git a/web/templates.templ b/web/templates.templ index f2b62f8..0660d07 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -84,7 +84,7 @@ templ RidesContent(data RidesPageData) { </tr></thead> <tbody> for _, item := range data.Items { - <tr><td><strong><a href={ templ.URL(rideDetailURL(item.ID)) }>{ item.Name }</a></strong><br/><small>{ item.Type }</small></td><td>{ formatRideDate(item.StartDate) }</td><td class="numeric">{ formatDistance(item.DistanceM) }</td><td class="numeric">{ item.MovingTime }</td><td class="numeric">{ formatSpeed(item.AverageSpeedMps) }</td><td class="numeric">{ formatElevation(item.TotalElevationGainM) }</td><td class="numeric">{ item.Cotacol }</td><td class="numeric">{ item.CotacolPer100Km }</td></tr> + <tr><td><strong><a href={ templ.URL(rideDetailURL(item.Ride.ID)) }>{ item.Ride.Name }</a></strong><br/><small>{ item.Ride.Type }</small></td><td>{ formatRideDate(item.Ride.StartDate) }</td><td class="numeric">{ formatDistance(item.Ride.DistanceM) }</td><td class="numeric">{ item.MovingTime }</td><td class="numeric">{ formatSpeed(item.Ride.AverageSpeedMps) }</td><td class="numeric">{ formatElevation(item.Ride.TotalElevationGainM) }</td><td class="numeric">{ item.Cotacol }</td><td class="numeric">{ item.CotacolPer100Km }</td></tr> } </tbody> </table> 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) |
