import { useEffect, useRef, useState } from "react"; type ProgressStatus = "queued" | "running" | "submitting" | "completed" | "failed" | "success" | "error"; interface SmoothedProgressOptions { creepSpeed?: number; chaseRate?: number; creepCeiling?: number; creepAhead?: number; completionDuration?: number; } const DEFAULT_CREEP_SPEED = 0.5; const DEFAULT_CHASE_RATE = 0.09; const DEFAULT_CREEP_CEILING = 97; const DEFAULT_CREEP_AHEAD = 25; const DEFAULT_COMPLETION_DURATION = 450; function isTerminal(status: ProgressStatus): boolean { return status === "completed" || status === "success" || status === "failed" || status === "error"; } function isSuccess(status: ProgressStatus): boolean { return status === "completed" || status === "success"; } /** * Smoothly interpolates between coarse server-reported progress values. * On completion, animates quickly to 100% instead of jumping. */ export function useSmoothedProgress( realProgress: number, status: ProgressStatus, options?: SmoothedProgressOptions, ): number { const creepSpeed = options?.creepSpeed ?? DEFAULT_CREEP_SPEED; const chaseRate = options?.chaseRate ?? DEFAULT_CHASE_RATE; const creepCeiling = options?.creepCeiling ?? DEFAULT_CREEP_CEILING; const creepAhead = options?.creepAhead ?? DEFAULT_CREEP_AHEAD; const completionDuration = options?.completionDuration ?? DEFAULT_COMPLETION_DURATION; const [displayed, setDisplayed] = useState(0); const rafRef = useRef(0); const targetRef = useRef(realProgress); const statusRef = useRef(status); const completionStartRef = useRef(null); const completionBaseRef = useRef(0); useEffect(() => { targetRef.current = realProgress; statusRef.current = status; if (isSuccess(status) && completionStartRef.current === null) { completionStartRef.current = performance.now(); completionBaseRef.current = displayed; } }, [realProgress, status]); useEffect(() => { if (status === "failed" || status === "error") { cancelAnimationFrame(rafRef.current); return; } let lastFrame = performance.now(); const tick = (now: number) => { const dt = Math.min((now - lastFrame) / 1000, 0.1); lastFrame = now; setDisplayed((current) => { const currentStatus = statusRef.current; if (isSuccess(currentStatus)) { const start = completionStartRef.current ?? now; const elapsed = now - start; const t = Math.min(elapsed / completionDuration, 1); const eased = 1 - (1 - t) * (1 - t); const base = completionBaseRef.current; return base + (100 - base) * eased; } if (isTerminal(currentStatus)) return current; const target = targetRef.current; if (current >= target) { const ceiling = Math.min(target + creepAhead, creepCeiling); if (current >= ceiling) return current; return current + creepSpeed * dt; } const gap = target - current; const step = (gap * chaseRate + 0.3) * dt * 60; return Math.min(current + step, target); }); rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafRef.current); }, [status, chaseRate, creepSpeed, creepCeiling, creepAhead, completionDuration]); if (status === "failed" || status === "error") return Math.round(displayed); if (isSuccess(status) && displayed >= 99.5) return 100; return Math.round(Math.max(0, Math.min(99, displayed))); }