From 4f6467f077e00af34ffa20b97a7bb517d4f84b0f Mon Sep 17 00:00:00 2001 From: varutasu <104105839+varutasu@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:44:16 -0500 Subject: [PATCH] refactor(deck): extract grouping lib and stats sidebar (Brief 1) (#137) Reuse computeDeckStats from deck-builder-stats; add groupDeckCards lib, DeckDetailStatsSidebar component, and vitest coverage. Fixes stray semicolon after useEffect. Page drops ~205 lines. Co-authored-by: Cursor --- components/DeckDetailStatsSidebar.js | 107 ++++++++++++ lib/deck-detail-grouping.js | 42 +++++ pages/deck/[id].js | 233 ++------------------------ test/lib/deck-detail-grouping.test.js | 37 ++++ 4 files changed, 200 insertions(+), 219 deletions(-) create mode 100644 components/DeckDetailStatsSidebar.js create mode 100644 lib/deck-detail-grouping.js create mode 100644 test/lib/deck-detail-grouping.test.js diff --git a/components/DeckDetailStatsSidebar.js b/components/DeckDetailStatsSidebar.js new file mode 100644 index 0000000..4095013 --- /dev/null +++ b/components/DeckDetailStatsSidebar.js @@ -0,0 +1,107 @@ +const GROUP_OPTIONS = [ + { value: 'type', label: 'Card Type' }, + { value: 'cmc', label: 'Mana Cost' }, + { value: 'color', label: 'Color' }, + { value: 'rarity', label: 'Rarity' }, +]; + +export default function DeckDetailStatsSidebar({ deck, stats, groupBy, onGroupByChange }) { + return ( +
+
+

+ Statistics +

+ +
+
+ Total Cards: + + {stats.totalCards} + +
+
+ Avg. CMC: + + {stats.avgCmc} + +
+
+ Format: + + {deck.format} + +
+
+ + {Object.keys(stats.colorCounts).length > 0 && ( +
+

+ Color Distribution +

+
+ {Object.entries(stats.colorCounts) + .sort(([, a], [, b]) => b - a) + .map(([color, count]) => ( +
+
+ {color} + + {color} + +
+ + {count} + +
+ ))} +
+
+ )} + + {Object.keys(stats.typeCounts).length > 0 && ( +
+

+ Card Types +

+
+ {Object.entries(stats.typeCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 8) + .map(([type, count]) => ( +
+ + {type} + + + {count} + +
+ ))} +
+
+ )} +
+ +
+

+ Group Cards By +

+
+ {GROUP_OPTIONS.map((option) => ( + + ))} +
+
+
+ ); +} diff --git a/lib/deck-detail-grouping.js b/lib/deck-detail-grouping.js new file mode 100644 index 0000000..c9c8d85 --- /dev/null +++ b/lib/deck-detail-grouping.js @@ -0,0 +1,42 @@ +/** + * Group deck card rows for the deck detail view. + * @param {Array|undefined} cards + * @param {'type'|'cmc'|'color'|'rarity'|string} groupBy + */ +export function groupDeckCards(cards, groupBy) { + if (!cards) { + return {}; + } + + return cards.reduce((groups, card) => { + let key; + + switch (groupBy) { + case 'type': + key = card.card_type ? card.card_type.split(' — ')[0] : 'Unknown'; + break; + case 'cmc': + key = `${card.cmc || 0} Mana`; + break; + case 'color': + try { + const colors = card.colors ? JSON.parse(card.colors) : []; + key = colors.length === 0 ? 'Colorless' : colors.join(''); + } catch { + key = 'Colorless'; + } + break; + case 'rarity': + key = card.rarity || 'Unknown'; + break; + default: + key = 'All Cards'; + } + + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(card); + return groups; + }, {}); +} diff --git a/pages/deck/[id].js b/pages/deck/[id].js index ad5e0b6..54d3808 100644 --- a/pages/deck/[id].js +++ b/pages/deck/[id].js @@ -3,10 +3,13 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/router'; import Link from 'next/link'; import Layout from '../../components/Layout'; -import { ManaCost, ColorIdentity } from '../../components/ManaSymbols'; +import DeckDetailStatsSidebar from '../../components/DeckDetailStatsSidebar'; +import { ManaCost } from '../../components/ManaSymbols'; import { Button } from '../../components/ui'; +import { computeDeckStats } from '../../lib/deck-builder-stats.js'; +import { groupDeckCards } from '../../lib/deck-detail-grouping.js'; +import { getDeckFormatIcon } from '../../lib/deck-format-utils.js'; import { useAuth } from '../../lib/use-auth'; -import { getColorIdentity } from '../../lib/mana-symbols'; /* 2026-06-04 design-sweep pass: this page used the broken Tailwind token classes (bg-bg-*, text-text-*, bg-accent-ember, @@ -55,92 +58,6 @@ export default function DeckDetail() { // eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when route deck id changes }, [deckId]); -; - - const getDeckStats = () => { - if (!deck?.cards) return { totalCards: 0, avgCmc: 0, colorCounts: {}, typeCounts: {} }; - - const totalCards = deck.cards.reduce((sum, card) => sum + card.quantity, 0); - const avgCmc = deck.cards.length > 0 - ? (deck.cards.reduce((sum, card) => sum + (card.cmc || 0) * card.quantity, 0) / totalCards).toFixed(1) - : 0; - - const colorCounts = deck.cards.reduce((counts, card) => { - if (card.colors) { - try { - const colors = JSON.parse(card.colors); - colors.forEach(color => { - counts[color] = (counts[color] || 0) + card.quantity; - }); - } catch (e) { - // Handle non-JSON color format - } - } - return counts; - }, {}); - - const typeCounts = deck.cards.reduce((counts, card) => { - if (card.card_type) { - const types = card.card_type.split(' — ')[0].split(' '); - types.forEach(type => { - counts[type] = (counts[type] || 0) + card.quantity; - }); - } - return counts; - }, {}); - - return { totalCards, avgCmc, colorCounts, typeCounts }; - }; - - const getGroupedCards = () => { - if (!deck?.cards) return {}; - - return deck.cards.reduce((groups, card) => { - let key; - - switch (groupBy) { - case 'type': - key = card.card_type ? card.card_type.split(' — ')[0] : 'Unknown'; - break; - case 'cmc': - key = `${card.cmc || 0} Mana`; - break; - case 'color': - try { - const colors = card.colors ? JSON.parse(card.colors) : []; - key = colors.length === 0 ? 'Colorless' : colors.map(c => c).join(''); - } catch (e) { - key = 'Colorless'; - } - break; - case 'rarity': - key = card.rarity || 'Unknown'; - break; - default: - key = 'All Cards'; - } - - if (!groups[key]) groups[key] = []; - groups[key].push(card); - return groups; - }, {}); - }; - - const getFormatIcon = (format) => { - switch (format) { - case 'Commander': - return '⚔️'; - case 'Standard': - return '🏆'; - case 'Modern': - return '🔥'; - case 'Legacy': - return '💎'; - default: - return '🃏'; - } - }; - if (loading) { return ( @@ -178,8 +95,8 @@ export default function DeckDetail() { ); } - const stats = getDeckStats(); - const groupedCards = getGroupedCards(); + const stats = computeDeckStats(deck.cards || []); + const groupedCards = groupDeckCards(deck.cards, groupBy); const isOwner = user && deck.user_id === user.userId; return ( @@ -198,7 +115,7 @@ export default function DeckDetail() {
- {getFormatIcon(deck.format)} + {getDeckFormatIcon(deck.format)}

