import { useEffect, useRef, useState } from 'react' import LocalisedText from '@/components/LocalisedText' import { apiDelete, apiGet, apiPost, isApiError, startAPIDownload } from '@/lib/api' import { countsErrorMessage } from '@/lib/countsDisplay' import { useClientMotion } from '@/lib/useClientMotion' import { useUITranslation } from '@/lib/useUITranslation' import { cn } from '@/lib/utils' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { LocaleCode } from '@/lib/locales' export type CountsExportHandle = { cancel: () => Promise } type ExportStatus = { status: 'starting' | 'queued' | 'running' | 'cancelling' | 'cancelled' | 'ready' | 'failed' processed: number; total: number | null; token?: string downloadAttempt?: string; downloadStatus?: string; error?: string phase?: 'queued' | 'aggregating' | 'writing'; heartbeatAt?: number; updatedAt?: number } const CountsExportProgress = ( { payload, locale, handle, controls }: { payload: Record; locale: LocaleCode handle: CountsExportHandle; controls: DialogueFormControls }, ) => { const { label, message } = useUITranslation ('counts') const motion = useClientMotion () const [state, setState] = useState ({ status: 'starting', processed: 0, total: null }) const [cancelError, setCancelError] = useState (null) const [failure, setFailure] = useState (null) const [downloadError, setDownloadError] = useState (null) // One mounted dialogue owns one immutable job, even if its parent rerenders. const initial = useRef ({ payload, locale }) const actions = useRef (controls) actions.current = controls const terminal = useRef (false) const finished = useRef (false) const cancelAction = useRef<() => Promise> (async () => false) const cancelled = useRef (false) const generation = useRef (0) const job = useRef (null) const creation = useRef | null> (null) const registration = useRef | null> (null) const attempt = useRef (null) const downloadDeadline = useRef (0) const releaseDownload = useRef<(() => void) | null> (null) const cancellation = useRef | null> (null) useEffect (() => { handle.cancel = () => cancelAction.current () }, [handle]) useEffect (() => { const epoch = ++generation.current let active = true let lastResponse = Date.now () let timer: ReturnType | null = null let acknowledgementTimer: ReturnType | null = null let request: AbortController | null = null const current = () => active && generation.current === epoch const stop = (reason: string) => { if (!current () || terminal.current) return terminal.current = true if (timer != null) clearTimeout (timer) if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer) request?.abort () releaseDownload.current?.() releaseDownload.current = null setDownloadError (reason) } const cancel = (): Promise => { if (finished.current || (terminal.current && job.current == null)) return Promise.resolve (true) cancelled.current = true terminal.current = true if (timer != null) clearTimeout (timer) if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer) request?.abort () releaseDownload.current?.() releaseDownload.current = null cancellation.current ??= (async () => { const controller = new AbortController () const deadline = setTimeout (() => controller.abort (), 15_000) try { const created = await creation.current if (created == null) return false job.current = created.job const result = await apiDelete ( `/counts/export/jobs/${encodeURIComponent (created.job)}`, { locale: initial.current.locale, signal: controller.signal }) if (current ()) { setState (result) setCancelError (null) finished.current = ['cancelled', 'failed'].includes (result.status) terminal.current = finished.current if (!terminal.current) { if (timer != null) clearTimeout (timer) timer = setTimeout (() => poll (created.job), 800) } } return ['cancelled', 'failed'].includes (result.status) } catch (error) { if (isApiError (error) && error.response?.status === 404) return true if (current ()) setCancelError (error) return false } finally { clearTimeout (deadline) cancellation.current = null } }) () return cancellation.current } cancelAction.current = cancel const armDeadline = () => { if (downloadDeadline.current === 0) return acknowledgementTimer = setTimeout (() => stop ('timeout'), Math.max (0, downloadDeadline.current - Date.now ())) } const poll = async (token: string) => { if (!current () || terminal.current) return const controller = new AbortController () request = controller const deadline = setTimeout (() => controller.abort (), 15_000) try { const result = await apiGet ( `/counts/export/jobs/${encodeURIComponent (token)}`, { locale: initial.current.locale, signal: controller.signal }) if (!current () || terminal.current) return lastResponse = Date.now () setState (result) if (cancelled.current) { if (await cancel ()) return } else if (result.status === 'ready') { if (result.token == null) { stop ('failed'); return } if (attempt.current == null) { registration.current ??= apiPost ( `/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, { locale: initial.current.locale, signal: controller.signal }) const receipt = await registration.current if (!current () || terminal.current || cancelled.current) return if (receipt.downloadAttempt == null) { stop ('failed'); return } attempt.current = receipt.downloadAttempt downloadDeadline.current = Date.now () + 30_000 armDeadline () try { releaseDownload.current = startAPIDownload ('/counts/export', result.token, () => stop ('failed'), new URLSearchParams ({ job: token, attempt: receipt.downloadAttempt })) } catch { stop ('failed') return } } if (result.downloadAttempt === attempt.current) { if (result.downloadStatus === 'started') { terminal.current = true finished.current = true if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer) // Rack yielded a chunk; browser receipt and saving are not confirmed. releaseDownload.current = null actions.current.close () return } if (['failed', 'unavailable', 'timeout'].includes (result.downloadStatus ?? '')) { stop (result.downloadStatus!); return } } } else if (['failed', 'cancelled'].includes (result.status)) { finished.current = true terminal.current = true return } if (timer != null) clearTimeout (timer) timer = setTimeout (() => poll (token), 800) } catch (error) { if (!current () || terminal.current) return const status = isApiError (error) ? error.response?.status : null if ([400, 401, 403, 422].includes (status ?? 0)) setFailure (error) if (registration.current != null && attempt.current == null) { stop ('failed'); return } if (status != null && status >= 400 && status < 500) { stop ('unavailable'); return } if (Date.now () - lastResponse >= 30_000) { stop ('timeout'); return } timer = setTimeout (() => poll (token), 2000) } finally { clearTimeout (deadline) if (request === controller) request = null } } if (creation.current == null) { const controller = new AbortController () const deadline = setTimeout (() => controller.abort (), 15_000) creation.current = apiPost<{ job: string }> ( '/counts/export/jobs', initial.current.payload, { locale: initial.current.locale, signal: controller.signal }) .finally (() => clearTimeout (deadline)) } armDeadline () void creation.current.then (result => { if (!current () || terminal.current) return job.current = result.job void poll (result.job) }).catch (error => { if (current ()) setFailure (error) stop ('failed') }) return () => { active = false if (timer != null) clearTimeout (timer) if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer) request?.abort () // StrictMode immediately installs another effect; real unmount releases the frame. queueMicrotask (() => { if (generation.current !== epoch) return releaseDownload.current?.() releaseDownload.current = null }) } }, []) useEffect (() => { controls.setActions ([]) }, [controls]) const percent = state.total == null ? null : (state.total === 0 ? 100 : Math.min (100, Math.floor (state.processed / state.total * 100))) const busy = downloadError == null && cancelError == null && !['failed', 'cancelled'].includes (state.status) const phaseKey = (downloadError != null || cancelError != null) ? 'failed' : (state.status === 'running' ? state.phase ?? 'running' : (state.status === 'ready' ? 'writing' : state.status)) const phase = message (`exportProgress.${phaseKey}`) const failureMessage = countsErrorMessage (failure ?? state, locale) const cancellationMessage = countsErrorMessage (cancelError, locale) return

{label ('exportProgress.format', { format: message (`exportFormats.${payload.format}`).text })}

{busy &&

{percent == null ? label ('exportProgress.processed', { count: state.processed }) : label ('exportProgress.measured', { processed: state.processed, total: state.total ?? 0, percent })}

{(downloadError != null || state.status === 'failed') &&

{failure != null || state.status === 'failed' ? failureMessage.text : label (`downloadErrors.${downloadError}`)} {downloadError != null && <>
{label ('errors.stopped')}}

} {cancelError != null &&

{cancellationMessage.text}

}
} export default CountsExportProgress