import { useCallback, useEffect, useRef, useState } from 'react' import { CheckCircle, Loader2, XCircle, RefreshCw, ChevronRight } from 'lucide-react' import { Button, Card } from '../common' import { endpoints, getErrorMessage } from '../../api/client' import { t, tf, isRtl } from '../../lib/i18n' import type { SprintArticle, SprintResult, SprintStatusResponse, SprintJobStatus } from '../../types' import type { GenerationConfig } from './SelectPostTypeStep' const SPRINT_JOB_STORAGE_KEY = 'bc:active-sprint-job' const POLL_INTERVAL_MS = 5000 const MAX_POLLS = 120 interface SprintStepProps { config: GenerationConfig intentState: Record technicalState: Record existingSprintJobId?: string | null onComplete: (result: SprintResult, articles: SprintArticle[]) => void onBack: () => void onError: (error: string) => void onSprintJobStarted?: (jobId: string) => void } type SprintPhase = 'submitting' | 'queued' | 'researching' | 'forming_articles' | 'complete' | 'error' function formatElapsed(seconds: number): string { const mins = Math.floor(seconds / 60) const secs = seconds % 60 if (mins === 0) return tf('%d seconds', secs) return tf('%d:%02d minutes', mins, String(secs).padStart(2, '0')) } function buildSprintRequest( config: GenerationConfig, intentState: Record, technicalState: Record, ): Record { const contentPlan = intentState.content_plan || {} const capturedAnswers = Array.isArray(intentState.captured_answers) ? intentState.captured_answers : [] return { post_type: config.postType, taxonomy: config.taxonomy || undefined, taxonomy_term: config.term || undefined, reporter_id: config.reporterId || undefined, topic: config.topic, keywords: config.keywords, length: config.length, count: config.count, generation_type: config.generationType, technical_summary: String(technicalState.technical_summary || ''), technical_answers: Array.isArray(technicalState.captured_answers) ? technicalState.captured_answers : [], content_plan: contentPlan, content_answers: capturedAnswers, sprint_mode: true, } } function getStatusMessage(phase: SprintPhase, roundsUsed: number, roundsMax: number, findingsCount: number): string { switch (phase) { case 'submitting': return t('Submitting research sprint...') case 'queued': return t('Waiting to start...') case 'researching': if (roundsUsed > 0) { return tf('Research round %d of %d — found %d findings so far', roundsUsed, roundsMax, findingsCount) } return t('Reporter is researching your field...') case 'forming_articles': return t('Analyzing findings and forming article candidates...') case 'complete': return t('Research complete!') case 'error': return t('Research encountered an error') default: return '' } } export function SprintStep({ config, intentState, technicalState, existingSprintJobId, onComplete, onBack, onError, onSprintJobStarted, }: SprintStepProps) { const [phase, setPhase] = useState('submitting') const [errorMsg, setErrorMsg] = useState('') const [elapsedSeconds, setElapsedSeconds] = useState(0) const [, setSprintJobId] = useState(existingSprintJobId || null) const [roundsUsed, setRoundsUsed] = useState(0) const [roundsMax, setRoundsMax] = useState(0) const [findingsCount, setFindingsCount] = useState(0) const abortRef = useRef(false) const hasStartedRef = useRef(false) useEffect(() => { const isActive = phase === 'submitting' || phase === 'queued' || phase === 'researching' || phase === 'forming_articles' if (!isActive) return const timer = setInterval(() => { setElapsedSeconds((prev) => prev + 1) }, 1000) return () => clearInterval(timer) }, [phase]) const pollSprintStatus = useCallback(async (jobId: string): Promise => { for (let attempt = 0; attempt < MAX_POLLS; attempt++) { if (abortRef.current) return await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) if (abortRef.current) return try { const res = await endpoints.getSprintStatus(jobId) const status = res.data as SprintStatusResponse setRoundsUsed(status.rounds_used || 0) setRoundsMax(status.rounds_max || 0) setFindingsCount(status.findings_count || 0) const statusToPhase: Record = { queued: 'queued', running: 'researching', forming_articles: 'forming_articles', complete: 'complete', failed: 'error', } const nextPhase = statusToPhase[status.status] || 'researching' setPhase(nextPhase) if (status.status === 'complete') { sessionStorage.removeItem(SPRINT_JOB_STORAGE_KEY) const sprintResult: SprintResult = { runId: status.sprint_job_id, rounds: status.rounds_used || 0, findings: status.findings_count || 0, costUsd: status.cost_usd || 0, articleCount: status.articles_formed || 0, jobs: status.jobs || [], } const articles: SprintArticle[] = Array.isArray(status.articles) ? status.articles.map((a) => ({ angle: a.angle || '', hook: a.hook || '', confidence: a.confidence || 'medium', backingFindings: Array.isArray(a.backingFindings) ? a.backingFindings : [], branchName: a.branchName, branchId: a.branchId, })) : [] onComplete(sprintResult, articles) return } if (status.status === 'failed') { sessionStorage.removeItem(SPRINT_JOB_STORAGE_KEY) const msg = status.error_message || t('Sprint failed') setErrorMsg(msg) onError(msg) return } } catch (err) { // Transient poll failure — continue polling console.warn('Sprint poll error:', err) } } sessionStorage.removeItem(SPRINT_JOB_STORAGE_KEY) const msg = t('Sprint timed out') setPhase('error') setErrorMsg(msg) onError(msg) }, [onComplete, onError]) const runSprint = useCallback(async () => { if (abortRef.current) return try { setPhase('submitting') setErrorMsg('') setElapsedSeconds(0) setRoundsUsed(0) setRoundsMax(0) setFindingsCount(0) const sprintConfig = buildSprintRequest(config, intentState, technicalState) const result = await endpoints.sprintGenerate(sprintConfig) const data = result.data as { sprint_job_id?: string status?: string error?: string } if (abortRef.current) return if (!data?.sprint_job_id) { throw new Error(data?.error || t('Sprint submission failed')) } const jobId = data.sprint_job_id setSprintJobId(jobId) setPhase('queued') sessionStorage.setItem(SPRINT_JOB_STORAGE_KEY, jobId) onSprintJobStarted?.(jobId) await pollSprintStatus(jobId) } catch (err) { if (abortRef.current) return const msg = getErrorMessage(err, String(err)) setPhase('error') setErrorMsg(msg) onError(msg) } }, [config, intentState, technicalState, onError, onSprintJobStarted, pollSprintStatus]) useEffect(() => { if (hasStartedRef.current) return hasStartedRef.current = true abortRef.current = false if (existingSprintJobId) { setSprintJobId(existingSprintJobId) setPhase('researching') void pollSprintStatus(existingSprintJobId) } else { void runSprint() } return () => { abortRef.current = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const handleRetry = () => { abortRef.current = false hasStartedRef.current = false setPhase('submitting') setErrorMsg('') setElapsedSeconds(0) setSprintJobId(null) sessionStorage.removeItem(SPRINT_JOB_STORAGE_KEY) void runSprint() } const isActive = phase === 'submitting' || phase === 'queued' || phase === 'researching' || phase === 'forming_articles' return (

{isActive && t('Reporter is researching your field...')} {phase === 'complete' && t('Research complete!')} {phase === 'error' && t('Research encountered an error')}

{isActive && (
)} {phase === 'complete' && (
)} {phase === 'error' && (
)}
{isActive && (

{getStatusMessage(phase, roundsUsed, roundsMax, findingsCount)}

{phase === 'researching' && roundsMax > 0 && (
{tf('Round %d', roundsUsed)} {tf('%d of %d', roundsUsed, roundsMax)}
0 ? (roundsUsed / roundsMax) * 100 : 0}%` }} />
{findingsCount > 0 && (

{tf('%d findings discovered', findingsCount)}

)}
)}

{t('This usually takes 1-2 minutes. You can navigate away — we\'ll notify you when done.')}

{tf('Elapsed: %s', formatElapsed(elapsedSeconds))}

)} {phase === 'error' && errorMsg && (

{errorMsg}

)}
{phase === 'error' && ( )}
) }