diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-24 09:06:37 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-24 09:06:37 +0200 |
| commit | 7f33c7c55773663a0c7fe30585e2099d4b5ba6d2 (patch) | |
| tree | b11329627aaddb085ae96253d0e896c30054edcd /web | |
| parent | 08fdd6964fd9ef29356420b035e2d186546f674a (diff) | |
Add data quality bulk categorisation page
Diffstat (limited to 'web')
| -rw-r--r-- | web/data_quality.go | 101 | ||||
| -rw-r--r-- | web/frontend/data-quality.ts | 31 | ||||
| -rw-r--r-- | web/server.go | 2 | ||||
| -rw-r--r-- | web/static/style.css | 7 | ||||
| -rw-r--r-- | web/templates.templ | 67 |
5 files changed, 207 insertions, 1 deletions
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<HTMLInputElement>("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) { <body> <header class="site-header"> <a class="brand" href="/">biking_home</a> - <nav class="nav"><a href="/">Rides</a><a href="/analytics">Analytics</a><a href="/sync">Sync Strava</a></nav> + <nav class="nav"><a href="/">Rides</a><a href="/analytics">Analytics</a><a href="/data-quality">Data quality</a><a href="/sync">Sync Strava</a></nav> </header> <main class="container"> @content @@ -359,3 +359,68 @@ templ SyncContent(data SyncPageData) { </section> <script type="module" src="/static/sync.js"></script> } + +templ DataQualityPage(data DataQualityPageData) { + @Layout("Data quality", DataQualityContent(data)) +} + +templ DataQualityContent(data DataQualityPageData) { + <p class="eyebrow">Curation</p> + <h1>Data quality</h1> + <p class="lead">Categorise rides that are missing bike or ride type. Select rides, pick categories, and apply in bulk.</p> + if data.Notice != "" { + <div class="notice">{ data.Notice }</div> + } + <section class="panel"> + <div class="section-heading"> + <div><p class="eyebrow">Todo</p><h2>Uncategorised rides</h2></div> + <span class="section-count">{ formatRideCount(data.Total) }</span> + </div> + if data.Total == 0 { + <div class="empty">All rides are categorised.</div> + } else if len(data.Items) == 0 { + <div class="empty">No uncategorised rides match the current view.</div> + } else { + <form method="post" action="/data-quality/categories" id="data-quality-form"> + <div class="data-quality-bulk"> + <label for="bulk-bike-type">Bike type + <select id="bulk-bike-type" name="bike_type" required> + <option value="">—</option> + for _, opt := range bikeTypeOptions { + <option value={ string(opt) }>{ bikeTypeLabels[opt] }</option> + } + </select> + </label> + <label for="bulk-ride-type">Ride type + <select id="bulk-ride-type" name="ride_type" required> + <option value="">—</option> + for _, opt := range rideTypeOptions { + <option value={ string(opt) }>{ rideTypeLabels[opt] }</option> + } + </select> + </label> + <button class="button" type="submit">Apply to selected</button> + <span class="data-quality-selected" id="data-quality-selected">0 selected</span> + </div> + <div class="data-quality-table-wrap"> + <table> + <thead><tr><th><input type="checkbox" id="data-quality-select-all" aria-label="Select all rides"/></th><th>Ride</th><th>Started</th><th class="numeric">Distance</th><th>Bike</th><th>Ride type</th></tr></thead> + <tbody> + for _, item := range data.Items { + <tr> + <td><input type="checkbox" name="ride_ids" value={ formatRideID(item.Ride.ID) } class="data-quality-checkbox"/></td> + <td><strong><a href={ templ.URL(rideDetailURL(item.Ride.ID)) }>{ item.Ride.Name }</a></strong><br/><small>{ item.Ride.Type }</small></td> + <td>{ formatRideDate(item.Ride.StartDate) }</td> + <td class="numeric">{ formatDistance(item.Ride.DistanceM) }</td> + <td>{ formatCategoryOrEmpty(string(item.Ride.BikeType)) }</td> + <td>{ formatCategoryOrEmpty(string(item.Ride.RideType)) }</td> + </tr> + } + </tbody> + </table> + </div> + </form> + <script type="module" src="/static/data-quality.js"></script> + } + </section> +} |
