Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
name: verify
description: Build, launch, and drive maindeck locally to observe a change end-to-end (dev server + Postgres + browser).
---

# Verifying maindeck changes at runtime

## Launch

```bash
docker compose up -d # postgres :5432 (maindeck/maindeck/maindeck_dev), redis, srh
npm run dev # Next.js on http://localhost:3000 (~30s to first 200)
```

`DATABASE_URL` in `.env` points at the docker Postgres. The dev DB usually
already has ~33k cards ingested (Sol Ring=1386, Lightning Bolt=871,
Llanowar Elves=657, Forest=1 — ids stable in the local dataset; re-check with
`SELECT id FROM card WHERE name=...`).

## Auth

Sign-up form needs username, email, DOB, password. Two gotchas:

- The DOB `<input type="date">` rejects accessibility-ref fills; set it via
`chrome-devtools-axi eval` with the native value setter + `input` event.
- Email verification blocks sign-in; flip it in SQL:
`UPDATE "user" SET email_verified = true WHERE email = '...';`

## Seeding deck fixtures

Fastest path is SQL against `maindeck_dev` (`PGPASSWORD=maindeck psql -h
localhost -U maindeck -d maindeck_dev`). Minimum viable deck:

```sql
INSERT INTO deck (id, user_id, name, format, visibility, created_at, updated_at) VALUES (...);
INSERT INTO deck_category (id, deck_id, name, sort_order) VALUES (...); -- names lowercase
INSERT INTO deck_card (id, deck_id, card_id, quantity, zone, created_at, updated_at) VALUES (...);
INSERT INTO deck_card_category (deck_card_id, deck_category_id, position) VALUES (...); -- 0 = primary
```

`user.username`, `name`, `date_of_birth` are all NOT NULL.

## Driving the builder

- Card row menus: `button "Move card"` → tabs Actions / Category / Zone.
Number keys toggle memberships, Shift+digit promotes primary, menu stays
open (`closeOnClick=false`).
- Section menus: `button "Actions for <category>"` → Move all cards to /
Delete.
- Bulk edit: deck page `button "Bulk edit decklist"` → textarea + Save.
**React textareas ignore plain accessibility fills for state purposes** —
set values with the native setter + `input` event via `eval`, or type
through the keyboard. `press Meta+a` inside the dialog does NOT reliably
select-all; verify the textarea value before submitting (a botched paste
that prepends to existing text parses as garbage and replace-mode will
happily wipe the deck).
- History/revert: `/deck/<id>/history` → "Revert all" → confirm dialog
`button "Revert"`.
- Server actions log one line each to the dev-server stdout
(`ƒ actionName(args…)`) — grep it to confirm what actually fired.

## Evidence

Assert final state in SQL (memberships, quantities, revisions) — the
`deck_revision.changes` JSON column shows delta payloads directly.
17 changes: 12 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ A rule for bulk-reselecting the pinned **Printing** of every **DeckCard** in a *
A user-owned collection of **DeckCards** under one **Format**, with a **Visibility** and an optional **Bracket** override. May be **forked** from another **Deck**.

**DeckCard**:
A single line in a **Deck** — `(Card, Zone, Category?, Printing?, quantity, isFoil)`. The unit of mutation in the editor.
A single line in a **Deck** — `(Card, Zone, Printing?, quantity, isFoil)` plus an ordered list of **Category** memberships (join table `DeckCardCategory`).
The unit of mutation in the editor.
**Category** is not part of its identity: one row per `(Card, Zone, Printing, isFoil)` regardless of how many **Categories** it belongs to.
_Avoid_: deck entry, slot, card-in-deck. When the surrounding code is unambiguous, "card" is acceptable shorthand — but at module interfaces, prefer **DeckCard**.

**Zone**:
Expand All @@ -47,7 +49,11 @@ _Avoid_: maybeboard (use `CONSIDERING`), board (ambiguous with mainboard).
A **Card** placed in the `COMPANION` **Zone** whose deckbuilding restriction the rest of the deck (`MAINBOARD` + `COMMANDER`) must satisfy. There is a fixed set of ten companions; each restriction is a condition from the card's oracle text (e.g. Lurrus: every permanent has mana value ≤ 2). The restrictions are **not** present in Scryfall's structured data — only the `Companion` keyword is — so they are encoded as a name-keyed predicate registry in `lib/deck/legality/companions.ts` and validated as part of `fullLegality`.

