diff --git a/frontend/src/components/SyncModal.tsx b/frontend/src/components/SyncModal.tsx index d90469b..3082dc2 100644 --- a/frontend/src/components/SyncModal.tsx +++ b/frontend/src/components/SyncModal.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { api } from "../api/client"; +import { showError, showSuccess } from "../banner"; import type { SyncStatus } from "../types/api"; function phaseDisplay(status: SyncStatus): { label: string; bar: { done: number; total: number } | null } { @@ -16,14 +17,44 @@ function phaseDisplay(status: SyncStatus): { label: string; bar: { done: number; } } -// Blocking overlay shown while "Sync now" is running -- the only place sync -// progress/results are shown (see docs/superpowers/specs/2026-07-26-improve-synchronization-design.md). -// Never auto-closes, even on success: the user decides when to dismiss it, -// so a sync error can't be missed by looking away for a moment. +// Builds the single banner message covering both the sync outcome and any +// pending-activities/pending-workouts nudge, so completion produces exactly +// one banner (with the nudges as extra lines) rather than several separate +// ones landing at once. +function completionMessage(status: SyncStatus): string { + const lines: string[] = []; + if (status.last_run) { + lines.push( + status.last_run.Status === "error" + ? `Sync failed: ${status.last_run.ErrorMessage}` + : `Sync complete: ${status.last_run.ActivitiesFetched} new activities`, + ); + } + if (status.activities_pending_details > 0) { + lines.push(`${status.activities_pending_details} more activities pending — click Sync now again`); + } + if (status.workouts_pending > 0) { + lines.push(`${status.workouts_pending} more workouts pending — click Sync now again`); + } + return lines.join("\n"); +} + +// Blocking overlay shown while "Sync now" is running -- purely the progress +// bar/spinner (see docs/superpowers/specs/2026-07-27-modern-error-displays-design.md). +// The instant a poll reports the sync finished, it reports the result via a +// banner (success/error, folded together with any pending nudge) and closes +// itself -- there's no manual Close step for the *result* anymore. The Close +// button stays as a manual escape hatch for a still-running (or stuck) sync. export function SyncModal({ onClose }: { onClose: () => void }) { const [status, setStatus] = useState(null); - const [error, setError] = useState(null); const stoppedRef = useRef(false); + // Captures the latest onClose without making it an effect dependency -- + // GarminConnection passes a fresh inline arrow function on every one of + // its own re-renders (e.g. its 6s auth-status poll), and this effect must + // not restart because of that (it would reset stoppedRef/the in-flight + // timeout and silently double the poll cadence). + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; useEffect(() => { stoppedRef.current = false; @@ -33,15 +64,19 @@ export function SyncModal({ onClose }: { onClose: () => void }) { .syncStatus() .then((s) => { setStatus(s); - setError(null); - // Once a fetched status reports the sync finished, stop polling -- - // the final result is already shown, and there's nothing new left - // to fetch until the user starts another sync ("Close" unmounts - // this component, which is the only other way polling stops). - if (!stoppedRef.current && s.in_progress) timeout = setTimeout(tick, 1500); + if (!s.in_progress) { + if (s.last_run?.Status === "error") { + showError(completionMessage(s)); + } else { + showSuccess(completionMessage(s)); + } + onCloseRef.current(); + return; + } + if (!stoppedRef.current) timeout = setTimeout(tick, 1500); }) .catch((e) => { - setError(String(e)); + showError(String(e)); if (!stoppedRef.current) timeout = setTimeout(tick, 1500); }); }; @@ -52,7 +87,6 @@ export function SyncModal({ onClose }: { onClose: () => void }) { }; }, []); - const inProgress = status?.in_progress ?? true; const { label, bar } = status ? phaseDisplay(status) : { label: "Starting…", bar: null }; return ( @@ -62,40 +96,17 @@ export function SyncModal({ onClose }: { onClose: () => void }) { Synchronizing
- {inProgress ? ( - bar ? ( - <> - -

{label}

- - ) : ( - <> -
-

{label}

- - ) + {bar ? ( + <> + +

{label}

+ ) : ( <> - {status?.last_run && ( -

- {status.last_run.Status === "error" - ? `Sync failed: ${status.last_run.ErrorMessage}` - : `Sync complete: ${status.last_run.ActivitiesFetched} new activities`} -

- )} - {status && status.activities_pending_details > 0 && ( -

- {status.activities_pending_details} more activities pending — click Sync now again -

- )} - {status && status.workouts_pending > 0 && ( -

- {status.workouts_pending} more workouts pending — click Sync now again -

- )} +
+

{label}

)} - {error &&

{error}

}