Normalize collector numbers in catalog match and harden scanner adds.

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:
Randall Stillwell 2026-05-28 14:22:06 -05:00
parent 174a370fc3
commit 470d373849
7 changed files with 170 additions and 68 deletions

View file

@ -15,6 +15,7 @@ export default function ScannedCardItem({
onAddToCollection,
onAddToDeck,
onRemove,
isAdding = false,
}) {
const [ownedQuantity, setOwnedQuantity] = useState(null);
@ -217,11 +218,12 @@ export default function ScannedCardItem({
<button
type="button"
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' }}
>
<span aria-hidden="true">💎 </span>
Mark Owned
{isAdding ? 'Adding…' : 'Mark Owned'}
</button>
<button
type="button"

View file

@ -1,5 +1,10 @@
import { sql } from '@vercel/postgres';
import {
cardNumbersEquivalent,
findPrintingByCollectorNumber,
} from './card-number-utils.js';
function mapCardRow(card) {
return {
id: card.id,
@ -112,6 +117,32 @@ export async function matchCardInCatalog({
if (exactResult.rows.length > 0) {
existingCard = exactResult.rows[0];
} 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(
userId,
{
@ -131,6 +162,7 @@ export async function matchCardInCatalog({
);
}
}
}
if (!existingCard && (set || setCode)) {
const setResult = set
@ -225,19 +257,7 @@ export async function matchCardInCatalog({
const hasSpecificPrinting = Boolean((set || setCode) && cardNumber);
if (hasSpecificPrinting) {
const printingMatch = await sql`
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) {
if (!cardNumbersEquivalent(existingCard.card_number, cardNumber)) {
const siblingIds = await sql`
SELECT id FROM cards WHERE LOWER(name) = LOWER(${trimmedName})
`;

View file

@ -1,25 +1,8 @@
import { sql } from '@vercel/postgres';
/**
* 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();
}
import { cardNumbersEquivalent, normalizeCardNumber } from '../card-number-utils.js';
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 { normalizeCardNumber };
function normalizeGame(value) {
if (!value || typeof value !== 'string') return null;
@ -62,7 +45,7 @@ export function submissionPayloadMatchesSet(payload, { game, setCode, setName })
function cardNumberMatches(catalogNumber, payloadNumber) {
if (!payloadNumber) return true;
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 [isProcessing, setIsProcessing] = useState(false);
const addingInFlightRef = useRef(new Set());
const [addingCardIds, setAddingCardIds] = useState(() => new Set());
const [sessionDestination, setSessionDestination] = useState(
() => loadSavedScannerSession().destination
);
@ -191,11 +192,14 @@ export default function Scanner() {
}
try {
if (!tryBeginAdding(cardEntry.id)) return;
await routeCardToDestination(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);
}
};
@ -221,35 +225,56 @@ export default function Scanner() {
// 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
const addSingleCardToOwned = async (card) => {
if (addingInFlightRef.current.has(card.id)) return;
addingInFlightRef.current.add(card.id);
if (!tryBeginAdding(card.id)) return;
try {
await addToOwnedCards(card);
markCardAsProcessed(card.id, 'owned');
} catch (error) {
console.error('Error adding card to owned:', error);
} finally {
addingInFlightRef.current.delete(card.id);
endAdding(card.id);
}
};
const addSingleCardToCollection = async (card, collectionId) => {
if (!tryBeginAdding(card.id)) return;
try {
await addToCollection(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 addToDeck(card, deckId);
markCardAsProcessed(card.id, 'deck');
} catch (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) {
if (action === 'owned') {
if (addingInFlightRef.current.has(card.id)) continue;
addingInFlightRef.current.add(card.id);
if (!tryBeginAdding(card.id)) continue;
try {
await addToOwnedCards(card);
} finally {
addingInFlightRef.current.delete(card.id);
endAdding(card.id);
}
} else if (action === 'collection' && target) {
if (!tryBeginAdding(card.id)) continue;
try {
await addToCollection(card, target);
} finally {
endAdding(card.id);
}
} else if (action === 'deck' && target) {
if (!tryBeginAdding(card.id)) continue;
try {
await addToDeck(card, target);
} finally {
endAdding(card.id);
}
} else {
continue;
}
@ -610,6 +644,7 @@ export default function Scanner() {
onAddToCollection={(collectionId) => addSingleCardToCollection(card, collectionId)}
onAddToDeck={(deckId) => addSingleCardToDeck(card, deckId)}
onRemove={() => removeScannedCard(card.id)}
isAdding={addingCardIds.has(card.id)}
/>
</li>
))}

View file

@ -1,18 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
normalizeCardNumber,
pickCatalogCardForPayload,
submissionPayloadMatchesSet,
} 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', () => {
it('matches by set code or set name for the imported game', () => {
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);
});
});