deckhearth/lib/use-scanner-queue.js

389 lines
11 KiB
JavaScript
Raw Normal View History

import { useCallback, useEffect, useRef, useState } from 'react';
import {
addScannedCardToCollection,
addScannedCardToDeck,
addScannedCardToOwned,
createScannerCollection,
fetchBatchOwnership,
fetchScannerCollections,
fetchScannerDecks,
} from './scanner-route-api.js';
import {
loadScannerCart,
mergeScannedCardEntry,
saveScannerCart,
} from './scanner-session.js';
const UNDO_TTL_MS = 30_000;
const UNDO_CLEANUP_INTERVAL_MS = 5_000;
const OWNERSHIP_DEBOUNCE_MS = 500;
const CART_PERSIST_DEBOUNCE_MS = 300;
/**
* Scanned-card queue, bulk selection, in-flight guards, and destination lists.
*/
export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMode }) {
const [scannedCards, setScannedCards] = useState(() => {
const cart = loadScannerCart();
return cart?.scannedCards ?? [];
});
const [collections, setCollections] = useState([]);
const [decks, setDecks] = useState([]);
const [selectedCards, setSelectedCards] = useState(() => {
const cart = loadScannerCart();
return new Set(cart?.selectedCardIds ?? []);
});
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());
const cartPersistTimerRef = useRef(null);
const persistCart = (cards, selection) => {
saveScannerCart({
scannedCards: cards,
selectedCardIds: Array.from(selection),
});
};
useEffect(() => {
clearTimeout(cartPersistTimerRef.current);
cartPersistTimerRef.current = setTimeout(() => {
persistCart(scannedCards, selectedCards);
}, CART_PERSIST_DEBOUNCE_MS);
return () => clearTimeout(cartPersistTimerRef.current);
}, [scannedCards, selectedCards]);
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 = (cardData) => {
setAutoRouteError(null);
const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry(
scannedCards,
cardData,
scanDefaults
);
setScannedCards(nextQueue);
setSelectedCards((prev) => {
const next = new Set([...prev, cardEntry.id]);
persistCart(nextQueue, next);
return next;
});
};
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);
const successfulIds = [];
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);
successfulIds.push(card.id);
} catch (error) {
console.error('Error adding card to owned:', error);
} finally {
endAdding(card.id);
}
} else if (action === 'collection' && target) {
if (!tryBeginAdding(card.id)) continue;
try {
await addScannedCardToCollection(card, target);
successfulIds.push(card.id);
} catch (error) {
console.error('Error adding card to collection:', error);
} finally {
endAdding(card.id);
}
} else if (action === 'deck' && target) {
if (!tryBeginAdding(card.id)) continue;
try {
await addScannedCardToDeck(card, target);
successfulIds.push(card.id);
} catch (error) {
console.error('Error adding card to deck:', error);
} finally {
endAdding(card.id);
}
}
}
if (successfulIds.length > 0) {
setScannedCards((prev) => prev.filter((card) => !successfulIds.includes(card.id)));
setSelectedCards((prev) => {
const next = new Set(prev);
successfulIds.forEach((id) => next.delete(id));
return next;
});
}
setBulkAction('');
setBulkTarget('');
} catch (error) {
console.error('Error processing bulk action:', error);
} finally {
setIsProcessing(false);
}
return successfulIds;
};
const commitSelectedToOwned = async () => handleBulkAction('owned');
const commitSelectedToCollection = async (collectionId) =>
handleBulkAction('collection', collectionId);
const unprocessedCount = scannedCards.filter((card) => !card.processed).length;
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,
commitSelectedToOwned,
commitSelectedToCollection,
unprocessedCount,
createCollection,
clearScannedCards,
removeScannedCard,
toggleCardSelection,
clearSelection,
undoCardProcess,
undoStack,
deckComplete,
ownershipMap,
};
}