**Category**:
A user-defined free-text grouping within a **Zone** (e.g. "Ramp", "Removal"). Distinct from **CardType** (Creature/Instant/...) and from **Format**.
A user-defined free-text grouping within the `MAINBOARD` **Zone** (e.g. "Ramp", "Removal").
A **DeckCard** may belong to several **Categories** at once: memberships are ordered, the first is the **primary**, and the rest are secondary.
The card renders in full under its primary and ghosted under each secondary; section header counts and commander-template targets count every membership (ghosts included), while the stats distribution bar tallies primaries only so it sums to deck size.
Zero memberships means uncategorized; leaving `MAINBOARD` clears all memberships.
Distinct from **CardType** (Creature/Instant/...) and from **Format**.

**Visibility**:
`PRIVATE` (owner-only, 404s for others), `UNLISTED` (link-accessible, not indexed), or `PUBLIC` (discoverable). Default is `PRIVATE`. Discovery is `kind=DECK` only: a wishlist (`kind=WISHLIST`) made `PUBLIC` is link-accessible via `/deck/[id]` but stays out of discovery and is always `noindex`.
Expand All @@ -57,6 +63,7 @@ A copy of another **Deck**, retaining a `forkedFromId` pointer to its origin.

**Revision**:
A `DeckRevision` row capturing a JSON change payload for a **Deck** edit, used for the history view.
Pure recategorizations are recorded too: a zero-quantity delta carrying the memberships before (`previousCategories`) and after (`categories`), so the history shows the change and a revert restores the prior memberships.

**Comparison**:
A visibility-aware diff of two **Decks** — the **Cards** added, removed, and shared (with quantity deltas), plus a per-deck stat block (mana curve, color pips, type breakdown, average MV, land counts). Computed from each **Deck**'s `MAINBOARD` + `COMMANDER` **Zones** only; `SIDEBOARD` and `CONSIDERING` are excluded, mirroring deck stats. Both **Decks** must be viewable by the viewer (their own or non-`PRIVATE`), else the view 404s rather than 403s to avoid probing which **Deck** ids exist.
Expand Down Expand Up @@ -103,7 +110,7 @@ A textual representation of a **Deck**. Three on-the-wire shapes: plain `text`,

- A **Card** has many **Printings** and may produce many **Tokens**.
- A **Deck** has many **DeckCards** and many **Categories**; each **DeckCard** points at one **Card** and optionally pins one **Printing**.
- A **DeckCard** belongs to exactly one **Zone**; **Category** is meaningful only within `MAINBOARD`.
- A **DeckCard** belongs to exactly one **Zone** and to zero or more **Categories** (ordered; first = primary), meaningful only within `MAINBOARD`. Moving out of `MAINBOARD` clears the memberships.
- A **Deck** has one **Format**; **Bracket** applies only when the **Format** is `COMMANDER`.
- **Legality** is computed from a **Deck** against its **Format** (and, for singleton formats, the commander-zone **Color identity**).
- A **Deck** may be **forked** from another **Deck** (`forkedFromId`).
Expand All @@ -115,8 +122,8 @@ A textual representation of a **Deck**. Three on-the-wire shapes: plain `text`,
> **Dev:** "If a user pins a specific **Printing** on a **DeckCard** and that **Printing** later gets pulled from Scryfall, do we drop the **DeckCard**?"
> **Domain expert:** "No — only the pin is nullable. The **DeckCard** still references the **Card**, so the deck stays whole; the **Printing** picker just falls back to the canonical first **Printing**."