- {/* Stats Sidebar */} -
-
-

- Statistics -

- -
-
- Total Cards: - - {stats.totalCards} - -
-
- Avg. CMC: - - {stats.avgCmc} - -
-
- Format: - - {deck.format} - -
-
- - {/* Color Distribution */} - {Object.keys(stats.colorCounts).length > 0 && ( -
-

- Color Distribution -

-
- {Object.entries(stats.colorCounts) - .sort(([, a], [, b]) => b - a) - .map(([color, count]) => ( -
-
- {color} - - {color} - -
- - {count} - -
- ))} -
-
- )} - - {/* Type Distribution */} - {Object.keys(stats.typeCounts).length > 0 && ( -
-

- Card Types -

-
- {Object.entries(stats.typeCounts) - .sort(([, a], [, b]) => b - a) - .slice(0, 8) - .map(([type, count]) => ( -
- - {type} - - - {count} - -
- ))} -
-
- )} -
- - {/* Group By Controls */} -
-

- Group Cards By -

-
- {[ - { value: 'type', label: 'Card Type' }, - { value: 'cmc', label: 'Mana Cost' }, - { value: 'color', label: 'Color' }, - { value: 'rarity', label: 'Rarity' }, - ].map(option => ( - - ))} -
-
-
+ {/* Card List */}
diff --git a/test/lib/deck-detail-grouping.test.js b/test/lib/deck-detail-grouping.test.js new file mode 100644 index 0000000..5945f24 --- /dev/null +++ b/test/lib/deck-detail-grouping.test.js @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { groupDeckCards } from '../../lib/deck-detail-grouping.js'; +import { getDeckFormatIcon } from '../../lib/deck-format-utils.js'; + +describe('getDeckFormatIcon', () => { + it('returns emoji for known formats', () => { + expect(getDeckFormatIcon('Commander')).toBe('⚔️'); + expect(getDeckFormatIcon('Standard')).toBe('🏆'); + }); + + it('returns default for unknown format', () => { + expect(getDeckFormatIcon('Pioneer')).toBe('🃏'); + }); +}); + +describe('groupDeckCards', () => { + const cards = [ + { name: 'Bolt', card_type: 'Instant', cmc: 1, colors: '["R"]', rarity: 'common', quantity: 4 }, + { name: 'Giant', card_type: 'Creature — Giant', cmc: 5, colors: '["R","G"]', rarity: 'rare', quantity: 1 }, + ]; + + it('returns empty object when cards is undefined', () => { + expect(groupDeckCards(undefined, 'type')).toEqual({}); + }); + + it('groups by card type prefix', () => { + const groups = groupDeckCards(cards, 'type'); + expect(groups.Instant).toHaveLength(1); + expect(groups.Creature).toHaveLength(1); + }); + + it('groups by cmc', () => { + const groups = groupDeckCards(cards, 'cmc'); + expect(groups['1 Mana']).toHaveLength(1); + expect(groups['5 Mana']).toHaveLength(1); + }); +});