feat(deck): multi-category cards#90
Conversation
Replace DeckCard.category with an ordered join table deck_card_category(deck_card_id, deck_category_id, position); lowest position is the primary membership. The migration backfills registry rows for orphan MAINBOARD category strings, merges rows that only differed by category into one row per (deck, card, zone, printing, foil) with total quantity preserved, and converts old strings into memberships ordered by copies held. Non-MAINBOARD category strings are dropped (already invisible in the app). Part of #30.
PlannedChange add/move, SnapshotCard, DbOp, and RevisionDelta now carry categories: string[] ([0] = primary) instead of a single nullable string. Merge-target identity drops category — rows merge on (cardId, zone, printingId, isFoil) — and category-set changes surface as plain update ops with replace-all join-row writes (delete + createMany renumbering positions 0..n-1). Revision delta identity drops category too; stored legacy payloads in DeckRevision.changes and DeckProposal.changes parse through a zod union that normalizes category → categories. checkStructural gains an unknown_category issue validated against the deck's registry, and revert filters restored memberships to categories that still exist. Part of #30.
renameCategory only touches the registry row (memberships follow the DeckCategory id). deleteCategory's uncategorize mode is a plain registry delete — the FK cascade removes memberships and the next one auto-promotes on read — while deleteCards removes only cards whose primary is the category, through applyChanges so revisions record it. moveCardSubcategory is replaced by setCardCategories (whole-list replace, validated and deduped); moveCardTo promotes the drop target to primary while preserving other memberships; moveCategoryCards operates on primary members and swaps the primary in place; autogenerateCategories routes through applyChanges so runs are recorded as revisions. Wishlist adds now register the source deck name as a real DeckCategory and link it. duplicate copies join rows onto the copy's own registry; export's category filter matches on any membership. Part of #30.
ParsedCard and DeckCardWithDetails carry categories: string[]. The JSON adapter serializes the full ordered list and parses both the modern shape and legacy single-category exports via a per-card union; names are normalized (trim/lowercase) and deduped on parse. Text and Arena exports group by the primary membership only so re-import can't double quantities. Import registers any missing category names in the deck's registry before applying changes, keeping JSON round-trips lossless. Part of #30.
Deck reads select categoryLinks ordered by position and flatten to categories: string[] on the client DeckCard type. groupByCategory fans a card into every member section — full appearance under its primary, a ghosted (opacity, non-draggable, uncounted) copy under each secondary — with composite dnd-kit ids so ghosts never collide with the real entry. Section counts, commander template targets, and the role bar tally primary membership only. The card menu's category tab becomes multi-select: taps toggle membership without closing, the primary shows a filled star, a hollow star promotes, number keys toggle 1-9 and 0 clears. Dragging onto a section promotes it to primary while preserving other memberships; dragging onto Uncategorized or leaving MAINBOARD clears them. Part of #30.
Category and DeckCard entries now describe ordered memberships with a primary, primary-only counting, MAINBOARD-only scope, and clearing on zone exit; the example dialogue reflects the join-table model. Part of #30.
…orizations - applyMove merge now mirrors applyAdd: plain moves keep the target's memberships, categorized moves promote-merge instead of wiping (C1) - reject duplicate membership names structurally and normalize/dedupe at the editor-action boundary via shared normalizeCategory (I2) - record recategorizations as zero-delta revision entries carrying previousCategories; invertDeltas swaps before/after so revert restores prior memberships through a new setCategories op (I3) - replace-import keeper prefers the categorized duplicate row (I5) - deterministic snapshot row order (M3); category-only updates touch the row so @updatedat reflects membership changes (M4)
- deleteCategory deleteCards mode runs member removal and registry delete in one transaction (I1) - duplicateDeck copies rows with one createMany + identity-tuple remap instead of a per-card create loop in the 5s tx (I4) - wishlist source-deck category names truncate to CATEGORY_NAME_MAX (M1) - autogenerateCategories creates missing registry rows atomically via shared ensureDeckCategories and wraps assignment in the same tx (M2)
- JSON adapter drops non-MAINBOARD categories with a warning instead of failing the whole batch (I6), and caps category name/count via schema (M5) - intake registers categories inside the apply transaction so a failed batch can't leave phantom registry rows (I7) - JSON parse carries the export's category registry through intake, restoring empty categories and sortOrder on round-trip (M6) - replace-mode diff threads first-occurrence memberships onto add ops instead of hardcoding empty categories (M7)
- serialize category/zone server mutations through a promise queue so rapid toggles can't interleave and drop memberships (I9) - moveAll optimistic dispatch mirrors moveCategoryCards promote- preserving semantics and excludes ghost fan-out copies (I10) - Shift+1..9 promotes a membership to primary from the keyboard; the nested promote <button> becomes a presentational span so menuitems hold no interactive children (I8) - squash revision deltas by key before rendering so legacy per-category payloads can't produce duplicate React keys (M9) - RoleBar drops zero-count legend rows for ghost-only sections (M11)
…tion Guard both backfill predicates with btrim(category) <> '' so legacy empty/whitespace category values produce no phantom registry rows or memberships. Verified against a scratch database seeded with '', ' ', and real category values: only the real name lands in deck_category and row merging is unaffected.
- render tests pin primary-only tallies: section headers count a multi-category card once; RoleBar drops ghost-only legend rows - gapped-position promotion covered through snapshot and getDeckById flattening (position asc ordering asserted) - composed text/arena round-trip: multi-category card serializes once and re-imports as a single row with its original quantity - re-enable the bulkUpdateDeck InvariantViolation propagation test - CONTEXT.md: Revision entry documents zero-delta recategorizations
|
Warning Review limit reached
Next review available in: 36 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (105)
📝 WalkthroughWalkthroughThis change replaces single deck-card categories with ordered multi-category memberships backed by a join table. It updates migrations, mutations, revisions, imports/exports, category UI interactions, drag-and-drop behavior, statistics, proposal rendering, and related tests. ChangesMulti-category deck cards
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
CI failed the coverage gate at 99.92% lines / 99.78% functions / 99.36% branches. Closed every remaining gap: added the missing test case for each unexercised branch or function, and marked three genuinely unreachable branches with the existing v8-ignore convention (mirroring lib/search/card-search.ts:371) instead of writing dead tests for them. Key changes: - Added missing test cases: inventory.ts, collaboration.ts, duplicate.ts, revisions.ts, mutation/plan.ts, legality/shared.ts, printing-heuristics.ts, card-search.ts, use-menu-shortcuts.ts, scryfall/steps.ts, and revision.ts's untested squashDeltas export - Removed provably-dead branches (with rationale comments): group-deltas.ts (?? [] after an already-confirmed .has() filter), edhrec/recommendations.ts (ternary whose false arm can't occur), mutation/apply.ts (an update op with quantity/zone/categories all undefined, impossible per diffSnapshots) - No production behavior changes outside the two one-line simplifications
Multi-category cards render ghosted under secondary categories but the section header "(N)" tallied primary memberships only, so ghosts were visible yet uncounted. Header counts (and the commander-template "X/Y" progress, which shares the total) now count every membership. The stats distribution bar stays primary-only so its segments still sum to deck size. Key changes: - decklist-section.tsx: header total is a plain quantity sum over all entries, ghosts included - decklist.test.tsx: secondary section now asserts (1) instead of (0) - CONTEXT.md: Category definition updated to match
|
@coderabbitai full review |
✅ Action performedFull review finished. Your plan includes PR reviews subject to rate limits. More reviews will be available in 36 minutes. |
feat(deck): multi-category cards
Description
A
DeckCardcan now belong to severalCategorymemberships instead of one free-textcategorystring.Membership is a new
DeckCardCategoryjoin table (deckCardId,deckCategoryId,position), ordered per card — the first membership is primary, the rest are secondary.DeckCard.category(nullable string) replaced byDeckCardCategoryjoin rows; migration backfills existing single-category assignments and skips blank legacy strings.lib/deck/mutation/*): diff/apply/invariants/plan/snapshot all thread ordered category memberships instead of a single string. Recategorization-only edits (no quantity change) are now recorded as zero-quantityDeckRevisiondeltas carryingpreviousCategories/categories, so history shows the change and revert restores the prior memberships.app/_actions/deck/categories.ts,duplicate.ts,collaboration.ts): category mutations made atomic;duplicateDeckbulk-copies memberships instead of per-card round trips.lib/deck/io/*): plain/arena/dek/JSON adapters read and write ordered category lists; blank legacy category strings are skipped during migration and import.app/_components/builder/*): a card renders in full under its primary category and ghosted under each secondary;move-card-menu.tsxgained multi-select category assignment; fixed a11y and race conditions in the category UI.CONTEXT.mdupdated —CategoryandDeckCarddefinitions, relationships, and example dialogue now reflect ordered multi-membership semantics.Fixes: #30
Checklist
pnpm test) and lint is cleanrevalidate/dynamic/unstable_cache— used'use cache'+cacheLife/cacheTag<Link>imported fromapp/_components/link.tsx, notnext/linkScreenshots / notes
Not applicable — this PR touches server actions, mutation core, decklist IO, and existing builder components' category logic; no new routes, links, or Suspense boundaries were added.
Migration:
prisma/migrations/.../migration.sqlbackfillsDeckCardCategoryfrom the oldDeckCard.categorycolumn and drops it.Summary by CodeRabbit
New Features
Bug Fixes
Documentation