> **Dev:** "A user moved a **DeckCard** from `MAINBOARD` to `SIDEBOARD`. Does its **Category** come along?"
> **Domain expert:** "No. **Category** is mainboard-only — moving out of `MAINBOARD` clears it. **Categories** are per-**Deck** strings, not first-class entities the **DeckCard** belongs to."
> **Dev:** "A user moved a **DeckCard** from `MAINBOARD` to `SIDEBOARD`. Do its **Category** memberships come along?"
> **Domain expert:** "No. **Category** is mainboard-only — moving out of `MAINBOARD` clears every membership. Deleting a **Category** cascades its memberships away; a card whose primary was deleted just promotes its next membership."

> **Dev:** "Should we recompute the **Bracket** when the user changes the **Format** from `COMMANDER` to `MODERN`?"
> **Domain expert:** "**Bracket** doesn't exist outside Commander. Hide it. The `manualBracket` value can stay on the row — it just isn't surfaced — so they don't lose the override if they switch back."
Expand Down
1 change: 0 additions & 1 deletion app/(ui)/deck/[id]/play/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ async function PlayPageContent({ params }: { params: Promise<{ id: string }> })
id: dc.id,
quantity: dc.quantity,
zone: dc.zone,
category: dc.category,
card: {
id: String(dc.card.id),
name: dc.card.name,
Expand Down
1 change: 0 additions & 1 deletion app/(ui)/deck/[id]/play/playtest-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ interface DeckCardInput {
id: string;
quantity: number;
zone: string;
category: string | null;
card: {
id: string;
name: string;
Expand Down
29 changes: 21 additions & 8 deletions app/(ui)/deck/[id]/propose/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,27 @@ async function DeckProposeContent({ id }: { id: string }) {
const deck = await getDeckById(id);
if (!deck) notFound();

const mainboard = deck.cards
.filter((c) => c.zone === "MAINBOARD")
.map((c) => ({
cardId: c.cardId,
cardName: c.card.name,
category: c.category,
quantity: c.quantity,
}));
// Aggregate per card: proposal deltas are keyed by (cardId, zone), so rows
// that only differ by pinned printing collapse into one entry.
const byCard = new Map<
number,
{ cardId: number; cardName: string; categories: string[]; quantity: number }
>();
for (const c of deck.cards) {
if (c.zone !== "MAINBOARD") continue;
const prior = byCard.get(c.cardId);
if (prior) {
prior.quantity += c.quantity;
} else {
byCard.set(c.cardId, {
cardId: c.cardId,
cardName: c.card.name,
categories: c.categories,
quantity: c.quantity,
});
}
}
const mainboard = [...byCard.values()];

return (
<div className="flex flex-col gap-6">
Expand Down
18 changes: 11 additions & 7 deletions app/_actions/__tests__/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ const DECK_ID = "deck-1";
function makeCard(
name: string,
zone: "MAINBOARD" | "SIDEBOARD" | "COMMANDER" | "CONSIDERING",
category: string | null = null,
categories: string[] = [],
) {
return {
id: name,
deckId: DECK_ID,
cardId: name,
quantity: 1,
zone,
category,
categories,
printingId: null,
isFoil: false,
card: { name },
Expand All @@ -62,8 +62,9 @@ const MOCK_DECK = {
description: null,
cards: [
makeCard("Sol Ring", "COMMANDER"),
makeCard("Lightning Bolt", "MAINBOARD", "Removal"),
makeCard("Cultivate", "MAINBOARD", "Ramp"),
makeCard("Lightning Bolt", "MAINBOARD", ["Removal"]),
makeCard("Cultivate", "MAINBOARD", ["Ramp"]),
makeCard("Nature's Lore", "MAINBOARD", ["Removal", "Ramp"]),
makeCard("Forest", "MAINBOARD"),
makeCard("Snapcaster Mage", "SIDEBOARD"),
makeCard("Ponder", "CONSIDERING"),
Expand Down Expand Up @@ -104,7 +105,7 @@ describe("getDeckExports", () => {
await getDeckExports(DECK_ID);
const textAdapter = serializers.find((a) => a.id === "text")!;
const calledDeck = vi.mocked(textAdapter.serialize).mock.calls[0]?.[0];
expect(calledDeck?.cards).toHaveLength(6);
expect(calledDeck?.cards).toHaveLength(7);
});

it("zone filter: MAINBOARD only → excludes sideboard/considering/commander", async () => {
Expand All @@ -113,7 +114,7 @@ describe("getDeckExports", () => {
const textAdapter = serializers.find((a) => a.id === "text")!;
const calledDeck = vi.mocked(textAdapter.serialize).mock.calls[0]?.[0];
expect(calledDeck?.cards.every((c: { zone: string }) => c.zone === "MAINBOARD")).toBe(true);
expect(calledDeck?.cards).toHaveLength(3);
expect(calledDeck?.cards).toHaveLength(4);
});

it("zone filter: COMMANDER + MAINBOARD → excludes sideboard and considering", async () => {
Expand All @@ -134,9 +135,12 @@ describe("getDeckExports", () => {
});
const textAdapter = serializers.find((a) => a.id === "text")!;
const calledDeck = vi.mocked(textAdapter.serialize).mock.calls[0]?.[0];
// Cultivate (Ramp) + Forest (null category, passes through) remain
// Cultivate (Ramp) + Forest (uncategorized, passes through) remain.
expect(calledDeck?.cards.find((c: { card: { name: string } }) => c.card.name === "Lightning Bolt")).toBeUndefined();
expect(calledDeck?.cards.find((c: { card: { name: string } }) => c.card.name === "Cultivate")).toBeDefined();
// A multi-category card passes when ANY of its memberships is selected,
// even if its primary is a filtered-out category.
expect(calledDeck?.cards.find((c: { card: { name: string } }) => c.card.name === "Nature's Lore")).toBeDefined();
});

it("category filter passes uncategorized cards", async () => {
Expand Down
95 changes: 89 additions & 6 deletions app/_actions/__tests__/inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ vi.mock("@/lib/db", () => ({
create: vi.fn(),
deleteMany: vi.fn(),
},
deckCategory: {
findFirst: vi.fn(),
upsert: vi.fn(),
},
deck: { findFirst: vi.fn() },
},
}));
Expand All @@ -45,6 +49,8 @@ const mockHoldingDeleteMany = vi.mocked(prisma.holding.deleteMany);
const mockDeckCardFindFirst = vi.mocked(prisma.deckCard.findFirst);
const mockDeckCardCreate = vi.mocked(prisma.deckCard.create);
const mockDeckCardDeleteMany = vi.mocked(prisma.deckCard.deleteMany);
const mockDeckCategoryFindFirst = vi.mocked(prisma.deckCategory.findFirst);
const mockDeckCategoryUpsert = vi.mocked(prisma.deckCategory.upsert);
const mockDeckFindFirst = vi.mocked(prisma.deck.findFirst);
const mockGetOrCreateWishlistDeck = vi.mocked(getOrCreateWishlistDeck);
const mockUpdateTag = vi.mocked(updateTag);
Expand Down Expand Up @@ -182,7 +188,6 @@ describe("setWishlist", () => {
printingId: PRINTING_ID,
isFoil: false,
zone: "MAINBOARD",
category: null,
quantity: 1,
},
});
Expand All @@ -202,36 +207,112 @@ describe("setWishlist", () => {
mockDeckCardFindFirst.mockResolvedValue(null);
mockDeckCardCreate.mockResolvedValue({} as never);
mockDeckFindFirst.mockResolvedValue({ name: "Krenko Goblins" } as never);
mockDeckCategoryFindFirst.mockResolvedValue(null);
mockDeckCategoryUpsert.mockResolvedValue({ id: "cat-krenko" } as never);

await setWishlist(PRINTING_ID, false, true, "deck-99");

expect(mockDeckFindFirst).toHaveBeenCalledWith({
where: { id: "deck-99", userId: USER_ID },
select: { name: true },
});
// The source deck's name is registered (normalized) in the wishlist's
// category registry on first use.
expect(mockDeckCategoryUpsert).toHaveBeenCalledWith({
where: {
deckId_name: { deckId: WISHLIST_DECK_ID, name: "krenko goblins" },
},
create: {
deckId: WISHLIST_DECK_ID,
name: "krenko goblins",
sortOrder: 0,
},
update: {},
select: { id: true },
});
expect(mockDeckCardCreate).toHaveBeenCalledWith({
data: {
deckId: WISHLIST_DECK_ID,
cardId: CARD_ID,
printingId: PRINTING_ID,
isFoil: false,
zone: "MAINBOARD",
category: "Krenko Goblins",
quantity: 1,
categoryLinks: {
create: [{ deckCategoryId: "cat-krenko", position: 0 }],
},
},
});
});

it("leaves the category null when no source deck is given (non-deck context)", async () => {
it("appends the new category after existing registry entries (sortOrder = max+1)", async () => {
mockDeckCardFindFirst.mockResolvedValue(null);
mockDeckCardCreate.mockResolvedValue({} as never);
mockDeckFindFirst.mockResolvedValue({ name: "Krenko Goblins" } as never);
mockDeckCategoryFindFirst.mockResolvedValue({ sortOrder: 3 } as never);
mockDeckCategoryUpsert.mockResolvedValue({ id: "cat-krenko" } as never);

await setWishlist(PRINTING_ID, false, true, "deck-99");

expect(mockDeckCategoryUpsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({ sortOrder: 4 }),
}),
);
});

it("truncates a max-length source deck name to the category cap", async () => {
mockDeckCardFindFirst.mockResolvedValue(null);
mockDeckCardCreate.mockResolvedValue({} as never);
const longName = "x".repeat(100); // DECK_NAME_MAX
mockDeckFindFirst.mockResolvedValue({ name: longName } as never);
mockDeckCategoryFindFirst.mockResolvedValue(null);
mockDeckCategoryUpsert.mockResolvedValue({ id: "cat-long" } as never);

await setWishlist(PRINTING_ID, false, true, "deck-99");

const expected = "x".repeat(50); // CATEGORY_NAME_MAX
expect(mockDeckCategoryUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
deckId_name: { deckId: WISHLIST_DECK_ID, name: expected },
},
create: expect.objectContaining({ name: expected }),
}),
);
});

it("leaves the card uncategorized when no source deck is given (non-deck context)", async () => {
mockDeckCardFindFirst.mockResolvedValue(null);
mockDeckCardCreate.mockResolvedValue({} as never);

await setWishlist(PRINTING_ID, false, true);

expect(mockDeckFindFirst).not.toHaveBeenCalled();
expect(mockDeckCategoryUpsert).not.toHaveBeenCalled();
expect(mockDeckCardCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.not.objectContaining({ categoryLinks: expect.anything() }),
}),
);
});

it("leaves the card uncategorized when the source deck name normalizes to empty", async () => {
mockDeckCardFindFirst.mockResolvedValue(null);
mockDeckCardCreate.mockResolvedValue({} as never);
mockDeckFindFirst.mockResolvedValue({ name: " " } as never);

await setWishlist(PRINTING_ID, false, true, "deck-99");

expect(mockDeckFindFirst).toHaveBeenCalledWith({
where: { id: "deck-99", userId: USER_ID },
select: { name: true },
});
expect(mockDeckCategoryFindFirst).not.toHaveBeenCalled();
expect(mockDeckCategoryUpsert).not.toHaveBeenCalled();
expect(mockDeckCardCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ category: null }),
data: expect.not.objectContaining({ categoryLinks: expect.anything() }),
}),
);
});
Expand All @@ -243,9 +324,10 @@ describe("setWishlist", () => {
await setWishlist(PRINTING_ID, false, true, WISHLIST_DECK_ID);

expect(mockDeckFindFirst).not.toHaveBeenCalled();
expect(mockDeckCategoryUpsert).not.toHaveBeenCalled();
expect(mockDeckCardCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ category: null }),
data: expect.not.objectContaining({ categoryLinks: expect.anything() }),
}),
);
});
Expand All @@ -257,9 +339,10 @@ describe("setWishlist", () => {

await setWishlist(PRINTING_ID, false, true, "deck-gone");

expect(mockDeckCategoryUpsert).not.toHaveBeenCalled();
expect(mockDeckCardCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ category: null }),
data: expect.not.objectContaining({ categoryLinks: expect.anything() }),
}),
);
});
Expand Down
Loading
Loading