aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/data_quality.go101
-rw-r--r--web/frontend/data-quality.ts31
-rw-r--r--web/server.go2
-rw-r--r--web/static/style.css7
-rw-r--r--web/templates.templ67
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>
+}