Main synchronization orchestration component for syncing manga entries to AniList.

Manages the complete sync lifecycle: initial state setup, progress tracking, error handling with recovery options, pause/resume functionality, and result display. Supports incremental progress updates, rate limiting awareness, and checkpointed resumption.

UI includes:

  • Real-time progress bar with percentage and entry count
  • Status-dependent alerts (idle, syncing, paused, completed, failed)
  • Current entry display with cover image and metadata
  • Error details with action buttons for recovery
  • Results summary with statistics and export capability
const SyncManager: React.FC<SyncManagerProps> = ({
entries,
token,
onComplete,
onCancel,
autoStart: shouldAutoStart = true,
syncState,
syncActions,
incrementalSync: isIncrementalSync = false,
onIncrementalSyncChange,
displayOrderMediaIds,
}) => {
const { rateLimitState } = useRateLimit();
const [progressBaseline, setProgressBaseline] = useState<SyncProgress | null>(
null,
);
const [resumeOffsets, setResumeOffsets] = useState({
initialized: false,
completed: 0,
successful: 0,
failed: 0,
skipped: 0,
});

useEffect(() => {
if (syncState?.isPaused && syncState.progress) {
setProgressBaseline(syncState.progress);
}
}, [syncState?.isPaused, syncState?.progress]);

useEffect(() => {
if (syncState?.resumeAvailable && syncState.progress && !progressBaseline) {
setProgressBaseline(syncState.progress);
}
}, [progressBaseline, syncState?.progress, syncState?.resumeAvailable]);

useEffect(() => {
if (
!syncState?.isActive &&
!syncState?.isPaused &&
!syncState?.resumeAvailable
) {
setProgressBaseline(null);
setResumeOffsets({
initialized: false,
completed: 0,
successful: 0,
failed: 0,
skipped: 0,
});
}
}, [syncState?.isActive, syncState?.isPaused, syncState?.resumeAvailable]);

// Use progress from sync-service, fallback to default
const defaultProgress: SyncProgress = {
total: progressBaseline?.total ?? entries.length,
completed: 0,
successful: 0,
failed: 0,
skipped: 0,
currentEntry: null,
currentStep: null,
totalSteps: null,
rateLimited: false,
retryAfter: null,
};

const liveProgress = syncState?.progress ?? null;

const computeStatus = (s?: SyncManagerProps["syncState"]) => {
if (!s) return "idle" as const;
if (s.isActive) return "syncing" as const;
if (s.isPaused || s.resumeAvailable) return "paused" as const;
if (s.report)
return s.report.failedUpdates > 0
? ("failed" as const)
: ("completed" as const);
if (s.error) return "failed" as const;
return "idle" as const;
};

const status = computeStatus(syncState);

useEffect(() => {
if (
status === "syncing" &&
progressBaseline &&
(progressBaseline.completed > 0 ||
progressBaseline.successful > 0 ||
progressBaseline.failed > 0 ||
progressBaseline.skipped > 0) &&
liveProgress &&
!resumeOffsets.initialized
) {
setResumeOffsets({
initialized: true,
completed: liveProgress.completed,
successful: liveProgress.successful,
failed: liveProgress.failed,
skipped: liveProgress.skipped,
});
}

if (status !== "syncing" || !progressBaseline) {
if (resumeOffsets.initialized || resumeOffsets.completed !== 0) {
setResumeOffsets({
initialized: false,
completed: 0,
successful: 0,
failed: 0,
skipped: 0,
});
}
}
}, [status, progressBaseline, liveProgress, resumeOffsets]);

const displayProgress = useMemo<SyncProgress>(() => {
if (!progressBaseline && !liveProgress) {
return defaultProgress;
}

if (!progressBaseline) {
return liveProgress ?? defaultProgress;
}

if (!liveProgress) {
return progressBaseline;
}

if (status === "paused") {
return {
...progressBaseline,
currentEntry:
liveProgress.currentEntry ?? progressBaseline.currentEntry,
currentStep: liveProgress.currentStep ?? progressBaseline.currentStep,
totalSteps: liveProgress.totalSteps ?? progressBaseline.totalSteps,
rateLimited: liveProgress.rateLimited,
retryAfter: liveProgress.retryAfter,
};
}

const combinedTotal =
progressBaseline.total || liveProgress.total || defaultProgress.total;

const liveCompletedOffset = resumeOffsets.initialized
? resumeOffsets.completed
: 0;
const liveSuccessfulOffset = resumeOffsets.initialized
? resumeOffsets.successful
: 0;
const liveFailedOffset = resumeOffsets.initialized
? resumeOffsets.failed
: 0;
const liveSkippedOffset = resumeOffsets.initialized
? resumeOffsets.skipped
: 0;

const incrementalCompleted = Math.max(
(liveProgress.completed ?? 0) - liveCompletedOffset,
0,
);
const incrementalSuccessful = Math.max(
(liveProgress.successful ?? 0) - liveSuccessfulOffset,
0,
);
const incrementalFailed = Math.max(
(liveProgress.failed ?? 0) - liveFailedOffset,
0,
);
const incrementalSkipped = Math.max(
(liveProgress.skipped ?? 0) - liveSkippedOffset,
0,
);

return {
...liveProgress,
total: combinedTotal,
completed: Math.min(
combinedTotal,
progressBaseline.completed + incrementalCompleted,
),
successful: progressBaseline.successful + incrementalSuccessful,
failed: progressBaseline.failed + incrementalFailed,
skipped: progressBaseline.skipped + incrementalSkipped,
rateLimited: liveProgress.rateLimited,
retryAfter: liveProgress.retryAfter,
};
}, [defaultProgress, liveProgress, progressBaseline, resumeOffsets, status]);

const completedEntries = displayProgress.completed;
const totalEntries = displayProgress.total;
const progressPercentage =
totalEntries > 0 ? Math.floor((completedEntries / totalEntries) * 100) : 0;

const remainingEntries = Math.max(totalEntries - completedEntries, 0);
const retryAfterMs =
displayProgress.retryAfter ?? rateLimitState.retryAfter ?? null;
const retryAfterSeconds =
typeof retryAfterMs === "number"
? Math.max(Math.ceil(retryAfterMs / 1000), 1)
: null;

const statusDetails = (() => {
switch (status) {
case "syncing":
return {
label: "Running now",
icon: RefreshCw,
badgeClass:
"bg-blue-500/15 text-blue-600 ring-1 ring-inset ring-blue-500/30 dark:text-blue-200",
iconClass: "text-blue-500 animate-spin",
};
case "completed":
return {
label: "Finished",
icon: CheckCircle,
badgeClass:
"bg-emerald-500/10 text-emerald-600 ring-1 ring-inset ring-emerald-500/30 dark:text-emerald-300",
iconClass: "text-emerald-500",
};
case "failed":
return {
label: "Attention needed",
icon: ShieldAlert,
badgeClass:
"bg-rose-500/10 text-rose-600 ring-1 ring-inset ring-rose-500/30 dark:text-rose-300",
iconClass: "text-rose-500",
};
case "paused":
return {
label: "Paused",
icon: PauseCircle,
badgeClass:
"bg-amber-500/10 text-amber-600 ring-1 ring-inset ring-amber-500/30 dark:text-amber-300",
iconClass: "text-amber-500",
};
case "idle":
default:
return {
label: "Awaiting launch",
icon: Sparkles,
badgeClass:
"bg-indigo-500/10 text-indigo-600 ring-1 ring-inset ring-indigo-500/30 dark:text-indigo-200",
iconClass: "text-indigo-500",
};
}
})();

const StatusIcon = statusDetails.icon;

// Handle start synchronization
const handleStartSync = async () => {
console.info(
`[SyncManager] πŸš€ Starting sync with ${entries.length} entries (incremental: ${isIncrementalSync})`,
);
setProgressBaseline(null);
if (syncActions?.startSync) {
if (isIncrementalSync) {
console.debug(
"[SyncManager] πŸ” Processing entries for incremental sync...",
);
const processedEntries = entries.map((entry) => {
// For new entries (no previousValues), use incremental sync if progress > 1
if (!entry.previousValues) {
const shouldUseIncremental = entry.progress > 1;
return {
...entry,
syncMetadata: {
useIncrementalSync: shouldUseIncremental,
targetProgress: entry.progress,
progress: shouldUseIncremental ? 1 : entry.progress, // Start from 1 for incremental
// For new entries, mark that metadata should be set if provided
updatedStatus: !!entry.status,
updatedScore:
typeof entry.score === "number" && entry.score > 0,
updatedPrivate: entry.private !== undefined,
},
};
}

// For existing entries, check if incremental sync is needed
const previousProgress = entry.previousValues.progress || 0;
const targetProgress = entry.progress;
const shouldUseIncremental = targetProgress - previousProgress > 1;

return {
...entry,
syncMetadata: {
useIncrementalSync: shouldUseIncremental,
targetProgress,
progress: shouldUseIncremental
? previousProgress + 1
: entry.progress,
updatedStatus: entry.status !== entry.previousValues?.status,
updatedScore: entry.score !== entry.previousValues?.score,
updatedPrivate: entry.private !== entry.previousValues?.private,
},
};
});
const incrementalCount = processedEntries.filter(
(e) => e.syncMetadata?.useIncrementalSync,
).length;
console.info(
`[SyncManager] βœ… Prepared ${incrementalCount} entries for incremental sync`,
);

await syncActions.startSync(
processedEntries,
token,
undefined,
displayOrderMediaIds,
);
} else {
console.debug("[SyncManager] πŸ” Starting standard sync...");
await syncActions.startSync(
entries,
token,
undefined,
displayOrderMediaIds,
);
}
}
};

// Handle cancellation
const handleCancel = () => {
console.info("[SyncManager] πŸ›‘ Cancelling sync operation");
if (syncActions?.cancelSync) {
syncActions.cancelSync();
}
if (onCancel) {
onCancel();
}
setProgressBaseline(null);
};

const handlePause = () => {
console.info("[SyncManager] ⏸️ Pausing sync operation");
if (syncActions?.pauseSync) {
syncActions.pauseSync();
}
};

const handleResume = () => {
console.info("[SyncManager] ▢️ Resuming sync operation");
if (syncActions?.resumeSync) {
syncActions.resumeSync(entries, token, undefined, displayOrderMediaIds);
}
};

// Recovery action handlers for error details
const handleErrorRetry = async (mediaId: number) => {
console.info(`[SyncManager] πŸ”„ Retrying single entry: ${mediaId}`);
// This would typically trigger a single-entry retry through syncActions
// For now, we'll log it - the actual implementation depends on syncActions
};

const handleErrorRefreshToken = async () => {
console.info("[SyncManager] πŸ” Refreshing authentication token");
// This would typically trigger token refresh through auth context
// For now, we'll log it - the actual implementation depends on auth context
};

const handleErrorCheckConnection = async () => {
console.info("[SyncManager] πŸ“‘ Checking connection status");
// This would typically trigger a connection check
// For now, we'll log it - the actual implementation depends on network utilities
};

// If completed, notify parent
useEffect(() => {
if (status === "completed" || (status === "failed" && syncState?.report)) {
if (onComplete && syncState?.report) {
onComplete(syncState.report);
}
}
}, [status, syncState?.report, onComplete]);

// Auto-start synchronization if enabled
useEffect(() => {
if (shouldAutoStart && status === "idle" && entries.length > 0) {
handleStartSync();
}
}, [shouldAutoStart, status, entries.length]);

return (
<Card className="relative mx-auto w-full max-w-3xl overflow-hidden border border-slate-200/70 bg-white/85 shadow-xl shadow-blue-500/10 backdrop-blur-2xl dark:border-slate-800/60 dark:bg-slate-950/75">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(99,102,241,0.18),transparent_60%)] dark:bg-[radial-gradient(circle_at_top,rgba(76,29,149,0.32),transparent_60%)]" />
<CardHeader className="relative space-y-4 pb-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-slate-500 dark:text-slate-400">
AniList sync mission
</p>
<CardTitle className="mt-1 text-2xl font-semibold text-slate-900 dark:text-slate-100">
AniList synchronization
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600 dark:text-slate-300">
Updating {totalEntries} manga{" "}
{totalEntries === 1 ? "entry" : "entries"} with the latest Kenmei
changes.
</CardDescription>
</div>
<div className="bg-linear-to-br flex h-14 w-14 items-center justify-center rounded-2xl from-blue-500 via-indigo-500 to-purple-500 text-white shadow-lg shadow-blue-500/30">
<Activity className="h-6 w-6" />
</div>
</div>

