import { createContext, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { updateItemImage } from "../api/list"; import { deleteUploadJob, getAllUploadJobs, saveUploadJob, } from "../lib/uploadQueueStorage"; const SUCCESS_DISMISS_DELAY_MS = 2500; const NETWORK_TIMEOUT_MS = 90000; export const IMAGE_UPLOAD_SUCCESS_EVENT = "upload-queue:image-upload-success"; export const UploadQueueContext = createContext({ uploads: [], isOnline: true, enqueueImageUpload: () => "", retryUpload: () => {}, discardUpload: () => {}, }); function nowTs() { return Date.now(); } function createId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `upl_${nowTs()}_${Math.random().toString(16).slice(2)}`; } function classifyUploadError(error, isOnline) { if (!isOnline || error?.code === "ERR_NETWORK" || error?.code === "ECONNABORTED") { return "Network issue. Check your connection and retry."; } const responseMessage = error?.response?.data?.error?.message || error?.response?.data?.message || error?.message; return responseMessage || "Upload failed. Retry or discard."; } function matchesItemKey(a, b) { return ( a.householdId === b.householdId && a.storeId === b.storeId && String(a.itemName || "").toLowerCase() === String(b.itemName || "").toLowerCase() ); } export function UploadQueueProvider({ children }) { const [uploads, setUploads] = useState([]); const [isOnline, setIsOnline] = useState( typeof navigator === "undefined" ? true : navigator.onLine ); const uploadsRef = useRef([]); const processingRef = useRef(false); const controllerByIdRef = useRef(new Map()); const successTimerByIdRef = useRef(new Map()); useEffect(() => { uploadsRef.current = uploads; }, [uploads]); const removeUpload = useCallback((uploadId) => { const timer = successTimerByIdRef.current.get(uploadId); if (timer) { clearTimeout(timer); successTimerByIdRef.current.delete(uploadId); } const controller = controllerByIdRef.current.get(uploadId); if (controller) { controller.abort(); controllerByIdRef.current.delete(uploadId); } setUploads((prev) => prev.filter((upload) => upload.id !== uploadId)); deleteUploadJob(uploadId).catch((error) => { console.error("[UploadQueue] Failed to delete upload job:", error); }); }, []); const updateUpload = useCallback((uploadId, updater) => { let updatedUpload = null; setUploads((prev) => prev.map((upload) => { if (upload.id !== uploadId) { return upload; } updatedUpload = { ...updater(upload), updatedAt: nowTs(), }; return updatedUpload; }) ); if (updatedUpload) { saveUploadJob(updatedUpload).catch((error) => { console.error("[UploadQueue] Failed to persist upload job:", error); }); } return updatedUpload; }, []); useEffect(() => { let isCancelled = false; const hydrateUploads = async () => { try { const stored = await getAllUploadJobs(); if (isCancelled) return; const hydrated = []; for (const upload of stored) { if (upload.status === "discarded") { deleteUploadJob(upload.id).catch(() => {}); continue; } if (upload.status === "uploading") { const interrupted = { ...upload, status: "failed", progress: 0, lastError: "Upload interrupted. Retry or discard.", updatedAt: nowTs(), }; hydrated.push(interrupted); saveUploadJob(interrupted).catch((error) => { console.error("[UploadQueue] Failed to persist interrupted upload:", error); }); continue; } hydrated.push(upload); } hydrated.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0)); setUploads(hydrated); } catch (error) { console.error("[UploadQueue] Failed to hydrate uploads:", error); } }; hydrateUploads(); return () => { isCancelled = true; }; }, []); useEffect(() => { const onOnline = () => setIsOnline(true); const onOffline = () => setIsOnline(false); window.addEventListener("online", onOnline); window.addEventListener("offline", onOffline); return () => { window.removeEventListener("online", onOnline); window.removeEventListener("offline", onOffline); }; }, []); const processNextUpload = useCallback(async () => { if (processingRef.current || !isOnline) { return; } const queuedUpload = uploadsRef.current.find((upload) => upload.status === "queued"); if (!queuedUpload) { return; } processingRef.current = true; const controller = new AbortController(); controllerByIdRef.current.set(queuedUpload.id, controller); updateUpload(queuedUpload.id, (current) => ({ ...current, status: "uploading", progress: 0, lastError: null, attemptCount: (current.attemptCount || 0) + 1, })); try { await updateItemImage( queuedUpload.householdId, queuedUpload.storeId, queuedUpload.itemName, queuedUpload.quantity, queuedUpload.fileBlob, { signal: controller.signal, timeoutMs: NETWORK_TIMEOUT_MS, onUploadProgress: (event) => { if (!event?.total || event.total <= 0) { return; } const progress = Math.max( 1, Math.min(99, Math.round((event.loaded / event.total) * 100)) ); updateUpload(queuedUpload.id, (current) => ({ ...current, status: "uploading", progress, })); }, } ); updateUpload(queuedUpload.id, (current) => ({ ...current, status: "success", progress: 100, lastError: null, })); window.dispatchEvent( new CustomEvent(IMAGE_UPLOAD_SUCCESS_EVENT, { detail: { uploadId: queuedUpload.id, householdId: queuedUpload.householdId, storeId: queuedUpload.storeId, itemName: queuedUpload.itemName, localItemId: queuedUpload.localItemId || null, }, }) ); } catch (error) { if (error?.code !== "ERR_CANCELED") { updateUpload(queuedUpload.id, (current) => ({ ...current, status: "failed", progress: 0, lastError: classifyUploadError(error, isOnline), })); } } finally { controllerByIdRef.current.delete(queuedUpload.id); processingRef.current = false; setTimeout(() => { void processNextUpload(); }, 0); } }, [isOnline, updateUpload]); useEffect(() => { void processNextUpload(); }, [uploads, isOnline, processNextUpload]); useEffect(() => { const activeIds = new Set(uploads.map((upload) => upload.id)); for (const [id, timer] of successTimerByIdRef.current.entries()) { if (!activeIds.has(id)) { clearTimeout(timer); successTimerByIdRef.current.delete(id); } } for (const upload of uploads) { if (upload.status !== "success") { continue; } if (successTimerByIdRef.current.has(upload.id)) { continue; } const timer = setTimeout(() => { removeUpload(upload.id); }, SUCCESS_DISMISS_DELAY_MS); successTimerByIdRef.current.set(upload.id, timer); } }, [uploads, removeUpload]); useEffect(() => { return () => { for (const timer of successTimerByIdRef.current.values()) { clearTimeout(timer); } successTimerByIdRef.current.clear(); for (const controller of controllerByIdRef.current.values()) { controller.abort(); } controllerByIdRef.current.clear(); }; }, []); const enqueueImageUpload = useCallback( ({ householdId, storeId, itemName, quantity, fileBlob, fileName, fileType, fileSize, source, localItemId = null, }) => { const upload = { id: createId(), kind: "item_image_upload", status: "queued", householdId, storeId, itemName, quantity, fileBlob, fileName, fileType, fileSize, source, localItemId, progress: 0, attemptCount: 0, lastError: null, createdAt: nowTs(), updatedAt: nowTs(), }; const toRemove = []; setUploads((prev) => { const next = []; for (const current of prev) { const isDedupCandidate = current.kind === "item_image_upload" && ["queued", "uploading", "failed"].includes(current.status) && matchesItemKey(current, upload); if (isDedupCandidate) { toRemove.push(current.id); continue; } next.push(current); } next.push(upload); return next; }); for (const uploadId of toRemove) { const activeController = controllerByIdRef.current.get(uploadId); if (activeController) { activeController.abort(); } removeUpload(uploadId); } saveUploadJob(upload).catch((error) => { console.error("[UploadQueue] Failed to save queued upload:", error); }); return upload.id; }, [removeUpload] ); const retryUpload = useCallback( (uploadId) => { updateUpload(uploadId, (upload) => ({ ...upload, status: "queued", progress: 0, lastError: null, })); }, [updateUpload] ); const discardUpload = useCallback( (uploadId) => { removeUpload(uploadId); }, [removeUpload] ); const value = useMemo( () => ({ uploads, isOnline, enqueueImageUpload, retryUpload, discardUpload, }), [uploads, isOnline, enqueueImageUpload, retryUpload, discardUpload] ); return {children}; }