feat(frontend): add SyncModal component
Blocking overlay polling /api/sync/status, rendering by progress.Phase (indeterminate spinner while discovering, a real progress bar for the activities/workouts phases), then the final sync result (success count, or the error message that was already fetched but never rendered before) plus the existing pending-details/pending-workouts nudge. Never auto-closes -- not yet wired into GarminConnection.tsx (next commit).
This commit is contained in:
@@ -485,6 +485,51 @@ button:disabled {
|
|||||||
word-break: break-word;
|
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 {
|
.json-toggle {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
|
|||||||
103
frontend/src/components/SyncModal.tsx
Normal file
103
frontend/src/components/SyncModal.tsx
Normal file
@@ -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<SyncStatus | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const stoppedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
stoppedRef.current = false;
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
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 (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal-content sync-modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<span>Synchronizing</span>
|
||||||
|
</div>
|
||||||
|
<div className="sync-modal-body">
|
||||||
|
{inProgress ? (
|
||||||
|
bar ? (
|
||||||
|
<>
|
||||||
|
<progress className="sync-modal-progress" value={bar.done} max={Math.max(bar.total, 1)} />
|
||||||
|
<p className="sync-modal-label">{label}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="sync-modal-spinner" />
|
||||||
|
<p className="sync-modal-label">{label}</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{status?.last_run && (
|
||||||
|
<p className={status.last_run.Status === "error" ? "error" : "sync-modal-label"}>
|
||||||
|
{status.last_run.Status === "error"
|
||||||
|
? `Sync failed: ${status.last_run.ErrorMessage}`
|
||||||
|
: `Sync complete: ${status.last_run.ActivitiesFetched} new activities`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status && status.activities_pending_details > 0 && (
|
||||||
|
<p className="sync-modal-label">
|
||||||
|
{status.activities_pending_details} more activities pending — click Sync now again
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status && status.workouts_pending > 0 && (
|
||||||
|
<p className="sync-modal-label">
|
||||||
|
{status.workouts_pending} more workouts pending — click Sync now again
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="modal-header-actions sync-modal-actions">
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user