diff --git a/.changeset/fix-buy-list-scope.md b/.changeset/fix-buy-list-scope.md new file mode 100644 index 0000000..9c3617e --- /dev/null +++ b/.changeset/fix-buy-list-scope.md @@ -0,0 +1,7 @@ +--- +"@mtg-deckbuilder/shared": patch +"@mtg-deckbuilder/mcp-server": patch +"@mtg-deckbuilder/electron-app": patch +--- + +Fix the Buy List view only showing "need to buy" cards from a deck's mainboard. `useBuyList` scanned `getMainboard(deck)`, so cards flagged `need_to_buy` in the `alternates` or `sideboard` sets — where this ownership tag is actually used most often — never appeared. It now scans `getNonCutEntries(deck)` (mainboard + sideboard + alternates, excluding the cut pile), matching the scope `DeckStats` already uses for its own "cards needing purchase" count. diff --git a/packages/electron-app/src/hooks/useStore.test.ts b/packages/electron-app/src/hooks/useStore.test.ts new file mode 100644 index 0000000..96ec670 --- /dev/null +++ b/packages/electron-app/src/hooks/useStore.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { buildBuyList } from './useStore' +import { FORMAT_TYPE, formatDefaults } from '@mtg-deckbuilder/shared' +import type { Deck, CardEntry, CardSet } from '@/types' + +function entry(name: string, quantity: number, ownership: CardEntry['ownership']): CardEntry { + return { + id: name, + card: { name, scryfallId: 'sf-1', setCode: 'abc', collectorNumber: '1' }, + addedAt: '2026-01-01T00:00:00.000Z', + source: 'user', + quantity, + ownership, + roles: [] + } +} + +function deck(id: string, name: string, cardSets: CardSet[]): Deck { + return { + id, + name, + format: formatDefaults[FORMAT_TYPE.COMMANDER], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + version: 1, + cardSets, + commanders: [], + customRoles: [], + notes: [] + } +} + +describe('buildBuyList', () => { + it('includes need-to-buy cards from the alternates set, not just mainboard', () => { + const decks = [ + deck('d1', 'Draw Two', [ + { name: 'mainboard', entries: [] }, + { name: 'alternates', entries: [entry('Consider', 4, 'need_to_buy')] } + ]) + ] + + const buyList = buildBuyList(decks) + + expect(buyList.map(i => i.cardName)).toContain('Consider') + }) + + it('excludes cards in the cut set', () => { + const decks = [ + deck('d1', 'Niv-Mizzet, Spellslinger', [ + { name: 'mainboard', entries: [] }, + { name: 'cut', entries: [entry('Harmonic Prodigy', 1, 'need_to_buy')] } + ]) + ] + + const buyList = buildBuyList(decks) + + expect(buyList.map(i => i.cardName)).not.toContain('Harmonic Prodigy') + }) +}) diff --git a/packages/electron-app/src/hooks/useStore.ts b/packages/electron-app/src/hooks/useStore.ts index d4e85a9..da09ced 100644 --- a/packages/electron-app/src/hooks/useStore.ts +++ b/packages/electron-app/src/hooks/useStore.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { create } from 'zustand' import type { Deck, Taxonomy, CardList, Config, RoleDefinition, SetCollectionFile, PullListConfig } from '@/types' -import { getMainboard } from '@mtg-deckbuilder/shared' +import { getNonCutEntries } from '@mtg-deckbuilder/shared' import type { AppState } from '@/stores/types' import { createDeckSlice } from '@/stores/deckSlice' import { createCardSlice } from '@/stores/cardSlice' @@ -130,52 +130,58 @@ export interface BuyListItem { collectorNumber: string } -export const useBuyList = (): BuyListItem[] => { - const decks = useStore(state => state.decks) - - // Memoized so the returned array is referentially stable across renders - // when `decks` is unchanged. Consumers depend on this in effect deps — - // returning a fresh array each render caused a self-sustaining fetch loop - // in BuyListView (price fetch → setState → rerender → new ref → refetch). - return useMemo(() => { - const buyMap = new Map() - - for (const deck of decks) { - for (const card of getMainboard(deck)) { - if (card.ownership === 'need_to_buy') { - const key = card.card.name.toLowerCase() - const existing = buyMap.get(key) - const qty = card.quantity - - if (existing) { - existing.totalQuantity += qty - existing.decks.push({ +// Scans mainboard, sideboard, and alternates — everything except the cut +// pile, since a cut card isn't something the deck still needs. Exported +// standalone (rather than inlined in the hook) so it's testable without +// rendering: the hook's job is just memoizing this over `decks`. +export function buildBuyList(decks: Deck[]): BuyListItem[] { + const buyMap = new Map() + + for (const deck of decks) { + for (const card of getNonCutEntries(deck)) { + if (card.ownership === 'need_to_buy') { + const key = card.card.name.toLowerCase() + const existing = buyMap.get(key) + const qty = card.quantity + + if (existing) { + existing.totalQuantity += qty + existing.decks.push({ + deckId: deck.id, + deckName: deck.name, + quantity: qty + }) + } else { + buyMap.set(key, { + cardName: card.card.name, + totalQuantity: qty, + decks: [{ deckId: deck.id, deckName: deck.name, quantity: qty - }) - } else { - buyMap.set(key, { - cardName: card.card.name, - totalQuantity: qty, - decks: [{ - deckId: deck.id, - deckName: deck.name, - quantity: qty - }], - scryfallId: card.card.scryfallId, - setCode: card.card.setCode, - collectorNumber: card.card.collectorNumber - }) - } + }], + scryfallId: card.card.scryfallId, + setCode: card.card.setCode, + collectorNumber: card.card.collectorNumber + }) } } } + } + + return Array.from(buyMap.values()).sort((a, b) => + a.cardName.localeCompare(b.cardName) + ) +} + +export const useBuyList = (): BuyListItem[] => { + const decks = useStore(state => state.decks) - return Array.from(buyMap.values()).sort((a, b) => - a.cardName.localeCompare(b.cardName) - ) - }, [decks]) + // Memoized so the returned array is referentially stable across renders + // when `decks` is unchanged. Consumers depend on this in effect deps — + // returning a fresh array each render caused a self-sustaining fetch loop + // in BuyListView (price fetch → setState → rerender → new ref → refetch). + return useMemo(() => buildBuyList(decks), [decks]) } // Role hooks diff --git a/packages/electron-app/vitest.config.ts b/packages/electron-app/vitest.config.ts index db29bda..71bb4dc 100644 --- a/packages/electron-app/vitest.config.ts +++ b/packages/electron-app/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from 'vitest/config' +import path from 'path' export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + }, test: { globals: true, include: ['electron/**/*.test.ts', 'src/**/*.test.ts'],