<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-600 dark:text-slate-300">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 ${statusDetails.badgeClass}`}
>
<StatusIcon className={`h-3.5 w-3.5 ${statusDetails.iconClass}`} />
{statusDetails.label}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200/70 bg-white/70 px-3 py-1 text-slate-600 backdrop-blur dark:border-slate-700/60 dark:bg-slate-950/60 dark:text-slate-200">
<Gauge className="h-3.5 w-3.5 text-slate-500 dark:text-slate-300" />
{totalEntries} queued
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200/70 bg-white/70 px-3 py-1 text-slate-600 backdrop-blur dark:border-slate-700/60 dark:bg-slate-950/60 dark:text-slate-200">
<TimerReset className="h-3.5 w-3.5 text-indigo-500 dark:text-indigo-300" />
{isIncrementalSync ? "Incremental mode" : "Direct mode"}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200/70 bg-white/70 px-3 py-1 text-slate-600 backdrop-blur dark:border-slate-700/60 dark:bg-slate-950/60 dark:text-slate-200">
<Clock className="h-3.5 w-3.5 text-slate-500 dark:text-slate-300" />
{progressPercentage}% complete
</span>
</div>
</CardHeader>

<CardContent className="relative z-10 space-y-8">
<ProgressDisplay
completedEntries={completedEntries}
totalEntries={totalEntries}
progressPercentage={progressPercentage}
status={status}
/>

