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:
2026-07-27 08:31:01 +02:00
parent 39aa121420
commit 1fb9271aaa
2 changed files with 148 additions and 0 deletions

View File

@@ -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;

View 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>
);
}