Normalize collector numbers in catalog match and harden scanner adds. (#53)

Share card-number normalization across reconcile and identify paths, retry set/name matches when OCR uses leading-zero collector numbers, and extend in-flight locks to all scanner destination actions with disabled Mark Owned feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
varutasu 2026-05-28 14:45:55 -05:00 committed by GitHub
parent 174a370fc3
commit c51ec6a04c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 170 additions and 68 deletions

View file

@ -15,6 +15,7 @@ export default function ScannedCardItem({
onAddToCollection, onAddToCollection,
onAddToDeck, onAddToDeck,
onRemove, onRemove,
isAdding = false,
}) { }) {
const [ownedQuantity, setOwnedQuantity] = useState(null); const [ownedQuantity, setOwnedQuantity] = useState(null);
@ -217,11 +218,12 @@ export default function ScannedCardItem({
<button <button
type="button" type="button"
onClick={onMarkOwned} onClick={onMarkOwned}
className="flex-1 px-3 py-2 rounded text-sm font-medium hover:opacity-80 flex items-center justify-center gap-1" disabled={isAdding}
className="flex-1 px-3 py-2 rounded text-sm font-medium hover:opacity-80 flex items-center justify-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }} style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
> >
<span aria-hidden="true">💎 </span> <span aria-hidden="true">💎 </span>
Mark Owned {isAdding ? 'Adding…' : 'Mark Owned'}
</button> </button>
<button <button
type="button" type="button"

View file

@ -1,5 +1,10 @@
import { sql } from '@vercel/postgres'; import { sql } from '@vercel/postgres';
import {
cardNumbersEquivalent,
findPrintingByCollectorNumber,
} from './card-number-utils.js';
function mapCardRow(card) { function mapCardRow(card) {
return { return {
id: card.id, id: card.id,
@ -112,6 +117,32 @@ export async function matchCardInCatalog({
if (exactResult.rows.length > 0) { if (exactResult.rows.length > 0) {
existingCard = exactResult.rows[0]; existingCard = exactResult.rows[0];
} else { } else {
const setCandidates = await sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName})
AND (
LOWER(set_name) = LOWER(${set || setCode})
OR LOWER(set_code) = LOWER(${setCode || set})
)
`;
const normalizedMatch = findPrintingByCollectorNumber(setCandidates.rows, cardNumber);
if (normalizedMatch) {
existingCard = normalizedMatch;
} else {
const ambiguousMatches = setCandidates.rows.filter((row) =>
cardNumbersEquivalent(row.card_number, cardNumber)
);
if (ambiguousMatches.length > 1) {
return {
type: 'disambiguation',
card: null,
matches: ambiguousMatches.map(mapCardRow),
needsUserSelection: true,
message: `Found ${ambiguousMatches.length} matches for "${trimmedName}" in that set. Select the correct printing.`,
};
}
return submitScanForReview( return submitScanForReview(
userId, userId,
{ {
@ -131,6 +162,7 @@ export async function matchCardInCatalog({
); );
} }
} }
}
if (!existingCard && (set || setCode)) { if (!existingCard && (set || setCode)) {
const setResult = set const setResult = set
@ -225,19 +257,7 @@ export async function matchCardInCatalog({
const hasSpecificPrinting = Boolean((set || setCode) && cardNumber); const hasSpecificPrinting = Boolean((set || setCode) && cardNumber);
if (hasSpecificPrinting) { if (hasSpecificPrinting) {
const printingMatch = await sql` if (!cardNumbersEquivalent(existingCard.card_number, cardNumber)) {
SELECT * FROM cards
WHERE id = ${existingCard.id}
AND (
LOWER(card_number) = LOWER(${cardNumber})
AND (
LOWER(set_name) = LOWER(${set || setCode})
OR LOWER(set_code) = LOWER(${setCode || set})
)
)
LIMIT 1
`;
if (printingMatch.rows.length === 0) {
const siblingIds = await sql` const siblingIds = await sql`
SELECT id FROM cards WHERE LOWER(name) = LOWER(${trimmedName}) SELECT id FROM cards WHERE LOWER(name) = LOWER(${trimmedName})
`; `;

View file

@ -1,25 +1,8 @@
import { sql } from '@vercel/postgres'; import { sql } from '@vercel/postgres';
/** import { cardNumbersEquivalent, normalizeCardNumber } from '../card-number-utils.js';
* Normalize collector numbers so "015/208" and "15/208" compare equal.
*/
export function normalizeCardNumber(value) {
if (value == null || value === '') return '';
const trimmed = String(value).trim();
const slashIndex = trimmed.indexOf('/');
if (slashIndex === -1) {
return trimmed.toLowerCase();
}
const numerator = trimmed.slice(0, slashIndex).trim(); export { normalizeCardNumber };
const denominator = trimmed.slice(slashIndex + 1).trim();
const normalizedNumerator = String(parseInt(numerator, 10));
if (normalizedNumerator === 'NaN') {
return trimmed.toLowerCase();
}
return `${normalizedNumerator}/${denominator}`.toLowerCase();
}
function normalizeGame(value) { function normalizeGame(value) {
if (!value || typeof value !== 'string') return null; if (!value || typeof value !== 'string') return null;
@ -62,7 +45,7 @@ export function submissionPayloadMatchesSet(payload, { game, setCode, setName })
function cardNumberMatches(catalogNumber, payloadNumber) { function cardNumberMatches(catalogNumber, payloadNumber) {
if (!payloadNumber) return true; if (!payloadNumber) return true;
if (!catalogNumber) return false; if (!catalogNumber) return false;
return normalizeCardNumber(catalogNumber) === normalizeCardNumber(payloadNumber); return cardNumbersEquivalent(catalogNumber, payloadNumber);
} }
/** /**

35
lib/card-number-utils.js Normal file
View file

@ -0,0 +1,35 @@
/**
* Normalize collector numbers so "015/208" and "15/208" compare equal.
*/
export function normalizeCardNumber(value) {
if (value == null || value === '') return '';
const trimmed = String(value).trim();
const slashIndex = trimmed.indexOf('/');
if (slashIndex === -1) {
return trimmed.toLowerCase();
}
const numerator = trimmed.slice(0, slashIndex).trim();
const denominator = trimmed.slice(slashIndex + 1).trim();
const normalizedNumerator = String(parseInt(numerator, 10));
if (normalizedNumerator === 'NaN') {
return trimmed.toLowerCase();
}
return `${normalizedNumerator}/${denominator}`.toLowerCase();
}
export function cardNumbersEquivalent(a, b) {
if (a == null || b == null || a === '' || b === '') return false;
return normalizeCardNumber(a) === normalizeCardNumber(b);
}
/**
* Pick one catalog row when name+set candidates share an equivalent collector number.
*/
export function findPrintingByCollectorNumber(candidates, cardNumber) {
if (!cardNumber || candidates.length === 0) return null;
const matches = candidates.filter((row) => cardNumbersEquivalent(row.card_number, cardNumber));
return matches.length === 1 ? matches[0] : null;
}

View file

@ -53,6 +53,7 @@ export default function Scanner() {
const [bulkTarget, setBulkTarget] = useState(''); const [bulkTarget, setBulkTarget] = useState('');
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const addingInFlightRef = useRef(new Set()); const addingInFlightRef = useRef(new Set());
const [addingCardIds, setAddingCardIds] = useState(() => new Set());
const [sessionDestination, setSessionDestination] = useState( const [sessionDestination, setSessionDestination] = useState(
() => loadSavedScannerSession().destination () => loadSavedScannerSession().destination
); );
@ -191,11 +192,14 @@ export default function Scanner() {
} }
try { try {
if (!tryBeginAdding(cardEntry.id)) return;
await routeCardToDestination(cardEntry, sessionDestination); await routeCardToDestination(cardEntry, sessionDestination);
markCardAsProcessed(cardEntry.id, destinationActionKey(sessionDestination)); markCardAsProcessed(cardEntry.id, destinationActionKey(sessionDestination));
} catch (error) { } catch (error) {
console.error('Auto-route failed:', error); console.error('Auto-route failed:', error);
setAutoRouteError(`Could not add "${cardEntry.name}" to ${sessionDestination.label}. Use the card actions below.`); setAutoRouteError(`Could not add "${cardEntry.name}" to ${sessionDestination.label}. Use the card actions below.`);
} finally {
endAdding(cardEntry.id);
} }
}; };
@ -221,35 +225,56 @@ export default function Scanner() {
// You could show a toast notification here // You could show a toast notification here
}; };
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();
};
// Individual card actions // Individual card actions
const addSingleCardToOwned = async (card) => { const addSingleCardToOwned = async (card) => {
if (addingInFlightRef.current.has(card.id)) return; if (!tryBeginAdding(card.id)) return;
addingInFlightRef.current.add(card.id);
try { try {
await addToOwnedCards(card); await addToOwnedCards(card);
markCardAsProcessed(card.id, 'owned'); markCardAsProcessed(card.id, 'owned');
} catch (error) { } catch (error) {
console.error('Error adding card to owned:', error); console.error('Error adding card to owned:', error);
} finally { } finally {
addingInFlightRef.current.delete(card.id); endAdding(card.id);
} }
}; };
const addSingleCardToCollection = async (card, collectionId) => { const addSingleCardToCollection = async (card, collectionId) => {
if (!tryBeginAdding(card.id)) return;
try { try {
await addToCollection(card, collectionId); await addToCollection(card, collectionId);
markCardAsProcessed(card.id, 'collection'); markCardAsProcessed(card.id, 'collection');
} catch (error) { } catch (error) {
console.error('Error adding card to collection:', error); console.error('Error adding card to collection:', error);
} finally {
endAdding(card.id);
} }
}; };
const addSingleCardToDeck = async (card, deckId) => { const addSingleCardToDeck = async (card, deckId) => {
if (!tryBeginAdding(card.id)) return;
try { try {
await addToDeck(card, deckId); await addToDeck(card, deckId);
markCardAsProcessed(card.id, 'deck'); markCardAsProcessed(card.id, 'deck');
} catch (error) { } catch (error) {
console.error('Error adding card to deck:', error); console.error('Error adding card to deck:', error);
} finally {
endAdding(card.id);
} }
}; };
@ -265,17 +290,26 @@ export default function Scanner() {
for (const card of cardsToProcess) { for (const card of cardsToProcess) {
if (action === 'owned') { if (action === 'owned') {
if (addingInFlightRef.current.has(card.id)) continue; if (!tryBeginAdding(card.id)) continue;
addingInFlightRef.current.add(card.id);
try { try {
await addToOwnedCards(card); await addToOwnedCards(card);
} finally { } finally {
addingInFlightRef.current.delete(card.id); endAdding(card.id);
} }
} else if (action === 'collection' && target) { } else if (action === 'collection' && target) {
if (!tryBeginAdding(card.id)) continue;
try {
await addToCollection(card, target); await addToCollection(card, target);
} finally {
endAdding(card.id);
}
} else if (action === 'deck' && target) { } else if (action === 'deck' && target) {
if (!tryBeginAdding(card.id)) continue;
try {
await addToDeck(card, target); await addToDeck(card, target);
} finally {
endAdding(card.id);
}
} else { } else {
continue; continue;
} }
@ -610,6 +644,7 @@ export default function Scanner() {
onAddToCollection={(collectionId) => addSingleCardToCollection(card, collectionId)} onAddToCollection={(collectionId) => addSingleCardToCollection(card, collectionId)}
onAddToDeck={(deckId) => addSingleCardToDeck(card, deckId)} onAddToDeck={(deckId) => addSingleCardToDeck(card, deckId)}
onRemove={() => removeScannedCard(card.id)} onRemove={() => removeScannedCard(card.id)}
isAdding={addingCardIds.has(card.id)}
/> />
</li> </li>
))} ))}

View file

@ -1,18 +1,10 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
normalizeCardNumber,
pickCatalogCardForPayload, pickCatalogCardForPayload,
submissionPayloadMatchesSet, submissionPayloadMatchesSet,
} from '../../lib/card-import/reconcile-submissions.js'; } from '../../lib/card-import/reconcile-submissions.js';
describe('normalizeCardNumber', () => {
it('treats leading-zero numerators as equivalent', () => {
expect(normalizeCardNumber('015/208')).toBe('15/208');
expect(normalizeCardNumber('15/208')).toBe('15/208');
});
});
describe('submissionPayloadMatchesSet', () => { describe('submissionPayloadMatchesSet', () => {
it('matches by set code or set name for the imported game', () => { it('matches by set code or set name for the imported game', () => {
const imported = { game: 'pokemon', setCode: 'sv10', setName: 'Perfect Order' }; const imported = { game: 'pokemon', setCode: 'sv10', setName: 'Perfect Order' };

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import {
cardNumbersEquivalent,
findPrintingByCollectorNumber,
normalizeCardNumber,
} from '../../lib/card-number-utils.js';
describe('normalizeCardNumber', () => {
it('treats leading-zero numerators as equivalent', () => {
expect(normalizeCardNumber('015/208')).toBe('15/208');
expect(normalizeCardNumber('15/208')).toBe('15/208');
expect(normalizeCardNumber('18/88')).toBe('18/88');
});
});
describe('cardNumbersEquivalent', () => {
it('matches normalized collector numbers', () => {
expect(cardNumbersEquivalent('015/208', '15/208')).toBe(true);
expect(cardNumbersEquivalent('18/88', '015/208')).toBe(false);
});
});
describe('findPrintingByCollectorNumber', () => {
it('returns the sole matching printing', () => {
const match = findPrintingByCollectorNumber(
[
{ id: 1, card_number: '18/88' },
{ id: 2, card_number: '19/88' },
],
'018/88'
);
expect(match?.id).toBe(1);
});
});