costco-grocery-list/frontend/src/context/UploadQueueContext.jsx
Nico 77ae5be445
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
refactor
2026-02-22 01:27:03 -08:00

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>;
}