summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-07 19:10:30 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-07 19:10:30 +0200
commit7aa64b0917130fcc1dc9f3da30d8f825136fe541 (patch)
tree361fb90e0b68a6356c7c7b9667b3f09400680287 /web
parent5b74dc4321e63f251190a975b151875b64656c56 (diff)
feat: Add ride table sorting
Diffstat (limited to 'web')
-rw-r--r--web/server.go46
-rw-r--r--web/server_test.go82
-rw-r--r--web/templates.templ15
-rw-r--r--web/templates_templ.go198
-rw-r--r--web/views.go110
5 files changed, 384 insertions, 67 deletions
diff --git a/web/server.go b/web/server.go
index 10cf30e..c7b585e 100644
--- a/web/server.go
+++ b/web/server.go
@@ -11,6 +11,7 @@ import (
"net/url"
"os"
"path/filepath"
+ "sort"
"strconv"
"strings"
"sync"
@@ -53,12 +54,25 @@ func (s *Server) ListenAndServe(addr string) error {
}
func (s *Server) handleRides(w http.ResponseWriter, r *http.Request) {
- items, err := rides.List(s.db)
+ rideSort := parseRideSort(r.URL.Query())
+ items, err := s.listRides(rideSort)
if err != nil {
http.Error(w, "failed to load rides", http.StatusInternalServerError)
return
}
- kcore.RenderPage(r.Context(), RidesPage(buildRideViews(items)), w)
+ views := buildRideViews(items)
+ if _, databaseColumn := rideSort.databaseColumn(); !databaseColumn {
+ sortRideViews(views, rideSort)
+ }
+ kcore.RenderPage(r.Context(), RidesPage(views, rideSortHeaders(rideSort)), w)
+}
+
+func (s *Server) listRides(rideSort RideSort) ([]rides.Ride, error) {
+ column, found := rideSort.databaseColumn()
+ if !found {
+ return rides.List(s.db)
+ }
+ return rides.ListSorted(s.db, column, rideSort.Descending)
}
const minimumDisplayedDistanceM = 10_000
@@ -78,12 +92,40 @@ func buildRideViews(items []rides.Ride) []RideView {
score := parsed.DifficultyScore()
view.Cotacol = formatCotacol(score)
view.CotacolPer100Km = formatCotacolPer100Km(score, item.DistanceM)
+ view.cotacolScore = score
+ view.cotacolPer100Km = score * 100 / (item.DistanceM / 1000)
+ view.cotacolReady = true
}
views = append(views, view)
}
return views
}
+func sortRideViews(items []RideView, rideSort RideSort) {
+ sort.SliceStable(items, func(i, j int) bool {
+ left, right := items[i], items[j]
+ if !left.cotacolReady || !right.cotacolReady {
+ if left.cotacolReady != right.cotacolReady {
+ return left.cotacolReady
+ }
+ return false
+ }
+ var leftValue, rightValue float64
+ switch rideSort.Column {
+ case rideSortCotacol:
+ leftValue, rightValue = left.cotacolScore, right.cotacolScore
+ case rideSortCotacolKm:
+ leftValue, rightValue = left.cotacolPer100Km, right.cotacolPer100Km
+ default:
+ return false
+ }
+ if rideSort.Descending {
+ return leftValue > rightValue
+ }
+ return leftValue < rightValue
+ })
+}
+
func (s *Server) handleSyncForm(w http.ResponseWriter, r *http.Request) {
data := SyncPageData{
From: queryOrDefault(r, "from", time.Now().AddDate(0, 0, -30).Format(dateFormat)),
diff --git a/web/server_test.go b/web/server_test.go
index 0324558..b6799cd 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -134,3 +134,85 @@ func TestFormatCotacolPer100Km(t *testing.T) {
assert.Equal(t, "20.0", formatCotacolPer100Km(2, 10_000))
assert.Equal(t, "-", formatCotacolPer100Km(2, 0))
}
+
+func TestParseRideSort(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ column string
+ descending bool
+ }{
+ {name: "default", query: "", column: rideSortStarted, descending: true},
+ {name: "ascending distance", query: "sort=distance&dir=asc", column: rideSortDistance, descending: false},
+ {name: "invalid values", query: "sort=unknown&dir=sideways", column: rideSortStarted, descending: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ query, err := url.ParseQuery(tt.query)
+ require.NoError(t, err)
+ got := parseRideSort(query)
+ assert.Equal(t, tt.column, got.Column)
+ assert.Equal(t, tt.descending, got.Descending)
+ })
+ }
+}
+
+func TestRideSortHeadersToggleDirection(t *testing.T) {
+ headers := rideSortHeaders(RideSort{Column: rideSortDistance, Descending: true})
+ require.Len(t, headers, 7)
+ assert.Equal(t, "/?dir=asc&sort=distance", headers[2].URL)
+ assert.Equal(t, "descending", headers[2].AriaSort)
+ assert.Contains(t, headers[2].Class, "active")
+ assert.Equal(t, "/?dir=asc&sort=name", headers[0].URL)
+
+ headers = rideSortHeaders(RideSort{Column: rideSortDistance, Descending: false})
+ assert.Equal(t, "/?dir=desc&sort=distance", headers[2].URL)
+ assert.Equal(t, "ascending", headers[2].AriaSort)
+}
+
+func TestSortRideViewsByCotacolKeepsMissingLast(t *testing.T) {
+ items := []RideView{
+ {cotacolScore: 4, cotacolReady: true},
+ {cotacolScore: 1, cotacolReady: true},
+ {cotacolReady: false},
+ }
+
+ sortRideViews(items, RideSort{Column: rideSortCotacol})
+ assert.Equal(t, 1.0, items[0].cotacolScore)
+ assert.Equal(t, 4.0, items[1].cotacolScore)
+ assert.False(t, items[2].cotacolReady)
+
+ sortRideViews(items, RideSort{Column: rideSortCotacol, Descending: true})
+ assert.Equal(t, 4.0, items[0].cotacolScore)
+ assert.Equal(t, 1.0, items[1].cotacolScore)
+ assert.False(t, items[2].cotacolReady)
+}
+
+func TestHandlerSortsRidesByDistance(t *testing.T) {
+ server, db := newWebTestServer(t)
+ require.NoError(t, rides.Save(db, rides.Ride{
+ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000",
+ GPXPath: "near.gpx",
+ Name: "Near Ride",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
+ DistanceM: 20_000,
+ }))
+ require.NoError(t, rides.Save(db, rides.Ride{
+ ExternalID: "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8",
+ GPXPath: "far.gpx",
+ Name: "Far Ride",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC),
+ DistanceM: 40_000,
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "/?sort=distance&dir=asc", nil)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, req)
+
+ body := response.Body.String()
+ assert.Equal(t, http.StatusOK, response.Code)
+ assert.Less(t, strings.Index(body, "Near Ride"), strings.Index(body, "Far Ride"))
+ assert.Contains(t, body, "dir=desc&amp;sort=distance")
+}
diff --git a/web/templates.templ b/web/templates.templ
index d0b1c0c..b061f1e 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -32,6 +32,9 @@ templ Layout(title string, content templ.Component) {
table { width: 100%; border-collapse: collapse; }
th, td { padding: .85rem .5rem; border-bottom: 1px solid #e5e9e5; text-align: left; }
th { color: #68746c; font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }
+ .sort-link { color: inherit; text-decoration: none; }
+ .sort-link:hover, .sort-link:focus-visible, th.active .sort-link { color: #d96932; }
+ .sort-indicator { display: inline-block; margin-left: .2rem; }
.numeric { text-align: right; white-space: nowrap; }
.empty { padding: 2.5rem 1rem; text-align: center; color: #68746c; }
@media (max-width: 700px) { .container { width: min(100% - 1rem, 70rem); padding-top: 1.5rem; } .site-header { padding-inline: 1rem; } th:nth-child(n+4), td:nth-child(n+4) { display: none; } .panel { padding: .8rem; } }
@@ -49,11 +52,11 @@ templ Layout(title string, content templ.Component) {
</html>
}
-templ RidesPage(items []RideView) {
- @Layout("Rides", RidesContent(items))
+templ RidesPage(items []RideView, headers []RideSortHeader) {
+ @Layout("Rides", RidesContent(items, headers))
}
-templ RidesContent(items []RideView) {
+templ RidesContent(items []RideView, headers []RideSortHeader) {
<p class="eyebrow">Ride library</p>
<div class="toolbar">
<div><h1>All rides</h1><p class="lead">Your imported rides, ready for climb analysis.</p></div>
@@ -64,7 +67,11 @@ templ RidesContent(items []RideView) {
<div class="empty">No rides stored yet. <a href="/sync">Import your first Strava rides.</a></div>
} else {
<table>
- <thead><tr><th>Ride</th><th>Started</th><th class="numeric">Distance</th><th class="numeric">Moving time</th><th class="numeric">Elevation</th><th class="numeric">Cotacol</th><th class="numeric">Cotacol / 100 km</th></tr></thead>
+ <thead><tr>
+ for _, header := range headers {
+ <th class={ header.Class } scope="col" aria-sort={ header.AriaSort }><a class="sort-link" href={ templ.URL(header.URL) }>{ header.Label }<span class="sort-indicator" aria-hidden="true">{ header.Indicator }</span></a></th>
+ }
+ </tr></thead>
<tbody>
for _, item := range items {
<tr><td><strong>{ item.Name }</strong><br/><small>{ item.Type }</small></td><td>{ formatRideDate(item.StartDate) }</td><td class="numeric">{ formatDistance(item.DistanceM) }</td><td class="numeric">{ formatDuration(item.MovingTimeS) }</td><td class="numeric">{ formatElevation(item.TotalElevationGainM) }</td><td class="numeric">{ item.Cotacol }</td><td class="numeric">{ item.CotacolPer100Km }</td></tr>
diff --git a/web/templates_templ.go b/web/templates_templ.go
index 8ab1d83..a247fce 100644
--- a/web/templates_templ.go
+++ b/web/templates_templ.go
@@ -39,7 +39,7 @@ func Layout(title string, content templ.Component) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" · biking_home</title><style>\n\t\t\t\t:root { color-scheme: light; font-family: system-ui, sans-serif; background: #f5f3ee; color: #1d2a22; }\n\t\t\t\t* { box-sizing: border-box; }\n\t\t\t\tbody { margin: 0; }\n\t\t\t\ta { color: #18794e; }\n\t\t\t\t.site-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem max(1rem, calc((100vw - 70rem) / 2)); background: #173b2a; color: #fff; }\n\t\t\t\t.site-header a { color: #fff; text-decoration: none; }\n\t\t\t\t.brand { font-weight: 750; letter-spacing: .02em; }\n\t\t\t\t.nav { display: flex; gap: 1rem; font-size: .95rem; }\n\t\t\t\t.container { width: min(70rem, calc(100% - 2rem)); margin: 0 auto; padding: 2.5rem 0 4rem; }\n\t\t\t\t.eyebrow { margin: 0 0 .4rem; color: #18794e; font-size: .78rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }\n\t\t\t\th1 { margin: 0; font-size: clamp(2rem, 4vw, 3.2rem); line-height: 1; }\n\t\t\t\t.lead { max-width: 42rem; color: #5b675f; }\n\t\t\t\t.panel { margin-top: 2rem; padding: 1.25rem; border: 1px solid #d9ded8; border-radius: 1rem; background: #fff; box-shadow: 0 1rem 2.5rem #173b2a0d; }\n\t\t\t\t.toolbar { display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }\n\t\t\t\t.button { display: inline-block; border: 0; border-radius: .65rem; padding: .7rem 1rem; background: #d96932; color: #fff; font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; }\n\t\t\t\t.button.secondary { background: #e8eee9; color: #173b2a; }\n\t\t\t\tlabel { display: grid; gap: .35rem; color: #5b675f; font-size: .85rem; font-weight: 650; }\n\t\t\t\tinput { border: 1px solid #c8d0c9; border-radius: .55rem; padding: .65rem .7rem; font: inherit; color: inherit; background: #fff; }\n\t\t\t\t.form-row { display: flex; align-items: end; gap: .75rem; flex-wrap: wrap; }\n\t\t\t\t.notice { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: #e5f3e9; color: #17633f; }\n\t\t\t\t.error { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: #fbe8df; color: #8c3517; }\n\t\t\t\ttable { width: 100%; border-collapse: collapse; }\n\t\t\t\tth, td { padding: .85rem .5rem; border-bottom: 1px solid #e5e9e5; text-align: left; }\n\t\t\t\tth { color: #68746c; font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }\n\t\t\t\t.numeric { text-align: right; white-space: nowrap; }\n\t\t\t\t.empty { padding: 2.5rem 1rem; text-align: center; color: #68746c; }\n\t\t\t\t@media (max-width: 700px) { .container { width: min(100% - 1rem, 70rem); padding-top: 1.5rem; } .site-header { padding-inline: 1rem; } th:nth-child(n+4), td:nth-child(n+4) { display: none; } .panel { padding: .8rem; } }\n\t\t\t</style></head><body><header class=\"site-header\"><a class=\"brand\" href=\"/\">biking_home</a><nav class=\"nav\"><a href=\"/\">Rides</a><a href=\"/sync\">Sync Strava</a></nav></header><main class=\"container\">")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" · biking_home</title><style>\n\t\t\t\t:root { color-scheme: light; font-family: system-ui, sans-serif; background: #f5f3ee; color: #1d2a22; }\n\t\t\t\t* { box-sizing: border-box; }\n\t\t\t\tbody { margin: 0; }\n\t\t\t\ta { color: #18794e; }\n\t\t\t\t.site-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem max(1rem, calc((100vw - 70rem) / 2)); background: #173b2a; color: #fff; }\n\t\t\t\t.site-header a { color: #fff; text-decoration: none; }\n\t\t\t\t.brand { font-weight: 750; letter-spacing: .02em; }\n\t\t\t\t.nav { display: flex; gap: 1rem; font-size: .95rem; }\n\t\t\t\t.container { width: min(70rem, calc(100% - 2rem)); margin: 0 auto; padding: 2.5rem 0 4rem; }\n\t\t\t\t.eyebrow { margin: 0 0 .4rem; color: #18794e; font-size: .78rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }\n\t\t\t\th1 { margin: 0; font-size: clamp(2rem, 4vw, 3.2rem); line-height: 1; }\n\t\t\t\t.lead { max-width: 42rem; color: #5b675f; }\n\t\t\t\t.panel { margin-top: 2rem; padding: 1.25rem; border: 1px solid #d9ded8; border-radius: 1rem; background: #fff; box-shadow: 0 1rem 2.5rem #173b2a0d; }\n\t\t\t\t.toolbar { display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }\n\t\t\t\t.button { display: inline-block; border: 0; border-radius: .65rem; padding: .7rem 1rem; background: #d96932; color: #fff; font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; }\n\t\t\t\t.button.secondary { background: #e8eee9; color: #173b2a; }\n\t\t\t\tlabel { display: grid; gap: .35rem; color: #5b675f; font-size: .85rem; font-weight: 650; }\n\t\t\t\tinput { border: 1px solid #c8d0c9; border-radius: .55rem; padding: .65rem .7rem; font: inherit; color: inherit; background: #fff; }\n\t\t\t\t.form-row { display: flex; align-items: end; gap: .75rem; flex-wrap: wrap; }\n\t\t\t\t.notice { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: #e5f3e9; color: #17633f; }\n\t\t\t\t.error { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: #fbe8df; color: #8c3517; }\n\t\t\t\ttable { width: 100%; border-collapse: collapse; }\n\t\t\t\tth, td { padding: .85rem .5rem; border-bottom: 1px solid #e5e9e5; text-align: left; }\n\t\t\t\tth { color: #68746c; font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }\n\t\t\t\t.sort-link { color: inherit; text-decoration: none; }\n\t\t\t\t.sort-link:hover, .sort-link:focus-visible, th.active .sort-link { color: #d96932; }\n\t\t\t\t.sort-indicator { display: inline-block; margin-left: .2rem; }\n\t\t\t\t.numeric { text-align: right; white-space: nowrap; }\n\t\t\t\t.empty { padding: 2.5rem 1rem; text-align: center; color: #68746c; }\n\t\t\t\t@media (max-width: 700px) { .container { width: min(100% - 1rem, 70rem); padding-top: 1.5rem; } .site-header { padding-inline: 1rem; } th:nth-child(n+4), td:nth-child(n+4) { display: none; } .panel { padding: .8rem; } }\n\t\t\t</style></head><body><header class=\"site-header\"><a class=\"brand\" href=\"/\">biking_home</a><nav class=\"nav\"><a href=\"/\">Rides</a><a href=\"/sync\">Sync Strava</a></nav></header><main class=\"container\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -55,7 +55,7 @@ func Layout(title string, content templ.Component) templ.Component {
})
}
-func RidesPage(items []RideView) templ.Component {
+func RidesPage(items []RideView, headers []RideSortHeader) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
@@ -73,7 +73,7 @@ func RidesPage(items []RideView) templ.Component {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = Layout("Rides", RidesContent(items)).Render(ctx, templ_7745c5c3_Buffer)
+ templ_7745c5c3_Err = Layout("Rides", RidesContent(items, headers)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -81,7 +81,7 @@ func RidesPage(items []RideView) templ.Component {
})
}
-func RidesContent(items []RideView) templ.Component {
+func RidesContent(items []RideView, headers []RideSortHeader) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
@@ -109,7 +109,83 @@ func RidesContent(items []RideView) templ.Component {
return templ_7745c5c3_Err
}
} else {
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<table><thead><tr><th>Ride</th><th>Started</th><th class=\"numeric\">Distance</th><th class=\"numeric\">Moving time</th><th class=\"numeric\">Elevation</th><th class=\"numeric\">Cotacol</th><th class=\"numeric\">Cotacol / 100 km</th></tr></thead> <tbody>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<table><thead><tr>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, header := range headers {
+ var templ_7745c5c3_Var5 = []any{header.Class}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<th class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" scope=\"col\" aria-sort=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(header.AriaSort)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 72, Col: 72}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><a class=\"sort-link\" href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 templ.SafeURL = templ.URL(header.URL)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var8)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(header.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 72, Col: 141}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<span class=\"sort-indicator\" aria-hidden=\"true\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(header.Indicator)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 72, Col: 209}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span></a></th>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -118,12 +194,12 @@ func RidesContent(items []RideView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var5 string
- templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 33}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 33}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -131,12 +207,12 @@ func RidesContent(items []RideView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var6 string
- templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Type)
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.Type)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 67}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 67}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -144,12 +220,12 @@ func RidesContent(items []RideView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var7 string
- templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(formatRideDate(item.StartDate))
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(formatRideDate(item.StartDate))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 118}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 118}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -157,12 +233,12 @@ func RidesContent(items []RideView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var8 string
- templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(formatDistance(item.DistanceM))
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(formatDistance(item.DistanceM))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 177}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 177}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -170,12 +246,12 @@ func RidesContent(items []RideView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var9 string
- templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatDuration(item.MovingTimeS))
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(formatDuration(item.MovingTimeS))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 238}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 238}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -183,12 +259,12 @@ func RidesContent(items []RideView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var10 string
- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(formatElevation(item.TotalElevationGainM))
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(formatElevation(item.TotalElevationGainM))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 308}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 308}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -196,12 +272,12 @@ func RidesContent(items []RideView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var11 string
- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Cotacol)
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Cotacol)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 349}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 349}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -209,12 +285,12 @@ func RidesContent(items []RideView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var12 string
- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.CotacolPer100Km)
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.CotacolPer100Km)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 398}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 398}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -249,9 +325,9 @@ func SyncPage(data SyncPageData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var13 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var13 == nil {
- templ_7745c5c3_Var13 = templ.NopComponent
+ templ_7745c5c3_Var19 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var19 == nil {
+ templ_7745c5c3_Var19 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout("Sync Strava", SyncContent(data)).Render(ctx, templ_7745c5c3_Buffer)
@@ -275,9 +351,9 @@ func SyncContent(data SyncPageData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var14 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var14 == nil {
- templ_7745c5c3_Var14 = templ.NopComponent
+ templ_7745c5c3_Var20 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var20 == nil {
+ templ_7745c5c3_Var20 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"eyebrow\">Data import</p><h1>Sync Strava rides</h1><p class=\"lead\">Choose a date range. New rides are downloaded as GPX files and recorded in the local library.</p>")
@@ -289,12 +365,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var15 string
- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice)
+ var templ_7745c5c3_Var21 string
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 35}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 94, Col: 35}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -308,12 +384,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var16 string
- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error)
+ var templ_7745c5c3_Var22 string
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 90, Col: 33}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 97, Col: 33}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -331,8 +407,8 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var17 templ.SafeURL = templ.URL("/strava/login?return_to=/sync")
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var17)))
+ var templ_7745c5c3_Var23 templ.SafeURL = templ.URL("/strava/login?return_to=/sync")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var23)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -345,12 +421,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var18 string
- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(data.From)
+ var templ_7745c5c3_Var24 string
+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(data.From)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 99, Col: 64}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 106, Col: 64}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -358,12 +434,12 @@ func SyncContent(data SyncPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var19 string
- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(data.To)
+ var templ_7745c5c3_Var25 string
+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(data.To)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 100, Col: 58}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 107, Col: 58}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/web/views.go b/web/views.go
index 29f3c4f..a19ced9 100644
--- a/web/views.go
+++ b/web/views.go
@@ -2,6 +2,7 @@ package web
import (
"fmt"
+ "net/url"
"time"
"github.com/martinlehoux/biking_home/rides"
@@ -11,6 +12,115 @@ type RideView struct {
rides.Ride
Cotacol string
CotacolPer100Km string
+ cotacolScore float64
+ cotacolPer100Km float64
+ cotacolReady bool
+}
+
+type RideSort struct {
+ Column string
+ Descending bool
+}
+
+type RideSortHeader struct {
+ Label string
+ Class string
+ URL string
+ AriaSort string
+ Indicator string
+}
+
+const (
+ rideSortName = "name"
+ rideSortStarted = "started"
+ rideSortDistance = "distance"
+ rideSortMovingTime = "moving_time"
+ rideSortElevation = "elevation"
+ rideSortCotacol = "cotacol"
+ rideSortCotacolKm = "cotacol_100km"
+)
+
+var rideSortColumns = map[string]bool{
+ rideSortName: true,
+ rideSortStarted: true,
+ rideSortDistance: true,
+ rideSortMovingTime: true,
+ rideSortElevation: true,
+ rideSortCotacol: true,
+ rideSortCotacolKm: true,
+}
+
+func parseRideSort(query url.Values) RideSort {
+ column := query.Get("sort")
+ if !rideSortColumns[column] {
+ column = rideSortStarted
+ }
+ direction := query.Get("dir")
+ return RideSort{Column: column, Descending: direction != "asc"}
+}
+
+func (s RideSort) databaseColumn() (rides.SortColumn, bool) {
+ columns := map[string]rides.SortColumn{
+ rideSortName: rides.SortName,
+ rideSortStarted: rides.SortStartDate,
+ rideSortDistance: rides.SortDistance,
+ rideSortMovingTime: rides.SortMovingTime,
+ rideSortElevation: rides.SortElevation,
+ }
+ column, found := columns[s.Column]
+ return column, found
+}
+
+func rideSortHeaders(current RideSort) []RideSortHeader {
+ columns := []struct {
+ key string
+ label string
+ class string
+ }{
+ {key: rideSortName, label: "Ride"},
+ {key: rideSortStarted, label: "Started"},
+ {key: rideSortDistance, label: "Distance", class: "numeric"},
+ {key: rideSortMovingTime, label: "Moving time", class: "numeric"},
+ {key: rideSortElevation, label: "Elevation", class: "numeric"},
+ {key: rideSortCotacol, label: "Cotacol", class: "numeric"},
+ {key: rideSortCotacolKm, label: "Cotacol / 100 km", class: "numeric"},
+ }
+ headers := make([]RideSortHeader, 0, len(columns))
+ for _, column := range columns {
+ descending := false
+ active := current.Column == column.key
+ if active {
+ descending = !current.Descending
+ }
+ query := url.Values{}
+ query.Set("sort", column.key)
+ if descending {
+ query.Set("dir", "desc")
+ } else {
+ query.Set("dir", "asc")
+ }
+ headerClass := column.class
+ ariaSort := "none"
+ indicator := ""
+ if active {
+ headerClass += " active"
+ if current.Descending {
+ ariaSort = "descending"
+ indicator = "↓"
+ } else {
+ ariaSort = "ascending"
+ indicator = "↑"
+ }
+ }
+ headers = append(headers, RideSortHeader{
+ Label: column.label,
+ Class: headerClass,
+ URL: "/?" + query.Encode(),
+ AriaSort: ariaSort,
+ Indicator: indicator,
+ })
+ }
+ return headers
}
type SyncPageData struct {