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 /web | |
| parent | 2d8503cafb9f63ca4cb63f041281987f9a63f16a (diff) | |
Add hardcore ride categorisation with bike and ride types
Diffstat (limited to 'web')
| -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 |
5 files changed, 146 insertions, 0 deletions
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"> |