<StatusAlerts
status={status}
entries={entries}
isIncrementalSync={isIncrementalSync}
onIncrementalSyncChange={onIncrementalSyncChange}
shouldAutoStart={shouldAutoStart}
syncState={syncState}
/>

<CurrentEntryDisplay
progress={displayProgress}
entries={entries}
status={status}
isIncrementalSync={isIncrementalSync}
/>

<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="relative overflow-hidden rounded-3xl border border-emerald-200/60 bg-emerald-50/70 p-4 text-emerald-700 shadow-inner dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200">
<div className="bg-linear-to-br pointer-events-none absolute inset-0 from-emerald-200/40 via-transparent to-teal-200/20 dark:from-emerald-500/20 dark:to-teal-500/10" />
<div className="relative flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-sm font-semibold">
<CheckCircle className="h-4 w-4 text-emerald-500 dark:text-emerald-300" />
Successful
</div>
<p className="text-3xl font-bold leading-none text-emerald-600 dark:text-emerald-200">
{displayProgress.successful}
</p>
<span className="text-xs text-emerald-600/80 dark:text-emerald-200/70">
Synced cleanly
</span>
</div>
</div>

<div className="relative overflow-hidden rounded-3xl border border-rose-200/60 bg-rose-50/70 p-4 text-rose-700 shadow-inner dark:border-rose-900/50 dark:bg-rose-950/30 dark:text-rose-200">
<div className="bg-linear-to-br pointer-events-none absolute inset-0 from-rose-200/40 via-transparent to-red-200/20 dark:from-rose-500/20 dark:to-red-500/10" />
<div className="relative flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-sm font-semibold">
<XCircle className="h-4 w-4 text-rose-500 dark:text-rose-300" />
Failed
</div>
<p className="text-3xl font-bold leading-none text-rose-600 dark:text-rose-200">
{displayProgress.failed}
</p>
<span className="text-xs text-rose-600/80 dark:text-rose-200/70">
{displayProgress.failed > 0 ? "Needs follow-up" : "No failures"}
</span>
</div>
</div>

