Deck status (Idea/Building/Built/Retired) and pulled-card tracking

Only building and built decks now claim owned copies, so several idea
decks can share one copy without an over-assignment warning. Deck cards
track how many copies are physically pulled: physical decks list cards
to pull with their source (a storage box, or another deck to take a copy
from), and idea decks split cards into available / in other decks / not
owned. The deck list shows status chips with to-pull counts, filters by
status, and collapses retired decks. Status and pulled counts sync,
which bumps the library document to version 4.

Existing decks migrate as ideas.

Closes #25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:35:41 +00:00
parent eb342f5fbb
commit 99a579a697
20 changed files with 1087 additions and 103 deletions

View File

@@ -25,7 +25,12 @@ export interface CardRef {
export interface LibraryCard extends CardRef {
quantity_owned: number;
decks: string[];
/** The subset of `decks` that physically exist (building or built). */
physical_decks: string[];
/** Copies claimed by building/built decks (idea/retired decks claim none). */
quantity_assigned: number;
/** Copies physically pulled into decks; the rest are at `location`. */
quantity_in_decks: number;
location: string;
}
@@ -46,8 +51,41 @@ export interface DeckEntry extends CardRef {
/** The printing whose art this slot shows; `image_uri` already reflects it. */
preferred_scryfall_id: string | null;
partner: Partner | null;
/** Copies physically in this deck; meaningful for building/built decks. */
quantity_pulled: number;
/** Owned copies not claimed by any *other* building/built deck. */
quantity_spare: number;
/** Owned copies not pulled into any deck — still in their box. */
quantity_in_box: number;
/** The *other* building/built decks claiming copies; those with copies
* pulled are where to take one from when the box is empty. */
claimed_by: DeckHolding[];
/** Where the card's library copies are stored ("" if unset). */
location: string;
}
export interface DeckHolding {
deck_id: number;
name: string;
/** Copies the deck's main board lists. */
quantity: number;
/** Of those, copies physically in the deck. */
pulled: number;
}
/** Where a deck stands physically. Building and built decks hold cards and
* claim owned copies; ideas and retired decks are just lists. */
export type DeckStatus = "idea" | "building" | "built" | "retired";
export const DECK_STATUSES: { value: DeckStatus; label: string }[] = [
{ value: "idea", label: "Idea" },
{ value: "building", label: "Building" },
{ value: "built", label: "Built" },
{ value: "retired", label: "Retired" },
];
export const isPhysical = (s: DeckStatus) => s === "building" || s === "built";
/** One printing of a card, for the printing picker and copy labels. */
export interface PrintingRef {
scryfall_id: string;
@@ -82,6 +120,9 @@ export interface LibraryCopy {
export type PreviewCard = CardRef & {
quantity_owned?: number;
decks?: string[];
physical_decks?: string[];
quantity_in_decks?: number;
location?: string;
preferred_scryfall_id?: string | null;
};
@@ -107,6 +148,7 @@ export interface Deck {
notes: string;
/** The card chosen to headline the deck in the deck list; null = automatic. */
cover_oracle_id: string | null;
status: DeckStatus;
}
export interface DeckImportResult {
@@ -497,6 +539,17 @@ export const api = {
invoke<Deck>("duplicate_deck", { deckId, newName }),
setDeckFormat: (deckId: number, format: string) =>
invoke<void>("set_deck_format", { deckId, format }),
/** Change a deck's status. Leaving building/built returns pulled copies to
* their boxes; entering it with `assembled` marks the copies in the box as
* already in the deck. */
setDeckStatus: (deckId: number, status: DeckStatus, assembled = false) =>
invoke<void>("set_deck_status", { deckId, status, assembled }),
/** Record copies physically in a deck; returns the (clamped) count stored. */
setDeckCardPulled: (deckId: number, oracleId: string, pulled: number) =>
invoke<number>("set_deck_card_pulled", { deckId, oracleId, pulled }),
/** Move one pulled copy between decks; false if there was nothing to move. */
takeDeckCardCopy: (fromDeckId: number, toDeckId: number, oracleId: string) =>
invoke<boolean>("take_deck_card_copy", { fromDeckId, toDeckId, oracleId }),
/** Choose the card headlining the deck in the deck list; null = automatic. */
setDeckCover: (deckId: number, oracleId: string | null) =>
invoke<void>("set_deck_cover", { deckId, oracleId }),

View File

@@ -816,18 +816,23 @@ export function DeckChip({
name,
className = "deck-chip",
onOpen,
idea,
}: {
name: string;
className?: string;
onOpen?: () => void;
/** The deck is an idea or retired: listed, but holding no copies. */
idea?: boolean;
}) {
const openDeck = useContext(OpenDeckCtx);
if (!openDeck) return <span className={className}>{name}</span>;
if (idea) className += " deck-chip-idea";
const note = idea ? " (not built — holds no copies)" : "";
if (!openDeck) return <span className={className} title={note ? `${name}${note}` : undefined}>{name}</span>;
return (
<button
type="button"
className={`${className} deck-chip-link`}
title={`Open ${name}`}
title={`Open ${name}${note}`}
onClick={(e) => {
e.stopPropagation();
onOpen?.();
@@ -1037,11 +1042,27 @@ const CardInfoTile = forwardRef<
<span className="preview-info-label">In decks</span>
<div className="preview-info-deck-chips">
{card.decks.map((d) => (
<DeckChip key={d} name={d} className="badge" onOpen={onClose} />
<DeckChip
key={d}
name={d}
className="badge"
onOpen={onClose}
idea={card.physical_decks && !card.physical_decks.includes(d)}
/>
))}
</div>
</div>
)}
{!!card.quantity_owned && !!card.quantity_in_decks && (
<div className="preview-info-section">
<span className="preview-info-label">Where</span>
<span>
{card.quantity_in_decks} in decks
{card.quantity_owned > card.quantity_in_decks &&
` · ${card.quantity_owned - card.quantity_in_decks} in ${card.location || "your library"}`}
</span>
</div>
)}
<div className="preview-info-section">
<span className="preview-info-label">Legalities</span>
<LegalityPills legalities={card.legalities} />

View File

@@ -1,7 +1,7 @@
// Pure deck analysis shared by the deck builder and the deck list: composition
// stats, format legality, commander pairing, and the deck list's per-deck
// summary.
import { COLORS, Deck, DeckEntry, legalStatusFor, Partner } from "./api";
import { COLORS, Deck, DeckEntry, isPhysical, legalStatusFor, Partner } from "./api";
/** Copies of a deck card you'd need to acquire to cover the deck. */
export const shortBy = (e: DeckEntry) => Math.max(0, e.quantity - e.quantity_owned);
@@ -391,6 +391,8 @@ export interface DeckSummary {
cost: number;
legality: DeckLegality;
maybeboardCount: number;
/** Mainboard cards not yet physically in the deck (building/built decks). */
toPull: number;
}
/**
@@ -435,5 +437,6 @@ export function summarizeDeck(deck: Deck, contents: DeckEntry[]): DeckSummary {
cost: main.reduce((sum, e) => sum + e.price_usd * e.quantity, 0),
legality: computeDeckLegality(deck, contents),
maybeboardCount: contents.length - main.length,
toPull: isPhysical(deck.status) ? main.filter((e) => e.quantity_pulled < e.quantity).length : 0,
};
}

View File

@@ -1715,6 +1715,78 @@ tbody tr:hover {
color: var(--text-strong);
}
/* A deck that's an idea or retired: listed, but holding no copies. */
.deck-chip.deck-chip-idea,
.badge.deck-chip-idea {
border-style: dashed;
color: var(--muted);
}
/* ============================================================
Deck status: Idea / Building / Built / Retired
============================================================ */
.deck-status-chip {
display: inline-flex;
align-items: center;
border-radius: var(--radius-pill);
padding: 1px 8px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
border: 1px solid var(--border);
color: var(--muted);
margin-right: 4px;
}
.deck-status-chip.idea {
border-style: dashed;
}
.deck-status-chip.building {
border-color: var(--warn);
color: var(--warn);
}
.deck-status-chip.built {
border-color: var(--success);
background: var(--success);
color: #fff;
}
.deck-status-chip.retired {
color: var(--faint);
}
.deck-status-hint {
flex-basis: 100%;
font-size: var(--fs-caption);
}
.deck-gallery-retired {
margin-top: var(--space-5);
}
.deck-gallery-retired > summary {
cursor: pointer;
font-weight: 600;
color: var(--muted);
margin-bottom: var(--space-3);
}
/* Where a deck card's copies are: the "To pull" source and its action. */
.whereabouts {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 6px;
font-size: 12px;
}
.whereabouts.compact {
font-size: 11px;
}
.pull-source {
color: var(--text);
}
button.pull-action {
padding: 1px 8px;
font-size: 11px;
font-weight: 600;
}
/* ============================================================
Bulk-selection action bar (Library tab)
============================================================ */

View File

@@ -254,7 +254,7 @@ export function CardSearch({
</td>
<td data-label="In decks">
{c.decks.map((d) => (
<DeckChip key={d} name={d} />
<DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />
))}
</td>
<td>
@@ -309,7 +309,7 @@ export function CardSearch({
}
footer={
c.decks.length > 0
? c.decks.map((d) => <DeckChip key={d} name={d} />)
? c.decks.map((d) => <DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />)
: undefined
}
/>

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { api, Deck, DeckEntry } from "../api";
import { api, Deck, DECK_STATUSES, DeckEntry, DeckStatus } from "../api";
import { artCrop, ColorPips, fold, IconButton, Icons, usePersistedState, useViewMode, ViewToggle } from "../components";
import { DeckSummary, formatCost, summarizeDeck } from "../deckAnalysis";
@@ -82,6 +82,19 @@ function LegalityBadge({ summary: s, compact }: { summary: DeckSummary; compact?
);
}
/** "Built", "Building · 5 to pull", etc. Ideas — the default — say so too,
* so it's clear at a glance which decks are real. */
function StatusChip({ row }: { row: Row }) {
const { deck, summary: s } = row;
const label = DECK_STATUSES.find((d) => d.value === deck.status)?.label ?? deck.status;
return (
<span className={`deck-status-chip ${deck.status}`}>
{label}
{s.toPull > 0 && <span> · {s.toPull} to pull</span>}
</span>
);
}
function OwnedBar({ summary: s }: { summary: DeckSummary }) {
return (
<span className="deck-owned-bar" aria-hidden="true">
@@ -210,6 +223,7 @@ function DeckTile({ row, actions }: { row: Row; actions: DeckActions }) {
<div className="deck-tile-body">
<div className="deck-tile-sub">{subtitle(row)}</div>
<div className="deck-tile-meta">
<StatusChip row={row} />
{deck.format ? <span className="deck-chip">{deck.format}</span> : <span className="muted">No format</span>}
{s.maybeboardCount > 0 && <span className="muted">+{s.maybeboardCount} maybe</span>}
<span className="spacer" />
@@ -239,6 +253,7 @@ function DeckRow({ row, actions }: { row: Row; actions: DeckActions }) {
<div className="deck-row-sub">{subtitle(row)}</div>
</div>
<div className="deck-row-stats">
<StatusChip row={row} />
{deck.format && <span className="deck-chip">{deck.format}</span>}
<DeckSize summary={s} />
<span className="deck-row-owned" title={ownedLabel(s)}>
@@ -278,6 +293,7 @@ export function DeckList({
const [sort, setSort] = usePersistedState<SortMode>("deck-list-sort", "recent");
const [query, setQuery] = useState("");
const [format, setFormat] = useState<string | null>(null);
const [status, setStatus] = useState<DeckStatus | null>(null);
// `decks` is refetched after anything that adds a deck, so it doubles as
// the signal that contents may have changed.
@@ -298,6 +314,7 @@ export function DeckList({
);
const formats = useMemo(() => [...new Set(decks.map((d) => d.format))].sort(), [decks]);
const statuses = useMemo(() => DECK_STATUSES.filter((s) => decks.some((d) => d.status === s.value)), [decks]);
const shown = useMemo(() => {
const q = fold(query.trim());
@@ -321,11 +338,34 @@ export function DeckList({
owned: desc((r) => (r.summary.size ? r.summary.owned : -1)),
};
return rows
.filter((r) => (format === null || r.deck.format === format) && matches(r))
.filter(
(r) => (format === null || r.deck.format === format) && (status === null || r.deck.status === status) && matches(r),
)
.sort(compare[sort] ?? byName);
}, [rows, query, format, sort, openedAt]);
}, [rows, query, format, status, sort, openedAt]);
const filtering = query.trim() !== "" || format !== null;
// Retired decks are kept for reference, so unless they're asked for they
// sit collapsed below the rest.
const active = status === null ? shown.filter((r) => r.deck.status !== "retired") : shown;
const retired = status === null ? shown.filter((r) => r.deck.status === "retired") : [];
const filtering = query.trim() !== "" || format !== null || status !== null;
function renderDecks(list: Row[]) {
return view === "tiles" ? (
<div className="deck-gallery-grid">
{list.map((r) => (
<DeckTile key={r.deck.id} row={r} actions={actions} />
))}
</div>
) : (
<div className="deck-gallery-rows">
{list.map((r) => (
<DeckRow key={r.deck.id} row={r} actions={actions} />
))}
</div>
);
}
return (
<div className="deck-gallery">
@@ -358,6 +398,20 @@ export function DeckList({
))}
</select>
)}
{statuses.length > 1 && (
<select
value={status ?? "*"}
onChange={(e) => setStatus(e.target.value === "*" ? null : (e.target.value as DeckStatus))}
title="Filter by status"
>
<option value="*">All statuses</option>
{statuses.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
)}
<select value={sort} onChange={(e) => setSort(e.target.value as SortMode)} title="Sort decks">
{SORTS.map((s) => (
<option key={s.id} value={s.id}>
@@ -402,24 +456,25 @@ export function DeckList({
onClick={() => {
setQuery("");
setFormat(null);
setStatus(null);
}}
>
Clear filters
</button>
)}
</div>
) : view === "tiles" ? (
<div className="deck-gallery-grid">
{shown.map((r) => (
<DeckTile key={r.deck.id} row={r} actions={actions} />
))}
</div>
) : (
<div className="deck-gallery-rows">
{shown.map((r) => (
<DeckRow key={r.deck.id} row={r} actions={actions} />
))}
</div>
<>
{renderDecks(active)}
{retired.length > 0 && (
<details className="deck-gallery-retired">
<summary>
Retired <span className="badge">{retired.length}</span>
</summary>
{renderDecks(retired)}
</details>
)}
</>
)}
</div>
);

View File

@@ -1,6 +1,18 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
import { api, CardRef, Deck, DeckEntry, DeckImportResult, libraryKey, PreviewContext } from "../api";
import {
api,
CardRef,
Deck,
DECK_STATUSES,
DeckEntry,
DeckHolding,
DeckImportResult,
DeckStatus,
isPhysical,
libraryKey,
PreviewContext,
} from "../api";
import {
CardSearchBox,
CardThumb,
@@ -164,6 +176,18 @@ const REMOVAL_GRACE_MS = 5000;
const extraCopiesLabel = (n: number) => `${n} extra ${n === 1 ? "copy" : "copies"}`;
const copies = (n: number) => `${n} ${n === 1 ? "copy" : "copies"}`;
const statusLabel = (s: DeckStatus) => DECK_STATUSES.find((d) => d.value === s)?.label ?? s;
/** One line under the status select saying what the status means. */
const STATUS_HINTS: Record<DeckStatus, string> = {
idea: "Just a list — it claims none of your cards.",
building: "Being assembled: pull its cards as you go.",
built: "Assembled: its cards are in the deck box.",
retired: "Taken apart, kept for reference.",
};
const STARTING_HAND_SIZE = 7;
/**
@@ -231,6 +255,9 @@ function DeckBuilder({
const [hand, setHand] = useState<DeckEntry[]>([]);
const [libraryStack, setLibraryStack] = useState<DeckEntry[]>([]);
const [suggestOpen, setSuggestOpen] = useState(false);
const [statusDialog, setStatusDialog] = useState<{ to: DeckStatus; kind: "assemble" | "release" } | null>(
null,
);
// Keyed by deck id in `DeckView`, so switching decks remounts with fresh
// state rather than resetting it here.
@@ -528,6 +555,53 @@ function DeckBuilder({
await onDecksChanged();
}
async function applyStatus(status: DeckStatus, assembled = false) {
setStatusDialog(null);
await api.setDeckStatus(deckId, status, assembled);
await onDecksChanged();
await loadContents();
}
/** Change status, first asking how to treat the cards when the deck starts
* or stops physically holding them. */
function changeStatus(status: DeckStatus) {
if (status === deck.status) return;
if (isPhysical(status) && !isPhysical(deck.status) && contents.some((e) => !e.is_maybeboard)) {
setStatusDialog({ to: status, kind: "assemble" });
} else if (!isPhysical(status) && pulledCopies > 0) {
setStatusDialog({ to: status, kind: "release" });
} else {
applyStatus(status);
}
}
/** Mark as many copies pulled as the deck needs (the backend clamps to
* what's actually in the box). */
async function pullCard(e: DeckEntry) {
await api.setDeckCardPulled(deckId, e.oracle_id, e.quantity);
await loadContents();
}
async function takeCard(e: DeckEntry, from: DeckHolding) {
await api.takeDeckCardCopy(from.deck_id, deckId, e.oracle_id);
await loadContents();
}
async function bulkSetPulled(pulled: boolean) {
setBulkBusy(true);
try {
await Promise.all(
contents
.filter((e) => selectedCards.has(e.oracle_id) && !e.is_maybeboard)
.map((e) => api.setDeckCardPulled(deckId, e.oracle_id, pulled ? e.quantity : 0)),
);
clearSelection();
await loadContents();
} finally {
setBulkBusy(false);
}
}
async function setCommander(card: CardRef) {
// Ensure the card is in the deck (mainboard, not maybeboard) and flagged
// commander — covers both "not in the deck yet" and "was maybeboarded".
@@ -579,8 +653,17 @@ function DeckBuilder({
return fold(e.name).includes(q) || fold(e.type_line).includes(q);
}
const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0 && matchesFilter(e));
const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0 && matchesFilter(e));
// Building/built decks split by what's physically in them; ideas and
// retired decks by whether the cards are free to use.
const physical = isPhysical(deck.status);
const mainboard = contents.filter((e) => !e.is_maybeboard && matchesFilter(e));
const toPull = mainboard.filter((e) => e.quantity_pulled < e.quantity);
const inDeck = mainboard.filter((e) => e.quantity_pulled >= e.quantity);
const available = mainboard.filter((e) => e.quantity_owned > 0 && e.quantity_spare >= e.quantity);
const elsewhere = mainboard.filter((e) => e.quantity_owned > 0 && e.quantity_spare < e.quantity);
const unowned = mainboard.filter((e) => e.quantity_owned === 0);
const pulledCopies = contents.reduce((n, e) => n + (e.is_maybeboard ? 0 : e.quantity_pulled), 0);
const leftToPull = contents.filter((e) => !e.is_maybeboard && e.quantity_pulled < e.quantity).length;
const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e));
// The primary commander first, then its partner, if any.
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck?.commander_oracle_id);
@@ -602,6 +685,68 @@ function DeckBuilder({
},
};
/** Where a card's copies are, when that's worth saying: for a deck being
* built, where to pull what's missing from; for an idea, which real decks
* already use the copies. Null when there's nothing to act on. */
function whereabouts(e: DeckEntry, compact = false) {
if (e.is_maybeboard) return null;
if (physical) {
if (e.quantity_pulled >= e.quantity) return null;
const progress = e.quantity_pulled > 0 && (
<span className="muted">
{e.quantity_pulled}/{e.quantity} pulled
</span>
);
const holders = e.claimed_by.filter((h) => h.pulled > 0);
let source;
if (e.quantity_in_box > 0) {
source = (
<>
<span className="pull-source">In {e.location || "your library"}</span>
<button className="pull-action" onClick={() => pullCard(e)}>
Pulled
</button>
</>
);
} else if (holders.length > 0) {
source = holders.map((h) => (
<span key={h.deck_id} className="row" style={{ gap: 4 }}>
<span className="pull-source">In {h.name}</span>
<button className="pull-action" onClick={() => takeCard(e, h)} title={`Move a copy from ${h.name}`}>
Take
</button>
</span>
));
} else if (e.quantity_owned === 0) {
source = <span className="pull-source muted">Not owned</span>;
} else {
source = (
<span className="pull-source muted">Need {copies(e.quantity - e.quantity_pulled)} more</span>
);
}
return (
<span className={`whereabouts ${compact ? "compact" : ""}`}>
{progress}
{source}
</span>
);
}
if (e.quantity_owned === 0 || e.quantity_spare >= e.quantity) return null;
const names = e.claimed_by.map((h) => h.name).join(", ");
return (
<span className={`whereabouts ${compact ? "compact" : ""}`}>
<span className="pull-source" title={`Copies claimed by: ${names}`}>
In {names}
</span>
{e.quantity_spare > 0 && (
<span className="muted">
{e.quantity_spare} of {e.quantity} free
</span>
)}
</span>
);
}
function deckTiles(entries: DeckEntry[]) {
return (
<div {...tiles.gridProps}>
@@ -644,12 +789,13 @@ function DeckBuilder({
</span>
}
footer={
e.is_commander || partialRemoval.has(e.oracle_id) ? (
<span className="row" style={{ gap: 4 }}>
e.is_commander || partialRemoval.has(e.oracle_id) || whereabouts(e) ? (
<span className="row" style={{ gap: 4, flexWrap: "wrap" }}>
{e.is_commander && <span className="tile-badge">★ commander</span>}
{partialRemoval.has(e.oracle_id) && (
<span className="tile-badge">{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}</span>
)}
{whereabouts(e, true)}
</span>
) : undefined
}
@@ -671,6 +817,7 @@ function DeckBuilder({
<th>Type</th>
<th>In deck</th>
<th>Owned</th>
<th>Where</th>
<th></th>
</tr>
</thead>
@@ -710,6 +857,7 @@ function DeckBuilder({
/>
</td>
<td data-label="Owned">{e.quantity_owned}</td>
<td data-label="Where">{whereabouts(e)}</td>
<td>
<span className="row" style={{ gap: 4 }}>
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
@@ -796,6 +944,23 @@ function DeckBuilder({
))}
</select>
</label>
<label className="row" style={{ gap: 6 }}>
<span className="muted">Status</span>
<select value={deck.status} onChange={(e) => changeStatus(e.target.value as DeckStatus)}>
{DECK_STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
<span className="muted deck-status-hint">
{deck.status === "building" && leftToPull > 0
? `Being assembled: ${leftToPull} card${leftToPull === 1 ? "" : "s"} left to pull.`
: deck.status === "built" && leftToPull > 0
? `Assembled, with ${leftToPull} card${leftToPull === 1 ? "" : "s"} still to pull.`
: STATUS_HINTS[deck.status]}
</span>
{COMMANDER_FORMATS.includes(deck.format) && (
<div className="commander-box">
@@ -979,6 +1144,35 @@ function DeckBuilder({
>
Add to library
</button>
{physical && (
<>
<button
onClick={() => bulkSetPulled(true)}
disabled={
bulkBusy ||
!contents.some(
(e) =>
selectedCards.has(e.oracle_id) &&
!e.is_maybeboard &&
e.quantity_pulled < e.quantity &&
e.quantity_in_box > 0,
)
}
title="Record that these cards' copies have been taken from their boxes"
>
Mark pulled
</button>
<button
onClick={() => bulkSetPulled(false)}
disabled={
bulkBusy || !contents.some((e) => selectedCards.has(e.oracle_id) && e.quantity_pulled > 0)
}
title="Record that these cards' copies are back in their boxes"
>
Mark not pulled
</button>
</>
)}
</div>
<span className="spacer" />
<button onClick={clearSelection} disabled={bulkBusy}>
@@ -990,7 +1184,7 @@ function DeckBuilder({
{combineSections ? (
<section style={{ marginTop: 10 }}>
<h3>
All cards <span className="badge">{owned.length + unowned.length}</span>
All cards <span className="badge">{mainboard.length}</span>
</h3>
<CardSearchBox
placeholder="Search all cards to add to your deck…"
@@ -999,14 +1193,52 @@ function DeckBuilder({
results={anyResults}
onPick={addToDeck}
/>
{cardsGroup([...owned, ...unowned])}
{cardsGroup(physical ? [...toPull, ...inDeck] : [...available, ...elsewhere, ...unowned])}
</section>
) : physical ? (
<>
<section style={{ marginTop: 10 }}>
<h3>
To pull <span className="badge">{toPull.length}</span>
</h3>
<p className="hint" style={{ marginTop: -4 }}>
In the deck's list, but not physically in it yet. New cards land here.
</p>
<CardSearchBox
placeholder="Search all cards to add to your deck…"
value={anyQuery}
onChange={setAnyQuery}
results={anyResults}
onPick={addToDeck}
/>
{toPull.length === 0 && deck.status === "building" && mainboard.length > 0 ? (
<p className="hint row" style={{ gap: 8 }}>
Everything's pulled.
<button className="primary" onClick={() => applyStatus("built")}>
Mark as Built
</button>
</p>
) : (
cardsGroup(toPull)
)}
</section>
<section style={{ marginTop: 26 }}>
<h3>
In deck <span className="badge">{inDeck.length}</span>
</h3>
{cardsGroup(inDeck)}
</section>
</>
) : (
<>
<section style={{ marginTop: 10 }}>
<h3>
In your library <span className="badge">{owned.length}</span>
Available <span className="badge">{available.length}</span>
</h3>
<p className="hint" style={{ marginTop: -4 }}>
Owned, with copies no built deck is using.
</p>
<CardSearchBox
placeholder="Search your library to add owned cards…"
value={libQuery}
@@ -1014,9 +1246,21 @@ function DeckBuilder({
results={libResults}
onPick={addToDeck}
/>
{cardsGroup(owned)}
{cardsGroup(available)}
</section>
{elsewhere.length > 0 && (
<section style={{ marginTop: 26 }}>
<h3>
In other decks <span className="badge">{elsewhere.length}</span>
</h3>
<p className="hint" style={{ marginTop: -4 }}>
Owned, but your copies are used by decks you're building or have built.
</p>
{cardsGroup(elsewhere)}
</section>
)}
<section style={{ marginTop: 26 }}>
<h3>
Not owned <span className="badge">{unowned.length}</span>
@@ -1059,6 +1303,32 @@ function DeckBuilder({
onClose={() => setHandOpen(false)}
/>
)}
{statusDialog?.kind === "assemble" && (
<DialogFrame title={`Mark as ${statusLabel(statusDialog.to)}`} onCancel={() => setStatusDialog(null)}>
<p className="dialog-message">
Are this deck's cards already together? If so, the copies in your library are recorded as in the
deck. Otherwise, they're listed under "To pull" until you take them out of their boxes.
</p>
<div className="dialog-actions">
<button onClick={() => setStatusDialog(null)}>Cancel</button>
<button onClick={() => applyStatus(statusDialog.to, false)}>Start pulling</button>
<button className="primary" onClick={() => applyStatus(statusDialog.to, true)} autoFocus>
Already assembled
</button>
</div>
</DialogFrame>
)}
{statusDialog?.kind === "release" && (
<ConfirmDialog
title={`Mark as ${statusLabel(statusDialog.to)}`}
message={`${copies(pulledCopies)} in this deck will be recorded as back in ${
pulledCopies === 1 ? "its box" : "their boxes"
}.`}
confirmLabel={`Mark as ${statusLabel(statusDialog.to)}`}
onConfirm={() => applyStatus(statusDialog.to)}
onCancel={() => setStatusDialog(null)}
/>
)}
{suggestOpen && (
<SuggestionsModal
deckId={deck.id}

View File

@@ -300,7 +300,7 @@ export function LibraryBrowser({
onSaved={() => setRefreshKey((k) => k + 1)}
/>
) : (
c.decks.map((d) => <DeckChip key={d} name={d} />)
c.decks.map((d) => <DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />)
);
// Under the spares filter, how many copies are free — only worth saying for
@@ -308,7 +308,7 @@ export function LibraryBrowser({
const spareNote = (c: LibraryCard) =>
criteria.sparesOnly &&
c.quantity_assigned > 0 && (
<span className="muted" title="Copies no deck is using">
<span className="muted" title="Copies no deck you're building or have built is using">
{c.quantity_owned - c.quantity_assigned} spare
</span>
);