From 464509510589b43b533780971a04f0ceef6247f0 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 3 Jun 2026 17:11:32 -0500 Subject: [PATCH] refactor(deck-builder): extract stats lib and stats bar (Brief 1) Move Commander basic-land checks and deck aggregate metrics into lib/deck-builder-stats.js with unit tests; render the summary row via DeckBuilderStatsBar to shrink the page god-component. Co-authored-by: Cursor --- components/DeckBuilderStatsBar.js | 30 +++++++++++++ lib/deck-builder-stats.js | 47 ++++++++++++++++++++ pages/deck-builder.js | 66 ++--------------------------- test/lib/deck-builder-stats.test.js | 37 ++++++++++++++++ 4 files changed, 118 insertions(+), 62 deletions(-) create mode 100644 components/DeckBuilderStatsBar.js create mode 100644 lib/deck-builder-stats.js create mode 100644 test/lib/deck-builder-stats.test.js diff --git a/components/DeckBuilderStatsBar.js b/components/DeckBuilderStatsBar.js new file mode 100644 index 0000000..0d67b24 --- /dev/null +++ b/components/DeckBuilderStatsBar.js @@ -0,0 +1,30 @@ +/** + * Summary metrics row for the deck builder main panel. + * @param {{ stats: { totalCards: number, avgCmc: string|number, colorCounts: object, typeCounts: object } }} props + */ +export default function DeckBuilderStatsBar({ stats }) { + return ( +
+
+
{stats.totalCards}/100
+
Cards
+
+
+
{stats.avgCmc}
+
Avg CMC
+
+
+
+ {Object.keys(stats.colorCounts).length} +
+
Colors
+
+
+
+ {Object.keys(stats.typeCounts).length} +
+
Types
+
+
+ ); +} diff --git a/lib/deck-builder-stats.js b/lib/deck-builder-stats.js new file mode 100644 index 0000000..947e600 --- /dev/null +++ b/lib/deck-builder-stats.js @@ -0,0 +1,47 @@ +/** Basic land names for Commander singleton rule checks. */ +export const BASIC_LAND_NAMES = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']; + +export function isBasicLand(card) { + return BASIC_LAND_NAMES.includes(card.name); +} + +/** + * Aggregate deck list metrics from deck card rows (quantity-weighted). + * @param {Array<{ quantity: number, cmc?: number, colors?: string, card_type?: string }>} deckCards + */ +export function computeDeckStats(deckCards) { + const totalCards = deckCards.reduce((sum, card) => sum + card.quantity, 0); + const avgCmc = + deckCards.length > 0 + ? ( + deckCards.reduce((sum, card) => sum + (card.cmc || 0) * card.quantity, 0) / + totalCards + ).toFixed(1) + : 0; + + const colorCounts = deckCards.reduce((counts, card) => { + if (card.colors) { + try { + const colors = JSON.parse(card.colors); + colors.forEach((color) => { + counts[color] = (counts[color] || 0) + card.quantity; + }); + } catch { + // Handle non-JSON color format + } + } + return counts; + }, {}); + + const typeCounts = deckCards.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 }; +} diff --git a/pages/deck-builder.js b/pages/deck-builder.js index 84bd5ed..70daf63 100644 --- a/pages/deck-builder.js +++ b/pages/deck-builder.js @@ -3,9 +3,11 @@ import { useState, useEffect, useRef } from 'react'; import { useRouter } from 'next/router'; import Link from 'next/link'; import Layout from '../components/Layout'; +import DeckBuilderStatsBar from '../components/DeckBuilderStatsBar'; import { ManaCost, ColorIdentity, ColorFilterSymbol } from '../components/ManaSymbols'; import ManaSymbolSettings from '../components/ManaSymbolSettings'; import { useAuth } from '../lib/use-auth'; +import { computeDeckStats, isBasicLand } from '../lib/deck-builder-stats'; import { getColorIdentity, getColorSymbol } from '../lib/mana-symbols'; export default function DeckBuilder() { @@ -207,44 +209,6 @@ export default function DeckBuilder() { } }; - const isBasicLand = (card) => { - const basicLands = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']; - return basicLands.includes(card.name); - }; - - const getDeckStats = () => { - const totalCards = deckCards.reduce((sum, card) => sum + card.quantity, 0); - const avgCmc = deckCards.length > 0 - ? (deckCards.reduce((sum, card) => sum + (card.cmc || 0) * card.quantity, 0) / totalCards).toFixed(1) - : 0; - - const colorCounts = deckCards.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 = deckCards.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 toggleColorFilter = (color) => { setFilters(prev => ({ ...prev, @@ -303,7 +267,7 @@ export default function DeckBuilder() { ); } - const stats = getDeckStats(); + const stats = computeDeckStats(deckCards); return ( @@ -351,29 +315,7 @@ export default function DeckBuilder() { - {/* Deck Stats Bar */} -
-
-
{stats.totalCards}/100
-
Cards
-
-
-
{stats.avgCmc}
-
Avg CMC
-
-
-
- {Object.keys(stats.colorCounts).length} -
-
Colors
-
-
-
- {Object.keys(stats.typeCounts).length} -
-
Types
-
-
+ {/* Deck Cards List */}
diff --git a/test/lib/deck-builder-stats.test.js b/test/lib/deck-builder-stats.test.js new file mode 100644 index 0000000..eb40670 --- /dev/null +++ b/test/lib/deck-builder-stats.test.js @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { computeDeckStats, isBasicLand, BASIC_LAND_NAMES } from '../../lib/deck-builder-stats.js'; + +describe('isBasicLand', () => { + it('returns true for each basic land name', () => { + for (const name of BASIC_LAND_NAMES) { + expect(isBasicLand({ name })).toBe(true); + } + }); + + it('returns false for non-basic cards', () => { + expect(isBasicLand({ name: 'Lightning Bolt' })).toBe(false); + }); +}); + +describe('computeDeckStats', () => { + it('returns zeros for an empty deck', () => { + expect(computeDeckStats([])).toEqual({ + totalCards: 0, + avgCmc: 0, + colorCounts: {}, + typeCounts: {}, + }); + }); + + it('quantity-weights CMC and parses JSON colors', () => { + const stats = computeDeckStats([ + { quantity: 2, cmc: 3, colors: '["R","G"]', card_type: 'Creature — Elf' }, + { quantity: 1, cmc: 1, colors: '["R"]', card_type: 'Instant' }, + ]); + expect(stats.totalCards).toBe(3); + expect(stats.avgCmc).toBe('2.3'); + expect(stats.colorCounts).toEqual({ R: 3, G: 2 }); + expect(stats.typeCounts.Creature).toBe(2); + expect(stats.typeCounts.Instant).toBe(1); + }); +});