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 { useEffect, useRef, useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { showError, showSuccess } from "../banner";
|
||||||
import type { SyncStatus } from "../types/api";
|
import type { SyncStatus } from "../types/api";
|
||||||
|
|
||||||
function phaseDisplay(status: SyncStatus): { label: string; bar: { done: number; total: number } | null } {
|
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
|
// Builds the single banner message covering both the sync outcome and any
|
||||||
// progress/results are shown (see docs/superpowers/specs/2026-07-26-improve-synchronization-design.md).
|
// pending-activities/pending-workouts nudge, so completion produces exactly
|
||||||
// Never auto-closes, even on success: the user decides when to dismiss it,
|
// one banner (with the nudges as extra lines) rather than several separate
|
||||||
// so a sync error can't be missed by looking away for a moment.
|
// 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 }) {
|
export function SyncModal({ onClose }: { onClose: () => void }) {
|
||||||
const [status, setStatus] = useState<SyncStatus | null>(null);
|
const [status, setStatus] = useState<SyncStatus | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const stoppedRef = useRef(false);
|
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(() => {
|
useEffect(() => {
|
||||||
stoppedRef.current = false;
|
stoppedRef.current = false;
|
||||||
@@ -33,15 +64,19 @@ export function SyncModal({ onClose }: { onClose: () => void }) {
|
|||||||
.syncStatus()
|
.syncStatus()
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
setStatus(s);
|
setStatus(s);
|
||||||
setError(null);
|
if (!s.in_progress) {
|
||||||
// Once a fetched status reports the sync finished, stop polling --
|
if (s.last_run?.Status === "error") {
|
||||||
// the final result is already shown, and there's nothing new left
|
showError(completionMessage(s));
|
||||||
// to fetch until the user starts another sync ("Close" unmounts
|
} else {
|
||||||
// this component, which is the only other way polling stops).
|
showSuccess(completionMessage(s));
|
||||||
if (!stoppedRef.current && s.in_progress) timeout = setTimeout(tick, 1500);
|
}
|
||||||
|
onCloseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!stoppedRef.current) timeout = setTimeout(tick, 1500);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
setError(String(e));
|
showError(String(e));
|
||||||
if (!stoppedRef.current) timeout = setTimeout(tick, 1500);
|
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 };
|
const { label, bar } = status ? phaseDisplay(status) : { label: "Starting…", bar: null };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -62,40 +96,17 @@ export function SyncModal({ onClose }: { onClose: () => void }) {
|
|||||||
<span>Synchronizing</span>
|
<span>Synchronizing</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="sync-modal-body">
|
<div className="sync-modal-body">
|
||||||
{inProgress ? (
|
{bar ? (
|
||||||
bar ? (
|
<>
|
||||||
<>
|
<progress className="sync-modal-progress" value={bar.done} max={Math.max(bar.total, 1)} />
|
||||||
<progress className="sync-modal-progress" value={bar.done} max={Math.max(bar.total, 1)} />
|
<p className="sync-modal-label">{label}</p>
|
||||||
<p className="sync-modal-label">{label}</p>
|
</>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="sync-modal-spinner" />
|
|
||||||
<p className="sync-modal-label">{label}</p>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{status?.last_run && (
|
<div className="sync-modal-spinner" />
|
||||||
<p className={status.last_run.Status === "error" ? "error" : "sync-modal-label"}>
|
<p className="sync-modal-label">{label}</p>
|
||||||
{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>
|
||||||
<div className="modal-header-actions sync-modal-actions">
|
<div className="modal-header-actions sync-modal-actions">
|
||||||
<button type="button" onClick={onClose}>
|
<button type="button" onClick={onClose}>
|
||||||
|
|||||||
Reference in New Issue
Block a user