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 { useRouter } from 'next/router';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
|
import DeckBuilderStatsBar from '../components/DeckBuilderStatsBar';
|
||||||
import { ManaCost, ColorIdentity, ColorFilterSymbol } from '../components/ManaSymbols';
|
import { ManaCost, ColorIdentity, ColorFilterSymbol } from '../components/ManaSymbols';
|
||||||
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
|
import { computeDeckStats, isBasicLand } from '../lib/deck-builder-stats';
|
||||||
import { getColorIdentity, getColorSymbol } from '../lib/mana-symbols';
|
import { getColorIdentity, getColorSymbol } from '../lib/mana-symbols';
|
||||||
|
|
||||||
export default function DeckBuilder() {
|
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) => {
|
const toggleColorFilter = (color) => {
|
||||||
setFilters(prev => ({
|
setFilters(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -303,7 +267,7 @@ export default function DeckBuilder() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = getDeckStats();
|
const stats = computeDeckStats(deckCards);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
|
|
@ -351,29 +315,7 @@ export default function DeckBuilder() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Deck Stats Bar */}
|
<DeckBuilderStatsBar stats={stats} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Deck Cards List */}
|
{/* Deck Cards List */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-2">
|
<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