Decks tab: a deck list page, with the builder on its own screen

The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.

A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.

Closes #9
Closes #11
Closes #12
This commit is contained in:
2026-10-25 15:04:41 -05:00
parent fae1aa2b05
commit d286d3420c
15 changed files with 2113 additions and 970 deletions

378
src/deckAnalysis.ts Normal file
View File

@@ -0,0 +1,378 @@
// 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";
/** 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);
export interface DeckStats {
totalCards: number;
uniqueCards: number;
landCount: number;
avgManaValue: number;
/** USD for every copy in the deck, commander included; cards without a
* known price count as $0. */
totalCost: number;
curve: {
label: string;
count: number;
colorCounts: [number, number, number, number, number];
colorlessCount: number;
}[];
colorCounts: [number, number, number, number, number];
colorlessCount: number;
manaProduction: [number, number, number, number, number];
colorlessProduction: number;
manaConsumption: [number, number, number, number, number];
colorlessConsumption: number;
}
/**
* Deck composition stats, excluding the commander (shown separately in its
* own hero display) and the maybeboard (cards under consideration, not
* actually part of the deck) — matches the usual "99 + commander" framing
* even for non-Commander decks, where it's just "everything but those".
*/
export function computeDeckStats(contents: DeckEntry[]): DeckStats {
const cards = contents.filter((e) => !e.is_commander && !e.is_maybeboard);
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
const lands = cards.filter((e) => e.type_line.includes("Land"));
const nonlands = cards.filter((e) => !e.type_line.includes("Land"));
const landCount = lands.reduce((sum, e) => sum + e.quantity, 0);
const nonlandCount = nonlands.reduce((sum, e) => sum + e.quantity, 0);
const avgManaValue = nonlandCount
? nonlands.reduce((sum, e) => sum + e.mana_value * e.quantity, 0) / nonlandCount
: 0;
const bucketLabels = ["0", "1", "2", "3", "4", "5", "6+"];
const curveBuckets = new Map(
bucketLabels.map((l) => [
l,
{ count: 0, colorCounts: [0, 0, 0, 0, 0] as [number, number, number, number, number], colorlessCount: 0 },
]),
);
for (const e of nonlands) {
const label = e.mana_value >= 6 ? "6+" : String(Math.floor(e.mana_value));
const bucket = curveBuckets.get(label)!;
bucket.count += e.quantity;
if (e.color_identity === 0) {
bucket.colorlessCount += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.color_identity & c.bit) bucket.colorCounts[i] += e.quantity;
});
}
}
const curve = bucketLabels.map((label) => ({ label, ...curveBuckets.get(label)! }));
const colorCounts: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessCount = 0;
for (const e of cards) {
if (e.color_identity === 0) {
colorlessCount += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.color_identity & c.bit) colorCounts[i] += e.quantity;
});
}
}
const manaProduction: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessProduction = 0;
for (const e of cards) {
if (e.produces_colorless) colorlessProduction += e.quantity;
COLORS.forEach((c, i) => {
if (e.produced_mana & c.bit) manaProduction[i] += e.quantity;
});
}
// Consumption: colored cost requirements of the spells actually cast —
// lands are excluded since they have no colored casting cost.
const manaConsumption: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessConsumption = 0;
for (const e of nonlands) {
if (e.colors === 0) {
colorlessConsumption += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.colors & c.bit) manaConsumption[i] += e.quantity;
});
}
}
// Unlike the rest, cost counts the commander: it's a card you have to buy.
const totalCost = contents
.filter((e) => !e.is_maybeboard)
.reduce((sum, e) => sum + e.price_usd * e.quantity, 0);
return {
totalCards,
uniqueCards: cards.length,
landCount,
avgManaValue,
totalCost,
curve,
colorCounts,
colorlessCount,
manaProduction,
colorlessProduction,
manaConsumption,
colorlessConsumption,
};
}
/** "$84.50", or whole dollars once it's big enough that cents are noise. */
export function formatCost(usd: number): string {
return `$${usd.toLocaleString(undefined, {
minimumFractionDigits: usd >= 100 ? 0 : 2,
maximumFractionDigits: usd >= 100 ? 0 : 2,
})}`;
}
/** Whether two cards can be commanders together. Mirrors `pairs_with` in
* crates/mtgc-core/src/partner.rs — keep the two in step. */
export function pairsWith(a: DeckEntry, b: DeckEntry): boolean {
const p = a.partner;
const q = b.partner;
if (!p || !q) return false;
switch (p.kind) {
case "partner":
return q.kind === "partner" && a.oracle_id !== b.oracle_id;
case "variant":
return q.kind === "variant" && q.name === p.name && a.oracle_id !== b.oracle_id;
case "with":
return q.kind === "with" && p.name === b.name && q.name === a.name;
case "chooses_background":
return q.kind === "background";
case "background":
return q.kind === "chooses_background";
case "doctors_companion":
return q.kind === "doctor";
case "doctor":
return q.kind === "doctors_companion";
}
}
/** What the second-commander search box asks for, given the first's ability. */
export function partnerPlaceholder(partner: Partner): string {
switch (partner.kind) {
case "chooses_background":
return "Search to add a Background…";
case "background":
return "Search to add a commander that chooses a Background…";
case "doctors_companion":
return "Search to add a Doctor…";
case "doctor":
return "Search to add a Doctor's companion…";
case "with":
return `Search to add ${partner.name}…`;
case "variant":
return `Search to add a partner (${partner.name})…`;
case "partner":
return "Search to add a partner…";
}
}
export const FORMATS = [
"",
"Commander",
"Brawl",
"Standard",
"Pioneer",
"Modern",
"Legacy",
"Vintage",
"Pauper",
"Other",
];
export const COMMANDER_FORMATS = ["Commander", "Brawl"];
export interface DeckLegality {
status: "legal" | "illegal" | "unchecked";
reasons: string[];
}
export const COMMANDER_DECK_SIZE = 100;
export const CONSTRUCTED_MIN_DECK_SIZE = 60;
export const CONSTRUCTED_MAX_COPIES = 4;
/**
* Format-legality check for the deck as a whole: structural deck-building
* rules (size, singleton/copy limits, commander color identity) plus
* Scryfall's per-format banned/restricted lists (via each card's
* `legalities` bitmask). Blank format and "Other" run no checks at all —
* "unchecked" is a neutral state, distinct from "illegal".
*/
export function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[]): DeckLegality {
if (!deck || deck.format === "" || deck.format === "Other") {
return { status: "unchecked", reasons: [] };
}
const reasons: string[] = [];
const cards = contents.filter((e) => !e.is_maybeboard);
const isBasicLand = (e: DeckEntry) => e.type_line.includes("Basic Land");
// NOTE: a handful of real cards (Relentless Rats, Persistent Petitioners,
// Shadowborn Apostle, Dragon's Approach, Seven Dwarves, etc.) override the
// singleton/copy-limit rule via oracle text ("a deck can have any number
// of cards named..."). Detecting this needs oracle_text on CardRef, which
// isn't wired through yet — known limitation, not blocking this feature.
if (COMMANDER_FORMATS.includes(deck.format)) {
// Brawl (Scryfall's `brawl` legality key = paper/Historic Brawl) is
// structurally identical to Commander — 100 cards incl. commander,
// singleton, commander color-identity restriction — differing only in
// which legal/banned card pool applies. Both formats share this
// rule-set and differ only in which legalities bit (deck.format itself,
// "Commander" or "Brawl") is consulted for the banned/restricted check
// below.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards !== COMMANDER_DECK_SIZE) {
reasons.push(
`Deck has ${totalCards} cards; ${deck.format} decks need exactly ${COMMANDER_DECK_SIZE} (including the commander).`,
);
}
// Two commanders are allowed when their partner abilities pair them up.
const commanders = cards.filter((e) => e.is_commander);
if (commanders.length === 0) {
reasons.push(`No commander is set.`);
} else if (commanders.length > 2) {
reasons.push(`More than two cards are marked as commanders.`);
} else if (commanders.length === 2 && !pairsWith(commanders[0], commanders[1])) {
reasons.push(`${commanders[0].name} and ${commanders[1].name} can't be commanders together.`);
}
for (const e of cards) {
if (e.is_commander) continue;
if (!isBasicLand(e) && e.quantity > 1) {
reasons.push(
`${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`,
);
}
}
if (commanders.length > 0) {
const identity = commanders.reduce((mask, e) => mask | e.color_identity, 0);
const whose = commanders.length > 1 ? "commanders'" : "commander's";
for (const e of cards) {
if (e.is_commander) continue;
const outsideIdentity = e.color_identity & ~identity;
if (outsideIdentity !== 0) {
reasons.push(`${e.name}'s color identity is outside the ${whose} color identity.`);
}
}
}
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
reasons.push(`${e.name} is banned in ${deck.format}.`);
} else if (status === "restricted" && e.quantity > 1) {
reasons.push(
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
);
}
}
} else {
// 60-card constructed formats: Standard, Pioneer, Modern, Legacy,
// Vintage, Pauper. No commander concept — is_commander is ignored here.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards < CONSTRUCTED_MIN_DECK_SIZE) {
reasons.push(
`Deck has ${totalCards} cards; ${deck.format} decks need at least ${CONSTRUCTED_MIN_DECK_SIZE}.`,
);
}
for (const e of cards) {
if (!isBasicLand(e) && e.quantity > CONSTRUCTED_MAX_COPIES) {
reasons.push(
`${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`,
);
}
}
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
reasons.push(`${e.name} is banned in ${deck.format}.`);
} else if (status === "restricted" && e.quantity > 1) {
// In practice this only ever fires for Vintage among the 8 tracked
// formats, since Vintage is the only format with a restricted list —
// but the check is generic/future-proof rather than Vintage-special-cased.
reasons.push(
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
);
}
}
}
return { status: reasons.length ? "illegal" : "legal", reasons };
}
/** What the deck list shows for one deck. */
export interface DeckSummary {
/** The card whose art headlines the deck, or null for an empty deck. */
cover: DeckEntry | null;
/** The primary commander first, then its partner. */
commanders: DeckEntry[];
/** WUBRG mask: the commanders' identity, else the whole mainboard's. */
colors: number;
/** Mainboard copies, commander included. */
size: number;
/** The size the format calls for (a minimum for 60-card formats), or null
* when there's no format to measure against. */
target: number | null;
/** Fraction of the mainboard's copies you own, 0–1 (1 for an empty deck). */
owned: number;
cost: number;
legality: DeckLegality;
maybeboardCount: number;
}
/**
* The card whose art headlines a deck: the chosen cover while it's still on
* the mainboard, then the primary commander, then the priciest nonland (the
* card most likely to be the deck's centrepiece), then any card at all.
*/
export function pickCover(deck: Deck, contents: DeckEntry[]): DeckEntry | null {
const main = contents.filter((e) => !e.is_maybeboard);
const byId = (id: string | null) => (id ? main.find((e) => e.oracle_id === id) : undefined);
const priciest = (cards: DeckEntry[]) =>
cards.reduce<DeckEntry | null>((best, e) => (!best || e.price_usd > best.price_usd ? e : best), null);
return (
byId(deck.cover_oracle_id) ??
byId(deck.commander_oracle_id) ??
main.find((e) => e.is_commander) ??
priciest(main.filter((e) => !e.type_line.includes("Land"))) ??
priciest(main) ??
priciest(contents)
);
}
export function summarizeDeck(deck: Deck, contents: DeckEntry[]): DeckSummary {
const main = contents.filter((e) => !e.is_maybeboard);
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck.commander_oracle_id);
const commanders = main.filter((e) => e.is_commander).sort((a, b) => isPrimary(b) - isPrimary(a));
const colorSource = commanders.length ? commanders : main;
const size = main.reduce((n, e) => n + e.quantity, 0);
const ownedCopies = main.reduce((n, e) => n + Math.min(e.quantity, e.quantity_owned), 0);
const target = COMMANDER_FORMATS.includes(deck.format)
? COMMANDER_DECK_SIZE
: deck.format && deck.format !== "Other"
? CONSTRUCTED_MIN_DECK_SIZE
: null;
return {
cover: pickCover(deck, contents),
commanders,
colors: colorSource.reduce((mask, e) => mask | e.color_identity, 0),
size,
target,
owned: size ? ownedCopies / size : 1,
cost: main.reduce((sum, e) => sum + e.price_usd * e.quantity, 0),
legality: computeDeckLegality(deck, contents),
maybeboardCount: contents.length - main.length,
};
}