From d4e4616ba49f3a18a52e3197e1c77639a93b0330 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 11 Aug 2026 12:38:45 +0200 Subject: feat: Add ride detail map --- web/server.go | 48 +++++++-- web/server_test.go | 64 ++++++++++++ web/templates.templ | 66 +++++++++++- web/templates_templ.go | 278 +++++++++++++++++++++++++++++++++++++++---------- web/views.go | 50 +++++++++ 5 files changed, 441 insertions(+), 65 deletions(-) (limited to 'web') diff --git a/web/server.go b/web/server.go index 845c31c..cc3ad2e 100644 --- a/web/server.go +++ b/web/server.go @@ -41,6 +41,7 @@ func NewServer(db *sql.DB, configPath string) *Server { func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /", s.handleRides) + mux.HandleFunc("GET /rides/{id}", s.handleRide) mux.HandleFunc("GET /sync", s.handleSyncForm) mux.HandleFunc("POST /sync", s.handleSync) mux.HandleFunc("GET /strava/login", s.handleStravaLogin) @@ -64,6 +65,35 @@ func (s *Server) handleRides(w http.ResponseWriter, r *http.Request) { kcore.RenderPage(r.Context(), RidesPage(views, rideSortHeaders(rideSort)), w) } +func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + http.NotFound(w, r) + return + } + item, found, err := rides.GetByID(s.db, id) + if err != nil { + slog.Error("Failed to load ride", "ride_id", id, "error", err) + http.Error(w, "failed to load ride", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + parsed, err := ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) + if err != nil { + slog.Error("Failed to load ride route", "ride_id", id, "file", item.GPXPath, "error", err) + kcore.RenderPage(r.Context(), RideDetailPage(RideDetailView{ + RideView: buildRideView(item), + RouteError: "The recorded route is unavailable.", + }), w) + return + } + slog.Info("Loaded ride detail", "ride_id", id) + kcore.RenderPage(r.Context(), RideDetailPage(buildRideDetailView(item, parsed)), w) +} + func (s *Server) listRides(rideSort RideSort) ([]rides.Ride, error) { column, found := rideSort.databaseColumn() if !found { @@ -80,17 +110,21 @@ func buildRideViews(items []rides.Ride) []RideView { if item.DistanceM < minimumDisplayedDistanceM { continue } - view := RideView{Ride: item, Cotacol: "-", CotacolPer100Km: "-"} - score, ready := item.CotacolScore() - if ready && item.CotacolAlgorithmVersion() == ride.CotacolAlgorithmVersion && item.DistanceM > 0 { - view.Cotacol = formatCotacol(score) - view.CotacolPer100Km = formatCotacolPer100Km(score, item.DistanceM) - } - views = append(views, view) + views = append(views, buildRideView(item)) } return views } +func buildRideView(item rides.Ride) RideView { + view := RideView{Ride: item, Cotacol: "-", CotacolPer100Km: "-"} + score, ready := item.CotacolScore() + if ready && item.CotacolAlgorithmVersion() == ride.CotacolAlgorithmVersion && item.DistanceM > 0 { + view.Cotacol = formatCotacol(score) + view.CotacolPer100Km = formatCotacolPer100Km(score, item.DistanceM) + } + return view +} + 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 d2e14f0..cf8460c 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -2,6 +2,7 @@ package web import ( "database/sql" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -87,6 +88,69 @@ func TestHandlerRendersRidesPage(t *testing.T) { assert.NotContains(t, response.Body.String(), "Short Ride") assert.Contains(t, response.Body.String(), "Cotacol") assert.Contains(t, response.Body.String(), "Cotacol / 100 km") + assert.Contains(t, response.Body.String(), `href="/rides/1"`) +} + +func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { + server, db := newWebTestServer(t) + require.NoError(t, rides.Save(db, rides.Ride{ + ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", + GPXPath: testGPXPath(t, "detail.gpx"), + Name: "Detailed Ride", + Type: "Ride", + StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + DistanceM: 20_000, + })) + item, found, err := rides.GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000") + require.NoError(t, err) + require.True(t, found) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d", item.ID), nil) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, req) + + body := response.Body.String() + assert.Equal(t, http.StatusOK, response.Code) + assert.Contains(t, body, "Detailed Ride") + assert.Contains(t, body, `id="ride-route"`) + assert.Contains(t, body, `id="ride-map"`) + assert.Contains(t, body, "leaflet@1.9.4/dist/leaflet.js") + assert.Contains(t, body, "tile.openstreetmap.org/{z}/{x}/{y}.png") + assert.Contains(t, body, `"type":"FeatureCollection"`) + assert.Contains(t, body, `"type":"LineString"`) + assert.Contains(t, body, `"coordinates":[[5,43]`) +} + +func TestHandlerReturnsNotFoundForUnknownRide(t *testing.T) { + server, _ := newWebTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/rides/999999", nil) + response := httptest.NewRecorder() + + server.Handler().ServeHTTP(response, req) + + assert.Equal(t, http.StatusNotFound, response.Code) +} + +func TestHandlerShowsErrorForUnavailableRideRoute(t *testing.T) { + server, db := newWebTestServer(t) + _, err := db.Exec(` + INSERT INTO rides (external_id, gpx_path, name, type, start_date, distance_m, moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", "missing.gpx", "Unavailable Route", "Ride", "2026-08-01T07:00:00Z", 20_000, 0, 0, 0, 0) + require.NoError(t, err) + item, found, err := rides.GetByExternalID(db, "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8") + require.NoError(t, err) + require.True(t, found) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d", item.ID), nil) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, req) + + body := response.Body.String() + assert.Equal(t, http.StatusOK, response.Code) + assert.Contains(t, body, "Unavailable Route") + assert.Contains(t, body, "The recorded route is unavailable.") + assert.NotContains(t, body, `id="ride-route"`) } func TestSyncPageRequestsAuthorization(t *testing.T) { diff --git a/web/templates.templ b/web/templates.templ index b061f1e..85ee03d 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -4,10 +4,11 @@ templ Layout(title string, content templ.Component) { - - - { title } · biking_home - @@ -74,7 +81,7 @@ templ RidesContent(items []RideView, headers []RideSortHeader) { for _, item := range items { - { item.Name }
{ item.Type }{ formatRideDate(item.StartDate) }{ formatDistance(item.DistanceM) }{ formatDuration(item.MovingTimeS) }{ formatElevation(item.TotalElevationGainM) }{ item.Cotacol }{ item.CotacolPer100Km } + { item.Name }
{ item.Type }{ formatRideDate(item.StartDate) }{ formatDistance(item.DistanceM) }{ formatDuration(item.MovingTimeS) }{ formatElevation(item.TotalElevationGainM) }{ item.Cotacol }{ item.CotacolPer100Km } } @@ -82,6 +89,55 @@ templ RidesContent(items []RideView, headers []RideSortHeader) { } +templ RideDetailPage(data RideDetailView) { + @Layout("Ride detail", RideDetailContent(data)) +} + +templ RideDetailContent(data RideDetailView) { +

Ride detail

+
+

{ data.Ride.Name }

{ data.Ride.Type } · { formatRideDate(data.Ride.StartDate) }

+ Back to rides +
+
+
+
Distance
{ formatDistance(data.Ride.DistanceM) }
+
Moving time
{ formatDuration(data.Ride.MovingTimeS) }
+
Elevation
{ formatElevation(data.Ride.TotalElevationGainM) }
+
+
+ if data.RouteError != "" { +
{ data.RouteError }
+ } + if data.HasRoute { +
+ +
+ @templ.JSONScript("ride-route", data.Route) + + + } +} + templ SyncPage(data SyncPageData) { @Layout("Sync Strava", SyncContent(data)) } diff --git a/web/templates_templ.go b/web/templates_templ.go index a247fce..c79f07e 100644 --- a/web/templates_templ.go +++ b/web/templates_templ.go @@ -33,13 +33,13 @@ func Layout(title string, content templ.Component) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 9, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 9, Col: 16} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" · biking_home
biking_home
") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" · biking_home
biking_home
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -139,7 +139,7 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 79, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -161,7 +161,7 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 79, Col: 141} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -174,7 +174,7 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 79, Col: 209} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -190,53 +190,49 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { return templ_7745c5c3_Err } for _, item := range items { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.Type) + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 84, Col: 79} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(formatRideDate(item.StartDate)) + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.Type) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 118} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 84, Col: 117} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(formatDistance(item.DistanceM)) + templ_7745c5c3_Var14, 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: 77, Col: 177} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 84, Col: 168} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -247,9 +243,9 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(formatDuration(item.MovingTimeS)) + templ_7745c5c3_Var15, 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: 77, Col: 238} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 84, Col: 227} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -260,9 +256,9 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(formatElevation(item.TotalElevationGainM)) + templ_7745c5c3_Var16, 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: 77, Col: 308} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 84, Col: 288} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -273,9 +269,9 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Cotacol) + templ_7745c5c3_Var17, 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: 77, Col: 349} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 84, Col: 358} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -286,14 +282,27 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.CotacolPer100Km) + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.Cotacol) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 77, Col: 398} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 84, Col: 399} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) 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_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.CotacolPer100Km) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 84, Col: 448} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + 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 @@ -312,6 +321,169 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { }) } +func RideDetailPage(data RideDetailView) 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) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var20 := templ.GetChildren(ctx) + if templ_7745c5c3_Var20 == nil { + templ_7745c5c3_Var20 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = Layout("Ride detail", RideDetailContent(data)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func RideDetailContent(data RideDetailView) 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) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var21 := templ.GetChildren(ctx) + if templ_7745c5c3_Var21 == nil { + templ_7745c5c3_Var21 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Ride detail

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.Ride.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 99, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + 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_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(data.Ride.Type) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 99, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + 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_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(formatRideDate(data.Ride.StartDate)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 99, Col: 109} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Back to rides
Distance
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(formatDistance(data.Ride.DistanceM)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 104, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Moving time
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(formatDuration(data.Ride.MovingTimeS)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 105, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Elevation
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(formatElevation(data.Ride.TotalElevationGainM)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 106, Col: 78} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + 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 + } + if data.RouteError != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(data.RouteError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 110, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + 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 + } + } + if data.HasRoute { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.JSONScript("ride-route", data.Route).Render(ctx, templ_7745c5c3_Buffer) + 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 + } + } + return templ_7745c5c3_Err + }) +} + func SyncPage(data SyncPageData) 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 @@ -325,9 +497,9 @@ func SyncPage(data SyncPageData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var19 := templ.GetChildren(ctx) - if templ_7745c5c3_Var19 == nil { - templ_7745c5c3_Var19 = templ.NopComponent + templ_7745c5c3_Var29 := templ.GetChildren(ctx) + if templ_7745c5c3_Var29 == nil { + templ_7745c5c3_Var29 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = Layout("Sync Strava", SyncContent(data)).Render(ctx, templ_7745c5c3_Buffer) @@ -351,9 +523,9 @@ func SyncContent(data SyncPageData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var20 := templ.GetChildren(ctx) - if templ_7745c5c3_Var20 == nil { - templ_7745c5c3_Var20 = templ.NopComponent + templ_7745c5c3_Var30 := templ.GetChildren(ctx) + if templ_7745c5c3_Var30 == nil { + templ_7745c5c3_Var30 = templ.NopComponent } ctx = templ.ClearChildren(ctx) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Data import

Sync Strava rides

Choose a date range. New rides are downloaded as GPX files and recorded in the local library.

") @@ -365,12 +537,12 @@ func SyncContent(data SyncPageData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice) + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 94, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 150, Col: 35} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -384,12 +556,12 @@ func SyncContent(data SyncPageData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) + var templ_7745c5c3_Var32 string + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 97, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 153, Col: 33} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -407,8 +579,8 @@ func SyncContent(data SyncPageData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - 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))) + var templ_7745c5c3_Var33 templ.SafeURL = templ.URL("/strava/login?return_to=/sync") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var33))) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -421,12 +593,12 @@ func SyncContent(data SyncPageData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(data.From) + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(data.From) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 106, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 162, Col: 64} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -434,12 +606,12 @@ func SyncContent(data SyncPageData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var25 string - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(data.To) + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(data.To) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 107, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 163, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/views.go b/web/views.go index a9ffffa..f6e84d5 100644 --- a/web/views.go +++ b/web/views.go @@ -5,6 +5,7 @@ import ( "net/url" "time" + "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/biking_home/rides" ) @@ -14,6 +15,29 @@ type RideView struct { CotacolPer100Km string } +type RideDetailView struct { + RideView + Route GeoJSONFeatureCollection + HasRoute bool + RouteError string +} + +type GeoJSONFeatureCollection struct { + Type string `json:"type"` + Features []GeoJSONFeature `json:"features"` +} + +type GeoJSONFeature struct { + Type string `json:"type"` + Geometry GeoJSONGeometry `json:"geometry"` + Properties map[string]any `json:"properties,omitempty"` +} + +type GeoJSONGeometry struct { + Type string `json:"type"` + Coordinates [][]float64 `json:"coordinates"` +} + type RideSort struct { Column string Descending bool @@ -122,6 +146,32 @@ func rideSortHeaders(current RideSort) []RideSortHeader { return headers } +func rideDetailURL(id int64) string { + return fmt.Sprintf("/rides/%d", id) +} + +func buildRideDetailView(item rides.Ride, parsed ride.Ride) RideDetailView { + coordinates := make([][]float64, parsed.Len()) + for i := 0; i < parsed.Len(); i++ { + coordinate := parsed.Coord(i) + coordinates[i] = []float64{coordinate.Lon, coordinate.Lat} + } + return RideDetailView{ + RideView: buildRideView(item), + Route: GeoJSONFeatureCollection{ + Type: "FeatureCollection", + Features: []GeoJSONFeature{{ + Type: "Feature", + Geometry: GeoJSONGeometry{ + Type: "LineString", + Coordinates: coordinates, + }, + }}, + }, + HasRoute: true, + } +} + type SyncPageData struct { From string To string -- cgit v1.2.3