Normalize collector numbers in catalog match and harden scanner adds #53
7 changed files with 170 additions and 68 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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
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 [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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
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