refactor(deck-builder): extract stats lib and stats bar (Brief 1) #89
4 changed files with 118 additions and 62 deletions
30
components/DeckBuilderStatsBar.js
Normal file
30
components/DeckBuilderStatsBar.js
Normal 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
47
lib/deck-builder-stats.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
37
test/lib/deck-builder-stats.test.js
Normal file
37
test/lib/deck-builder-stats.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue