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

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