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:
187
docs/deck-tags-design.md
Normal file
187
docs/deck-tags-design.md
Normal 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)
|
||||
Reference in New Issue
Block a user