diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/start-address-matching/route.ts similarity index 100% rename from src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts rename to src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/start-address-matching/route.ts diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx index 91c81ef4..68160ebb 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useRef } from "react"; +import { useState, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; @@ -26,10 +26,126 @@ interface Props { isDomnaUser: boolean; } +interface Snapshot { + data: TaskData | null; + uploadStatus: UploadStatus | null; + fetchError: boolean; + finalizeError: string | null; +} + const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "failure", "error"]); const FAILED_STATUSES = new Set(["failed", "failure", "error"]); const FINAL_UPLOAD_STATUSES = new Set(["complete", "needs_review"]); +function createProgressStore(args: { + taskId: string; + portfolioId: string; + uploadId: string; + onComplete: () => void; +}) { + let snapshot: Snapshot = { + data: null, + uploadStatus: null, + fetchError: false, + finalizeError: null, + }; + const listeners = new Set<() => void>(); + let intervalId: ReturnType | null = null; + let combineFired = false; + let finalizeFired = false; + let refreshed = false; + + function emit(patch: Partial) { + snapshot = { ...snapshot, ...patch }; + listeners.forEach((l) => l()); + } + + async function fireFinalize() { + try { + const res = await fetch( + `/api/portfolio/${args.portfolioId}/bulk-uploads/${args.uploadId}/finalize`, + { method: "POST" } + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const msg = + body?.detail || body?.error || `Finalize failed (${res.status})`; + emit({ finalizeError: msg }); + finalizeFired = false; + } + } catch (err) { + console.error("Failed to trigger finalize:", err); + emit({ finalizeError: err instanceof Error ? err.message : "Network error" }); + finalizeFired = false; + } + } + + async function poll() { + try { + const res = await fetch(`/api/tasks/${args.taskId}/summary`); + if (!res.ok) { emit({ fetchError: true }); return; } + const json: TaskData = await res.json(); + emit({ data: json }); + const status = json.status.toLowerCase(); + + if (TERMINAL_STATUSES.has(status)) { + if (!FAILED_STATUSES.has(status) && !combineFired) { + combineFired = true; + fetch(`/api/portfolio/${args.portfolioId}/bulk-uploads/${args.uploadId}/combine`, { + method: "POST", + }).catch((err) => console.error("Failed to trigger combiner:", err)); + } + + const uploadRes = await fetch( + `/api/portfolio/${args.portfolioId}/bulk-uploads/${args.uploadId}` + ); + if (uploadRes.ok) { + const upload: UploadStatus = await uploadRes.json(); + emit({ uploadStatus: upload }); + + if (upload.status === "awaiting_review" && !finalizeFired) { + finalizeFired = true; + fireFinalize(); + } + + if (FINAL_UPLOAD_STATUSES.has(upload.status) && !refreshed) { + refreshed = true; + if (intervalId) clearInterval(intervalId); + intervalId = null; + args.onComplete(); + return; + } + } + } + } catch { + emit({ fetchError: true }); + } + } + + return { + subscribe(listener: () => void) { + listeners.add(listener); + if (listeners.size === 1 && intervalId === null && !refreshed) { + poll(); + intervalId = setInterval(poll, 3000); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0 && intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + }; + }, + getSnapshot: () => snapshot, + retryFinalize() { + emit({ finalizeError: null }); + finalizeFired = true; + fireFinalize(); + }, + }; +} + export default function OnboardingProgress({ taskId, portfolioSlug, @@ -38,88 +154,16 @@ export default function OnboardingProgress({ isDomnaUser, }: Props) { const router = useRouter(); - const [data, setData] = useState(null); - const [uploadStatus, setUploadStatus] = useState(null); - const [fetchError, setFetchError] = useState(false); - const [finalizeError, setFinalizeError] = useState(null); - const intervalRef = useRef | null>(null); - const combineFiredRef = useRef(false); - const finalizeFiredRef = useRef(false); - const refreshedRef = useRef(false); - - async function fireFinalize() { - try { - const res = await fetch( - `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/finalize`, - { method: "POST" } - ); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - const msg = - body?.detail || body?.error || `Finalize failed (${res.status})`; - setFinalizeError(msg); - finalizeFiredRef.current = false; - } - } catch (err) { - console.error("Failed to trigger finalize:", err); - setFinalizeError(err instanceof Error ? err.message : "Network error"); - finalizeFiredRef.current = false; - } - } - - function retryFinalize() { - setFinalizeError(null); - finalizeFiredRef.current = true; - fireFinalize(); - } - - useEffect(() => { - async function poll() { - try { - const res = await fetch(`/api/tasks/${taskId}/summary`); - if (!res.ok) { setFetchError(true); return; } - const json: TaskData = await res.json(); - setData(json); - const status = json.status.toLowerCase(); - - if (TERMINAL_STATUSES.has(status)) { - if (!FAILED_STATUSES.has(status) && !combineFiredRef.current) { - combineFiredRef.current = true; - fetch(`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/combine`, { - method: "POST", - }).catch((err) => console.error("Failed to trigger combiner:", err)); - } - - const uploadRes = await fetch( - `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}` - ); - if (uploadRes.ok) { - const upload: UploadStatus = await uploadRes.json(); - setUploadStatus(upload); - - if (upload.status === "awaiting_review" && !finalizeFiredRef.current) { - finalizeFiredRef.current = true; - fireFinalize(); - } - - if (FINAL_UPLOAD_STATUSES.has(upload.status) && !refreshedRef.current) { - refreshedRef.current = true; - if (intervalRef.current) clearInterval(intervalRef.current); - router.refresh(); - return; - } - } - } - } catch { - setFetchError(true); - } - } - - poll(); - intervalRef.current = setInterval(poll, 3000); - return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [taskId, portfolioId, portfolioSlug, uploadId, router]); + const [store] = useState(() => + createProgressStore({ + taskId, + portfolioId, + uploadId, + onComplete: () => router.refresh(), + }) + ); + const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + const { data, uploadStatus, fetchError, finalizeError } = snap; if (fetchError) return null; if (!data) { @@ -191,7 +235,7 @@ export default function OnboardingProgress({