From 36a91362d986da7d297f18808249459c85f29667 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 8 Apr 2026 14:23:52 +0200 Subject: [PATCH] Strava sync improvements - Fix first sync finding 0 activities: remove last_sync_at stamp at connect time so the first sync checks all Strava history (existence check skips already-extracted files without fetching streams) - Add POST /api/strava/reset with soft/hard modes: soft sets last_sync_at to the most recent activity already on disk; hard clears it entirely - Surface error_count in sync response and status message - Add Reset / Hard reset buttons below Sync now in the upload modal - Reload on bfcache restore so client:only components re-mount after back navigation --- bincio/edit/server.py | 50 ++++++++++++++++++++++++++++++++++--- site/src/layouts/Base.astro | 49 ++++++++++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/bincio/edit/server.py b/bincio/edit/server.py index e603d1f..ec28198 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -716,8 +716,6 @@ async def strava_callback(code: str = "", error: str = "") -> RedirectResponse: token = exchange_code(strava_client_id, strava_client_secret, code) except StravaError: return RedirectResponse(f"{site_url}?strava=error") - # Stamp last_sync_at at connect time so the first sync only fetches new activities - token.setdefault("last_sync_at", int(time.time())) save_token(dd, token) return RedirectResponse(f"{site_url}?strava=connected") @@ -787,4 +785,50 @@ async def strava_sync() -> JSONResponse: token["last_sync_at"] = int(time.time()) save_token(dd, token) - return JSONResponse({"ok": True, "imported": imported, "skipped": skipped, "errors": errors[:5]}) + return JSONResponse({"ok": True, "imported": imported, "skipped": skipped, "error_count": len(errors), "errors": errors[:5]}) + + +@app.post("/api/strava/reset") +async def strava_reset(request: Request) -> JSONResponse: + """Reset last_sync_at. + + mode=soft — set to the started_at of the most recent activity already on disk + (next sync only fetches activities newer than the last known one) + mode=hard — clear last_sync_at entirely + (next sync re-downloads the full Strava history, skipping existing files) + """ + dd = _get_data_dir() + from bincio.extract.strava_api import load_token, save_token + token = load_token(dd) + if token is None: + raise HTTPException(400, "Not connected to Strava") + + body = await request.json() + mode = body.get("mode", "soft") + + if mode == "hard": + token.pop("last_sync_at", None) + save_token(dd, token) + return JSONResponse({"ok": True, "mode": "hard", "last_sync_at": None}) + + # soft: find the most recent started_at in the current index + from datetime import datetime, timezone + index_path = dd / "index.json" + last_ts: int | None = None + if index_path.exists(): + index_data = json.loads(index_path.read_text(encoding="utf-8")) + started_ats = [ + a.get("started_at") for a in index_data.get("activities", []) + if a.get("started_at") + ] + if started_ats: + latest = max(started_ats) + dt = datetime.fromisoformat(latest.replace("Z", "+00:00")) + last_ts = int(dt.astimezone(timezone.utc).timestamp()) + + if last_ts is None: + token.pop("last_sync_at", None) + else: + token["last_sync_at"] = last_ts + save_token(dd, token) + return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts}) diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 3d17273..274b48a 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -204,6 +204,18 @@ const baseUrl = import.meta.env.BASE_URL ?? '/'; id="strava-sync-btn" class="w-full py-2 px-4 rounded-lg font-medium text-sm bg-zinc-700 hover:bg-zinc-600 text-white transition-colors mt-2" >Sync now +
+ + +

@@ -262,7 +274,9 @@ const baseUrl = import.meta.env.BASE_URL ?? '/'; const stravaConnect = document.getElementById('strava-connect-area'); const stravaSync = document.getElementById('strava-sync-area'); const stravaConnBtn = document.getElementById('strava-connect-btn'); - const stravaSyncBtn = document.getElementById('strava-sync-btn'); + const stravaSyncBtn = document.getElementById('strava-sync-btn'); + const stravaResetSoftBtn = document.getElementById('strava-reset-soft-btn'); + const stravaResetHardBtn = document.getElementById('strava-reset-hard-btn'); const stravaLastSync = document.getElementById('strava-last-sync'); const stravaChooseSub = document.getElementById('strava-choose-sub'); @@ -406,7 +420,8 @@ const baseUrl = import.meta.env.BASE_URL ?? '/'; if (!r.ok) throw new Error(await r.text()); const d = await r.json(); stravaLastSync.textContent = new Date().toLocaleString(); - stravaStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date.`; + const errNote = d.error_count ? `, ${d.error_count} errors` : ''; + stravaStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date${errNote}.`; stravaStatus.style.color = '#4ade80'; if (d.imported > 0) setTimeout(() => window.location.reload(), 1500); } catch (e) { @@ -418,6 +433,36 @@ const baseUrl = import.meta.env.BASE_URL ?? '/'; } }); + async function stravaReset(mode) { + const btn = mode === 'soft' ? stravaResetSoftBtn : stravaResetHardBtn; + btn.disabled = true; + stravaStatus.textContent = ''; + try { + const r = await fetch(`${editUrl}/api/strava/reset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }), + }); + if (!r.ok) throw new Error(await r.text()); + const d = await r.json(); + if (mode === 'hard') { + stravaStatus.textContent = 'Hard reset done — next sync will re-check all activities.'; + } else { + const date = d.last_sync_at ? new Date(d.last_sync_at * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : 'none'; + stravaStatus.textContent = `Reset to ${date} — next sync fetches only newer activities.`; + } + stravaStatus.style.color = '#a1a1aa'; + } catch (e) { + stravaStatus.textContent = 'Error: ' + e.message; + stravaStatus.style.color = '#f87171'; + } finally { + btn.disabled = false; + } + } + + stravaResetSoftBtn.addEventListener('click', () => stravaReset('soft')); + stravaResetHardBtn.addEventListener('click', () => stravaReset('hard')); + // Handle ?strava= param set by the callback redirect (popup scenario) const sp = new URLSearchParams(window.location.search); if (sp.has('strava')) {