Skip to content

feat(deck): multi-category cards#90

Merged
jcserv merged 16 commits into
mainfrom
jarrod/30-multi-category-cards
Jul 13, 2026
Merged

feat(deck): multi-category cards#90
jcserv merged 16 commits into
mainfrom
jarrod/30-multi-category-cards

Conversation

@jcserv

@jcserv jcserv commented Jul 13, 2026

Copy link
Copy Markdown
Owner

feat(deck): multi-category cards

Description

A DeckCard can now belong to several Category memberships instead of one free-text category string.
Membership is a new DeckCardCategory join table (deckCardId, deckCategoryId, position), ordered per card — the first membership is primary, the rest are secondary.

  • Schema: DeckCard.category (nullable string) replaced by DeckCardCategory join rows; migration backfills existing single-category assignments and skips blank legacy strings.
  • Mutation core (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-quantity DeckRevision deltas carrying previousCategories/categories, so history shows the change and revert restores the prior memberships.
  • Server actions (app/_actions/deck/categories.ts, duplicate.ts, collaboration.ts): category mutations made atomic; duplicateDeck bulk-copies memberships instead of per-card round trips.
  • Decklist import/export (lib/deck/io/*): plain/arena/dek/JSON adapters read and write ordered category lists; blank legacy category strings are skipped during migration and import.
  • Builder UI (app/_components/builder/*): a card renders in full under its primary category and ghosted under each secondary; move-card-menu.tsx gained multi-select category assignment; fixed a11y and race conditions in the category UI.
  • Domain doc: CONTEXT.md updated — Category and DeckCard definitions, relationships, and example dialogue now reflect ordered multi-membership semantics.

Fixes: #30

Checklist

  • Tests pass (pnpm test) and lint is clean
  • New behavior is covered by tests
  • No revalidate / dynamic / unstable_cache — used 'use cache' + cacheLife/cacheTag
  • <Link> imported from app/_components/link.tsx, not next/link
  • Suspense fallbacks reserve layout space (explicit heights)

Screenshots / 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.sql backfills DeckCardCategory from the old DeckCard.category column and drops it.

Summary by CodeRabbit

  • New Features

    • Deck cards can now belong to multiple ordered categories, with support for primary and secondary memberships.
    • Added category toggling, promotion, clearing, bulk movement, and keyboard shortcuts in the deck builder.
    • JSON exports and imports preserve category memberships and category ordering.
    • Revision history and proposal views now display category changes clearly.
  • Bug Fixes

    • Improved category filtering, statistics, duplication, and wishlist categorization.
    • Added safer handling for invalid imports and card-search collisions.
  • Documentation

    • Added a comprehensive guide for locally verifying deck-building workflows.

Jarrod Servilla and others added 13 commits July 12, 2026 09:49
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
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@jcserv, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e4b48532-aa6f-401e-aec6-c6e78c640cd9

📥 Commits

Reviewing files that changed from the base of the PR and between 8b01e0c and 2cfdbd4.

📒 Files selected for processing (105)
  • .claude/skills/verify/SKILL.md
  • CONTEXT.md
  • app/(ui)/deck/[id]/play/page.tsx
  • app/(ui)/deck/[id]/play/playtest-client.tsx
  • app/(ui)/deck/[id]/propose/page.tsx
  • app/_actions/__tests__/export.test.ts
  • app/_actions/__tests__/inventory.test.ts
  • app/_actions/deck/__tests__/bulk-edit.test.ts
  • app/_actions/deck/__tests__/categories.test.ts
  • app/_actions/deck/__tests__/collaboration.test.ts
  • app/_actions/deck/__tests__/duplicate.test.ts
  • app/_actions/deck/__tests__/import-invariant.test.ts
  • app/_actions/deck/__tests__/import.test.ts
  • app/_actions/deck/__tests__/manabase.test.ts
  • app/_actions/deck/__tests__/revisions.test.ts
  • app/_actions/deck/categories.ts
  • app/_actions/deck/collaboration.ts
  • app/_actions/deck/duplicate.ts
  • app/_actions/deck/export.ts
  • app/_actions/deck/manabase.ts
  • app/_actions/deck/revisions.ts
  • app/_actions/inventory.ts
  • app/_components/builder/card-browser/deck-browser-context.tsx
  • app/_components/builder/card-row-sortable.tsx
  • app/_components/builder/card-row.tsx
  • app/_components/builder/card-stack-sortable.tsx
  • app/_components/builder/card-stack.tsx
  • app/_components/builder/deck-builder-owner.tsx
  • app/_components/builder/decklist-dnd.tsx
  • app/_components/builder/decklist-section.tsx
  • app/_components/builder/decklist.test.tsx
  • app/_components/builder/decklist.tsx
  • app/_components/builder/move-card-menu.tsx
  • app/_components/deck/deck-detail-sheet.test.tsx
  • app/_components/deck/deck-proposal-review-list.tsx
  • app/_components/deck/deck-propose-draft.tsx
  • app/_components/deck/revision-diff.tsx
  • app/_components/header-search/deck-mode-bar.test.tsx
  • app/_components/header-search/deck-mode-bar.tsx
  • app/_components/hotkeys/__tests__/use-menu-shortcuts.test.tsx
  • app/_components/hotkeys/registry.ts
  • app/_components/hotkeys/use-menu-shortcuts.ts
  • app/_components/stats/deck-stats.test.tsx
  • app/_components/stats/role-bar.test.tsx
  • app/_components/stats/role-bar.tsx
  • lib/card/__tests__/printing-heuristics.test.ts
  • lib/deck/__tests__/editor-actions.test.ts
  • lib/deck/__tests__/external-fetch.test.ts
  • lib/deck/__tests__/group-deltas.test.ts
  • lib/deck/__tests__/group-sort.test.ts
  • lib/deck/__tests__/queries.test.ts
  • lib/deck/__tests__/revision.test.ts
  • lib/deck/__tests__/shuffle.test.ts
  • lib/deck/__tests__/zone-view.test.ts
  • lib/deck/category-registry.ts
  • lib/deck/constants.ts
  • lib/deck/editor-actions.ts
  • lib/deck/group-deltas.ts
  • lib/deck/group-sort.ts
  • lib/deck/io/__tests__/card-resolver.test.ts
  • lib/deck/io/__tests__/intake.test.ts
  • lib/deck/io/__tests__/parse.test.ts
  • lib/deck/io/__tests__/resolve.test.ts
  • lib/deck/io/__tests__/serialize.test.ts
  • lib/deck/io/adapters/__tests__/json.test.ts
  • lib/deck/io/adapters/_shared.ts
  • lib/deck/io/adapters/dek.ts
  • lib/deck/io/adapters/json.ts
  • lib/deck/io/adapters/types.ts
  • lib/deck/io/intake.ts
  • lib/deck/io/parse.ts
  • lib/deck/io/serialize.ts
  • lib/deck/legality.test.ts
  • lib/deck/legality/__tests__/brawl.test.ts
  • lib/deck/legality/__tests__/commander.test.ts
  • lib/deck/legality/__tests__/companions.test.ts
  • lib/deck/legality/__tests__/oathbreaker.test.ts
  • lib/deck/legality/__tests__/shared.test.ts
  • lib/deck/legality/__tests__/sixty-card.test.ts
  • lib/deck/legality/shared.ts
  • lib/deck/mutation/__tests__/apply.test.ts
  • lib/deck/mutation/__tests__/diff-snapshots.test.ts
  • lib/deck/mutation/__tests__/diff.test.ts
  • lib/deck/mutation/__tests__/invariants.test.ts
  • lib/deck/mutation/__tests__/plan.test.ts
  • lib/deck/mutation/__tests__/revision.test.ts
  • lib/deck/mutation/__tests__/snapshot-pure.test.ts
  • lib/deck/mutation/__tests__/snapshot.test.ts
  • lib/deck/mutation/apply.ts
  • lib/deck/mutation/diff-snapshots.ts
  • lib/deck/mutation/diff.ts
  • lib/deck/mutation/invariants.ts
  • lib/deck/mutation/plan.ts
  • lib/deck/mutation/snapshot-pure.ts
  • lib/deck/mutation/snapshot.ts
  • lib/deck/mutation/types.ts
  • lib/deck/queries.ts
  • lib/deck/revision.ts
  • lib/deck/zone-view.ts
  • lib/edhrec/recommendations.ts
  • lib/search/__tests__/card-search.test.ts
  • lib/user/__tests__/feed.test.ts
  • prisma/migrations/20260711000000_deck_card_multi_category/migration.sql
  • prisma/schema.prisma
  • workflows/scryfall/__tests__/steps.test.ts
📝 Walkthrough

Walkthrough

This 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.

Changes

Multi-category deck cards

Layer / File(s) Summary
Membership data model
prisma/schema.prisma, prisma/migrations/..., lib/deck/constants.ts, lib/deck/category-registry.ts
Deck cards use ordered category links with normalized names and registry ordering; migration logic merges legacy rows and backfills memberships.
Mutation and revision flow
lib/deck/mutation/*, lib/deck/revision.ts, lib/deck/queries.ts
Mutations replace category links transactionally, validate unknown/duplicate categories, and record pure recategorizations with zero-quantity deltas.
Import/export and intake
lib/deck/io/*
JSON supports ordered category arrays and registry metadata, legacy category fields are normalized, and category registration is atomic with card changes.
Server actions
app/_actions/deck/*, app/_actions/inventory.ts
Category moves, deletion, autogeneration, duplication, wishlist categorization, proposals, and exports use membership arrays.
Builder interactions
app/_components/builder/*, app/_components/hotkeys/*
Cards can toggle and promote memberships; secondary entries are ghosted and non-draggable, with section-unique drag identifiers.
Validation and presentation
app/_components/deck/*, app/_components/stats/*, lib/deck/group-sort.ts
Category sections fan cards out across memberships, stats count primary memberships, and revision/proposal views display recategorization changes.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

  • jcserv/maindeck#49 — Adds the bulk category movement flow that this change adapts to ordered memberships.
  • jcserv/maindeck#53 — Introduces export filtering behavior updated here for multi-category cards.
  • jcserv/maindeck#82 — Adds proposal review behavior whose change payloads are migrated here to category arrays.

Suggested reviewers: zandrosantos

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also includes unrelated changes, like a new verify skill guide and Scryfall workflow tests, that don't support multi-category cards. Split unrelated docs and workflow test changes into a separate PR so this one stays focused on multi-category deck cards.
Docstring Coverage ⚠️ Warning Docstring coverage is 8.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the primary change: adding multi-category cards.
Linked Issues check ✅ Passed The PR implements ordered multi-category memberships across schema, mutations, UI, import/export, and tests, matching issue #30.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jarrod/30-multi-category-cards

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jcserv jcserv self-assigned this Jul 13, 2026
jcserv added 2 commits July 13, 2026 10:59
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
@jcserv

jcserv commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your plan includes PR reviews subject to rate limits. More reviews will be available in 36 minutes.

@jcserv
jcserv merged commit 02f8b99 into main Jul 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Multi-category cards

1 participant