From 6b70fd9bd1925dd706ee6add10d7b505114b8ced Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 2 Jun 2026 16:07:33 -0500 Subject: [PATCH] refactor(scanner): extract session and route API libs (page Brief 1) Move scanner session persistence, queue merge helpers, and destination routing fetch calls into lib/scanner-session.js and lib/scanner-route-api.js. Load collections/decks on mount (were defined but never invoked). Remove unused mana-symbol imports and dead select-all helpers. Co-authored-by: Cursor --- lib/scanner-route-api.js | 113 ++++++++++++ lib/scanner-session.js | 87 +++++++++ pages/scanner.js | 281 ++++++----------------------- test/lib/scanner-route-api.test.js | 31 ++++ test/lib/scanner-session.test.js | 61 +++++++ 5 files changed, 351 insertions(+), 222 deletions(-) create mode 100644 lib/scanner-route-api.js create mode 100644 lib/scanner-session.js create mode 100644 test/lib/scanner-route-api.test.js create mode 100644 test/lib/scanner-session.test.js diff --git a/lib/scanner-route-api.js b/lib/scanner-route-api.js new file mode 100644 index 0000000..214f40c --- /dev/null +++ b/lib/scanner-route-api.js @@ -0,0 +1,113 @@ +import { VOCAB } from './collection-vocabulary.js'; + +function authHeaders(json = true) { + const headers = { + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, + }; + if (json) { + headers['Content-Type'] = 'application/json'; + } + return headers; +} + +export function buildScannerCardPayload(cardData) { + const payload = { + cardId: cardData.databaseId, + quantity: cardData.quantity || 1, + condition: cardData.condition || 'NM', + is_foil: Boolean(cardData.isFoil), + }; + + if (cardData.scanImageUrl) { + payload.scan_image_url = cardData.scanImageUrl; + } + + return payload; +} + +export async function fetchScannerCollections() { + const response = await fetch('/api/collections', { headers: authHeaders(false) }); + if (!response.ok) { + throw new Error('Failed to load lists'); + } + const data = await response.json(); + return data.filter((collection) => !collection.is_system_collection); +} + +export async function fetchScannerDecks() { + const response = await fetch('/api/decks', { headers: authHeaders(false) }); + if (!response.ok) { + throw new Error('Failed to load decks'); + } + return response.json(); +} + +export async function addScannedCardToOwned(cardData) { + const response = await fetch('/api/user-cards', { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify(buildScannerCardPayload(cardData)), + }); + + if (!response.ok) { + throw new Error(`Failed to add to ${VOCAB.MY_COLLECTION}`); + } +} + +export async function addScannedCardToCollection(cardData, collectionId) { + const response = await fetch(`/api/collections/${collectionId}/cards`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify(buildScannerCardPayload(cardData)), + }); + + if (!response.ok) { + throw new Error(`Failed to add to ${VOCAB.LIST}`); + } +} + +export async function addScannedCardToDeck(cardData, deckId) { + const response = await fetch(`/api/decks/${deckId}/cards`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify(buildScannerCardPayload(cardData)), + }); + + if (!response.ok) { + throw new Error('Failed to add to deck'); + } +} + +export async function createScannerCollection(name) { + const response = await fetch('/api/collections', { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ + name, + description: 'Created from card scanner', + is_public: false, + }), + }); + + if (!response.ok) { + throw new Error('Failed to create list'); + } + + return response.json(); +} + +export async function routeScannedCardToDestination(cardData, destination) { + if (!destination || !cardData.databaseId) return false; + + if (destination.type === 'owned') { + await addScannedCardToOwned(cardData); + } else if (destination.type === 'collection') { + await addScannedCardToCollection(cardData, destination.id); + } else if (destination.type === 'deck') { + await addScannedCardToDeck(cardData, destination.id); + } else { + return false; + } + + return true; +} diff --git a/lib/scanner-session.js b/lib/scanner-session.js new file mode 100644 index 0000000..49e57b6 --- /dev/null +++ b/lib/scanner-session.js @@ -0,0 +1,87 @@ +import { VOCAB } from './collection-vocabulary.js'; + +export const SCANNER_SESSION_STORAGE_KEY = 'deckhearth:scanner-session'; + +export const DEFAULT_SCANNER_DESTINATION = { + type: 'owned', + id: null, + label: VOCAB.MY_COLLECTION, +}; + +export const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false }; + +export function loadSavedScannerSession() { + if (typeof window === 'undefined') { + return { + destination: DEFAULT_SCANNER_DESTINATION, + gameFilter: 'All', + scanDefaults: DEFAULT_SCAN_DEFAULTS, + }; + } + + try { + const saved = JSON.parse(localStorage.getItem(SCANNER_SESSION_STORAGE_KEY)); + return { + destination: saved?.destination || DEFAULT_SCANNER_DESTINATION, + gameFilter: saved?.gameFilter || 'All', + scanDefaults: saved?.scanDefaults || DEFAULT_SCAN_DEFAULTS, + }; + } catch { + return { + destination: DEFAULT_SCANNER_DESTINATION, + gameFilter: 'All', + scanDefaults: DEFAULT_SCAN_DEFAULTS, + }; + } +} + +export function saveScannerSession({ destination, gameFilter, scanDefaults }) { + if (typeof window === 'undefined') return; + localStorage.setItem( + SCANNER_SESSION_STORAGE_KEY, + JSON.stringify({ destination, gameFilter, scanDefaults }) + ); +} + +export function createScanCardId() { + return Date.now() + Math.random(); +} + +export function destinationActionKey(destination) { + if (!destination) return 'pending'; + return destination.type === 'owned' ? 'owned' : destination.type; +} + +export function mergeScannedCardEntry(existingCards, cardData, scanDefaults) { + const existingCardIndex = existingCards.findIndex( + (existing) => + existing.name === cardData.name && existing.set === cardData.set && !existing.processed + ); + + if (existingCardIndex !== -1) { + const existing = existingCards[existingCardIndex]; + const cardEntry = { + ...existing, + quantity: (existing.quantity || 1) + 1, + scanImageUrl: cardData.scanImageUrl || existing.scanImageUrl, + timestamp: new Date().toISOString(), + }; + const updated = [...existingCards]; + updated[existingCardIndex] = cardEntry; + return { cardEntry, scannedCards: updated, merged: true }; + } + + const cardEntry = { + ...cardData, + id: createScanCardId(), + name: cardData.name, + set: cardData.set, + quantity: 1, + condition: scanDefaults.condition, + isFoil: scanDefaults.isFoil, + timestamp: new Date().toISOString(), + processed: false, + }; + + return { cardEntry, scannedCards: [cardEntry, ...existingCards], merged: false }; +} diff --git a/pages/scanner.js b/pages/scanner.js index 3e366c5..72d8c02 100644 --- a/pages/scanner.js +++ b/pages/scanner.js @@ -5,41 +5,25 @@ import CameraScanner from '../components/CameraScanner'; import ScannerDestinationPicker from '../components/ScannerDestinationPicker'; import ScannedCardItem, { CONDITION_OPTIONS } from '../components/ScannedCardItem'; import OCRSettings from '../components/OCRSettings'; -import { ManaCost, ColorIdentity } from '../components/ManaSymbols'; -import ManaSymbolSettings from '../components/ManaSymbolSettings'; import { useAuth } from '../lib/use-auth'; import { useFocusTrap } from '../lib/use-focus-trap.js'; import { VOCAB } from '../lib/collection-vocabulary.js'; - -const SESSION_STORAGE_KEY = 'deckhearth:scanner-session'; - -const DEFAULT_DESTINATION = { type: 'owned', id: null, label: VOCAB.MY_COLLECTION }; -const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false }; - -function loadSavedScannerSession() { - if (typeof window === 'undefined') { - return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS }; - } - try { - const saved = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY)); - return { - destination: saved?.destination || DEFAULT_DESTINATION, - gameFilter: saved?.gameFilter || 'All', - scanDefaults: saved?.scanDefaults || DEFAULT_SCAN_DEFAULTS, - }; - } catch { - return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS }; - } -} - -function createScanCardId() { - return Date.now() + Math.random(); -} - -function destinationActionKey(destination) { - if (!destination) return 'pending'; - return destination.type === 'owned' ? 'owned' : destination.type; -} +import { + addScannedCardToCollection, + addScannedCardToDeck, + addScannedCardToOwned, + createScannerCollection, + fetchScannerCollections, + fetchScannerDecks, + routeScannedCardToDestination, +} from '../lib/scanner-route-api.js'; +import { + DEFAULT_SCANNER_DESTINATION, + destinationActionKey, + loadSavedScannerSession, + mergeScannedCardEntry, + saveScannerSession, +} from '../lib/scanner-session.js'; export default function Scanner() { const { user, loading: authLoading } = useAuth(); @@ -66,9 +50,6 @@ export default function Scanner() { const [scanDefaults, setScanDefaults] = useState(() => loadSavedScannerSession().scanDefaults); const [autoRouteError, setAutoRouteError] = useState(null); - // Mana symbol settings - const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); - // Redirect to login if not authenticated (wait for verify to finish) useEffect(() => { if (!authLoading && !user) { @@ -77,109 +58,50 @@ export default function Scanner() { }, [authLoading, user, router]); useEffect(() => { - if (typeof window === 'undefined') return; - localStorage.setItem( - SESSION_STORAGE_KEY, - JSON.stringify({ destination: sessionDestination, gameFilter, scanDefaults }) - ); + saveScannerSession({ destination: sessionDestination, gameFilter, scanDefaults }); }, [sessionDestination, gameFilter, scanDefaults]); + 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 handleGameFilterChange = (nextFilter) => { setGameFilter(nextFilter); setSessionDestination((current) => { if (!current || current.type === 'owned') return current; - return DEFAULT_DESTINATION; + return DEFAULT_SCANNER_DESTINATION; }); }; - const routeCardToDestination = async (card, destination) => { - if (!destination || !card.databaseId) return false; - - if (destination.type === 'owned') { - await addToOwnedCards(card); - } else if (destination.type === 'collection') { - await addToCollection(card, destination.id); - } else if (destination.type === 'deck') { - await addToDeck(card, destination.id); - } else { - return false; - } - - return true; - }; - - const loadCollections = async () => { - try { - const response = await fetch('/api/collections', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` - } - }); - if (response.ok) { - const data = await response.json(); - // Filter out auto-sync system lists (not user-curated) - const userCollections = data.filter(collection => !collection.is_system_collection); - setCollections(userCollections); - } - } catch (error) { - console.error('Error loading collections:', error); - } - }; - - const loadDecks = async () => { - try { - const response = await fetch('/api/decks', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` - } - }); - if (response.ok) { - const data = await response.json(); - setDecks(data); - } - } catch (error) { - console.error('Error loading decks:', error); - } - }; - const handleCardScanned = async (cardData) => { setAutoRouteError(null); - const existingCardIndex = scannedCards.findIndex((existing) => - existing.name === cardData.name && - existing.set === cardData.set && - !existing.processed + const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry( + scannedCards, + cardData, + scanDefaults ); - - let cardEntry; - - if (existingCardIndex !== -1) { - const existing = scannedCards[existingCardIndex]; - cardEntry = { - ...existing, - quantity: (existing.quantity || 1) + 1, - scanImageUrl: cardData.scanImageUrl || existing.scanImageUrl, - timestamp: new Date().toISOString(), - }; - setScannedCards((prev) => { - const updated = [...prev]; - updated[existingCardIndex] = cardEntry; - return updated; - }); - } else { - cardEntry = { - ...cardData, - id: createScanCardId(), - name: cardData.name, - set: cardData.set, - quantity: 1, - condition: scanDefaults.condition, - isFoil: scanDefaults.isFoil, - timestamp: new Date().toISOString(), - processed: false, - }; - setScannedCards((prev) => [cardEntry, ...prev]); - } + setScannedCards(nextQueue); if (!sessionDestination) return; @@ -190,7 +112,7 @@ export default function Scanner() { try { if (!tryBeginAdding(cardEntry.id)) return; - await routeCardToDestination(cardEntry, sessionDestination); + await routeScannedCardToDestination(cardEntry, sessionDestination); markCardAsProcessed(cardEntry.id, destinationActionKey(sessionDestination)); } catch (error) { console.error('Auto-route failed:', error); @@ -242,7 +164,7 @@ export default function Scanner() { const addSingleCardToOwned = async (card) => { if (!tryBeginAdding(card.id)) return; try { - await addToOwnedCards(card); + await addScannedCardToOwned(card); markCardAsProcessed(card.id, 'owned'); } catch (error) { console.error('Error adding card to owned:', error); @@ -254,7 +176,7 @@ export default function Scanner() { const addSingleCardToCollection = async (card, collectionId) => { if (!tryBeginAdding(card.id)) return; try { - await addToCollection(card, collectionId); + await addScannedCardToCollection(card, collectionId); markCardAsProcessed(card.id, 'collection'); } catch (error) { console.error('Error adding card to collection:', error); @@ -266,7 +188,7 @@ export default function Scanner() { const addSingleCardToDeck = async (card, deckId) => { if (!tryBeginAdding(card.id)) return; try { - await addToDeck(card, deckId); + await addScannedCardToDeck(card, deckId); markCardAsProcessed(card.id, 'deck'); } catch (error) { console.error('Error adding card to deck:', error); @@ -289,21 +211,21 @@ export default function Scanner() { if (action === 'owned') { if (!tryBeginAdding(card.id)) continue; try { - await addToOwnedCards(card); + await addScannedCardToOwned(card); } finally { endAdding(card.id); } } else if (action === 'collection' && target) { if (!tryBeginAdding(card.id)) continue; try { - await addToCollection(card, target); + await addScannedCardToCollection(card, target); } finally { endAdding(card.id); } } else if (action === 'deck' && target) { if (!tryBeginAdding(card.id)) continue; try { - await addToDeck(card, target); + await addScannedCardToDeck(card, target); } finally { endAdding(card.id); } @@ -337,91 +259,15 @@ export default function Scanner() { ); }; - const buildCardPayload = (cardData) => { - const payload = { - cardId: cardData.databaseId, - quantity: cardData.quantity || 1, - condition: cardData.condition || 'NM', - is_foil: Boolean(cardData.isFoil), - }; - - if (cardData.scanImageUrl) { - payload.scan_image_url = cardData.scanImageUrl; - } - - return payload; - }; - - // Helper functions for API calls - const addToOwnedCards = async (cardData) => { - const response = await fetch('/api/user-cards', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${localStorage.getItem('auth_token')}`, - }, - body: JSON.stringify(buildCardPayload(cardData)), - }); - - if (!response.ok) { - throw new Error(`Failed to add to ${VOCAB.MY_COLLECTION}`); - } - }; - - const addToCollection = async (cardData, collectionId) => { - const response = await fetch(`/api/collections/${collectionId}/cards`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` - }, - body: JSON.stringify(buildCardPayload(cardData)), - }); - - if (!response.ok) { - throw new Error(`Failed to add to ${VOCAB.LIST}`); - } - }; - - const addToDeck = async (cardData, deckId) => { - const response = await fetch(`/api/decks/${deckId}/cards`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${localStorage.getItem('auth_token')}`, - }, - body: JSON.stringify(buildCardPayload(cardData)), - }); - - if (!response.ok) { - throw new Error('Failed to add to deck'); - } - }; - const createCollection = async () => { if (!newCollectionName.trim()) return; try { - const response = await fetch('/api/collections', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` - }, - body: JSON.stringify({ - name: newCollectionName, - description: 'Created from card scanner', - is_public: false - }) - }); - - if (response.ok) { - const newCollection = await response.json(); - setCollections(prev => [newCollection, ...prev]); - setBulkTarget(newCollection.id.toString()); - setNewCollectionName(''); - setShowCreateCollection(false); - } + 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); } @@ -453,15 +299,6 @@ export default function Scanner() { }); }; - const selectAllCards = () => { - const unprocessedCards = scannedCards.filter(card => !card.processed); - setSelectedCards(new Set(unprocessedCards.map(card => card.id))); - }; - - const deselectAllCards = () => { - setSelectedCards(new Set()); - }; - if (authLoading) { return ( diff --git a/test/lib/scanner-route-api.test.js b/test/lib/scanner-route-api.test.js new file mode 100644 index 0000000..dbfbf2c --- /dev/null +++ b/test/lib/scanner-route-api.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { buildScannerCardPayload } from '../../lib/scanner-route-api.js'; + +describe('buildScannerCardPayload', () => { + it('maps scanner queue fields to API body shape', () => { + expect( + buildScannerCardPayload({ + databaseId: 42, + quantity: 2, + condition: 'LP', + isFoil: true, + scanImageUrl: 'https://blob.example/scan.jpg', + }) + ).toEqual({ + cardId: 42, + quantity: 2, + condition: 'LP', + is_foil: true, + scan_image_url: 'https://blob.example/scan.jpg', + }); + }); + + it('omits scan_image_url when capture was not uploaded', () => { + expect(buildScannerCardPayload({ databaseId: 1 })).toEqual({ + cardId: 1, + quantity: 1, + condition: 'NM', + is_foil: false, + }); + }); +}); diff --git a/test/lib/scanner-session.test.js b/test/lib/scanner-session.test.js new file mode 100644 index 0000000..beae9f5 --- /dev/null +++ b/test/lib/scanner-session.test.js @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SCANNER_DESTINATION, + destinationActionKey, + mergeScannedCardEntry, +} from '../../lib/scanner-session.js'; + +describe('destinationActionKey', () => { + it('maps owned destination to owned action key', () => { + expect(destinationActionKey(DEFAULT_SCANNER_DESTINATION)).toBe('owned'); + }); + + it('returns pending when destination is missing', () => { + expect(destinationActionKey(null)).toBe('pending'); + }); +}); + +describe('mergeScannedCardEntry', () => { + const scanDefaults = { condition: 'NM', isFoil: false }; + + it('creates a new queue entry with scan defaults', () => { + const { cardEntry, scannedCards, merged } = mergeScannedCardEntry([], { + name: 'Pikachu', + set: 'Base', + databaseId: 1, + }, scanDefaults); + + expect(merged).toBe(false); + expect(scannedCards).toHaveLength(1); + expect(cardEntry).toMatchObject({ + name: 'Pikachu', + set: 'Base', + quantity: 1, + condition: 'NM', + isFoil: false, + processed: false, + }); + expect(cardEntry.id).toBeTruthy(); + }); + + it('increments quantity for an unprocessed duplicate name+set', () => { + const existing = { + id: 99, + name: 'Bolt', + set: 'Alpha', + quantity: 2, + processed: false, + }; + + const { cardEntry, scannedCards, merged } = mergeScannedCardEntry( + [existing], + { name: 'Bolt', set: 'Alpha', databaseId: 1, scanImageUrl: 'https://blob/new.jpg' }, + scanDefaults + ); + + expect(merged).toBe(true); + expect(scannedCards).toHaveLength(1); + expect(cardEntry.quantity).toBe(3); + expect(cardEntry.scanImageUrl).toBe('https://blob/new.jpg'); + }); +}); -- 2.45.2