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:
parent
174a370fc3
commit
470d373849
7 changed files with 170 additions and 68 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,23 +117,50 @@ export async function matchCardInCatalog({
|
||||||
if (exactResult.rows.length > 0) {
|
if (exactResult.rows.length > 0) {
|
||||||
existingCard = exactResult.rows[0];
|
existingCard = exactResult.rows[0];
|
||||||
} else {
|
} else {
|
||||||
return submitScanForReview(
|
const setCandidates = await sql`
|
||||||
userId,
|
SELECT * FROM cards
|
||||||
{
|
WHERE LOWER(name) = LOWER(${trimmedName})
|
||||||
name: trimmedName,
|
AND (
|
||||||
set,
|
LOWER(set_name) = LOWER(${set || setCode})
|
||||||
setCode,
|
OR LOWER(set_code) = LOWER(${setCode || set})
|
||||||
cardNumber,
|
)
|
||||||
game,
|
`;
|
||||||
cardType,
|
|
||||||
rarity,
|
const normalizedMatch = findPrintingByCollectorNumber(setCandidates.rows, cardNumber);
|
||||||
hp,
|
if (normalizedMatch) {
|
||||||
manaCost,
|
existingCard = normalizedMatch;
|
||||||
ocrData,
|
} else {
|
||||||
scanImageUrl,
|
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,
|
||||||
|
{
|
||||||
|
name: trimmedName,
|
||||||
|
set,
|
||||||
|
setCode,
|
||||||
|
cardNumber,
|
||||||
|
game,
|
||||||
|
cardType,
|
||||||
|
rarity,
|
||||||
|
hp,
|
||||||
|
manaCost,
|
||||||
|
ocrData,
|
||||||
|
scanImageUrl,
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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})
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -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
35
lib/card-number-utils.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
await addToCollection(card, target);
|
if (!tryBeginAdding(card.id)) continue;
|
||||||
|
try {
|
||||||
|
await addToCollection(card, target);
|
||||||
|
} finally {
|
||||||
|
endAdding(card.id);
|
||||||
|
}
|
||||||
} else if (action === 'deck' && target) {
|
} else if (action === 'deck' && target) {
|
||||||
await addToDeck(card, target);
|
if (!tryBeginAdding(card.id)) continue;
|
||||||
|
try {
|
||||||
|
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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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' };
|
||||||
|
|
|
||||||
35
test/lib/card-number-utils.test.js
Normal file
35
test/lib/card-number-utils.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue