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 <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-03 17:11:32 -05:00
parent 2dfe584eb0
commit 4645095105
4 changed files with 118 additions and 62 deletions

View file

@ -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 (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6 p-4 bg-bg-primary rounded-lg">
<div className="text-center">
<div className="text-lg font-bold text-text-primary">{stats.totalCards}/100</div>
<div className="text-text-secondary text-sm">Cards</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">{stats.avgCmc}</div>
<div className="text-text-secondary text-sm">Avg CMC</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">
{Object.keys(stats.colorCounts).length}
</div>
<div className="text-text-secondary text-sm">Colors</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">
{Object.keys(stats.typeCounts).length}
</div>
<div className="text-text-secondary text-sm">Types</div>
</div>
</div>
);
}

47
lib/deck-builder-stats.js Normal file
View file

@ -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 };
}

View file

@ -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 (
<Layout user={user}>
@ -351,29 +315,7 @@ export default function DeckBuilder() {
</button>
</div>
{/* Deck Stats Bar */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6 p-4 bg-bg-primary rounded-lg">
<div className="text-center">
<div className="text-lg font-bold text-text-primary">{stats.totalCards}/100</div>
<div className="text-text-secondary text-sm">Cards</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">{stats.avgCmc}</div>
<div className="text-text-secondary text-sm">Avg CMC</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">
{Object.keys(stats.colorCounts).length}
</div>
<div className="text-text-secondary text-sm">Colors</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">
{Object.keys(stats.typeCounts).length}
</div>
<div className="text-text-secondary text-sm">Types</div>
</div>
</div>
<DeckBuilderStatsBar stats={stats} />
{/* Deck Cards List */}
<div className="flex-1 overflow-y-auto space-y-2">

View file

@ -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);
});
});