refactor(deck): extract grouping lib and stats sidebar (Brief 1) #137
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 { 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 { ManaCost, ColorIdentity } from '../../components/ManaSymbols';
|
import DeckDetailStatsSidebar from '../../components/DeckDetailStatsSidebar';
|
||||||
|
import { ManaCost } from '../../components/ManaSymbols';
|
||||||
import { Button } from '../../components/ui';
|
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 { useAuth } from '../../lib/use-auth';
|
||||||
import { getColorIdentity } from '../../lib/mana-symbols';
|
|
||||||
|
|
||||||
/* 2026-06-04 design-sweep pass: this page used the broken Tailwind
|
/* 2026-06-04 design-sweep pass: this page used the broken Tailwind
|
||||||
token classes (bg-bg-*, text-text-*, bg-accent-ember,
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when route deck id changes
|
||||||
}, [deckId]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
|
|
@ -178,8 +95,8 @@ export default function DeckDetail() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = getDeckStats();
|
const stats = computeDeckStats(deck.cards || []);
|
||||||
const groupedCards = getGroupedCards();
|
const groupedCards = groupDeckCards(deck.cards, groupBy);
|
||||||
const isOwner = user && deck.user_id === user.userId;
|
const isOwner = user && deck.user_id === user.userId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -198,7 +115,7 @@ export default function DeckDetail() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<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
|
<h1
|
||||||
className="text-3xl font-bold"
|
className="text-3xl font-bold"
|
||||||
style={{ color: 'var(--text-primary)' }}
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
|
@ -240,134 +157,12 @@ export default function DeckDetail() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
{/* Stats Sidebar */}
|
<DeckDetailStatsSidebar
|
||||||
<div className="lg:col-span-1">
|
deck={deck}
|
||||||
<div className="glass-panel rounded-2xl p-6 mb-6">
|
stats={stats}
|
||||||
<h3
|
groupBy={groupBy}
|
||||||
className="text-lg font-semibold mb-4"
|
onGroupByChange={setGroupBy}
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Card List */}
|
{/* Card List */}
|
||||||
<div className="lg:col-span-3">
|
<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