From 7f33c7c55773663a0c7fe30585e2099d4b5ba6d2 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Mon, 24 Aug 2026 09:06:37 +0200 Subject: Add data quality bulk categorisation page --- web/data_quality.go | 101 +++++++++++++++++++++++++++++++++++++++++++ web/frontend/data-quality.ts | 31 +++++++++++++ web/server.go | 2 + web/static/style.css | 7 +++ web/templates.templ | 67 +++++++++++++++++++++++++++- 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 web/data_quality.go create mode 100644 web/frontend/data-quality.ts diff --git a/web/data_quality.go b/web/data_quality.go new file mode 100644 index 0000000..74e84af --- /dev/null +++ b/web/data_quality.go @@ -0,0 +1,101 @@ +package web + +import ( + "log/slog" + "net/http" + "strconv" + + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/kagamigo/kcore" +) + +func (s *Server) handleDataQuality(w http.ResponseWriter, r *http.Request) { + items, err := ride.ListUncategorised(s.db) + if err != nil { + slog.Error("Failed to load uncategorised rides", "error", err) + http.Error(w, "failed to load rides", http.StatusInternalServerError) + return + } + views := make([]RideView, 0, len(items)) + for _, item := range items { + views = append(views, buildRideView(item)) + } + total, err := ride.CountUncategorised(s.db) + if err != nil { + slog.Warn("Failed to count uncategorised rides", "error", err) + total = len(items) + } + slog.Info("Loaded data quality page", "uncategorised", total, "displayed", len(views)) + kcore.RenderPage(r.Context(), DataQualityPage(DataQualityPageData{ + Items: views, + Total: total, + Notice: dataQualityNotice(r), + }), w) +} + +func (s *Server) handleBulkUpdateCategories(w http.ResponseWriter, r *http.Request) { + 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 + } + rawIDs := r.Form["ride_ids"] + if len(rawIDs) == 0 { + http.Error(w, "no rides selected", http.StatusBadRequest) + return + } + ids := make([]int64, 0, len(rawIDs)) + for _, raw := range rawIDs { + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id <= 0 { + http.Error(w, "invalid ride id", http.StatusBadRequest) + return + } + ids = append(ids, id) + } + affected, err := ride.BulkUpdateCategories(s.db, ids, bikeType, rideType) + if err != nil { + slog.Error("Failed to bulk update categories", "error", err) + http.Error(w, "failed to update categories", http.StatusInternalServerError) + return + } + slog.Info("Bulk updated ride categories", "count", affected, "bike_type", bikeType, "ride_type", rideType) + http.Redirect(w, r, "/data-quality?bulk=updated&count="+strconv.FormatInt(affected, 10), http.StatusSeeOther) +} + +func dataQualityNotice(r *http.Request) string { + if r.URL.Query().Get("bulk") == "updated" { + count := r.URL.Query().Get("count") + if count != "" { + return count + " rides categorised." + } + return "Rides categorised." + } + if r.URL.Query().Get("categories") == "updated" { + return "Ride categories updated." + } + return "" +} + +func dataQualityURL() string { + return "/data-quality" +} + +type DataQualityPageData struct { + Items []RideView + Total int + Notice string +} + +func formatRideID(id int64) string { + return strconv.FormatInt(id, 10) +} diff --git a/web/frontend/data-quality.ts b/web/frontend/data-quality.ts new file mode 100644 index 0000000..32c3563 --- /dev/null +++ b/web/frontend/data-quality.ts @@ -0,0 +1,31 @@ +function initDataQuality(): void { + const form = document.getElementById("data-quality-form") as HTMLFormElement | null; + const selectAll = document.getElementById("data-quality-select-all") as HTMLInputElement | null; + const counter = document.getElementById("data-quality-selected"); + if (!form || !selectAll) return; + const checkboxes = Array.from(form.querySelectorAll("input.data-quality-checkbox")); + + function updateCount(): void { + const selected = checkboxes.filter((c) => c.checked).length; + if (counter) counter.textContent = `${selected} selected`; + const allChecked = selected === checkboxes.length && checkboxes.length > 0; + const noneChecked = selected === 0; + selectAll!.checked = allChecked; + selectAll!.indeterminate = !allChecked && !noneChecked; + } + + selectAll!.addEventListener("change", () => { + for (const cb of checkboxes) cb.checked = selectAll!.checked; + updateCount(); + }); + + for (const cb of checkboxes) cb.addEventListener("change", updateCount); + + updateCount(); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initDataQuality); +} else { + initDataQuality(); +} diff --git a/web/server.go b/web/server.go index 687fc8d..06b4ace 100644 --- a/web/server.go +++ b/web/server.go @@ -49,6 +49,8 @@ func (s *Server) Handler() http.Handler { 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 /data-quality", s.handleDataQuality) + handleRoute(mux, "POST /data-quality/categories", s.handleBulkUpdateCategories) 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 bda7188..491a54e 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -146,6 +146,13 @@ select { border: 1px solid var(--color-input-border); border-radius: .55rem; pad .category-form label { min-width: 8.5rem; } .category-form select { min-width: 8.5rem; } .category-form .button { align-self: end; padding: .55rem .9rem; } +.data-quality-bulk { display: flex; align-items: end; gap: .75rem; flex-wrap: wrap; margin-bottom: 1rem; } +.data-quality-bulk label { min-width: 8.5rem; } +.data-quality-bulk select { min-width: 8.5rem; } +.data-quality-selected { margin-left: auto; color: var(--color-muted); font-size: .9rem; font-weight: 650; } +.data-quality-table-wrap { overflow-x: auto; border: 1px solid var(--color-border-subtle); border-radius: .65rem; } +.data-quality-table-wrap table { margin: 0; } +.data-quality-table-wrap th:first-child, .data-quality-table-wrap td:first-child { width: 2.5rem; text-align: center; } .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 d1719b8..6c3b09d 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -13,7 +13,7 @@ templ Layout(title string, content templ.Component) {
@content @@ -359,3 +359,68 @@ templ SyncContent(data SyncPageData) { } + +templ DataQualityPage(data DataQualityPageData) { + @Layout("Data quality", DataQualityContent(data)) +} + +templ DataQualityContent(data DataQualityPageData) { +

Curation

+

Data quality

+

Categorise rides that are missing bike or ride type. Select rides, pick categories, and apply in bulk.

+ if data.Notice != "" { +
{ data.Notice }
+ } +
+
+

Todo

Uncategorised rides

+ { formatRideCount(data.Total) } +
+ if data.Total == 0 { +
All rides are categorised.
+ } else if len(data.Items) == 0 { +
No uncategorised rides match the current view.
+ } else { +
+
+ + + + 0 selected +
+
+ + + + for _, item := range data.Items { + + + + + + + + + } + +
RideStartedDistanceBikeRide type
{ item.Ride.Name }
{ item.Ride.Type }
{ formatRideDate(item.Ride.StartDate) }{ formatDistance(item.Ride.DistanceM) }{ formatCategoryOrEmpty(string(item.Ride.BikeType)) }{ formatCategoryOrEmpty(string(item.Ride.RideType)) }
+
+
+ + } +
+} -- cgit v1.3