From 08fdd6964fd9ef29356420b035e2d186546f674a Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 23 Aug 2026 21:35:59 +0200 Subject: Add hardcore ride categorisation with bike and ride types --- web/categories.go | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++ web/request.go | 3 ++ web/server.go | 1 + web/static/style.css | 10 ++++++ web/templates.templ | 43 +++++++++++++++++++++++++ 5 files changed, 146 insertions(+) create mode 100644 web/categories.go (limited to 'web') 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) {
Elevation
{ formatElevation(data.Ride.TotalElevationGainM) }
+
+
+
+

Curation

+

Categories

+
+
+ + + +
+
+
if data.HasRoute { if len(data.Profile.OfficialClimbs()) > 0 {
-- cgit v1.3