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 <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-02 16:07:33 -05:00
parent cf770b618c
commit 6b70fd9bd1
5 changed files with 351 additions and 222 deletions

113
lib/scanner-route-api.js Normal file
View file

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

87
lib/scanner-session.js Normal file
View file

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

View file

@ -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 (
<Layout user={null}>

View file

@ -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,
});
});
});

View file

@ -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');
});
});