diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-07 19:10:30 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-07 19:10:30 +0200 |
| commit | 7aa64b0917130fcc1dc9f3da30d8f825136fe541 (patch) | |
| tree | 361fb90e0b68a6356c7c7b9667b3f09400680287 /web | |
| parent | 5b74dc4321e63f251190a975b151875b64656c56 (diff) | |
feat: Add ride table sorting
Diffstat (limited to 'web')
| -rw-r--r-- | web/server.go | 46 | ||||
| -rw-r--r-- | web/server_test.go | 82 | ||||
| -rw-r--r-- | web/templates.templ | 15 | ||||
| -rw-r--r-- | web/templates_templ.go | 198 | ||||
| -rw-r--r-- | web/views.go | 110 |
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&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 { |