aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-21 19:30:50 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-21 19:30:50 +0200
commited9516cbcb3c5254980a9e1dba7d8fa5ea230b35 (patch)
tree36d70fc6e76a74c30d0e3b166fd3c00f88363700 /web
parent72cc720e91e531fda3da073a8231951e0cccb696 (diff)
Add distance filter to analytics and go fix to the check gate
Diffstat (limited to 'web')
-rw-r--r--web/rides.go22
-rw-r--r--web/server_test.go41
-rw-r--r--web/static/style.css2
-rw-r--r--web/templates.templ25
-rw-r--r--web/views.go5
5 files changed, 91 insertions, 4 deletions
diff --git a/web/rides.go b/web/rides.go
index aa39d69..cb38b3c 100644
--- a/web/rides.go
+++ b/web/rides.go
@@ -94,15 +94,33 @@ func (s *Server) handleAnalytics(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failed to load rides", http.StatusInternalServerError)
return
}
+ filters := parseRideFilters(r.URL.Query())
+ minKm := minimumDisplayedDistanceM / 1000
+ if filters.DistanceMinKm != nil && *filters.DistanceMinKm > minKm {
+ minKm = *filters.DistanceMinKm
+ }
+ maxKm := 0
+ if filters.DistanceMaxKm != nil {
+ maxKm = *filters.DistanceMaxKm
+ }
eligible := make([]rides.Ride, 0, len(items))
for _, item := range items {
- if item.DistanceM >= minimumDisplayedDistanceM {
- eligible = append(eligible, item)
+ distanceKm := int(item.DistanceM / 1000)
+ if distanceKm < minKm || (maxKm != 0 && distanceKm > maxKm) {
+ continue
}
+ eligible = append(eligible, item)
+ }
+ filterView := RideFilterView{
+ DistanceMinKm: rideFilterValue(filters.DistanceMinKm, rideFilterDistanceMinKm),
+ DistanceMaxKm: rideFilterValue(filters.DistanceMaxKm, rideFilterDistanceMaxKm),
+ Active: filters.Active(),
+ ResetURL: "/analytics",
}
slog.Info("Loaded ride analytics", "rides", len(eligible))
kcore.RenderPage(r.Context(), AnalyticsPage(AnalyticsPageData{
Points: buildAnalyticsPoints(eligible),
+ Filter: filterView,
}), w)
}
diff --git a/web/server_test.go b/web/server_test.go
index 42b85b1..0514dfc 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -390,6 +390,47 @@ func TestHandlerRendersAnalyticsChart(t *testing.T) {
assert.Contains(t, body, `href="/analytics"`)
}
+func TestHandlerFiltersAnalyticsByDistance(t *testing.T) {
+ server, db := newWebTestServer(t)
+ short := rides.Ride{
+ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440003",
+ Name: "Short Ride",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
+ DistanceM: 40_000,
+ }
+ long := rides.Ride{
+ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440004",
+ Name: "Long Ride",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC),
+ DistanceM: 100_000,
+ }
+ require.NoError(t, rides.Save(db, short))
+ require.NoError(t, rides.Save(db, long))
+ _, err := db.Exec(`
+ UPDATE rides
+ SET cotacol_score = CASE external_id WHEN ? THEN 20 ELSE 120 END,
+ cotacol_algo_version = ?,
+ average_speed_mps = CASE external_id WHEN ? THEN 7.0 ELSE 5.0 END
+ `, short.ExternalID, ride.CotacolAlgorithmVersion, long.ExternalID)
+ require.NoError(t, err)
+
+ req := httptest.NewRequest(http.MethodGet, "/analytics?distance_min=60", nil)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, req)
+
+ body := response.Body.String()
+ assert.Equal(t, http.StatusOK, response.Code)
+ assert.Contains(t, body, `id="analytics-chart"`)
+ assert.Contains(t, body, `id="analytics-points"`)
+ assert.Contains(t, body, `"cotacolPer100Km":120`)
+ assert.NotContains(t, body, `"cotacolPer100Km":50`)
+ assert.Contains(t, body, `name="distance_min"`)
+ assert.Contains(t, body, `href="/analytics"`)
+ assert.Contains(t, body, `data-analytics-count`)
+}
+
func TestHandlerReturnsNotFoundForUnknownRide(t *testing.T) {
server, _ := newWebTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/rides/999999", nil)
diff --git a/web/static/style.css b/web/static/style.css
index 55d6549..830e50a 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -90,6 +90,8 @@ th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text-
.ride-filters { display: grid; gap: 1.25rem; margin-top: 1.25rem; }
.ride-filters > .section-heading { flex-wrap: wrap; }
.ride-filter-ranges { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem 1.5rem; }
+.analytics-filters-panel { margin-top: 1.25rem; }
+.analytics-filters-panel .ride-filter-ranges { grid-template-columns: minmax(0, 1fr); max-width: 18rem; }
.ride-filter-group { min-width: 0; margin: 0; padding: 0; border: 0; }
.ride-filter-group legend { padding: 0; color: var(--color-forest); font-size: .9rem; font-weight: 750; }
.range-values { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; margin-top: .45rem; color: var(--color-muted); font-size: .85rem; }
diff --git a/web/templates.templ b/web/templates.templ
index 71df20b..f2b62f8 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -101,16 +101,37 @@ templ AnalyticsContent(data AnalyticsPageData) {
<p class="eyebrow">Analytics</p>
<h1>Ride analytics</h1>
<p class="lead">One point per ride: average speed on the x axis, Cotacol per 100 km on the y axis.</p>
+ <section class="panel analytics-filters-panel">
+ <form id="analytics-filters" class="ride-filters" method="get" action="/analytics" aria-label="Analytics filters">
+ <div class="section-heading">
+ <div><p class="eyebrow">Refine</p><h2>Analyses</h2></div>
+ <div class="ride-filter-actions"><button class="button" type="submit">Apply filters</button><a class="button secondary" href={ templ.URL(data.Filter.ResetURL) }>Reset</a></div>
+ </div>
+ <div class="ride-filter-ranges">
+ @RideRangeFilter(rideDistanceFilterConfig(data.Filter))
+ </div>
+ </form>
+ </section>
<section class="panel analytics-panel">
- <div class="section-heading"><div><p class="eyebrow">Chart</p><h2>Speed vs. Cotacol per 100 km</h2></div></div>
+ <div class="section-heading">
+ <div><p class="eyebrow">Chart</p><h2>Speed vs. Cotacol per 100 km</h2></div>
+ if len(data.Points) > 0 {
+ <span class="section-count" data-analytics-count>{ formatRideCount(len(data.Points)) }</span>
+ }
+ </div>
if len(data.Points) == 0 {
- <div class="empty">No eligible rides yet. <a href="/sync">Import rides to see the chart.</a></div>
+ if data.Filter.Active {
+ <div class="empty">No rides match the selected filters. <a href={ templ.URL(data.Filter.ResetURL) }>Clear filters.</a></div>
+ } else {
+ <div class="empty">No eligible rides yet. <a href="/sync">Import rides to see the chart.</a></div>
+ }
} else {
<div class="analytics-chart"><canvas id="analytics-chart" tabindex="0" role="img" aria-label="Scatter plot of ride average speed against Cotacol per 100 km"></canvas></div>
<output id="analytics-hover" class="analytics-hover" aria-live="polite">Hover a point to inspect a ride.</output>
}
</section>
@templ.JSONScript("analytics-points", data.Points)
+ <script type="module" src="/static/ride-filters.js"></script>
<script type="module" src="/static/analytics.js"></script>
}
diff --git a/web/views.go b/web/views.go
index 9771a4d..7a5a461 100644
--- a/web/views.go
+++ b/web/views.go
@@ -28,6 +28,7 @@ type RidesPageData struct {
type AnalyticsPageData struct {
Points []AnalyticsPoint
+ Filter RideFilterView
}
type AnalyticsPoint struct {
@@ -427,6 +428,10 @@ func formatClimbCount(count int) string {
return fmt.Sprintf("%d", count)
}
+func formatRideCount(count int) string {
+ return fmt.Sprintf("%d", count)
+}
+
func formatProfileDistance(distanceKm float64) string {
return fmt.Sprintf("%.1f km", distanceKm)
}