From 1da94931188c35c06dd032aadce2799fc13db1dd Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 11 Aug 2026 13:59:08 +0200 Subject: feat: Add Strava import progress --- README.md | 4 +- web/server.go | 108 ++++++++++++++++++++++++++++++++++++++-------- web/server_test.go | 79 +++++++++++++++++++++++++++++++++ web/templates.templ | 115 +++++++++++++++++++++++++++++++++++++++++++++++-- web/templates_templ.go | 66 ++++++++++++++++------------ web/views.go | 7 +++ 6 files changed, 327 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 9ce83ba..6b7de0a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c - **Strava stream metrics** — preserves heart rate, cadence, and power in Garmin-compatible GPX files and in-memory ride columns - **Ride table sorting** — sorts every ride-library column through clickable server-side headers and query parameters - **Ride detail maps** — opens a stored ride with its recorded route on an interactive OpenStreetMap map +- **Strava import progress** — streams live import progress with an aggregate progress bar and prevents overlapping syncs ## Getting started @@ -142,8 +143,7 @@ mise run build - Cotacol with a different step size - Cotacol with a variable step size (constant slope is the best?) - Road quality (Arbois = 2/5, Roquefavour = 4/5) -- Display rides + passes on map -- Strava import progress +- Display passes on map Workflow: - Detect climbs in ride (already exists) diff --git a/web/server.go b/web/server.go index cc3ad2e..53374ce 100644 --- a/web/server.go +++ b/web/server.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "encoding/json" "errors" "fmt" "log/slog" @@ -32,6 +33,8 @@ type Server struct { oauthMu sync.Mutex oauthState string returnToURL string + syncMu sync.Mutex + syncActive bool } func NewServer(db *sql.DB, configPath string) *Server { @@ -139,7 +142,7 @@ func (s *Server) handleSyncForm(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { + if err := r.ParseMultipartForm(10 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { http.Error(w, "invalid form", http.StatusBadRequest) return } @@ -164,6 +167,11 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/strava/login?"+query.Encode(), http.StatusFound) return } + if !s.beginSync() { + http.Error(w, "a Strava sync is already in progress", http.StatusConflict) + return + } + defer s.endSync() if refreshed, err := client.RefreshIfNeeded(); err != nil { s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) @@ -174,17 +182,22 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { return } } - imported, skipped, err := s.syncRides(client, appConfig.Storage.GPXDir, from, to) + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming responses are not supported", http.StatusInternalServerError) + return + } + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Content-Type", "text/event-stream") + progress, err := s.syncRides(client, appConfig.Storage.GPXDir, from, to, func(progress SyncProgress) error { + return writeSyncEvent(w, flusher, "progress", progress) + }) if err != nil { - s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) + _ = writeSyncEvent(w, flusher, "error", syncErrorEvent{Message: err.Error(), Progress: progress}) return } - query := url.Values{} - query.Set("from", r.FormValue("from")) - query.Set("to", r.FormValue("to")) - query.Set("imported", strconv.Itoa(imported)) - query.Set("skipped", strconv.Itoa(skipped)) - http.Redirect(w, r, "/sync?"+query.Encode(), http.StatusSeeOther) + slog.Info("Completed Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total, "imported", progress.Imported, "skipped", progress.Skipped) + _ = writeSyncEvent(w, flusher, "complete", progress) } func (s *Server) handleStravaLogin(w http.ResponseWriter, r *http.Request) { @@ -250,31 +263,40 @@ func (s *Server) handleStravaCallback(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, returnTo, http.StatusSeeOther) } -func (s *Server) syncRides(client *strava.Client, gpxDir string, from, to time.Time) (imported, skipped int, err error) { +func (s *Server) syncRides(client *strava.Client, gpxDir string, from, to time.Time, report func(SyncProgress) error) (SyncProgress, error) { activities, err := client.List(from, to) if err != nil { - return 0, 0, err + return SyncProgress{}, err + } + progress := SyncProgress{Total: len(activities)} + if err := reportSyncProgress(report, progress); err != nil { + return progress, err } + slog.Info("Started Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total) if err := os.MkdirAll(gpxDir, 0o755); err != nil { - return 0, 0, fmt.Errorf("create GPX directory: %w", err) + return progress, fmt.Errorf("create GPX directory: %w", err) } for _, summary := range activities { externalID := fmt.Sprintf("strava:%d", summary.ID) _, exists, err := rides.GetByExternalID(s.db, externalID) if err != nil { - return imported, skipped, err + return progress, err } if exists { - skipped++ + progress.Skipped++ + progress.Completed++ + if err := reportSyncProgress(report, progress); err != nil { + return progress, err + } continue } activity, gpxData, err := client.Get(summary.ID) if err != nil { - return imported, skipped, err + return progress, err } gpxPath := filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", activity.ID)) if err := os.WriteFile(gpxPath, gpxData, 0o644); err != nil { - return imported, skipped, fmt.Errorf("write GPX for activity %d: %w", activity.ID, err) + return progress, fmt.Errorf("write GPX for activity %d: %w", activity.ID, err) } activityType := activity.SportType if activityType == "" { @@ -292,12 +314,56 @@ func (s *Server) syncRides(client *strava.Client, gpxDir string, from, to time.T TotalElevationGainM: activity.TotalElevationGainM, AverageSpeedMps: activity.AverageSpeedMps, }); err != nil { - return imported, skipped, fmt.Errorf("save activity %d: %w", activity.ID, err) + return progress, fmt.Errorf("save activity %d: %w", activity.ID, err) + } + progress.Imported++ + progress.Completed++ + if err := reportSyncProgress(report, progress); err != nil { + return progress, err } - imported++ slog.Info("Imported Strava ride", "activity", activity.ID, "name", activity.Name) } - return imported, skipped, nil + return progress, nil +} + +func reportSyncProgress(report func(SyncProgress) error, progress SyncProgress) error { + if report == nil { + return nil + } + return report(progress) +} + +func (s *Server) beginSync() bool { + s.syncMu.Lock() + defer s.syncMu.Unlock() + if s.syncActive { + return false + } + s.syncActive = true + return true +} + +func (s *Server) endSync() { + s.syncMu.Lock() + s.syncActive = false + s.syncMu.Unlock() +} + +type syncErrorEvent struct { + Message string `json:"message"` + Progress SyncProgress `json:"progress"` +} + +func writeSyncEvent(w http.ResponseWriter, flusher http.Flusher, event string, data any) error { + payload, err := json.Marshal(data) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, payload); err != nil { + return err + } + flusher.Flush() + return nil } func (s *Server) stravaClient() (*strava.Client, bool, error) { @@ -360,6 +426,10 @@ func (s *Server) consumeOAuthState(state string) (string, bool) { } func (s *Server) renderSyncError(w http.ResponseWriter, r *http.Request, from, to string, err error) { + if strings.Contains(r.Header.Get("Accept"), "text/event-stream") { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } w.WriteHeader(http.StatusBadRequest) kcore.RenderPage(r.Context(), SyncPage(SyncPageData{From: from, To: to, Error: err.Error(), HasAuth: s.hasStravaToken()}), w) } diff --git a/web/server_test.go b/web/server_test.go index cf8460c..32ab448 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -1,8 +1,10 @@ package web import ( + "bytes" "database/sql" "fmt" + "mime/multipart" "net/http" "net/http/httptest" "net/url" @@ -164,6 +166,28 @@ func TestSyncPageRequestsAuthorization(t *testing.T) { assert.Contains(t, response.Body.String(), "Authorize Strava") } +func TestSyncPageIncludesProgressUIWhenAuthorized(t *testing.T) { + server, _ := newWebTestServer(t) + appConfig, err := config.Load(server.configPath) + require.NoError(t, err) + appConfig.Strava.AccessToken = "access" + appConfig.Strava.RefreshToken = "refresh" + appConfig.Strava.ExpiresAt = time.Now().Add(time.Hour).Unix() + require.NoError(t, config.Save(server.configPath, appConfig)) + + req := httptest.NewRequest(http.MethodGet, "/sync", nil) + response := httptest.NewRecorder() + + server.Handler().ServeHTTP(response, req) + + body := response.Body.String() + assert.Equal(t, http.StatusOK, response.Code) + assert.Contains(t, body, `id="sync-form"`) + assert.Contains(t, body, `id="sync-progress"`) + assert.Contains(t, body, `") +} + +func TestSyncRejectsConcurrentImport(t *testing.T) { + server, _ := newWebTestServer(t) + appConfig, err := config.Load(server.configPath) + require.NoError(t, err) + appConfig.Strava.AccessToken = "access" + appConfig.Strava.RefreshToken = "refresh" + appConfig.Strava.ExpiresAt = time.Now().Add(time.Hour).Unix() + require.NoError(t, config.Save(server.configPath, appConfig)) + require.True(t, server.beginSync()) + defer server.endSync() + + form := url.Values{"from": {"2026-08-01"}, "to": {"2026-08-04"}} + req := httptest.NewRequest(http.MethodPost, "/sync", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + + server.Handler().ServeHTTP(response, req) + + assert.Equal(t, http.StatusConflict, response.Code) + assert.Contains(t, response.Body.String(), "already in progress") +} + +func TestWriteSyncEvent(t *testing.T) { + response := httptest.NewRecorder() + progress := SyncProgress{Total: 2, Completed: 1, Imported: 1} + + require.NoError(t, writeSyncEvent(response, response, "progress", progress)) + require.NoError(t, writeSyncEvent(response, response, "complete", progress)) + + body := response.Body.String() + assert.Contains(t, body, "event: progress\ndata: {\"total\":2,\"completed\":1,\"imported\":1,\"skipped\":0}\n\n") + assert.Contains(t, body, "event: complete\ndata: {\"total\":2,\"completed\":1,\"imported\":1,\"skipped\":0}\n\n") + assert.Less(t, strings.Index(body, "event: progress"), strings.Index(body, "event: complete")) +} + func TestStravaLoginRedirectsToAuthorize(t *testing.T) { server, _ := newWebTestServer(t) req := httptest.NewRequest(http.MethodGet, "/strava/login?return_to=/sync", nil) diff --git a/web/templates.templ b/web/templates.templ index 85ee03d..74b0365 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -28,6 +28,9 @@ templ Layout(title string, content templ.Component) { label { display: grid; gap: .35rem; color: #5b675f; font-size: .85rem; font-weight: 650; } input { border: 1px solid #c8d0c9; border-radius: .55rem; padding: .65rem .7rem; font: inherit; color: inherit; background: #fff; } .form-row { display: flex; align-items: end; gap: .75rem; flex-wrap: wrap; } + .sync-progress { display: grid; gap: .7rem; margin-top: 1.25rem; } + .progress-heading { display: flex; justify-content: space-between; gap: 1rem; } + progress { width: 100%; height: .8rem; accent-color: #d96932; } .notice { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: #e5f3e9; color: #17633f; } .error { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: #fbe8df; color: #8c3517; } table { width: 100%; border-collapse: collapse; } @@ -147,23 +150,129 @@ templ SyncContent(data SyncPageData) {