<div className="relative overflow-hidden rounded-3xl border border-slate-200/70 bg-slate-50/70 p-4 text-slate-700 shadow-inner dark:border-slate-800/60 dark:bg-slate-950/40 dark:text-slate-200">
<div className="bg-linear-to-br pointer-events-none absolute inset-0 from-slate-200/50 via-transparent to-indigo-200/30 dark:from-slate-700/40 dark:to-indigo-900/30" />
<div className="relative flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-sm font-semibold">
<Clock className="h-4 w-4 text-slate-500 dark:text-slate-300" />
Remaining
</div>
<p className="text-3xl font-bold leading-none text-slate-700 dark:text-slate-200">
{remainingEntries}
</p>
</div>
</div>
</div>

{status === "syncing" && isIncrementalSync && (
<div className="overflow-hidden rounded-3xl border border-amber-200/60 bg-amber-50/70 p-5 shadow-sm dark:border-amber-900/50 dark:bg-amber-950/30">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<div className="bg-linear-to-br flex h-12 w-12 items-center justify-center rounded-2xl from-amber-400 to-orange-500 text-white shadow-lg">
<TimerReset className="h-6 w-6" />
</div>
<div>
<h3 className="text-sm font-semibold text-amber-700 dark:text-amber-300">
Incremental sync active
</h3>
<p className="mt-1 max-w-xs text-xs text-amber-700/80 dark:text-amber-200/80">
Large progress jumps are broken into smaller pulses so
AniList can merge activity smoothly.
</p>
</div>
</div>
<div className="grid flex-1 grid-cols-1 gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-amber-200/60 bg-amber-100/70 p-3 text-xs text-amber-700 dark:border-amber-900/40 dark:bg-amber-900/40 dark:text-amber-200">
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/90 text-sm font-semibold text-amber-600 shadow-sm dark:bg-amber-900/60">
1
</span>
<p className="font-semibold">Initial progress</p>
<p className="mt-1 text-amber-600/80 dark:text-amber-200/70">
Increment by +1
</p>
</div>
<div className="rounded-2xl border border-amber-200/60 bg-amber-100/70 p-3 text-xs text-amber-700 dark:border-amber-900/40 dark:bg-amber-900/40 dark:text-amber-200">
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/90 text-sm font-semibold text-amber-600 shadow-sm dark:bg-amber-900/60">
2
</span>
<p className="font-semibold">Final progress</p>
<p className="mt-1 text-amber-600/80 dark:text-amber-200/70">
Set target value
</p>
</div>
<div className="rounded-2xl border border-amber-200/60 bg-amber-100/70 p-3 text-xs text-amber-700 dark:border-amber-900/40 dark:bg-amber-900/40 dark:text-amber-200">
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/90 text-sm font-semibold text-amber-600 shadow-sm dark:bg-amber-900/60">
3
</span>
<p className="font-semibold">Status & score</p>
<p className="mt-1 text-amber-600/80 dark:text-amber-200/70">
Update metadata
</p>
</div>
</div>
</div>
</div>
)}

