refactor(deck): extract grouping lib and stats sidebar (Brief 1) #137

Merged
varutasu merged 1 commit from refactor/deck-detail-split-brief-1 into main 2026-06-13 01:44:17 -04:00
4 changed files with 200 additions and 219 deletions
Showing only changes of commit 1f5d50c5af - Show all commits

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

View 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;
}, {});
}

View file

@ -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">

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