diff --git a/docs/deck-tags-design.md b/docs/deck-tags-design.md new file mode 100644 index 0000000..c092c66 --- /dev/null +++ b/docs/deck-tags-design.md @@ -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`, 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`. +- 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)