{status === "syncing" &&
(displayProgress.rateLimited || rateLimitState.isRateLimited) &&
(displayProgress.retryAfter !== null ||
rateLimitState.retryAfter !== undefined) && (
<div className="overflow-hidden rounded-3xl border border-rose-200/60 bg-rose-50/80 p-5 shadow-sm backdrop-blur dark:border-rose-900/50 dark:bg-rose-950/30">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<div className="bg-linear-to-br flex h-12 w-12 items-center justify-center rounded-2xl from-rose-500 to-red-500 text-white shadow-lg">
<Clock className="h-6 w-6" />
</div>
<div>
<h3 className="text-sm font-semibold text-rose-700 dark:text-rose-300">
{rateLimitState.isRateLimited
? "Rate limit reached"
: "Retrying after hiccup"}
</h3>
<p className="mt-1 max-w-xs text-xs text-rose-700/80 dark:text-rose-200/80">
Pausing briefly to respect AniList limits before
continuing the sync.
</p>
</div>
</div>
<div className="flex flex-col items-start justify-center gap-1 rounded-2xl border border-white/40 bg-white/70 px-4 py-2 text-xs font-semibold text-rose-700 shadow-inner dark:border-rose-900/40 dark:bg-rose-950/40 dark:text-rose-200">
<span>
{retryAfterSeconds
? `Resuming in ~${retryAfterSeconds}s`
: "Auto-retrying shortly"}
</span>
<span className="text-[10px] uppercase tracking-[0.3em] text-rose-500 dark:text-rose-300">
Patience level: high
</span>
</div>
</div>
</div>
)}

{syncState?.report && (
<ErrorDetails
report={syncState.report}
onRetry={handleErrorRetry}
onRefreshToken={handleErrorRefreshToken}
onCheckConnection={handleErrorCheckConnection}
/>
)}
</CardContent>

<CardFooter className="relative z-10 flex flex-wrap items-center justify-end gap-2 border-t border-slate-200/60 bg-white/70 backdrop-blur dark:border-slate-800/60 dark:bg-slate-950/60">
<SyncActions
status={status}
onStartSync={handleStartSync}
onCancel={handleCancel}
onPause={handlePause}
onResume={handleResume}
canResume={syncState?.resumeAvailable ?? false}
syncState={syncState}
/>
</CardFooter>
</Card>
);
};