import React, { useEffect, useRef, useState } from 'react'; import type { ArticleDetailResponse, ArticleStatus, } from '../../service/visibility/visibility.interface'; import { getArticle } from '../../service/visibility/visibility.service'; import { toneToBadgeClass } from './helpers'; const POLL_INTERVAL_MS = 2_000; const SLOW_BANNER_AFTER_MS = 90_000; interface ArticleProgressProps { articleId: string; clientId: string; token: string; onReady: (article: ArticleDetailResponse) => void; onFailed: (message: string) => void; } const stepForStatus = (status: ArticleStatus): 0 | 1 | 2 | 3 => { switch (status) { case 'pending': return 0; case 'generating': return 1; case 'ready': return 3; case 'failed': default: return 0; } }; /** * Inline progress UI for an article generation job. Polls * ``GET /articles/{id}`` every 2s. After 90s without ``ready``, renders * a non-blocking "taking longer" banner but keeps polling. */ const ArticleProgress = ({ articleId, clientId, token, onReady, onFailed, }: ArticleProgressProps): JSX.Element => { const [status, setStatus] = useState('pending'); const [slow, setSlow] = useState(false); const startedAt = useRef(Date.now()); const intervalRef = useRef | null>(null); const cancelledRef = useRef(false); // Guards against firing onReady/onFailed twice if a poll tick races // unmount cleanup. The parent removes this row's pendingId on the // first fire, so a duplicate would steal focus from the modal that // just opened - looking like the popup briefly appeared and vanished. const hasFiredTerminalCallbackRef = useRef(false); useEffect(() => { cancelledRef.current = false; hasFiredTerminalCallbackRef.current = false; startedAt.current = Date.now(); const pollOnce = async () => { if (cancelledRef.current || hasFiredTerminalCallbackRef.current) return; try { const article = await getArticle(clientId, token, articleId); if (cancelledRef.current || hasFiredTerminalCallbackRef.current) { return; } const nextStatus = article.summary.status; setStatus(nextStatus); if (nextStatus === 'ready') { hasFiredTerminalCallbackRef.current = true; cancelledRef.current = true; if (intervalRef.current) clearInterval(intervalRef.current); onReady(article); return; } if (nextStatus === 'failed') { hasFiredTerminalCallbackRef.current = true; cancelledRef.current = true; if (intervalRef.current) clearInterval(intervalRef.current); onFailed('Article generation failed. Try regenerating.'); return; } if (!slow && Date.now() - startedAt.current >= SLOW_BANNER_AFTER_MS) { setSlow(true); } } catch { // Transient errors shouldn't kill polling. } }; pollOnce(); intervalRef.current = setInterval(pollOnce, POLL_INTERVAL_MS); return () => { cancelledRef.current = true; if (intervalRef.current) clearInterval(intervalRef.current); }; }, [articleId, clientId, token]); const step = stepForStatus(status); const Step = ({ index, label, }: { index: 0 | 1 | 2; label: string; }): JSX.Element => { const active = step === index; const done = step > index; const tone: 'success' | 'info' | 'attention' = done ? 'success' : active ? 'info' : 'attention'; return ( {`${index + 1}. ${label}${done ? ' ✓' : active ? '…' : ''}`} ); }; return (
{slow && (

This is taking longer than usual. We'll keep trying - feel free to come back later.

)}
); }; export default ArticleProgress;