Spare/rarity filters, owned-only suggestions, and a Game night tab

Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
  owned than the decks' main boards use, the opposite of the ⚠
  over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
  Card Search and the set browser all get them. Any printing counts,
  since the library tracks cards rather than printings; within a set
  it's that set's printing. Arena-only printings don't count for a card
  that also exists on paper, so a digital re-rarity can't make a paper
  common read as rare.

Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
  cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
  "N spare" / "all in other decks". suggest_cards now takes a
  SuggestOptions struct instead of a bare `unusual` flag.

Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.

Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
This commit is contained in:
2026-10-14 22:09:39 -05:00
parent 00a76a0f19
commit 067e85dc69
13 changed files with 1601 additions and 32 deletions

View File

@@ -1594,6 +1594,13 @@ const ICON_BLANK_BOX = icon(
<line x1="3" y1="9" x2="21" y2="9" />
</>,
);
/** A card with another peeking out behind it — a copy no deck is using. */
const ICON_SPARE = icon(
<>
<rect x="8" y="3" width="12" height="16" rx="2" />
<path d="M4 7v12a2 2 0 0 0 2 2h9" />
</>,
);
/** An open package — cards you don't have a copy of yet. */
const ICON_PACKAGE = icon(
<>
@@ -1622,6 +1629,9 @@ export type CardCriteria = {
canBeCommander: boolean;
vanillaOnly: boolean;
notOwned: boolean;
sparesOnly: boolean;
/** Scryfall rarity names; empty means any rarity. */
rarities: string[];
powerMin: string;
powerMax: string;
toughMin: string;
@@ -1638,12 +1648,21 @@ export const EMPTY_CRITERIA: CardCriteria = {
canBeCommander: false,
vanillaOnly: false,
notOwned: false,
sparesOnly: false,
rarities: [],
powerMin: "",
powerMax: "",
toughMin: "",
toughMax: "",
};
const RARITIES = [
{ key: "common", code: "C", label: "Common" },
{ key: "uncommon", code: "U", label: "Uncommon" },
{ key: "rare", code: "R", label: "Rare" },
{ key: "mythic", code: "M", label: "Mythic rare" },
];
export function useCardCriteria(key: string): [CardCriteria, (patch: Partial<CardCriteria>) => void] {
const [value, setValue] = usePersistedState<CardCriteria>(key, EMPTY_CRITERIA);
// Merged over the defaults so a stored object written before a field existed
@@ -1670,6 +1689,8 @@ export function applyCardCriteria(c: CardCriteria): Partial<LibraryFilter> {
can_be_commander: c.canBeCommander,
vanilla_only: c.vanillaOnly,
not_owned: c.notOwned,
spares_only: c.sparesOnly,
rarities: c.rarities,
power_min: num(c.powerMin),
power_max: num(c.powerMax),
toughness_min: num(c.toughMin),
@@ -1690,6 +1711,8 @@ export function criteriaActive(c: CardCriteria): boolean {
c.canBeCommander ||
c.vanillaOnly ||
c.notOwned ||
c.sparesOnly ||
c.rarities.length > 0 ||
c.legalIn !== "" ||
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
(v) => v.trim() !== "",
@@ -1746,15 +1769,41 @@ export function CardCriteriaFields({
value: c,
onChange,
hideNotOwned,
showSpares,
}: {
value: CardCriteria;
onChange: (patch: Partial<CardCriteria>) => void;
/** Omit the "Not owned" toggle — for the Library tab, which already shows
* nothing but owned cards, so the filter would just be a no-op there. */
hideNotOwned?: boolean;
/** Offer the "spare copies" toggle — a question about your collection, so
* it belongs in the Library rather than in a search of every card. */
showSpares?: boolean;
}) {
const toggleRarity = (key: string) =>
onChange({
rarities: c.rarities.includes(key)
? c.rarities.filter((r) => r !== key)
: [...c.rarities, key],
});
return (
<>
<div className="field">
<label>Rarity</label>
<span className="color-toggles">
{RARITIES.map((r) => (
<span
key={r.key}
title={`${r.label} — printed at this rarity in any set (or in the set being browsed)`}
className={`pip rarity-pip ${r.key} ${c.rarities.includes(r.key) ? "on" : ""}`}
onClick={() => toggleRarity(r.key)}
>
{r.code}
</span>
))}
</span>
</div>
<div className="field">
<label>Legal in</label>
<select value={c.legalIn} onChange={(e) => onChange({ legalIn: e.target.value })}>
@@ -1835,6 +1884,15 @@ export function CardCriteriaFields({
on={c.vanillaOnly}
onToggle={() => onChange({ vanillaOnly: !c.vanillaOnly })}
/>
{showSpares && (
<IconToggle
icon={ICON_SPARE}
label="Only spare copies"
hint="Cards you own more copies of than your decks use — free for a trade or a new deck. Maybeboards don't count."
on={c.sparesOnly}
onToggle={() => onChange({ sparesOnly: !c.sparesOnly })}
/>
)}
{!hideNotOwned && (
<IconToggle
icon={ICON_PACKAGE}