refactor(deck): extract grouping lib and stats sidebar (Brief 1)
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 <cursoragent@cursor.com>
This commit is contained in:
parent
e3b4b0e213
commit
1f5d50c5af
4 changed files with 200 additions and 219 deletions
107
components/DeckDetailStatsSidebar.js
Normal file
107
components/DeckDetailStatsSidebar.js
Normal file
|
|
@ -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 (
|
||||
<div className="lg:col-span-1">
|
||||
<div className="glass-panel rounded-2xl p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Statistics
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Total Cards:</span>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{stats.totalCards}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Avg. CMC:</span>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{stats.avgCmc}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Format:</span>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{deck.format}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.keys(stats.colorCounts).length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-medium mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Color Distribution
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(stats.colorCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([color, count]) => (
|
||||
<div key={color} className="flex justify-between items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg">{color}</span>
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{color}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(stats.typeCounts).length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-medium mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Card Types
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(stats.typeCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 8)
|
||||
.map(([type, count]) => (
|
||||
<div key={type} className="flex justify-between">
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{type}
|
||||
</span>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Group Cards By
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{GROUP_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onGroupByChange(option.value)}
|
||||
className={`nav-item w-full text-left px-3 py-2 ${
|
||||
groupBy === option.value ? 'nav-item-active' : 'nav-item-hover'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
lib/deck-detail-grouping.js
Normal file
42
lib/deck-detail-grouping.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Group deck card rows for the deck detail view.
|
||||
* @param {Array<object>|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;
|
||||
}, {});
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Layout user={user}>
|
||||
|
|
@ -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() {
|
|||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<span className="text-3xl">{getFormatIcon(deck.format)}</span>
|
||||
<span className="text-3xl">{getDeckFormatIcon(deck.format)}</span>
|
||||
<h1
|
||||
className="text-3xl font-bold"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
|
|
@ -240,134 +157,12 @@ export default function DeckDetail() {
|
|||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Stats Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="glass-panel rounded-2xl p-6 mb-6">
|
||||
<h3
|
||||
className="text-lg font-semibold mb-4"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
Statistics
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Total Cards:</span>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{stats.totalCards}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Avg. CMC:</span>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{stats.avgCmc}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Format:</span>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{deck.format}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Distribution */}
|
||||
{Object.keys(stats.colorCounts).length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4
|
||||
className="text-sm font-medium mb-3"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Color Distribution
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(stats.colorCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([color, count]) => (
|
||||
<div key={color} className="flex justify-between items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg">{color}</span>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{color}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type Distribution */}
|
||||
{Object.keys(stats.typeCounts).length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4
|
||||
className="text-sm font-medium mb-3"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Card Types
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(stats.typeCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 8)
|
||||
.map(([type, count]) => (
|
||||
<div key={type} className="flex justify-between">
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group By Controls */}
|
||||
<div className="glass-panel rounded-2xl p-6">
|
||||
<h3
|
||||
className="text-lg font-semibold mb-4"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
Group Cards By
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ value: 'type', label: 'Card Type' },
|
||||
{ value: 'cmc', label: 'Mana Cost' },
|
||||
{ value: 'color', label: 'Color' },
|
||||
{ value: 'rarity', label: 'Rarity' },
|
||||
].map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setGroupBy(option.value)}
|
||||
className={`nav-item w-full text-left px-3 py-2 ${
|
||||
groupBy === option.value ? 'nav-item-active' : 'nav-item-hover'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DeckDetailStatsSidebar
|
||||
deck={deck}
|
||||
stats={stats}
|
||||
groupBy={groupBy}
|
||||
onGroupByChange={setGroupBy}
|
||||
/>
|
||||
|
||||
{/* Card List */}
|
||||
<div className="lg:col-span-3">
|
||||
|
|
|
|||
37
test/lib/deck-detail-grouping.test.js
Normal file
37
test/lib/deck-detail-grouping.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue