All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 1m10s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 3s
Build & Deploy Costco Grocery List / deploy (push) Successful in 11s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
407 lines
10 KiB
JavaScript
407 lines
10 KiB
JavaScript
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 <UploadQueueContext.Provider value={value}>{children}</UploadQueueContext.Provider>;
|
|
}
|