diff --git a/docs/superpowers/specs/2026-07-26-improve-synchronization-design.md b/docs/superpowers/specs/2026-07-26-improve-synchronization-design.md new file mode 100644 index 0000000..2170fa8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-improve-synchronization-design.md @@ -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 `

` 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.