diff --git a/frontend/src/App.css b/frontend/src/App.css index 6073fbb..ef08ab3 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -485,6 +485,51 @@ button:disabled { word-break: break-word; } +.sync-modal-content { + width: min(420px, 90vw); +} + +.sync-modal-body { + padding: 1.5rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + text-align: center; +} + +.sync-modal-progress { + width: 100%; + height: 0.6rem; +} + +.sync-modal-label { + margin: 0; + color: #9aa0ab; + font-size: 0.9rem; +} + +.sync-modal-spinner { + width: 2rem; + height: 2rem; + border: 3px solid #2a2d35; + border-top-color: #3b82f6; + border-radius: 50%; + animation: sync-modal-spin 0.8s linear infinite; +} + +@keyframes sync-modal-spin { + to { + transform: rotate(360deg); + } +} + +.sync-modal-actions { + justify-content: flex-end; + padding: 0.75rem 1rem; + border-top: 1px solid #2a2d35; +} + .json-toggle { display: inline-block; width: 1rem; diff --git a/frontend/src/components/SyncModal.tsx b/frontend/src/components/SyncModal.tsx new file mode 100644 index 0000000..2926347 --- /dev/null +++ b/frontend/src/components/SyncModal.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from "react"; +import { api } from "../api/client"; +import type { SyncStatus } from "../types/api"; + +function phaseDisplay(status: SyncStatus): { label: string; bar: { done: number; total: number } | null } { + const { Phase, Done, Total } = status.progress; + switch (Phase) { + case "discovering": + return { label: "Discovering activities…", bar: null }; + case "activities": + return { label: `Activities: ${Done}/${Total}`, bar: { done: Done, total: Total } }; + case "workouts": + return { label: `Workouts: ${Done}/${Total}`, bar: { done: Done, total: Total } }; + default: + return { label: "Starting…", bar: null }; + } +} + +// 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. +export function SyncModal({ onClose }: { onClose: () => void }) { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const stoppedRef = useRef(false); + + useEffect(() => { + stoppedRef.current = false; + let timeout: ReturnType; + const tick = () => { + api + .syncStatus() + .then((s) => { + setStatus(s); + setError(null); + }) + .catch((e) => setError(String(e))) + .finally(() => { + if (!stoppedRef.current) timeout = setTimeout(tick, 1500); + }); + }; + tick(); + return () => { + stoppedRef.current = true; + clearTimeout(timeout); + }; + }, []); + + const inProgress = status?.in_progress ?? true; + const { label, bar } = status ? phaseDisplay(status) : { label: "Starting…", bar: null }; + + return ( +
+
+
+ Synchronizing +
+
+ {inProgress ? ( + bar ? ( + <> + +

{label}

+ + ) : ( + <> +
+

{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 +

+ )} + + )} + {error &&

{error}

} +
+
+ +
+
+
+ ); +}