Deck builder: sorting and a Price column

Adds the Library's Sort by control (plus a Quantity in deck option) to the
deck builder, applied within each section and group. The card table gets
an optional Price column showing the per-copy price, with the total for
the deck's copies in its tooltip. The toolbar now wraps on narrow screens.

Refs #6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 21:25:15 -05:00
parent 70b4d5427b
commit 1e0cd50c7d
3 changed files with 53 additions and 9 deletions

View File

@@ -1750,7 +1750,7 @@ export function GroupBySelect({
// --- Sorting ----------------------------------------------------------
export type SortMode = "name" | "mv" | "color" | "type" | "qty" | "price";
export type SortMode = "name" | "mv" | "color" | "type" | "deck-qty" | "qty" | "price";
export const SORT_OPTIONS: { value: SortMode; label: string }[] = [
{ value: "name", label: "Name" },
@@ -1761,13 +1761,20 @@ export const SORT_OPTIONS: { value: SortMode; label: string }[] = [
{ value: "price", label: "Price" },
];
/** The deck builder's: how many copies the deck runs matters there too. */
export const DECK_SORT_OPTIONS: { value: SortMode; label: string }[] = [
...SORT_OPTIONS.slice(0, 4),
{ value: "deck-qty", label: "Quantity in deck" },
...SORT_OPTIONS.slice(4),
];
export type SortDir = "asc" | "desc";
/** Sort cards by the chosen attribute; ties always break by name so the
* order stays stable and predictable regardless of `mode`. Grouping (see
* [`groupCards`]) determines section order independently — sort this array
* first, then group it, and each group keeps this order internally. */
export function sortCards<T extends CardRef & { quantity_owned: number }>(
export function sortCards<T extends CardRef & { quantity_owned: number; quantity?: number }>(
cards: T[],
mode: SortMode,
dir: SortDir,
@@ -1784,9 +1791,11 @@ export function sortCards<T extends CardRef & { quantity_owned: number }>(
: mode === "type"
? typeRank(primaryType(a.type_line)) - typeRank(primaryType(b.type_line)) ||
a.name.localeCompare(b.name)
: mode === "qty"
? a.quantity_owned - b.quantity_owned || a.name.localeCompare(b.name)
: a.price_usd - b.price_usd || a.name.localeCompare(b.name);
: mode === "deck-qty"
? (a.quantity ?? 0) - (b.quantity ?? 0) || a.name.localeCompare(b.name)
: mode === "qty"
? a.quantity_owned - b.quantity_owned || a.name.localeCompare(b.name)
: a.price_usd - b.price_usd || a.name.localeCompare(b.name);
return dir === "asc" ? cmp : -cmp;
});
return sorted;
@@ -1798,18 +1807,20 @@ export function SortBySelect({
dir,
onChangeValue,
onToggleDir,
options = SORT_OPTIONS,
}: {
value: SortMode;
dir: SortDir;
onChangeValue: (m: SortMode) => void;
onToggleDir: () => void;
options?: { value: SortMode; label: string }[];
}) {
return (
<span className="row" style={{ gap: 4 }}>
<label className="group-by">
<span className="muted">Sort by</span>
<select value={value} onChange={(e) => onChangeValue(e.target.value as SortMode)}>
{SORT_OPTIONS.map((o) => (
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>

View File

@@ -1704,6 +1704,10 @@ tbody tr:hover {
}
}
.cell-price {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.over-assigned {
color: var(--warn);
font-weight: 600;

View File

@@ -22,6 +22,7 @@ import {
ColumnDef,
ColumnsMenu,
ConfirmDialog,
DECK_SORT_OPTIONS,
DialogFrame,
GroupBySelect,
GroupMode,
@@ -32,10 +33,14 @@ import {
PromptDialog,
QtyStepper,
SampleHandModal,
SortBySelect,
SortDir,
SortMode,
StatTile,
TileColumnsControl,
ViewToggle,
groupCards,
sortCards,
useClickPreview,
useHoverPreview,
useColumns,
@@ -73,7 +78,7 @@ function boardToggleProps(e: DeckEntry) {
: { icon: Icons.helpCircle, label: "Move to maybeboard" };
}
type DeckColumn = "image" | "colors" | "type" | "qty" | "owned" | "where" | "actions";
type DeckColumn = "image" | "colors" | "type" | "qty" | "owned" | "price" | "where" | "actions";
// The List view's optional columns; the checkbox and name always show.
const DECK_COLUMNS: ColumnDef<DeckColumn>[] = [
@@ -82,6 +87,7 @@ const DECK_COLUMNS: ColumnDef<DeckColumn>[] = [
{ id: "type", label: "Type" },
{ id: "qty", label: "In deck" },
{ id: "owned", label: "Owned" },
{ id: "price", label: "Price" },
{ id: "where", label: "Where" },
{ id: "actions", label: "Actions" },
];
@@ -342,6 +348,8 @@ function DeckBuilder({
const tiles = useTileColumns();
const columns = useColumns("deck-columns", DECK_COLUMNS);
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
const [sortBy, setSortBy] = usePersistedState<SortMode>("deck-sort", "name");
const [sortDir, setSortDir] = usePersistedState<SortDir>("deck-sort-dir", "asc");
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
const [filterQuery, setFilterQuery] = useState("");
@@ -994,6 +1002,7 @@ function DeckBuilder({
{columns.shown("type") && <th>Type</th>}
{columns.shown("qty") && <th>In deck</th>}
{columns.shown("owned") && <th>Owned</th>}
{columns.shown("price") && <th>Price</th>}
{columns.shown("where") && <th>Where</th>}
{columns.shown("actions") && <th></th>}
</tr>
@@ -1040,6 +1049,19 @@ function DeckBuilder({
</td>
)}
{columns.shown("owned") && <td data-label="Owned">{e.quantity_owned}</td>}
{columns.shown("price") && (
<td
data-label="Price"
className="cell-price"
title={
e.price_usd > 0 && e.quantity > 1
? `${formatCost(e.price_usd * e.quantity)} for ${e.quantity} copies`
: undefined
}
>
{e.price_usd > 0 ? `$${e.price_usd.toFixed(2)}` : "—"}
</td>
)}
{columns.shown("where") && <td data-label="Where">{whereabouts(e)}</td>}
{columns.shown("actions") && (
<td>
@@ -1060,7 +1082,7 @@ function DeckBuilder({
function cardsGroup(entries: DeckEntry[]) {
if (entries.length === 0) return <p className="hint">None yet.</p>;
return groupCards(entries, groupBy).map((g) => (
return groupCards(sortCards(entries, sortBy, sortDir), groupBy).map((g) => (
<div key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
{g.label && (
<h4 className="group-header">
@@ -1277,7 +1299,7 @@ function DeckBuilder({
<hr className="deck-section-divider" />
<div className="row" style={{ gap: 16 }}>
<div className="row" style={{ gap: 16, flexWrap: "wrap" }}>
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all cards">
<input
ref={selectAllRef}
@@ -1302,6 +1324,13 @@ function DeckBuilder({
</label>
<span className="spacer" />
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<SortBySelect
value={sortBy}
dir={sortDir}
onChangeValue={setSortBy}
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
options={DECK_SORT_OPTIONS}
/>
<ViewToggle mode={view} onChange={setView} />
{view === "list" && <ColumnsMenu columns={columns} />}
{view === "tiles" && <TileColumnsControl tiles={tiles} />}