Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-buy-list-scope.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 59 additions & 0 deletions packages/electron-app/src/hooks/useStore.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
86 changes: 46 additions & 40 deletions packages/electron-app/src/hooks/useStore.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, BuyListItem>()

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<string, BuyListItem>()

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
Expand Down
6 changes: 6 additions & 0 deletions packages/electron-app/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
Expand Down
Loading