Discovered while verifying the sync design with the user: these two
exported methods are dead in production (only FullSync's *Core calls
are actually used), kept alive only by tests, and their doc comments
still reference the background sync loop removed in 4d2cbe4.
8.0 KiB
Improve Synchronization UX — Design
Source idea: docs/IDEAS.md — "improve synchronization: make it modal, split downloads of
activities and workouts (as workouts are currently downloaded from their ID in related
activities), add progress bar (which requires to know in advance how many activities or
workouts will have to be downloaded)."
Goal
Replace the Profile page's single inline "syncing: N/M activities" text line with a blocking modal that shows real progress through sync, broken into named phases the user can actually follow: discovering new activities, fetching activity details, then fetching workouts.
Cleanup folded into this work: dead Backfill/IncrementalSync wrappers
Service.Backfill(ctx) and Service.IncrementalSync(ctx) are exported methods that each record
their own SyncRun, wrapping backfillCore/incrementalSyncCore. Their doc comments say
they're used "by the periodic background loop" -- but that loop was removed in an earlier
session (4d2cbe4 refactor: remove automatic background incremental sync), and grepping
internal/api/ and cmd/ confirms nothing in production calls them anymore. FullSync (the
only production caller of the core logic) calls backfillCore/incrementalSyncCore directly.
Only internal/sync/service_test.go still calls Backfill/IncrementalSync, which is the only
reason they're not already flagged as unused by the compiler.
Since this plan already restructures service.go's progress model, remove these two dead
exported methods (and their now-inaccurate doc comments) as an early task, rewriting the tests
that called them to exercise the same behavior through FullSync or the *Core functions
directly (same package, so unexported functions are still directly testable) -- before any of
the progress-model changes below, so later tasks aren't touching code that's about to be
deleted.
Current state (for reference)
sync.Service.Progress()returns a flat{Done, Total}, written only byFillPendingDetails—Backfill/IncrementalSync(the "discover which activities exist" phase) report no progress at all today.FillPendingDetailsfetches, per activity, in one pass:GetActivitySplits,GetActivityDetails, and (if the activity has aworkout_id)GetWorkoutByID— then writes samples, laps (with workout-derived target pace/HR bands baked in), activity details, and marks splits fetched.GET /api/sync/statusreturns{in_progress, detail_fill_progress: {Done, Total}, activities_pending_details, last_run?}. The frontend (GarminConnection.tsx) polls this and renders an inline<p>line, both while syncing and once idle (a static "last sync: ..." summary).sync_runsstores one row perBackfill/IncrementalSync/FullSynccall (kind/started_at/finished_at/activities_fetched/status/error_message).GET /api/sync/runsexists but nothing in the frontend calls it.
Scope
In scope: "Sync now" (FullSync) gets the new modal and phase-aware progress. The static
idle "last sync" summary on the Profile page is removed — the modal becomes the only place
sync status/results are shown; closing it means nothing is shown again until the next sync.
Out of scope: "Reset all" keeps its current behavior unchanged (confirm dialog, fire, poll
via a blocking while loop, reload the page) — no modal, no progress UI. The "modern error
displays" idea (ephemeral banners, backend-down handling) from the same backlog section is a
separate, later feature — this one only makes sync's own errors visible via the modal's final
state, reusing the sync_runs.error_message field that's already fetched but never rendered
today.
Backend design
Phase-aware progress
type Progress struct {
Phase string // "idle" | "discovering" | "activities" | "workouts"
Done int
Total int
}
FullSync drives the phase transitions:
discovering— set (Done=0, Total=0, indeterminate) whileBackfill/IncrementalSyncrun. These still report no count; the modal shows a spinner, not a bar, during this phase.activities—FillPendingDetailsis split into two sequential passes. The first pass fetchesGetActivitySplits+GetActivityDetails+samples for every activity missing details (ActivitiesMissingDetails/CountActivitiesMissingDetails, unchanged queries), writing laps without workout-target alignment yet.Totalis the pending-details count taken once at the start of this pass;Doneadvances per activity.workouts— a new second pass.workout_idis decoded from Garmin's activity summary and stored on theactivitiesrow at upsert time, well before any detail fetch — so "activities needing a workout fetched" is an independently queryable condition (workout_id IS NOT NULL AND workout_raw_json IS NULL), needing no live Garmin call to count. Two new store methods,ActivitiesMissingWorkout/CountActivitiesMissingWorkout(mirroring the naming of the existingActivitiesMissingDetails/CountActivitiesMissingDetails), list/count these. For each: fetchGetWorkoutByID, re-derive lap target bands from the just-stored lap data (alignWorkoutTargets), update the laps and setworkout_raw_json. This condition also picks up any older activity that has aworkout_idbut never got its workout aligned in some prior run (e.g. a past transient failure) — not just ones touched in this run'sactivitiespass — a small latent-bug fix as a side effect.idle— reset to{Phase: "idle", Done: 0, Total: 0}onceFullSyncreturns, samedefer-based reset as today.
Restructuring fillActivityDetails to decouple lap-writing from workout-target alignment
means an activity with a workout gets its laps written twice (once plain in the activities
pass, once updated with target bands in the workouts pass) — this is a second local SQL
delete+insert, not a second Garmin API call, so it costs nothing against rate limits.
API
GET /api/sync/status changes shape:
{
"in_progress": true,
"progress": { "phase": "workouts", "done": 3, "total": 8 },
"activities_pending_details": 0,
"last_run": { "...": "SyncRun, unchanged fields" }
}
This replaces detail_fill_progress/DetailFillProgress outright (no other consumer exists).
SyncRun.Kind's frontend type also gains its missing "full" value (backend has always been
able to report it; the frontend type was just never updated to match).
Frontend design
GarminConnection.tsx keeps its connect/MFA/Reset-all logic and buttons untouched, but loses
its syncStatus polling, syncProgressLabel helper, and all inline sync-progress/last-sync
JSX.
A new SyncModal.tsx:
- Renders as a blocking overlay (page behind it non-interactive) the moment
api.syncRun()resolves successfully. - Polls
api.syncStatus()every 1500ms while open (same cadence as today's polling). - Renders by
progress.phase:discovering→ spinner + "Discovering activities…"activities→ progress bar + "Activities: {done}/{total}"workouts→ progress bar + "Workouts: {done}/{total}"
- Once
in_progressbecomesfalse: showslast_run.Status,ActivitiesFetched, andErrorMessage(if the run errored) plus the existing "N more pending — sync again" nudge ifactivities_pending_details > 0, and a Close button. No auto-close, no timeout — the user decides when to dismiss it (this is the one place sync errors are visible, so nothing should hide it automatically).
Testing
internal/sync: new/updated tests for the two-phaseFillPendingDetailssplit (an activity with a workout gets its laps written in both passes, target bands only present after theworkoutspass; an activity without a workout is untouched by theworkoutspass), and for phase-awareProgress()transitions.internal/api:sync_test.goupdated for the new/api/sync/statusJSON shape.- Frontend: no test suite exists (per CLAUDE.md) — manual smoke test against
seedsampledata, watching the modal move through all four phases.