aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/categories.go89
-rw-r--r--web/request.go3
-rw-r--r--web/server.go1
-rw-r--r--web/static/style.css10
-rw-r--r--web/templates.templ43
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">