Sync Strava rides

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

if data.Notice != "" { -
{ data.Notice }
+
{ data.Notice }
+ } else { + } if data.Error != "" { -
{ data.Error }
+
{ data.Error }
+ } else { + }
if !data.HasAuth {

Strava authorization is required before the first sync.

Authorize Strava } else { -
+
+ }
+ } diff --git a/web/templates_templ.go b/web/templates_templ.go index c79f07e..ba6910b 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
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: 79, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 82, 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: 79, Col: 141} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 82, 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: 79, Col: 209} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 82, Col: 209} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -206,7 +206,7 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { var templ_7745c5c3_Var12 string 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: 84, Col: 79} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 79} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -219,7 +219,7 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { var templ_7745c5c3_Var13 string 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: 84, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 117} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -232,7 +232,7 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { var templ_7745c5c3_Var14 string 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: 84, Col: 168} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 168} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -245,7 +245,7 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { var templ_7745c5c3_Var15 string 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: 84, Col: 227} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 227} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -258,7 +258,7 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { var templ_7745c5c3_Var16 string 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: 84, Col: 288} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 288} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -271,7 +271,7 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { var templ_7745c5c3_Var17 string 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: 84, Col: 358} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 358} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -284,7 +284,7 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { var templ_7745c5c3_Var18 string 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: 84, Col: 399} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 399} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -297,7 +297,7 @@ func RidesContent(items []RideView, headers []RideSortHeader) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 448} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -372,7 +372,7 @@ func RideDetailContent(data RideDetailView) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 102, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { @@ -385,7 +385,7 @@ func RideDetailContent(data RideDetailView) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 102, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { @@ -398,7 +398,7 @@ func RideDetailContent(data RideDetailView) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 102, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { @@ -411,7 +411,7 @@ func RideDetailContent(data RideDetailView) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 107, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { @@ -424,7 +424,7 @@ func RideDetailContent(data RideDetailView) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 108, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { @@ -437,7 +437,7 @@ func RideDetailContent(data RideDetailView) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 109, Col: 78} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { @@ -455,7 +455,7 @@ func RideDetailContent(data RideDetailView) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 113, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { @@ -533,14 +533,14 @@ func SyncContent(data SyncPageData) templ.Component { return templ_7745c5c3_Err } if data.Notice != "" { - _, 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_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: 150, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 153, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { @@ -550,16 +550,21 @@ func SyncContent(data SyncPageData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } if data.Error != "" { - _, 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_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: 153, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 158, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { @@ -569,6 +574,11 @@ func SyncContent(data SyncPageData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { @@ -589,14 +599,14 @@ func SyncContent(data SyncPageData) templ.Component { return templ_7745c5c3_Err } } else { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") 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 } diff --git a/web/views.go b/web/views.go index f6e84d5..a644d1e 100644 --- a/web/views.go +++ b/web/views.go @@ -180,6 +180,13 @@ type SyncPageData struct { HasAuth bool } +type SyncProgress struct { + Total int `json:"total"` + Completed int `json:"completed"` + Imported int `json:"imported"` + Skipped int `json:"skipped"` +} + func formatRideDate(value time.Time) string { return value.Local().Format("02 Jan 2006, 15:04") } -- cgit v1.2.3