import { useCallback, useEffect, useRef, useState } from 'react'; import { addScannedCardToCollection, addScannedCardToDeck, addScannedCardToOwned, createScannerCollection, fetchBatchOwnership, fetchScannerCollections, fetchScannerDecks, routeScannedCardToDestination, } from './scanner-route-api.js'; import { destinationActionKey, mergeScannedCardEntry } from './scanner-session.js'; const UNDO_TTL_MS = 30_000; const UNDO_CLEANUP_INTERVAL_MS = 5_000; const OWNERSHIP_DEBOUNCE_MS = 500; /** * Scanned-card queue, bulk selection, in-flight guards, and destination lists. */ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMode }) { const [scannedCards, setScannedCards] = useState([]); const [collections, setCollections] = useState([]); const [decks, setDecks] = useState([]); const [selectedCards, setSelectedCards] = useState(new Set()); const [bulkAction, setBulkAction] = useState(''); const [bulkTarget, setBulkTarget] = useState(''); const [isProcessing, setIsProcessing] = useState(false); const [autoRouteError, setAutoRouteError] = useState(null); const [showCreateCollection, setShowCreateCollection] = useState(false); const [newCollectionName, setNewCollectionName] = useState(''); const [undoStack, setUndoStack] = useState([]); const [ownershipMap, setOwnershipMap] = useState({}); const addingInFlightRef = useRef(new Set()); const [addingCardIds, setAddingCardIds] = useState(() => new Set()); useEffect(() => { if (!user) return; let cancelled = false; (async () => { try { const [loadedCollections, loadedDecks] = await Promise.all([ fetchScannerCollections(), fetchScannerDecks(), ]); if (cancelled) return; setCollections(loadedCollections); setDecks(loadedDecks); } catch (error) { console.error('Error loading scanner destinations:', error); } })(); return () => { cancelled = true; }; }, [user]); const syncAddingState = () => { setAddingCardIds(new Set(addingInFlightRef.current)); }; const tryBeginAdding = (cardId) => { if (addingInFlightRef.current.has(cardId)) return false; addingInFlightRef.current.add(cardId); syncAddingState(); return true; }; const endAdding = (cardId) => { addingInFlightRef.current.delete(cardId); syncAddingState(); }; // --- Undo stack: clear expired entries every 5s --- useEffect(() => { const interval = setInterval(() => { const cutoff = Date.now() - UNDO_TTL_MS; setUndoStack((prev) => { const next = prev.filter((entry) => entry.timestamp > cutoff); return next.length === prev.length ? prev : next; }); }, UNDO_CLEANUP_INTERVAL_MS); return () => clearInterval(interval); }, []); // --- Batch ownership: debounced fetch for new card IDs --- const ownershipTimerRef = useRef(null); useEffect(() => { if (!user) return; const dbIds = scannedCards .filter((c) => c.databaseId) .map((c) => c.databaseId); const newIds = dbIds.filter((id) => !(id in ownershipMap)); if (newIds.length === 0) return; clearTimeout(ownershipTimerRef.current); ownershipTimerRef.current = setTimeout(async () => { try { const result = await fetchBatchOwnership(newIds); setOwnershipMap((prev) => ({ ...prev, ...result })); } catch (err) { console.error('Batch ownership fetch failed:', err); } }, OWNERSHIP_DEBOUNCE_MS); return () => clearTimeout(ownershipTimerRef.current); // eslint-disable-next-line react-hooks/exhaustive-deps -- ownershipMap in deps would cause infinite loop }, [scannedCards, user]); // --- Deck mode completion: derived state, no effect needed --- const deckComplete = Boolean(deckMode && scannedCards.length >= deckMode.targetSize); const markCardAsProcessed = (cardId, action) => { setScannedCards((prev) => prev.map((card) => card.id === cardId ? { ...card, processed: true, processedAction: action } : card ) ); setUndoStack((prev) => [...prev, { cardId, action, timestamp: Date.now() }]); }; const undoCardProcess = useCallback((cardId) => { setScannedCards((prev) => prev.map((card) => card.id === cardId ? { ...card, processed: false, processedAction: null } : card ) ); setUndoStack((prev) => prev.filter((u) => u.cardId !== cardId)); }, []); const handleCardScanned = async (cardData) => { setAutoRouteError(null); const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry( scannedCards, cardData, scanDefaults ); setScannedCards(nextQueue); if (!sessionDestination) return; if (!cardEntry.databaseId) { setAutoRouteError( `"${cardEntry.name}" was queued but is not in the catalog yet — add it manually after review.` ); return; } try { if (!tryBeginAdding(cardEntry.id)) return; await routeScannedCardToDestination(cardEntry, sessionDestination); markCardAsProcessed(cardEntry.id, destinationActionKey(sessionDestination)); } catch (error) { console.error('Auto-route failed:', error); setAutoRouteError( `Could not add "${cardEntry.name}" to ${sessionDestination.label}. Use the card actions below.` ); } finally { endAdding(cardEntry.id); } }; const incrementCardQuantity = (cardId) => { setScannedCards((prev) => prev.map((card) => card.id === cardId ? { ...card, quantity: (card.quantity || 1) + 1 } : card ) ); }; const decrementCardQuantity = (cardId) => { setScannedCards((prev) => prev.map((card) => card.id === cardId ? { ...card, quantity: Math.max(1, (card.quantity || 1) - 1) } : card ) ); }; const updateCardMetadata = (cardId, patch) => { setScannedCards((prev) => prev.map((card) => (card.id === cardId ? { ...card, ...patch } : card)) ); }; const addSingleCardToOwned = async (card) => { if (!tryBeginAdding(card.id)) return; try { await addScannedCardToOwned(card); markCardAsProcessed(card.id, 'owned'); } catch (error) { console.error('Error adding card to owned:', error); } finally { endAdding(card.id); } }; const addSingleCardToCollection = async (card, collectionId) => { if (!tryBeginAdding(card.id)) return; try { await addScannedCardToCollection(card, collectionId); markCardAsProcessed(card.id, 'collection'); } catch (error) { console.error('Error adding card to collection:', error); } finally { endAdding(card.id); } }; const addSingleCardToDeck = async (card, deckId) => { if (!tryBeginAdding(card.id)) return; try { await addScannedCardToDeck(card, deckId); markCardAsProcessed(card.id, 'deck'); } catch (error) { console.error('Error adding card to deck:', error); } finally { endAdding(card.id); } }; const handleBulkAction = async (actionOverride, targetOverride) => { const action = actionOverride ?? bulkAction; const target = targetOverride ?? bulkTarget; if (!action || selectedCards.size === 0) return; setIsProcessing(true); try { const cardsToProcess = scannedCards.filter((card) => selectedCards.has(card.id)); for (const card of cardsToProcess) { if (action === 'owned') { if (!tryBeginAdding(card.id)) continue; try { await addScannedCardToOwned(card); } finally { endAdding(card.id); } } else if (action === 'collection' && target) { if (!tryBeginAdding(card.id)) continue; try { await addScannedCardToCollection(card, target); } finally { endAdding(card.id); } } else if (action === 'deck' && target) { if (!tryBeginAdding(card.id)) continue; try { await addScannedCardToDeck(card, target); } finally { endAdding(card.id); } } else { continue; } markCardAsProcessed(card.id, action); } setSelectedCards(new Set()); setBulkAction(''); setBulkTarget(''); } catch (error) { console.error('Error processing bulk action:', error); } finally { setIsProcessing(false); } }; const createCollection = async () => { if (!newCollectionName.trim()) return; try { const newCollection = await createScannerCollection(newCollectionName.trim()); setCollections((prev) => [newCollection, ...prev]); setBulkTarget(newCollection.id.toString()); setNewCollectionName(''); setShowCreateCollection(false); } catch (error) { console.error('Error creating collection:', error); } }; const clearScannedCards = () => { setScannedCards([]); setSelectedCards(new Set()); }; const removeScannedCard = (cardId) => { setScannedCards((prev) => prev.filter((card) => card.id !== cardId)); setSelectedCards((prev) => { const next = new Set(prev); next.delete(cardId); return next; }); }; const toggleCardSelection = (cardId) => { setSelectedCards((prev) => { const next = new Set(prev); if (next.has(cardId)) { next.delete(cardId); } else { next.add(cardId); } return next; }); }; const clearSelection = () => { setSelectedCards(new Set()); }; return { scannedCards, collections, decks, selectedCards, isProcessing, addingCardIds, autoRouteError, showCreateCollection, setShowCreateCollection, newCollectionName, setNewCollectionName, handleCardScanned, incrementCardQuantity, decrementCardQuantity, updateCardMetadata, addSingleCardToOwned, addSingleCardToCollection, addSingleCardToDeck, handleBulkAction, createCollection, clearScannedCards, removeScannedCard, toggleCardSelection, clearSelection, undoCardProcess, undoStack, deckComplete, ownershipMap, }; }