diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-23 21:35:59 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-23 21:35:59 +0200 |
| commit | 08fdd6964fd9ef29356420b035e2d186546f674a (patch) | |
| tree | 812565b5ca57405a5b36b2d7edfab983f6f079d4 | |
| parent | 2d8503cafb9f63ca4cb63f041281987f9a63f16a (diff) | |
Add hardcore ride categorisation with bike and ride types
| -rw-r--r-- | db/migrations/20260823000000_ride_categories.sql | 29 | ||||
| -rw-r--r-- | db/schema.sql | 5 | ||||
| -rw-r--r-- | ride/category.go | 56 | ||||
| -rw-r--r-- | ride/store.go | 102 | ||||
| -rw-r--r-- | web/categories.go | 89 | ||||
| -rw-r--r-- | web/request.go | 3 | ||||
| -rw-r--r-- | web/server.go | 1 | ||||
| -rw-r--r-- | web/static/style.css | 10 | ||||
| -rw-r--r-- | web/templates.templ | 43 |
9 files changed, 331 insertions, 7 deletions
diff --git a/db/migrations/20260823000000_ride_categories.sql b/db/migrations/20260823000000_ride_categories.sql new file mode 100644 index 0000000..0ae4f1b --- /dev/null +++ b/db/migrations/20260823000000_ride_categories.sql @@ -0,0 +1,29 @@ +-- migrate:up +ALTER TABLE rides ADD COLUMN bike_type TEXT NOT NULL DEFAULT '' CHECK (bike_type IN ('', 'road', 'mtb')); +ALTER TABLE rides ADD COLUMN ride_type TEXT NOT NULL DEFAULT '' CHECK (ride_type IN ('', 'training', 'indoor', 'race', 'transport')); + +-- migrate:down +-- SQLite does not support DROP COLUMN with CHECK in older versions, recreate table without columns +CREATE TABLE rides_new ( + id integer primary key, + external_id text unique not null, + gpx_path text not null, + name text not null, + type text not null, + start_date text not null, + distance_m real not null, + external_moving_time_s integer not null, + elapsed_time_s integer not null, + total_elevation_gain_m real not null, + average_speed_mps real not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + cotacol_score real, + cotacol_algo_version text, + moving_time_s integer, + moving_time_algo_version text +); +INSERT INTO rides_new (id, external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps, created_at, updated_at, cotacol_score, cotacol_algo_version, moving_time_s, moving_time_algo_version) +SELECT id, external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps, created_at, updated_at, cotacol_score, cotacol_algo_version, moving_time_s, moving_time_algo_version FROM rides; +DROP TABLE rides; +ALTER TABLE rides_new RENAME TO rides; diff --git a/db/schema.sql b/db/schema.sql index 60505bb..9713add 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -21,7 +21,7 @@ CREATE TABLE rides ( average_speed_mps real not null, created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) -, cotacol_score real, cotacol_algo_version text, moving_time_s integer, moving_time_algo_version text); +, cotacol_score real, cotacol_algo_version text, moving_time_s integer, moving_time_algo_version text, bike_type TEXT NOT NULL DEFAULT '' CHECK (bike_type IN ('', 'road', 'mtb')), ride_type TEXT NOT NULL DEFAULT '' CHECK (ride_type IN ('', 'training', 'indoor', 'race', 'transport'))); CREATE TABLE official_climbs ( id integer primary key, name text not null, @@ -40,4 +40,5 @@ INSERT INTO "schema_migrations" (version) VALUES ('20260807000000'), ('20260813000000'), ('20260820184307'), - ('20260821000000'); + ('20260821000000'), + ('20260823000000'); diff --git a/ride/category.go b/ride/category.go new file mode 100644 index 0000000..0eafcc0 --- /dev/null +++ b/ride/category.go @@ -0,0 +1,56 @@ +package ride + +type BikeType string + +const ( + BikeTypeRoad BikeType = "road" + BikeTypeMTB BikeType = "mtb" +) + +type RideType string + +const ( + RideTypeTraining RideType = "training" + RideTypeIndoor RideType = "indoor" + RideTypeRace RideType = "race" + RideTypeTransport RideType = "transport" +) + +var validBikeTypes = map[BikeType]struct{}{ + BikeTypeRoad: {}, + BikeTypeMTB: {}, +} + +var validRideTypes = map[RideType]struct{}{ + RideTypeTraining: {}, + RideTypeIndoor: {}, + RideTypeRace: {}, + RideTypeTransport: {}, +} + +var AllBikeTypes = []BikeType{BikeTypeRoad, BikeTypeMTB} +var AllRideTypes = []RideType{RideTypeTraining, RideTypeIndoor, RideTypeRace, RideTypeTransport} + +func IsValidBikeType(value string) bool { + _, ok := validBikeTypes[BikeType(value)] + return ok +} + +func IsValidRideType(value string) bool { + _, ok := validRideTypes[RideType(value)] + return ok +} + +func IsValidOrEmptyBikeType(value string) bool { + if value == "" { + return true + } + return IsValidBikeType(value) +} + +func IsValidOrEmptyRideType(value string) bool { + if value == "" { + return true + } + return IsValidRideType(value) +} diff --git a/ride/store.go b/ride/store.go index af598e8..162b912 100644 --- a/ride/store.go +++ b/ride/store.go @@ -25,6 +25,8 @@ type StoredRide struct { AverageSpeedMps float64 cotacolScore *float64 cotacolAlgoVersion string + BikeType BikeType + RideType RideType CreatedAt time.Time UpdatedAt time.Time } @@ -42,7 +44,7 @@ const ( 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" +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, bike_type, ride_type, created_at, updated_at" func Save(db *sql.DB, r StoredRide) error { var cotacolScore any @@ -59,9 +61,15 @@ func Save(db *sql.DB, r StoredRide) error { movingTimeS = int64(MovingTime(parsed) / time.Second) movingTimeAlgorithmVersion = MovingTimeAlgorithmVersion } + if !IsValidOrEmptyBikeType(string(r.BikeType)) { + return fmt.Errorf("invalid bike_type %q", r.BikeType) + } + if !IsValidOrEmptyRideType(string(r.RideType)) { + return fmt.Errorf("invalid ride_type %q", r.RideType) + } _, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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, bike_type, ride_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(external_id) DO UPDATE SET gpx_path = excluded.gpx_path, name = excluded.name, @@ -76,8 +84,10 @@ func Save(db *sql.DB, r StoredRide) error { average_speed_mps = excluded.average_speed_mps, cotacol_score = excluded.cotacol_score, cotacol_algo_version = excluded.cotacol_algo_version, + bike_type = CASE WHEN excluded.bike_type = '' THEN rides.bike_type ELSE excluded.bike_type END, + ride_type = CASE WHEN excluded.ride_type = '' THEN rides.ride_type ELSE excluded.ride_type END, 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) + `, 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, string(r.BikeType), string(r.RideType)) return err } @@ -202,10 +212,12 @@ func scanRide(s scanner) (StoredRide, error) { movingTimeAlgoVer sql.NullString cotacolScore sql.NullFloat64 cotacolAlgoVersion sql.NullString + bikeType string + rideType string 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) + 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, &bikeType, &rideType, &createdAt, &updatedAt) if err != nil { return StoredRide{}, err } @@ -221,6 +233,8 @@ func scanRide(s scanner) (StoredRide, error) { if cotacolAlgoVersion.Valid { ride.cotacolAlgoVersion = cotacolAlgoVersion.String } + ride.BikeType = BikeType(bikeType) + ride.RideType = RideType(rideType) ride.StartDate, err = time.Parse(time.RFC3339, startDate) if err != nil { return StoredRide{}, kcore.Wrap(err, "invalid start_date in rides row") @@ -266,3 +280,81 @@ func (r StoredRide) CotacolScore() (float64, bool) { } return *r.cotacolScore, true } + +func (r StoredRide) IsCategorised() bool { + return r.BikeType != "" && r.RideType != "" +} + +func UpdateCategories(db *sql.DB, id int64, bikeType BikeType, rideType RideType) error { + if !IsValidBikeType(string(bikeType)) { + return fmt.Errorf("invalid bike_type %q", bikeType) + } + if !IsValidRideType(string(rideType)) { + return fmt.Errorf("invalid ride_type %q", rideType) + } + result, err := db.Exec(`UPDATE rides SET bike_type = ?, ride_type = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?`, string(bikeType), string(rideType), id) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +func BulkUpdateCategories(db *sql.DB, ids []int64, bikeType BikeType, rideType RideType) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + if !IsValidBikeType(string(bikeType)) { + return 0, fmt.Errorf("invalid bike_type %q", bikeType) + } + if !IsValidRideType(string(rideType)) { + return 0, fmt.Errorf("invalid ride_type %q", rideType) + } + query := "UPDATE rides SET bike_type = ?, ride_type = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id IN (" + args := []any{string(bikeType), string(rideType)} + for i, id := range ids { + if i > 0 { + query += "," + } + query += "?" + args = append(args, id) + } + query += ")" + result, err := db.Exec(query, args...) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func CountUncategorised(db *sql.DB) (int, error) { + row := db.QueryRow(`SELECT COUNT(*) FROM rides WHERE bike_type = '' OR ride_type = ''`) + var count int + if err := row.Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func ListUncategorised(db *sql.DB) ([]StoredRide, error) { + rows, err := db.Query("SELECT " + columns + " FROM rides WHERE bike_type = '' OR ride_type = '' ORDER BY start_date DESC, id DESC") + 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() +} diff --git a/web/categories.go b/web/categories.go new file mode 100644 index 0000000..4077092 --- /dev/null +++ b/web/categories.go @@ -0,0 +1,89 @@ +package web + +import ( + "log/slog" + "net/http" + "strconv" + + "github.com/martinlehoux/biking_home/ride" +) + +func (s *Server) handleUpdateRideCategories(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + http.NotFound(w, r) + return + } + item, found, err := ride.GetByID(s.db, id) + if err != nil { + slog.Error("Failed to load ride for category update", "ride_id", id, "error", err) + http.Error(w, "failed to load ride", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + _ = item + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + bikeType := ride.BikeType(r.FormValue("bike_type")) + rideType := ride.RideType(r.FormValue("ride_type")) + if !ride.IsValidBikeType(string(bikeType)) { + http.Error(w, "invalid bike_type", http.StatusBadRequest) + return + } + if !ride.IsValidRideType(string(rideType)) { + http.Error(w, "invalid ride_type", http.StatusBadRequest) + return + } + if err := ride.UpdateCategories(s.db, id, bikeType, rideType); err != nil { + slog.Error("Failed to update ride categories", "ride_id", id, "error", err) + http.Error(w, "failed to update categories", http.StatusInternalServerError) + return + } + slog.Info("Updated ride categories", "ride_id", id, "bike_type", bikeType, "ride_type", rideType) + http.Redirect(w, r, rideDetailURL(id)+"?categories=updated", http.StatusSeeOther) +} + +func rideCategoriesUpdateURL(id int64) string { + return rideDetailURL(id) + "/categories" +} + +var bikeTypeOptions = []ride.BikeType{ride.BikeTypeRoad, ride.BikeTypeMTB} +var rideTypeOptions = []ride.RideType{ride.RideTypeTraining, ride.RideTypeIndoor, ride.RideTypeRace, ride.RideTypeTransport} + +var bikeTypeLabels = map[ride.BikeType]string{ + ride.BikeTypeRoad: "Road", + ride.BikeTypeMTB: "MTB", +} + +var rideTypeLabels = map[ride.RideType]string{ + ride.RideTypeTraining: "Training", + ride.RideTypeIndoor: "Indoor", + ride.RideTypeRace: "Race", + ride.RideTypeTransport: "Transport", +} + +func formatBikeType(value ride.BikeType) string { + if label, ok := bikeTypeLabels[value]; ok { + return label + } + return string(value) +} + +func formatRideType(value ride.RideType) string { + if label, ok := rideTypeLabels[value]; ok { + return label + } + return string(value) +} + +func formatCategoryOrEmpty(value string) string { + if value == "" { + return "—" + } + return value +} diff --git a/web/request.go b/web/request.go index d51a582..43eee48 100644 --- a/web/request.go +++ b/web/request.go @@ -25,6 +25,9 @@ func rideDetailNotice(r *http.Request) string { if r.URL.Query().Get("official_climb") == "created" { return "Official climb saved and matched to this ride by coordinates." } + if r.URL.Query().Get("categories") == "updated" { + return "Ride categories updated." + } return "" } diff --git a/web/server.go b/web/server.go index 26dbb06..687fc8d 100644 --- a/web/server.go +++ b/web/server.go @@ -48,6 +48,7 @@ func (s *Server) Handler() http.Handler { handleRoute(mux, "GET /rides/{id}", s.handleRide) handleRoute(mux, "GET /analytics", s.handleAnalytics) handleRoute(mux, "POST /rides/{id}/official-climbs", s.handleCreateOfficialClimb) + handleRoute(mux, "POST /rides/{id}/categories", s.handleUpdateRideCategories) handleRoute(mux, "GET /sync", s.handleSyncForm) handleRoute(mux, "POST /sync", s.handleSync) handleRoute(mux, "GET /strava/login", s.handleStravaLogin) diff --git a/web/static/style.css b/web/static/style.css index 830e50a..bda7188 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -136,6 +136,16 @@ select { border: 1px solid var(--color-input-border); border-radius: .55rem; pad .section-heading .eyebrow { margin-bottom: .2rem; } .section-heading h2 { margin: 0; font-size: 1.2rem; } .section-count { display: grid; place-items: center; min-width: 2rem; height: 2rem; border-radius: 999px; background: var(--color-secondary-surface); color: var(--color-forest); font-weight: 800; } +.categories-panel { padding: 1rem 1.25rem; } +.categories-layout { display: flex; align-items: center; justify-content: space-between; gap: 1.25rem; flex-wrap: wrap; } +.categories-heading { display: flex; align-items: baseline; gap: .75rem; flex-wrap: wrap; } +.categories-heading .eyebrow { margin: 0; } +.categories-heading h2 { margin: 0; font-size: 1.1rem; } +.category-badge { display: inline-flex; align-items: center; border-radius: 999px; padding: .35rem .65rem; background: var(--color-notice-surface); color: var(--color-notice-text); font-size: .75rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } +.category-form { display: flex; align-items: end; gap: .65rem; flex-wrap: wrap; margin-left: auto; } +.category-form label { min-width: 8.5rem; } +.category-form select { min-width: 8.5rem; } +.category-form .button { align-self: end; padding: .55rem .9rem; } .official-climbs-panel { padding: 0; border: 0; background: transparent; box-shadow: none; } .official-climbs-panel .section-heading { margin-bottom: .75rem; } .official-climb-list { display: grid; gap: 0; } diff --git a/web/templates.templ b/web/templates.templ index 9be2daf..d1719b8 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -180,6 +180,49 @@ templ RideDetailContent(data RideDetailView) { <div><dt>Elevation</dt><dd>{ formatElevation(data.Ride.TotalElevationGainM) }</dd></div> </dl> </section> + <section class="panel categories-panel"> + <div class="categories-layout"> + <div class="categories-heading"> + <p class="eyebrow">Curation</p> + <h2>Categories</h2> + </div> + <form method="post" action={ templ.URL(rideCategoriesUpdateURL(data.Ride.ID)) } class="category-form"> + <label for="ride-bike-type">Bike type + <select id="ride-bike-type" name="bike_type" required> + if data.Ride.BikeType == "" { + <option value="" selected>—</option> + } else { + <option value="">—</option> + } + for _, opt := range bikeTypeOptions { + if data.Ride.BikeType == opt { + <option value={ string(opt) } selected>{ bikeTypeLabels[opt] }</option> + } else { + <option value={ string(opt) }>{ bikeTypeLabels[opt] }</option> + } + } + </select> + </label> + <label for="ride-ride-type">Ride type + <select id="ride-ride-type" name="ride_type" required> + if data.Ride.RideType == "" { + <option value="" selected>—</option> + } else { + <option value="">—</option> + } + for _, opt := range rideTypeOptions { + if data.Ride.RideType == opt { + <option value={ string(opt) } selected>{ rideTypeLabels[opt] }</option> + } else { + <option value={ string(opt) }>{ rideTypeLabels[opt] }</option> + } + } + </select> + </label> + <button class="button" type="submit">Save</button> + </form> + </div> + </section> if data.HasRoute { if len(data.Profile.OfficialClimbs()) > 0 { <section class="panel official-climbs-panel"> |
