refactor(frontend): SyncModal reports its result via a banner, auto-closes
The modal is now purely the progress bar/spinner. The instant a poll reports the sync finished, it builds one banner message (the success/error line plus any pending-activities/pending-workouts nudge as extra lines) and closes itself -- no more manual Close click to dismiss a result the user already saw. The Close button stays as a manual escape hatch for a still-running sync.
This commit is contained in:
@@ -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<SyncStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(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,8 +96,7 @@ export function SyncModal({ onClose }: { onClose: () => void }) {
|
||||
<span>Synchronizing</span>
|
||||
</div>
|
||||
<div className="sync-modal-body">
|
||||
{inProgress ? (
|
||||
bar ? (
|
||||
{bar ? (
|
||||
<>
|
||||
<progress className="sync-modal-progress" value={bar.done} max={Math.max(bar.total, 1)} />
|
||||
<p className="sync-modal-label">{label}</p>
|
||||
@@ -73,29 +106,7 @@ export function SyncModal({ onClose }: { onClose: () => void }) {
|
||||
<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}>
|
||||
|
||||
Reference in New Issue
Block a user