docs: add design spec for improved synchronization UX
Phase-aware sync progress (discovering/activities/workouts), a new /api/sync/status shape, and a blocking SyncModal replacing the Profile page's inline sync text -- implementing the "improve synchronization" idea from docs/IDEAS.md.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
# 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.
|
||||
|
||||
## Current state (for reference)
|
||||
|
||||
- `sync.Service.Progress()` returns a flat `{Done, Total}`, written only by
|
||||
`FillPendingDetails` — `Backfill`/`IncrementalSync` (the "discover which activities exist"
|
||||
phase) report no progress at all today.
|
||||
- `FillPendingDetails` fetches, per activity, in one pass: `GetActivitySplits`,
|
||||
`GetActivityDetails`, and (if the activity has a `workout_id`) `GetWorkoutByID` — then writes
|
||||
samples, laps (with workout-derived target pace/HR bands baked in), activity details, and
|
||||
marks splits fetched.
|
||||
- `GET /api/sync/status` returns `{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_runs` stores one row per `Backfill`/`IncrementalSync`/`FullSync` call
|
||||
(`kind`/`started_at`/`finished_at`/`activities_fetched`/`status`/`error_message`).
|
||||
`GET /api/sync/runs` exists 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
|
||||
|
||||
```go
|
||||
type Progress struct {
|
||||
Phase string // "idle" | "discovering" | "activities" | "workouts"
|
||||
Done int
|
||||
Total int
|
||||
}
|
||||
```
|
||||
|
||||
`FullSync` drives the phase transitions:
|
||||
|
||||
1. **`discovering`** — set (Done=0, Total=0, indeterminate) while `Backfill`/`IncrementalSync`
|
||||
run. These still report no count; the modal shows a spinner, not a bar, during this phase.
|
||||
2. **`activities`** — `FillPendingDetails` is split into two sequential passes. The first pass
|
||||
fetches `GetActivitySplits`+`GetActivityDetails`+samples for every activity missing details
|
||||
(`ActivitiesMissingDetails`/`CountActivitiesMissingDetails`, unchanged queries), writing laps
|
||||
*without* workout-target alignment yet. `Total` is the pending-details count taken once at
|
||||
the start of this pass; `Done` advances per activity.
|
||||
3. **`workouts`** — a new second pass. `workout_id` is decoded from Garmin's activity summary
|
||||
and stored on the `activities` row 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 existing `ActivitiesMissingDetails`/
|
||||
`CountActivitiesMissingDetails`), list/count these. For each: fetch `GetWorkoutByID`,
|
||||
re-derive lap target bands from the just-stored lap data (`alignWorkoutTargets`), update
|
||||
the laps and set `workout_raw_json`. This condition also picks up any older activity that
|
||||
has a `workout_id` but never got its workout aligned in some prior run (e.g. a past
|
||||
transient failure) — not just ones touched in this run's `activities` pass — a small
|
||||
latent-bug fix as a side effect.
|
||||
4. **`idle`** — reset to `{Phase: "idle", Done: 0, Total: 0}` once `FullSync` returns, same
|
||||
`defer`-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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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_progress` becomes `false`: shows `last_run.Status`, `ActivitiesFetched`, and
|
||||
`ErrorMessage` (if the run errored) plus the existing "N more pending — sync again" nudge if
|
||||
`activities_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-phase `FillPendingDetails` split (an activity
|
||||
with a workout gets its laps written in both passes, target bands only present after the
|
||||
`workouts` pass; an activity without a workout is untouched by the `workouts` pass), and for
|
||||
phase-aware `Progress()` transitions.
|
||||
- `internal/api`: `sync_test.go` updated for the new `/api/sync/status` JSON shape.
|
||||
- Frontend: no test suite exists (per CLAUDE.md) — manual smoke test against `seedsample` data,
|
||||
watching the modal move through all four phases.
|
||||
Reference in New Issue
Block a user