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:
2026-07-27 09:47:53 +02:00
parent d48bbfd43e
commit a5fffff737

View File

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