From 7f7e4b10f04af80b0e4807ab8b6a0b5e834fd081 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sun, 19 Jul 2026 12:41:38 +0200 Subject: [PATCH] Merge backfill into Sync now, replace Full backfill with a destructive Reset all Sync now now runs Backfill (resumes from the watermark, so a widened history horizon is picked up automatically) then IncrementalSync then detail-fill, in one click. "Full backfill" is gone -- in its place, "Reset all" (styled as a destructive action, gated by a confirm dialog) wipes every synced activity and its laps/kind assignments and rewinds the backfill watermark, so the next sync performs a genuinely fresh pull instead of trying to patch up existing rows with newer schema fields. --- backend/internal/api/api_test.go | 36 ++++++++++ backend/internal/api/server.go | 2 +- backend/internal/api/sync.go | 20 ++++-- backend/internal/store/reset.go | 27 ++++++++ backend/internal/store/reset_test.go | 71 ++++++++++++++++++++ backend/internal/sync/service.go | 8 +++ backend/internal/sync/service_test.go | 41 +++++++++++ frontend/src/App.css | 11 +++ frontend/src/api/client.ts | 5 +- frontend/src/components/GarminConnection.tsx | 26 +++++-- 10 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 backend/internal/store/reset.go create mode 100644 backend/internal/store/reset_test.go diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index c6a0c38..87de865 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -335,6 +335,42 @@ func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) { } } +func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) { + s, db := newTestServer(t) + ctx := newCtx() + + if _, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil { + t.Fatalf("UpsertActivity: %v", err) + } + + router := s.Router() + rec := doJSON(t, router, http.MethodPost, "/api/sync/reset", nil) + if rec.Code != http.StatusAccepted { + t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String()) + } + + deadline := time.Now().Add(2 * time.Second) + for { + activities, err := db.ListActivities(ctx, store.ActivityFilter{}) + if err != nil { + t.Fatalf("ListActivities: %v", err) + } + if len(activities) == 0 { + break + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for reset to delete activities, still have %d", len(activities)) + } + time.Sleep(10 * time.Millisecond) + } + + // "Full backfill" no longer exists as an endpoint -- superseded by reset. + rec = doJSON(t, router, http.MethodPost, "/api/sync/backfill", nil) + if rec.Code != http.StatusNotFound { + t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code) + } +} + func TestProgression_ReturnsSortedTimeSeries(t *testing.T) { s, db := newTestServer(t) ctx := newCtx() diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 38897a7..22fe6c0 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -53,7 +53,7 @@ func (s *Server) Router() http.Handler { r.Route("/sync", func(r chi.Router) { r.Post("/run", s.handleSyncRun) - r.Post("/backfill", s.handleSyncBackfill) + r.Post("/reset", s.handleSyncReset) r.Get("/runs", s.handleSyncRuns) r.Get("/status", s.handleSyncStatus) }) diff --git a/backend/internal/api/sync.go b/backend/internal/api/sync.go index 3be9198..28b9975 100644 --- a/backend/internal/api/sync.go +++ b/backend/internal/api/sync.go @@ -10,8 +10,17 @@ import ( // internal/sync.Service.FillPendingDetails. const detailFillBatchSize = 50 +// handleSyncRun does a full sync pass: Backfill first (resumes from the +// watermark, so widening the configured history horizon between clicks is +// picked up automatically), then IncrementalSync (catches anything new since +// the latest known activity), then fills in details for whatever's still +// missing them. Activities already fully processed are left untouched -- +// see internal/sync.Service.FillPendingDetails. func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) { ok := s.backgroundSync(func(ctx context.Context) error { + if err := s.Sync.Backfill(ctx); err != nil { + return err + } if err := s.Sync.IncrementalSync(ctx); err != nil { return err } @@ -24,12 +33,13 @@ func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"}) } -func (s *Server) handleSyncBackfill(w http.ResponseWriter, r *http.Request) { +// handleSyncReset wipes every synced activity (and its laps/samples/kind +// assignments) and rewinds the backfill watermark, so the next Sync Now +// performs a genuinely fresh pull from Garmin. Destructive -- the frontend +// gates this behind a confirmation. +func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) { ok := s.backgroundSync(func(ctx context.Context) error { - if err := s.Sync.Backfill(ctx); err != nil { - return err - } - return s.Sync.FillPendingDetails(ctx, detailFillBatchSize) + return s.Sync.ResetAll(ctx) }) if !ok { writeError(w, http.StatusConflict, "a sync is already in progress") diff --git a/backend/internal/store/reset.go b/backend/internal/store/reset.go new file mode 100644 index 0000000..521cde6 --- /dev/null +++ b/backend/internal/store/reset.go @@ -0,0 +1,27 @@ +package store + +import ( + "context" + "fmt" +) + +// ResetAllSyncedData deletes every synced activity (cascading to its laps, +// activity_samples, and kind_assignments via ON DELETE CASCADE) and rewinds +// the backfill watermark to its initial state, so a subsequent Backfill +// starts a genuinely fresh pull instead of thinking history is already +// covered. Workout kinds (the user's taxonomy) are left untouched. +func (db *DB) ResetAllSyncedData(ctx context.Context) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin reset tx: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `DELETE FROM activities`); err != nil { + return fmt.Errorf("delete activities: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE sync_state SET earliest_synced_date = NULL, backfill_complete = 0 WHERE id = 1`); err != nil { + return fmt.Errorf("reset sync state: %w", err) + } + return tx.Commit() +} diff --git a/backend/internal/store/reset_test.go b/backend/internal/store/reset_test.go new file mode 100644 index 0000000..f257650 --- /dev/null +++ b/backend/internal/store/reset_test.go @@ -0,0 +1,71 @@ +package store + +import ( + "context" + "testing" +) + +func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + activityID, err := db.UpsertActivity(ctx, Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}) + if err != nil { + t.Fatalf("UpsertActivity: %v", err) + } + kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{Name: "Test Reset Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true}) + if err != nil { + t.Fatalf("CreateWorkoutKind: %v", err) + } + if err := db.ReplaceLaps(ctx, activityID, []Lap{{LapIndex: 1}}); err != nil { + t.Fatalf("ReplaceLaps: %v", err) + } + if _, err := db.InsertKindAssignment(ctx, KindAssignment{ + ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine, + Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]", + }); err != nil { + t.Fatalf("InsertKindAssignment: %v", err) + } + if err := db.UpdateSyncState(ctx, "2020-01-01", true); err != nil { + t.Fatalf("UpdateSyncState: %v", err) + } + + if err := db.ResetAllSyncedData(ctx); err != nil { + t.Fatalf("ResetAllSyncedData: %v", err) + } + + activities, err := db.ListActivities(ctx, ActivityFilter{}) + if err != nil { + t.Fatalf("ListActivities: %v", err) + } + if len(activities) != 0 { + t.Errorf("expected 0 activities after reset, got %d", len(activities)) + } + laps, err := db.LapsForActivity(ctx, activityID) + if err != nil { + t.Fatalf("LapsForActivity: %v", err) + } + if len(laps) != 0 { + t.Errorf("expected laps to cascade-delete, got %d", len(laps)) + } + if _, ok, err := db.CurrentAssignment(ctx, activityID); err != nil || ok { + t.Errorf("expected kind assignment to cascade-delete, ok=%v err=%v", ok, err) + } + + state, err := db.GetSyncState(ctx) + if err != nil { + t.Fatalf("GetSyncState: %v", err) + } + if state.EarliestSyncedDate != nil || state.BackfillComplete { + t.Errorf("expected watermark rewound to fresh state, got %+v", state) + } + + // Workout kinds (the user's taxonomy) must survive a reset. + kind, ok, err := db.GetWorkoutKind(ctx, kindID) + if err != nil || !ok { + t.Fatalf("GetWorkoutKind after reset: ok=%v err=%v", ok, err) + } + if kind.Name != "Test Reset Kind" { + t.Errorf("workout kind was unexpectedly affected by reset: %+v", kind) + } +} diff --git a/backend/internal/sync/service.go b/backend/internal/sync/service.go index 16bb1e1..9156be4 100644 --- a/backend/internal/sync/service.go +++ b/backend/internal/sync/service.go @@ -203,6 +203,14 @@ func (s *Service) IncrementalSync(ctx context.Context) error { return s.db.FinishSyncRun(ctx, runID, n, nil) } +// ResetAll deletes every synced activity (and its laps/samples/kind +// assignments) and rewinds the backfill watermark, so the next Backfill +// call performs a genuinely fresh pull from Garmin instead of resuming from +// wherever the previous one left off. Workout kinds are left untouched. +func (s *Service) ResetAll(ctx context.Context) error { + return s.db.ResetAllSyncedData(ctx) +} + func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (int, error) { activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500) if err != nil { diff --git a/backend/internal/sync/service_test.go b/backend/internal/sync/service_test.go index bbe6a03..4826eba 100644 --- a/backend/internal/sync/service_test.go +++ b/backend/internal/sync/service_test.go @@ -427,6 +427,47 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) { } } +func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + m := &mock.Client{Activities: []garmin.Activity{ + {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500}, + }} + svc := NewService(m, db, Config{BackfillHorizonDays: 10, BackfillWindowDays: 10}, + fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) + + if err := svc.Backfill(ctx); err != nil { + t.Fatalf("first Backfill: %v", err) + } + firstCallCount := m.GetActivitiesCalls + + if err := svc.ResetAll(ctx); err != nil { + t.Fatalf("ResetAll: %v", err) + } + activities, err := db.ListActivities(ctx, store.ActivityFilter{}) + if err != nil { + t.Fatalf("ListActivities: %v", err) + } + if len(activities) != 0 { + t.Fatalf("expected 0 activities after ResetAll, got %d", len(activities)) + } + + if err := svc.Backfill(ctx); err != nil { + t.Fatalf("Backfill after reset: %v", err) + } + if m.GetActivitiesCalls <= firstCallCount { + t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls) + } + activities, err = db.ListActivities(ctx, store.ActivityFilter{}) + if err != nil { + t.Fatalf("ListActivities after re-backfill: %v", err) + } + if len(activities) != 1 { + t.Fatalf("expected 1 activity re-fetched after reset+backfill, got %d", len(activities)) + } +} + func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) { db := openTestDB(t) ctx := context.Background() diff --git a/frontend/src/App.css b/frontend/src/App.css index 5b07eb8..5d56cf4 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -137,6 +137,17 @@ button:disabled { cursor: default; } +.button-danger { + border-color: #7f1d1d; + color: #f87171; +} + +.button-danger:hover:not(:disabled) { + background: #7f1d1d; + border-color: #f87171; + color: #fff; +} + .filter-pills { display: flex; gap: 0.5rem; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 262c8e5..f18cf6b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -36,7 +36,10 @@ export const api = { // Sync syncRun: () => request<{ status: string }>("/api/sync/run", { method: "POST" }), - syncBackfill: () => request<{ status: string }>("/api/sync/backfill", { method: "POST" }), + // Deletes every synced activity (and its laps/samples/kind assignments) + // and rewinds the backfill watermark -- destructive, gated by a + // confirmation in the UI. + resetSync: () => request<{ status: string }>("/api/sync/reset", { method: "POST" }), syncRuns: () => request("/api/sync/runs"), syncStatus: () => request("/api/sync/status"), diff --git a/frontend/src/components/GarminConnection.tsx b/frontend/src/components/GarminConnection.tsx index db547eb..4f09a27 100644 --- a/frontend/src/components/GarminConnection.tsx +++ b/frontend/src/components/GarminConnection.tsx @@ -68,11 +68,27 @@ export function GarminConnection() { } } - async function sync(kind: "run" | "backfill") { + async function sync() { setBusy(true); setError(null); try { - await (kind === "run" ? api.syncRun() : api.syncBackfill()); + await api.syncRun(); + refreshStatus(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + } + + async function resetAll() { + if (!window.confirm("This deletes every synced activity, lap, and kind assignment, then starts a fresh pull from Garmin next sync. This cannot be undone. Continue?")) { + return; + } + setBusy(true); + setError(null); + try { + await api.resetSync(); refreshStatus(); } catch (e) { setError(String(e)); @@ -102,11 +118,11 @@ export function GarminConnection() { {status === "authenticated" && ( <> - - {syncStatus?.in_progress && ( {syncProgressLabel(syncStatus)}