diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-11 13:59:08 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-11 13:59:08 +0200 |
| commit | 1da94931188c35c06dd032aadce2799fc13db1dd (patch) | |
| tree | 1298220b6ff67cb30178630e13c7f11a64265569 | |
| parent | d4e4616ba49f3a18a52e3197e1c77639a93b0330 (diff) | |
feat: Add Strava import progress
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | web/server.go | 108 | ||||
| -rw-r--r-- | web/server_test.go | 79 | ||||
| -rw-r--r-- | web/templates.templ | 115 | ||||
| -rw-r--r-- | web/templates_templ.go | 66 | ||||
| -rw-r--r-- | web/views.go | 7 |
6 files changed, 327 insertions, 52 deletions
@@ -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, `<progress id="sync-progress-bar"`) + assert.Contains(t, body, `text/event-stream`) +} + func TestSyncRedirectsToOAuthWhenUnauthenticated(t *testing.T) { server, _ := newWebTestServer(t) form := url.Values{"from": {"2026-08-01"}, "to": {"2026-08-04"}} @@ -180,6 +204,61 @@ func TestSyncRedirectsToOAuthWhenUnauthenticated(t *testing.T) { assert.Equal(t, "/sync?from=2026-08-01&to=2026-08-04", location.Query().Get("return_to")) } +func TestSyncParsesMultipartDateRange(t *testing.T) { + server, _ := newWebTestServer(t) + var body bytes.Buffer + form := multipart.NewWriter(&body) + require.NoError(t, form.WriteField("from", "2026-08-11")) + require.NoError(t, form.WriteField("to", "2026-08-01")) + require.NoError(t, form.Close()) + + req := httptest.NewRequest(http.MethodPost, "/sync", &body) + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Content-Type", form.FormDataContentType()) + response := httptest.NewRecorder() + + server.Handler().ServeHTTP(response, req) + + assert.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "the end date must not be before the start date\n", response.Body.String()) + assert.NotContains(t, response.Body.String(), "<!doctype html>") +} + +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) { <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> if data.Notice != "" { - <div class="notice">{ data.Notice }</div> + <div id="sync-notice" class="notice">{ data.Notice }</div> + } else { + <div id="sync-notice" class="notice" hidden></div> } if data.Error != "" { - <div class="error">{ data.Error }</div> + <div id="sync-error" class="error">{ data.Error }</div> + } else { + <div id="sync-error" class="error" hidden></div> } <section class="panel"> if !data.HasAuth { <p>Strava authorization is required before the first sync.</p> <a class="button secondary" href={ templ.URL("/strava/login?return_to=/sync") }>Authorize Strava</a> } else { - <form method="post" action="/sync"> + <form id="sync-form" method="post" action="/sync"> <div class="form-row"> <label>From<input type="date" name="from" value={ data.From } required/></label> <label>To<input type="date" name="to" value={ data.To } required/></label> <button class="button" type="submit">Sync rides</button> </div> </form> + <div id="sync-progress" class="sync-progress" role="status" aria-live="polite" hidden> + <div class="progress-heading"><strong>Importing rides</strong><span id="sync-progress-count">0 / 0</span></div> + <progress id="sync-progress-bar" max="1" value="0" aria-label="Strava import progress"></progress> + <span id="sync-progress-details">0 imported · 0 skipped</span> + </div> } </section> + <script> + (() => { + const form = document.getElementById("sync-form"); + if (!form) return; + const button = form.querySelector("button[type=submit]"); + const progressPanel = document.getElementById("sync-progress"); + const progressBar = document.getElementById("sync-progress-bar"); + const progressCount = document.getElementById("sync-progress-count"); + const progressDetails = document.getElementById("sync-progress-details"); + const notice = document.getElementById("sync-notice"); + const error = document.getElementById("sync-error"); + + const showError = (message) => { + error.textContent = message; + error.hidden = false; + }; + const updateProgress = (progress) => { + progressPanel.hidden = false; + progressBar.max = Math.max(progress.total, 1); + progressBar.value = progress.completed; + progressCount.textContent = `${progress.completed} / ${progress.total}`; + progressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`; + }; + const handleEvent = (eventName, data) => { + if (eventName === "progress") { + updateProgress(data); + return false; + } + if (eventName === "complete") { + updateProgress(data); + notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} already stored.`; + notice.hidden = false; + return true; + } + if (eventName === "error") { + if (data.progress) updateProgress(data.progress); + throw new Error(data.message || "Strava sync failed"); + } + return false; + }; + const consumeEvents = async (response) => { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let completed = false; + const consumeBlock = (block) => { + let eventName = "message"; + let data = ""; + for (const line of block.split(/\r?\n/)) { + if (line.startsWith("event: ")) eventName = line.slice(7); + if (line.startsWith("data: ")) data += line.slice(6); + } + if (data) completed = handleEvent(eventName, JSON.parse(data)) || completed; + }; + while (true) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + const blocks = buffer.split(/\r?\n\r?\n/); + buffer = blocks.pop(); + for (const block of blocks) consumeBlock(block); + if (done) break; + } + if (buffer.trim()) consumeBlock(buffer); + if (!completed) throw new Error("Strava sync ended before completion"); + }; + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + button.disabled = true; + button.textContent = "Syncing..."; + notice.hidden = true; + error.hidden = true; + progressPanel.hidden = false; + progressCount.textContent = "0 / 0"; + progressDetails.textContent = "Preparing import..."; + try { + const response = await fetch(form.action, { + method: "POST", + body: new FormData(form), + headers: { Accept: "text/event-stream" }, + }); + if (response.redirected) { + window.location.assign(response.url); + return; + } + if (!response.ok) throw new Error(await response.text() || `Sync failed (HTTP ${response.status})`); + if (!response.body) throw new Error("The browser does not support streaming responses"); + await consumeEvents(response); + } catch (caught) { + showError(caught instanceof Error ? caught.message : "Strava sync failed"); + } finally { + button.disabled = false; + button.textContent = "Sync rides"; + } + }); + })(); + </script> } 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</title><link rel=\"stylesheet\" href=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\"><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.map-panel { padding: 0; overflow: hidden; }\n\t\t\t\t.ride-map { min-height: 24rem; height: min(65vh, 40rem); }\n\t\t\t\tdl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 0; }\n\t\t\t\tdt { color: #68746c; font-size: .78rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }\n\t\t\t\tdd { margin: .25rem 0 0; font-size: 1.2rem; font-weight: 700; }\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\t@media (max-width: 500px) { dl { grid-template-columns: repeat(2, 1fr); } }\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><link rel=\"stylesheet\" href=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\"><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.sync-progress { display: grid; gap: .7rem; margin-top: 1.25rem; }\n\t\t\t\t.progress-heading { display: flex; justify-content: space-between; gap: 1rem; }\n\t\t\t\tprogress { width: 100%; height: .8rem; accent-color: #d96932; }\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.map-panel { padding: 0; overflow: hidden; }\n\t\t\t\t.ride-map { min-height: 24rem; height: min(65vh, 40rem); }\n\t\t\t\tdl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 0; }\n\t\t\t\tdt { color: #68746c; font-size: .78rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }\n\t\t\t\tdd { margin: .25rem 0 0; font-size: 1.2rem; font-weight: 700; }\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\t@media (max-width: 500px) { dl { grid-template-columns: repeat(2, 1fr); } }\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 } @@ -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("<div class=\"notice\">") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div id=\"sync-notice\" class=\"notice\">") 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("<div id=\"sync-notice\" class=\"notice\" hidden></div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } if data.Error != "" { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"error\">") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div id=\"sync-error\" class=\"error\">") 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("<div id=\"sync-error\" class=\"error\" hidden></div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<section class=\"panel\">") 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("<form method=\"post\" action=\"/sync\"><div class=\"form-row\"><label>From<input type=\"date\" name=\"from\" value=\"") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<form id=\"sync-form\" method=\"post\" action=\"/sync\"><div class=\"form-row\"><label>From<input type=\"date\" name=\"from\" value=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } 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: 162, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 169, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { @@ -609,18 +619,18 @@ func SyncContent(data SyncPageData) templ.Component { 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: 163, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 170, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" required></label> <button class=\"button\" type=\"submit\">Sync rides</button></div></form>") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" required></label> <button class=\"button\" type=\"submit\">Sync rides</button></div></form><div id=\"sync-progress\" class=\"sync-progress\" role=\"status\" aria-live=\"polite\" hidden><div class=\"progress-heading\"><strong>Importing rides</strong><span id=\"sync-progress-count\">0 / 0</span></div><progress id=\"sync-progress-bar\" max=\"1\" value=\"0\" aria-label=\"Strava import progress\"></progress> <span id=\"sync-progress-details\">0 imported · 0 skipped</span></div>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section>") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section><script>\n\t\t(() => {\n\t\t\tconst form = document.getElementById(\"sync-form\");\n\t\t\tif (!form) return;\n\t\t\tconst button = form.querySelector(\"button[type=submit]\");\n\t\t\tconst progressPanel = document.getElementById(\"sync-progress\");\n\t\t\tconst progressBar = document.getElementById(\"sync-progress-bar\");\n\t\t\tconst progressCount = document.getElementById(\"sync-progress-count\");\n\t\t\tconst progressDetails = document.getElementById(\"sync-progress-details\");\n\t\t\tconst notice = document.getElementById(\"sync-notice\");\n\t\t\tconst error = document.getElementById(\"sync-error\");\n\n\t\t\tconst showError = (message) => {\n\t\t\t\terror.textContent = message;\n\t\t\t\terror.hidden = false;\n\t\t\t};\n\t\t\tconst updateProgress = (progress) => {\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressBar.max = Math.max(progress.total, 1);\n\t\t\t\tprogressBar.value = progress.completed;\n\t\t\t\tprogressCount.textContent = `${progress.completed} / ${progress.total}`;\n\t\t\t\tprogressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;\n\t\t\t};\n\t\t\tconst handleEvent = (eventName, data) => {\n\t\t\t\tif (eventName === \"progress\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"complete\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\tnotice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} already stored.`;\n\t\t\t\t\tnotice.hidden = false;\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"error\") {\n\t\t\t\t\tif (data.progress) updateProgress(data.progress);\n\t\t\t\t\tthrow new Error(data.message || \"Strava sync failed\");\n\t\t\t\t}\n\t\t\t\treturn false;\n\t\t\t};\n\t\t\tconst consumeEvents = async (response) => {\n\t\t\t\tconst reader = response.body.getReader();\n\t\t\t\tconst decoder = new TextDecoder();\n\t\t\t\tlet buffer = \"\";\n\t\t\t\tlet completed = false;\n\t\t\t\tconst consumeBlock = (block) => {\n\t\t\t\t\tlet eventName = \"message\";\n\t\t\t\t\tlet data = \"\";\n\t\t\t\t\tfor (const line of block.split(/\\r?\\n/)) {\n\t\t\t\t\t\tif (line.startsWith(\"event: \")) eventName = line.slice(7);\n\t\t\t\t\t\tif (line.startsWith(\"data: \")) data += line.slice(6);\n\t\t\t\t\t}\n\t\t\t\t\tif (data) completed = handleEvent(eventName, JSON.parse(data)) || completed;\n\t\t\t\t};\n\t\t\t\twhile (true) {\n\t\t\t\t\tconst { value, done } = await reader.read();\n\t\t\t\t\tbuffer += decoder.decode(value || new Uint8Array(), { stream: !done });\n\t\t\t\t\tconst blocks = buffer.split(/\\r?\\n\\r?\\n/);\n\t\t\t\t\tbuffer = blocks.pop();\n\t\t\t\t\tfor (const block of blocks) consumeBlock(block);\n\t\t\t\t\tif (done) break;\n\t\t\t\t}\n\t\t\t\tif (buffer.trim()) consumeBlock(buffer);\n\t\t\t\tif (!completed) throw new Error(\"Strava sync ended before completion\");\n\t\t\t};\n\n\t\t\tform.addEventListener(\"submit\", async (event) => {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tbutton.disabled = true;\n\t\t\t\tbutton.textContent = \"Syncing...\";\n\t\t\t\tnotice.hidden = true;\n\t\t\t\terror.hidden = true;\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressCount.textContent = \"0 / 0\";\n\t\t\t\tprogressDetails.textContent = \"Preparing import...\";\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await fetch(form.action, {\n\t\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\t\tbody: new FormData(form),\n\t\t\t\t\t\theaders: { Accept: \"text/event-stream\" },\n\t\t\t\t\t});\n\t\t\t\t\tif (response.redirected) {\n\t\t\t\t\t\twindow.location.assign(response.url);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (!response.ok) throw new Error(await response.text() || `Sync failed (HTTP ${response.status})`);\n\t\t\t\t\tif (!response.body) throw new Error(\"The browser does not support streaming responses\");\n\t\t\t\t\tawait consumeEvents(response);\n\t\t\t\t} catch (caught) {\n\t\t\t\t\tshowError(caught instanceof Error ? caught.message : \"Strava sync failed\");\n\t\t\t\t} finally {\n\t\t\t\t\tbutton.disabled = false;\n\t\t\t\t\tbutton.textContent = \"Sync rides\";\n\t\t\t\t}\n\t\t\t});\n\t\t})();\n\t</script>") 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") } |