Add design doc for deck card tags

Proposed, not implemented: user-defined tags on cards within a deck,
filtering by them in the deck builder, and the sync format bump (v5)
they'd need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 21:47:45 -05:00
parent eff96a1fba
commit f1f6591319

187
docs/deck-tags-design.md Normal file
View File

@@ -0,0 +1,187 @@
# Design: deck card tags
Status: **proposed — not implemented.**
## Context
While building a deck you often want to mark cards for later ("to remove",
"swap?", "ramp") and then work with just those cards. Today the only way to do
that is to remember them. This document designs **user-defined tags on cards
within a deck**, and **filtering by them** in the deck builder.
**Decisions from the requesting conversation:**
- **Tags belong to a card in one deck.** The same card in two decks has its
own tags in each.
- **Tags are free-form.** Autocomplete only offers tags already used in the
**current deck**; there's no shared, global tag list.
- **Filtering** is a Tags field in the deck builder's advanced filter popup
(match any / all, or Untagged only), plus a Tags column whose chips you
click to filter to that tag.
- **Tags sync** across devices, which needs a new library document format
(v5).
## User experience
### Seeing tags
- **List view:** an optional **Tags** column, added to the existing Columns
menu and shown by default, holds tag chips (`.deck-chip` style). Clicking a
chip filters to that tag.
- **Tiles view:** tags appear as small badges in the tile footer, next to the
existing commander and extra-copies badges.
### Tagging
- **One card:** the card preview modal (click a thumbnail) already has a deck
section ("Shown in this deck", cover). It gains a **Tags** row using the
existing `TagInput` (chips, autocomplete, Backspace removes the last one).
Edits save immediately.
- **Many cards:** the bulk bar gets **Tag…**, which opens a small dialog "Tag
N cards":
- every tag on any selected card is listed with a count ("to remove — 2 of
3"), each with **Add to all** and **Remove** buttons;
- a `TagInput` adds new tags to every selected card.
### Filtering
- The advanced filter popup (the funnel in the section bar) gets a **Tags**
field: a `TagInput` limited to the deck's tags, an any/all switch, and an
**Untagged only** checkbox. This part is matched in the browser; the rest
of the popup is still matched by the backend.
- Clicking a chip in the table adds that tag to the filter.
- A tag filter counts as "filter on": the funnel lights up and the popup's
"N of M match" includes it.
- **Behaviour change:** **Select all** selects the cards currently *shown*,
so "filter to *to remove* → Select all → Remove from deck" works. Today it
selects every card in the deck even when filtered.
### Tag names
Trimmed, with inner whitespace collapsed and a 40-character limit. Matching
and de-duplication ignore case; a tag keeps the casing it was first written
with.
## Schema / migrations
### library.db (append-only migrations, per the existing convention in `db.rs`)
`0009_deck_card_tags.sql`:
```sql
ALTER TABLE deck_cards ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';
```
The column holds a JSON array of strings. It's a column on the slot rather
than a join table because:
- tags then go with their slot automatically: removing a card from a deck
(`set_deck_card` to 0) drops them;
- they survive maybeboard moves and quantity changes, since `set_deck_card`'s
`ON CONFLICT` update doesn't touch the column;
- they're only ever read alongside the rest of the slot.
## Core (`mtgc-core`)
- **`model.rs` `DeckEntry`:** add `tags: Vec<String>`, filled in `decks.rs`
`deck_entries` (one more selected column, parsed from JSON).
- **New `Catalog::set_deck_card_tags(deck_id, oracle_ids, add, remove)`:** in
one transaction, for each slot, apply the removals and then the additions
(normalised, and de-duplicated ignoring case). The same call serves a
single-card edit and bulk tagging.
- **`duplicate_deck`** copies `tags` along with the slot's other columns.
### Sync (`sync.rs`)
- `DeckCardEntry` gains
`#[serde(default, skip_serializing_if = "BTreeSet::is_empty")] tags: BTreeSet<String>`.
- The `deck_cards` export query and the import upsert include the column.
- `DOC_VERSION` becomes **5**, and `required_version()` returns 5 when any
deck card has tags. Libraries without tags keep writing at their old
version, so older app builds can still read them.
- Merging stays whole-entry, like quantity and pulled today: if two devices
change the same card's tags before syncing, the incoming version wins and
the merge reports a conflict.
### Rollout order
The sync server parses documents with `LibraryDoc::from_json`, which rejects
versions newer than it knows. **Deploy the rebuilt server image before
releasing app builds that write tags**, or their uploads fail. Older app
builds refuse v5 documents with the existing "update the app first" message.
v2–v4 followed the same rule.
## Commands + `src/api.ts`
- A Tauri command `set_deck_card_tags` in `src-tauri/src/commands.rs`,
wrapped as `api.setDeckCardTags(deckId, oracleIds, add, remove)`.
- `DeckEntry.tags: string[]` on the TypeScript side.
## Frontend (`src/`)
- **[`views/DeckView.tsx`](../src/views/DeckView.tsx):**
- `DECK_COLUMNS` gets `tags`; `deckTable` and `deckTiles` render the chips.
- `deckTags` is the sorted list of distinct tags in `contents`, used as
autocomplete suggestions.
- Tag filter state `{ tags: string[], mode: "any" | "all", untagged: boolean }`,
checked in `matchesFilter` next to `advMatches`; it counts toward
`advancedActive`.
- `AdvancedFilterDialog` renders the Tags field above `CardFilterPanel`,
and its match count combines both filters.
- Bulk bar **Tag…** button plus a new `TagCardsDialog`.
- `toggleSelectAllCards` works on the shown cards (main board and
maybeboard, after filtering).
- **[`components.tsx`](../src/components.tsx):**
- `PreviewContext` gains `tags?`, `tagSuggestions?` and `onTagsChange?`.
- The preview's deck section renders a `TagInput` when they're provided.
- Reuse `TagInput` as it is.
- **[`styles.css`](../src/styles.css):** tag chips (clickable look, and an
active state while filtering), tile tag badges.
## Out of scope (possible follow-ups)
- Renaming or deleting a tag across a whole deck.
- Tag colours.
- "Group by tag" (a card with several tags would show up in several groups).
- Tags in decklist export/import.
- A finer, set-wise sync merge for tags.
## Cost and risk
- About a day of work across about 8 files plus a migration. Most of it is
UI: two dialogs, the table and tile chips, and the popup field.
- The main risk is the **format bump**: deploy the server first, then the
apps.
- "Select all = shown cards" changes existing behaviour, but it's what the
filtered workflow needs.
## Verification plan (once implemented)
- **Rust unit tests** (`crates/mtgc-core`):
- `set_deck_card_tags` adds and removes, normalises case and whitespace,
and dedupes;
- tags survive maybeboard moves and quantity changes, and disappear when
the card is removed;
- `duplicate_deck` copies tags;
- a sync round-trip preserves tags;
- `required_version` is 5 only when tags exist;
- a v5 document is rejected by a reader capped at v4.
- **Server tests** (`crates/mtgc-server/tests/api.rs`): a v5 document is
accepted.
- `npx tsc --noEmit`, and `cargo test -p mtgc-core -p mtgc-server`.
- **In the app:**
- tag one card from the preview and several from the bulk bar;
- the Tags column shows chips, and clicking one filters;
- the popup's any/all/Untagged options narrow correctly, and the funnel
and count update;
- filter, then Select all, then Remove only removes the shown cards;
- the phone layout still works.
## Critical files
- [crates/mtgc-core/src/sql/library/](../crates/mtgc-core/src/sql/library/) — new `0009_deck_card_tags.sql`
- [crates/mtgc-core/src/model.rs](../crates/mtgc-core/src/model.rs) — `DeckEntry.tags`
- [crates/mtgc-core/src/decks.rs](../crates/mtgc-core/src/decks.rs) — reading tags, `set_deck_card_tags`, `duplicate_deck`
- [crates/mtgc-core/src/sync.rs](../crates/mtgc-core/src/sync.rs) — `DeckCardEntry.tags`, v5, export/import
- [src-tauri/src/commands.rs](../src-tauri/src/commands.rs) — the new command
- [src/api.ts](../src/api.ts), [src/views/DeckView.tsx](../src/views/DeckView.tsx), [src/components.tsx](../src/components.tsx), [src/styles.css](../src/styles.css)